summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core')
-rw-r--r--packages/taler-wallet-core/.gitignore2
-rw-r--r--packages/taler-wallet-core/README.md4
-rw-r--r--packages/taler-wallet-core/package.json83
-rw-r--r--packages/taler-wallet-core/rollup.config.js66
-rw-r--r--packages/taler-wallet-core/src/attention.ts139
-rw-r--r--packages/taler-wallet-core/src/backup/index.ts984
-rw-r--r--packages/taler-wallet-core/src/backup/state.ts (renamed from packages/taler-wallet-core/src/util/assertUnreachable.ts)6
-rw-r--r--packages/taler-wallet-core/src/balance.ts772
-rw-r--r--packages/taler-wallet-core/src/coinSelection.test.ts281
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts1258
-rw-r--r--packages/taler-wallet-core/src/common.ts897
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoImplementation.ts1787
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoTypes.ts254
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.test.ts128
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts386
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts457
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts593
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts8
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts65
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts64
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts136
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/synchronousWorkerFactoryPlain.ts (renamed from packages/taler-wallet-core/src/util/invariants.ts)33
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/synchronousWorkerPlain.ts98
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/worker-common.ts107
-rw-r--r--packages/taler-wallet-core/src/db-utils.ts172
-rw-r--r--packages/taler-wallet-core/src/db.ts3114
-rw-r--r--packages/taler-wallet-core/src/dbless.ts419
-rw-r--r--packages/taler-wallet-core/src/denomSelection.ts199
-rw-r--r--packages/taler-wallet-core/src/denominations.test.ts870
-rw-r--r--packages/taler-wallet-core/src/denominations.ts479
-rw-r--r--packages/taler-wallet-core/src/deposits.ts1775
-rw-r--r--packages/taler-wallet-core/src/dev-experiments.ts153
-rw-r--r--packages/taler-wallet-core/src/errors.ts132
-rw-r--r--packages/taler-wallet-core/src/exchanges.ts2581
-rw-r--r--packages/taler-wallet-core/src/headless/NodeHttpLib.ts175
-rw-r--r--packages/taler-wallet-core/src/headless/helpers.ts164
-rw-r--r--packages/taler-wallet-core/src/host-common.ts60
-rw-r--r--packages/taler-wallet-core/src/host-impl.missing.ts (renamed from packages/taler-wallet-core/src/util/debugFlags.ts)35
-rw-r--r--packages/taler-wallet-core/src/host-impl.node.ts212
-rw-r--r--packages/taler-wallet-core/src/host-impl.qtart.ts219
-rw-r--r--packages/taler-wallet-core/src/host.ts46
-rw-r--r--packages/taler-wallet-core/src/index.browser.ts1
-rw-r--r--packages/taler-wallet-core/src/index.node.ts10
-rw-r--r--packages/taler-wallet-core/src/index.ts46
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.test.ts767
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.ts872
-rw-r--r--packages/taler-wallet-core/src/observable-wrappers.ts295
-rw-r--r--packages/taler-wallet-core/src/operations/README.md7
-rw-r--r--packages/taler-wallet-core/src/operations/backup/export.ts512
-rw-r--r--packages/taler-wallet-core/src/operations/backup/import.ts915
-rw-r--r--packages/taler-wallet-core/src/operations/backup/index.ts1005
-rw-r--r--packages/taler-wallet-core/src/operations/backup/state.ts113
-rw-r--r--packages/taler-wallet-core/src/operations/balance.ts168
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts470
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.ts732
-rw-r--r--packages/taler-wallet-core/src/operations/pay.ts1768
-rw-r--r--packages/taler-wallet-core/src/operations/pending.ts354
-rw-r--r--packages/taler-wallet-core/src/operations/recoup.ts485
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts987
-rw-r--r--packages/taler-wallet-core/src/operations/refund.ts777
-rw-r--r--packages/taler-wallet-core/src/operations/reserves.ts829
-rw-r--r--packages/taler-wallet-core/src/operations/testing.ts435
-rw-r--r--packages/taler-wallet-core/src/operations/tip.ts420
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.ts597
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.test.ts345
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts1072
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts3502
-rw-r--r--packages/taler-wallet-core/src/pay-peer-common.ts163
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-credit.ts1215
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-debit.ts1019
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-credit.ts1036
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-debit.ts1322
-rw-r--r--packages/taler-wallet-core/src/pending-types.ts256
-rw-r--r--packages/taler-wallet-core/src/query.ts1004
-rw-r--r--packages/taler-wallet-core/src/recoup.ts555
-rw-r--r--packages/taler-wallet-core/src/refresh.ts1883
-rw-r--r--packages/taler-wallet-core/src/remote.ts191
-rw-r--r--packages/taler-wallet-core/src/shepherd.ts1128
-rw-r--r--packages/taler-wallet-core/src/testing.ts871
-rw-r--r--packages/taler-wallet-core/src/transactions.ts2045
-rw-r--r--packages/taler-wallet-core/src/util/RequestThrottler.ts156
-rw-r--r--packages/taler-wallet-core/src/util/asyncMemo.ts87
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.test.ts254
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts332
-rw-r--r--packages/taler-wallet-core/src/util/contractTerms.test.ts122
-rw-r--r--packages/taler-wallet-core/src/util/contractTerms.ts231
-rw-r--r--packages/taler-wallet-core/src/util/http.ts342
-rw-r--r--packages/taler-wallet-core/src/util/promiseUtils.ts60
-rw-r--r--packages/taler-wallet-core/src/util/query.ts615
-rw-r--r--packages/taler-wallet-core/src/util/retries.ts85
-rw-r--r--packages/taler-wallet-core/src/util/timer.ts199
-rw-r--r--packages/taler-wallet-core/src/versions.ts59
-rw-r--r--packages/taler-wallet-core/src/wallet-api-types.ts1443
-rw-r--r--packages/taler-wallet-core/src/wallet.ts2452
-rw-r--r--packages/taler-wallet-core/src/withdraw.test.ts364
-rw-r--r--packages/taler-wallet-core/src/withdraw.ts3483
-rw-r--r--packages/taler-wallet-core/tsconfig.json16
-rwxr-xr-xpackages/taler-wallet-core/watch_test.sh3
98 files changed, 41234 insertions, 18654 deletions
diff --git a/packages/taler-wallet-core/.gitignore b/packages/taler-wallet-core/.gitignore
index 502167fa0..13d7285e1 100644
--- a/packages/taler-wallet-core/.gitignore
+++ b/packages/taler-wallet-core/.gitignore
@@ -1 +1,3 @@
/lib
+/coverage
+/src/version.json
diff --git a/packages/taler-wallet-core/README.md b/packages/taler-wallet-core/README.md
new file mode 100644
index 000000000..73da40caf
--- /dev/null
+++ b/packages/taler-wallet-core/README.md
@@ -0,0 +1,4 @@
+# taler-wallet-core
+
+This package implements `taler-wallet-core`, the main logic and storage
+for the GNU Taler wallet.
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json
index 0d726a6d7..46b3cef4e 100644
--- a/packages/taler-wallet-core/package.json
+++ b/packages/taler-wallet-core/package.json
@@ -1,9 +1,9 @@
{
"name": "@gnu-taler/taler-wallet-core",
- "version": "0.8.1",
+ "version": "0.10.7",
"description": "",
"engines": {
- "node": ">=0.12.0"
+ "node": ">=0.18.0"
},
"repository": {
"type": "git",
@@ -12,12 +12,13 @@
"author": "Florian Dold",
"license": "GPL-3.0",
"scripts": {
- "prepare": "tsc && rollup -c",
- "compile": "tsc && rollup -c",
+ "compile": "tsc",
"pretty": "prettier --write src",
"test": "tsc && ava",
- "coverage": "tsc && nyc ava",
- "clean": "rimraf dist lib tsconfig.tsbuildinfo"
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "coverage": "tsc && c8 --src src --all ava",
+ "coverage:html": "tsc && c8 -r html --src src --all ava",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo"
},
"files": [
"AUTHORS",
@@ -28,47 +29,55 @@
"src/",
"lib/"
],
- "main": "./dist/taler-wallet-core.js",
- "browser": {
- "./dist/taler-wallet-core.js": "./dist/taler-wallet-core.browser.js",
- "./lib/index.node.js": "./lib/index.browser.js"
- },
- "module": "./lib/index.node.js",
"type": "module",
"types": "./lib/index.node.d.ts",
+ "exports": {
+ ".": {
+ "browser": "./lib/index.browser.js",
+ "node": "./lib/index.node.js",
+ "default": "./lib/index.js"
+ },
+ "./remote": {
+ "default": "./lib/remote.js"
+ },
+ "./dbless": {
+ "default": "./lib/dbless.js"
+ }
+ },
+ "imports": {
+ "#host-impl": {
+ "types": "./lib/host-impl.node.js",
+ "node": "./lib/host-impl.node.js",
+ "qtart": "./lib/host-impl.qtart.js",
+ "default": "./lib/host-impl.missing.js"
+ }
+ },
"devDependencies": {
- "@ava/typescript": "^1.1.1",
+ "@ava/typescript": "^4.1.0",
"@gnu-taler/pogen": "workspace:*",
- "@microsoft/api-extractor": "^7.13.0",
- "@typescript-eslint/eslint-plugin": "^4.14.0",
- "@typescript-eslint/parser": "^4.14.0",
- "ava": "^3.15.0",
- "eslint": "^7.18.0",
- "eslint-config-airbnb-typescript": "^12.0.0",
- "eslint-plugin-import": "^2.22.1",
- "eslint-plugin-jsx-a11y": "^6.4.1",
- "eslint-plugin-react": "^7.22.0",
- "eslint-plugin-react-hooks": "^4.2.0",
+ "@typescript-eslint/eslint-plugin": "^5.36.1",
+ "@typescript-eslint/parser": "^5.36.1",
+ "ava": "^6.0.1",
+ "c8": "^8.0.1",
+ "eslint": "^8.8.0",
+ "eslint-config-airbnb-typescript": "^17.1.0",
+ "eslint-plugin-import": "^2.29.1",
+ "eslint-plugin-jsx-a11y": "^6.8.0",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.3.0",
"jed": "^1.1.1",
- "nyc": "^15.1.0",
"po2json": "^0.4.5",
- "prettier": "^2.2.1",
- "rimraf": "^3.0.2",
- "rollup": "^2.37.1",
- "rollup-plugin-sourcemaps": "^0.6.3",
- "source-map-resolve": "^0.6.0",
- "typedoc": "^0.20.16",
- "typescript": "^4.1.3"
+ "prettier": "^3.1.1",
+ "typedoc": "^0.25.4",
+ "typescript": "^5.3.3"
},
"dependencies": {
"@gnu-taler/idb-bridge": "workspace:*",
"@gnu-taler/taler-util": "workspace:*",
- "@types/node": "^14.14.22",
- "axios": "^0.21.1",
- "big-integer": "^1.6.48",
- "fflate": "^0.6.0",
- "source-map-support": "^0.5.19",
- "tslib": "^2.1.0"
+ "@types/node": "^18.11.17",
+ "big-integer": "^1.6.52",
+ "fflate": "^0.8.1",
+ "tslib": "^2.6.2"
},
"ava": {
"files": [
diff --git a/packages/taler-wallet-core/rollup.config.js b/packages/taler-wallet-core/rollup.config.js
deleted file mode 100644
index 927cb8a2e..000000000
--- a/packages/taler-wallet-core/rollup.config.js
+++ /dev/null
@@ -1,66 +0,0 @@
-// rollup.config.js
-import commonjs from "@rollup/plugin-commonjs";
-import nodeResolve from "@rollup/plugin-node-resolve";
-import json from "@rollup/plugin-json";
-import builtins from "builtin-modules";
-import pkg from "./package.json";
-import sourcemaps from 'rollup-plugin-sourcemaps';
-
-const nodeEntryPoint = {
- input: "lib/index.node.js",
- output: {
- file: pkg.main,
- format: "cjs",
- sourcemap: true,
- },
- external: builtins,
- plugins: [
- nodeResolve({
- preferBuiltins: true,
- }),
-
- sourcemaps(),
-
- commonjs({
- include: [/node_modules/, /dist/],
- extensions: [".js"],
- ignoreGlobal: false,
- sourceMap: true,
- }),
-
- json(),
- ],
-}
-
-const browserEntryPoint = {
- input: "lib/index.browser.js",
- output: {
- file: pkg.browser[pkg.main],
- format: "cjs",
- sourcemap: true,
- },
- external: builtins,
- plugins: [
- nodeResolve({
- browser: true,
- preferBuiltins: true,
- }),
-
- sourcemaps(),
-
- commonjs({
- include: [/node_modules/, /dist/],
- extensions: [".js"],
- ignoreGlobal: false,
- sourceMap: true,
- }),
-
- json(),
- ],
-}
-
-export default [
- nodeEntryPoint,
- browserEntryPoint
-]
-
diff --git a/packages/taler-wallet-core/src/attention.ts b/packages/taler-wallet-core/src/attention.ts
new file mode 100644
index 000000000..7a52ceaa3
--- /dev/null
+++ b/packages/taler-wallet-core/src/attention.ts
@@ -0,0 +1,139 @@
+/*
+ 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 {
+ AttentionInfo,
+ Logger,
+ TalerPreciseTimestamp,
+ UserAttentionByIdRequest,
+ UserAttentionPriority,
+ UserAttentionUnreadList,
+ UserAttentionsCountResponse,
+ UserAttentionsRequest,
+ UserAttentionsResponse,
+} from "@gnu-taler/taler-util";
+import { timestampPreciseFromDb, timestampPreciseToDb } from "./db.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("operations/attention.ts");
+
+export async function getUserAttentionsUnreadCount(
+ wex: WalletExecutionContext,
+ req: UserAttentionsRequest,
+): Promise<UserAttentionsCountResponse> {
+ const total = await wex.db.runReadOnlyTx(
+ { storeNames: ["userAttention"] },
+ async (tx) => {
+ let count = 0;
+ await tx.userAttention.iter().forEach((x) => {
+ if (
+ req.priority !== undefined &&
+ UserAttentionPriority[x.info.type] !== req.priority
+ )
+ return;
+ if (x.read !== undefined) return;
+ count++;
+ });
+
+ return count;
+ },
+ );
+
+ return { total };
+}
+
+export async function getUserAttentions(
+ wex: WalletExecutionContext,
+ req: UserAttentionsRequest,
+): Promise<UserAttentionsResponse> {
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["userAttention"] },
+ async (tx) => {
+ const pending: UserAttentionUnreadList = [];
+ await tx.userAttention.iter().forEach((x) => {
+ if (
+ req.priority !== undefined &&
+ UserAttentionPriority[x.info.type] !== req.priority
+ )
+ return;
+ pending.push({
+ info: x.info,
+ when: timestampPreciseFromDb(x.created),
+ read: x.read !== undefined,
+ });
+ });
+
+ return { pending };
+ },
+ );
+}
+
+export async function markAttentionRequestAsRead(
+ wex: WalletExecutionContext,
+ req: UserAttentionByIdRequest,
+): Promise<void> {
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
+ const ua = await tx.userAttention.get([req.entityId, req.type]);
+ if (!ua) throw Error("attention request not found");
+ tx.userAttention.put({
+ ...ua,
+ read: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ });
+}
+
+/**
+ * the wallet need the user attention to complete a task
+ * internal API
+ *
+ * @param wex
+ * @param info
+ */
+export async function addAttentionRequest(
+ wex: WalletExecutionContext,
+ info: AttentionInfo,
+ entityId: string,
+): Promise<void> {
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
+ await tx.userAttention.put({
+ info,
+ entityId,
+ created: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ read: undefined,
+ });
+ });
+}
+
+/**
+ * user completed the task, attention request is not needed
+ * internal API
+ *
+ * @param wex
+ * @param created
+ */
+export async function removeAttentionRequest(
+ wex: WalletExecutionContext,
+ req: UserAttentionByIdRequest,
+): Promise<void> {
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
+ const ua = await tx.userAttention.get([req.entityId, req.type]);
+ if (!ua) throw Error("attention request not found");
+ await tx.userAttention.delete([req.entityId, req.type]);
+ });
+}
diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts
new file mode 100644
index 000000000..15904b470
--- /dev/null
+++ b/packages/taler-wallet-core/src/backup/index.ts
@@ -0,0 +1,984 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ 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/>
+ */
+
+/**
+ * Implementation of wallet backups (export/import/upload) and sync
+ * server management.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AttentionType,
+ BackupRecovery,
+ Codec,
+ Duration,
+ EddsaKeyPair,
+ HttpStatusCode,
+ Logger,
+ PreparePayResult,
+ ProviderInfo,
+ ProviderPaymentStatus,
+ RecoveryLoadRequest,
+ RecoveryMergeStrategy,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ URL,
+ buildCodecForObject,
+ buildCodecForUnion,
+ bytesToString,
+ canonicalJson,
+ checkDbInvariant,
+ checkLogicInvariant,
+ codecForBoolean,
+ codecForConstString,
+ codecForList,
+ codecForString,
+ codecForSyncTermsOfServiceResponse,
+ codecOptional,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ hash,
+ j2s,
+ kdf,
+ notEmpty,
+ secretbox,
+ secretbox_open,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import {
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { gunzipSync, gzipSync } from "fflate";
+import { addAttentionRequest, removeAttentionRequest } from "../attention.js";
+import {
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+} from "../common.js";
+import {
+ BackupProviderRecord,
+ BackupProviderState,
+ BackupProviderStateTag,
+ ConfigRecord,
+ ConfigRecordKey,
+ WalletBackupConfState,
+ WalletDbReadOnlyTransaction,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseToDb,
+} from "../db.js";
+import { preparePayForUri } from "../pay-merchant.js";
+import { InternalWalletState, WalletExecutionContext } from "../wallet.js";
+
+const logger = new Logger("operations/backup.ts");
+
+function concatArrays(xs: Uint8Array[]): Uint8Array {
+ let len = 0;
+ for (const x of xs) {
+ len += x.byteLength;
+ }
+ const out = new Uint8Array(len);
+ let offset = 0;
+ for (const x of xs) {
+ out.set(x, offset);
+ offset += x.length;
+ }
+ return out;
+}
+
+const magic = "TLRWBK01";
+
+/**
+ * Encrypt the backup.
+ *
+ * Blob format:
+ * Magic "TLRWBK01" (8 bytes)
+ * Nonce (24 bytes)
+ * Compressed JSON blob (rest)
+ */
+export async function encryptBackup(
+ config: WalletBackupConfState,
+ blob: any,
+): Promise<Uint8Array> {
+ const chunks: Uint8Array[] = [];
+ chunks.push(stringToBytes(magic));
+ const nonceStr = config.lastBackupNonce;
+ checkLogicInvariant(!!nonceStr);
+ const nonce = decodeCrock(nonceStr).slice(0, 24);
+ chunks.push(nonce);
+ const backupJsonContent = canonicalJson(blob);
+ logger.trace("backup JSON size", backupJsonContent.length);
+ const compressedContent = gzipSync(stringToBytes(backupJsonContent), {
+ mtime: 0,
+ });
+ const secret = deriveBlobSecret(config);
+ const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
+ chunks.push(encrypted);
+ return concatArrays(chunks);
+}
+
+function deriveAccountKeyPair(
+ bc: WalletBackupConfState,
+ providerUrl: string,
+): EddsaKeyPair {
+ const privateKey = kdf(
+ 32,
+ decodeCrock(bc.walletRootPriv),
+ stringToBytes("taler-sync-account-key-salt"),
+ stringToBytes(providerUrl),
+ );
+ return {
+ eddsaPriv: privateKey,
+ eddsaPub: eddsaGetPublic(privateKey),
+ };
+}
+
+function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
+ return kdf(
+ 32,
+ decodeCrock(bc.walletRootPriv),
+ stringToBytes("taler-sync-blob-secret-salt"),
+ stringToBytes("taler-sync-blob-secret-info"),
+ );
+}
+
+interface BackupForProviderArgs {
+ backupProviderBaseUrl: string;
+}
+
+function getNextBackupTimestamp(): TalerPreciseTimestamp {
+ // FIXME: Randomize!
+ return AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ );
+}
+
+async function runBackupCycleForProvider(
+ wex: WalletExecutionContext,
+ args: BackupForProviderArgs,
+): Promise<TaskRunResult> {
+ const provider = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ return tx.backupProviders.get(args.backupProviderBaseUrl);
+ },
+ );
+
+ if (!provider) {
+ logger.warn("provider disappeared");
+ return TaskRunResult.finished();
+ }
+
+ //const backupJson = await exportBackup(ws);
+ // FIXME: re-implement backup
+ const backupJson = {};
+ const backupConfig = await provideBackupState(wex);
+ const encBackup = await encryptBackup(backupConfig, backupJson);
+ const currentBackupHash = hash(encBackup);
+
+ const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
+
+ const newHash = encodeCrock(currentBackupHash);
+ const oldHash = provider.lastBackupHash;
+
+ logger.trace(`trying to upload backup to ${provider.baseUrl}`);
+ logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
+
+ const syncSigResp = await wex.cryptoApi.makeSyncSignature({
+ newHash: encodeCrock(currentBackupHash),
+ oldHash: provider.lastBackupHash,
+ accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
+ });
+
+ logger.trace(`sync signature is ${syncSigResp}`);
+
+ const accountBackupUrl = new URL(
+ `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
+ provider.baseUrl,
+ );
+
+ if (provider.shouldRetryFreshProposal) {
+ accountBackupUrl.searchParams.set("fresh", "yes");
+ }
+
+ const resp = await wex.http.fetch(accountBackupUrl.href, {
+ method: "POST",
+ body: encBackup,
+ headers: {
+ "content-type": "application/octet-stream",
+ "sync-signature": syncSigResp.sig,
+ "if-none-match": JSON.stringify(newHash),
+ ...(provider.lastBackupHash
+ ? {
+ "if-match": JSON.stringify(provider.lastBackupHash),
+ }
+ : {}),
+ },
+ });
+
+ logger.trace(`sync response status: ${resp.status}`);
+
+ if (resp.status === HttpStatusCode.NotModified) {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ removeAttentionRequest(wex, {
+ entityId: provider.baseUrl,
+ type: AttentionType.BackupUnpaid,
+ });
+
+ return TaskRunResult.finished();
+ }
+
+ if (resp.status === HttpStatusCode.PaymentRequired) {
+ logger.trace("payment required for backup");
+ logger.trace(`headers: ${j2s(resp.headers)}`);
+ const talerUri = resp.headers.get("taler");
+ if (!talerUri) {
+ throw Error("no taler URI available to pay provider");
+ }
+
+ //We can't delay downloading the proposal since we need the id
+ //FIXME: check download errors
+ let res: PreparePayResult | undefined = undefined;
+ try {
+ res = await preparePayForUri(wex, talerUri);
+ } catch (e) {
+ const error = TalerError.fromException(e);
+ if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) {
+ throw error;
+ }
+ }
+
+ if (res === undefined) {
+ //claimed
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.shouldRetryFreshProposal = true;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+ const result = res;
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ // const opId = TaskIdentifiers.forBackup(prov);
+ // await scheduleRetryInTx(ws, tx, opId);
+ prov.currentPaymentProposalId = result.proposalId;
+ prov.shouldRetryFreshProposal = false;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ addAttentionRequest(
+ wex,
+ {
+ type: AttentionType.BackupUnpaid,
+ provider_base_url: provider.baseUrl,
+ talerUri,
+ },
+ provider.baseUrl,
+ );
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+
+ if (resp.status === HttpStatusCode.NoContent) {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(currentBackupHash);
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ removeAttentionRequest(wex, {
+ entityId: provider.baseUrl,
+ type: AttentionType.BackupUnpaid,
+ });
+
+ return {
+ type: TaskRunResultType.Finished,
+ };
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ logger.info("conflicting backup found");
+ const backupEnc = new Uint8Array(await resp.bytes());
+ const backupConfig = await provideBackupState(wex);
+ // const blob = await decryptBackup(backupConfig, backupEnc);
+ // FIXME: Re-implement backup import with merging
+ // await importBackup(ws, blob, cryptoData);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(hash(backupEnc));
+ // FIXME: Allocate error code for this situation?
+ // FIXME: Add operation retry record!
+ const opId = TaskIdentifiers.forBackup(prov);
+ //await scheduleRetryInTx(ws, tx, opId);
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+ logger.info("processed existing backup");
+ // Now upload our own, merged backup.
+ return await runBackupCycleForProvider(wex, args);
+ }
+
+ // Some other response that we did not expect!
+
+ logger.error("parsing error response");
+
+ const err = await readTalerErrorResponse(resp);
+ logger.error(`got error response from backup provider: ${j2s(err)}`);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: err,
+ };
+}
+
+export async function processBackupForProvider(
+ wex: WalletExecutionContext,
+ backupProviderBaseUrl: string,
+): Promise<TaskRunResult> {
+ const provider = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ return await tx.backupProviders.get(backupProviderBaseUrl);
+ },
+ );
+ if (!provider) {
+ throw Error("unknown backup provider");
+ }
+
+ logger.info(`running backup for provider ${backupProviderBaseUrl}`);
+
+ return await runBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: provider.baseUrl,
+ });
+}
+
+export interface RemoveBackupProviderRequest {
+ provider: string;
+}
+
+export const codecForRemoveBackupProvider =
+ (): Codec<RemoveBackupProviderRequest> =>
+ buildCodecForObject<RemoveBackupProviderRequest>()
+ .property("provider", codecForString())
+ .build("RemoveBackupProviderRequest");
+
+export async function removeBackupProvider(
+ wex: WalletExecutionContext,
+ req: RemoveBackupProviderRequest,
+): Promise<void> {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ await tx.backupProviders.delete(req.provider);
+ },
+ );
+}
+
+export interface RunBackupCycleRequest {
+ /**
+ * List of providers to backup or empty for all known providers.
+ */
+ providers?: Array<string>;
+}
+
+export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
+ buildCodecForObject<RunBackupCycleRequest>()
+ .property("providers", codecOptional(codecForList(codecForString())))
+ .build("RunBackupCycleRequest");
+
+/**
+ * Do one backup cycle that consists of:
+ * 1. Exporting a backup and try to upload it.
+ * Stop if this step succeeds.
+ * 2. Download, verify and import backups from connected sync accounts.
+ * 3. Upload the updated backup blob.
+ */
+export async function runBackupCycle(
+ wex: WalletExecutionContext,
+ req: RunBackupCycleRequest,
+): Promise<void> {
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ if (req.providers) {
+ const rs = await Promise.all(
+ req.providers.map((id) => tx.backupProviders.get(id)),
+ );
+ return rs.filter(notEmpty);
+ }
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+
+ for (const provider of providers) {
+ await runBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: provider.baseUrl,
+ });
+ }
+}
+
+export interface AddBackupProviderRequest {
+ backupProviderBaseUrl: string;
+
+ name: string;
+ /**
+ * Activate the provider. Should only be done after
+ * the user has reviewed the provider.
+ */
+ activate?: boolean;
+}
+
+export const codecForAddBackupProviderRequest =
+ (): Codec<AddBackupProviderRequest> =>
+ buildCodecForObject<AddBackupProviderRequest>()
+ .property("backupProviderBaseUrl", codecForString())
+ .property("name", codecForString())
+ .property("activate", codecOptional(codecForBoolean()))
+ .build("AddBackupProviderRequest");
+
+export type AddBackupProviderResponse =
+ | AddBackupProviderOk
+ | AddBackupProviderPaymentRequired;
+
+interface AddBackupProviderOk {
+ status: "ok";
+}
+interface AddBackupProviderPaymentRequired {
+ status: "payment-required";
+ talerUri?: string;
+}
+
+export const codecForAddBackupProviderOk = (): Codec<AddBackupProviderOk> =>
+ buildCodecForObject<AddBackupProviderOk>()
+ .property("status", codecForConstString("ok"))
+ .build("AddBackupProviderOk");
+
+export const codecForAddBackupProviderPaymenrRequired =
+ (): Codec<AddBackupProviderPaymentRequired> =>
+ buildCodecForObject<AddBackupProviderPaymentRequired>()
+ .property("status", codecForConstString("payment-required"))
+ .property("talerUri", codecOptional(codecForString()))
+ .build("AddBackupProviderPaymentRequired");
+
+export const codecForAddBackupProviderResponse =
+ (): Codec<AddBackupProviderResponse> =>
+ buildCodecForUnion<AddBackupProviderResponse>()
+ .discriminateOn("status")
+ .alternative("ok", codecForAddBackupProviderOk())
+ .alternative(
+ "payment-required",
+ codecForAddBackupProviderPaymenrRequired(),
+ )
+ .build("AddBackupProviderResponse");
+
+export async function addBackupProvider(
+ wex: WalletExecutionContext,
+ req: AddBackupProviderRequest,
+): Promise<AddBackupProviderResponse> {
+ logger.info(`adding backup provider ${j2s(req)}`);
+ await provideBackupState(wex);
+ const canonUrl = req.backupProviderBaseUrl;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const oldProv = await tx.backupProviders.get(canonUrl);
+ if (oldProv) {
+ logger.info("old backup provider found");
+ if (req.activate) {
+ oldProv.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ };
+ logger.info("setting existing backup provider to active");
+ await tx.backupProviders.put(oldProv);
+ }
+ return;
+ }
+ },
+ );
+ const termsUrl = new URL("config", canonUrl);
+ const resp = await wex.http.fetch(termsUrl.href);
+ const terms = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForSyncTermsOfServiceResponse(),
+ );
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ let state: BackupProviderState;
+ //FIXME: what is the difference provisional and ready?
+ if (req.activate) {
+ state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ };
+ } else {
+ state = {
+ tag: BackupProviderStateTag.Provisional,
+ };
+ }
+ await tx.backupProviders.put({
+ state,
+ name: req.name,
+ terms: {
+ annualFee: terms.annual_fee,
+ storageLimitInMegabytes: terms.storage_limit_in_megabytes,
+ supportedProtocolVersion: terms.version,
+ },
+ shouldRetryFreshProposal: false,
+ paymentProposalIds: [],
+ baseUrl: canonUrl,
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ },
+ );
+
+ return await runFirstBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: canonUrl,
+ });
+}
+
+async function runFirstBackupCycleForProvider(
+ wex: WalletExecutionContext,
+ args: BackupForProviderArgs,
+): Promise<AddBackupProviderResponse> {
+ throw Error("not implemented");
+ // const resp = await runBackupCycleForProvider(ws, args);
+ // switch (resp.type) {
+ // case TaskRunResultType.Error:
+ // throw TalerError.fromDetail(
+ // TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ // resp.errorDetail as any, //FIXME create an error for backup problems
+ // );
+ // case TaskRunResultType.Finished:
+ // return {
+ // status: "ok",
+ // };
+ // case TaskRunResultType.Pending:
+ // return {
+ // status: "payment-required",
+ // talerUri: "FIXME",
+ // //talerUri: resp.result.talerUri,
+ // };
+ // default:
+ // assertUnreachable(resp);
+ // }
+}
+
+export async function restoreFromRecoverySecret(): Promise<void> {
+ return;
+}
+
+export interface BackupInfo {
+ walletRootPub: string;
+ deviceId: string;
+ providers: ProviderInfo[];
+}
+
+async function getProviderPaymentInfo(
+ wex: WalletExecutionContext,
+ provider: BackupProviderRecord,
+): Promise<ProviderPaymentStatus> {
+ throw Error("not implemented");
+ // if (!provider.currentPaymentProposalId) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+ // const status = await checkPaymentByProposalId(
+ // ws,
+ // provider.currentPaymentProposalId,
+ // ).catch(() => undefined);
+
+ // if (!status) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+
+ // switch (status.status) {
+ // case PreparePayResultType.InsufficientBalance:
+ // return {
+ // type: ProviderPaymentType.InsufficientBalance,
+ // amount: status.amountRaw,
+ // };
+ // case PreparePayResultType.PaymentPossible:
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // case PreparePayResultType.AlreadyConfirmed:
+ // if (status.paid) {
+ // return {
+ // type: ProviderPaymentType.Paid,
+ // paidUntil: AbsoluteTime.addDuration(
+ // AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp),
+ // durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
+ // ),
+ // };
+ // } else {
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // }
+ // default:
+ // assertUnreachable(status);
+ // }
+}
+
+/**
+ * Get information about the current state of wallet backups.
+ */
+export async function getBackupInfo(
+ wex: WalletExecutionContext,
+): Promise<BackupInfo> {
+ const backupConfig = await provideBackupState(wex);
+ const providerRecords = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders", "operationRetries"] },
+ async (tx) => {
+ return await tx.backupProviders.iter().mapAsync(async (bp) => {
+ const opId = TaskIdentifiers.forBackup(bp);
+ const retryRecord = await tx.operationRetries.get(opId);
+ return {
+ provider: bp,
+ retryRecord,
+ };
+ });
+ },
+ );
+ const providers: ProviderInfo[] = [];
+ for (const x of providerRecords) {
+ providers.push({
+ active: x.provider.state.tag !== BackupProviderStateTag.Provisional,
+ syncProviderBaseUrl: x.provider.baseUrl,
+ lastSuccessfulBackupTimestamp: timestampOptionalPreciseFromDb(
+ x.provider.lastBackupCycleTimestamp,
+ ),
+ paymentProposalIds: x.provider.paymentProposalIds,
+ lastError:
+ x.provider.state.tag === BackupProviderStateTag.Retrying
+ ? x.retryRecord?.lastError
+ : undefined,
+ paymentStatus: await getProviderPaymentInfo(wex, x.provider),
+ terms: x.provider.terms,
+ name: x.provider.name,
+ });
+ }
+ return {
+ deviceId: backupConfig.deviceId,
+ walletRootPub: backupConfig.walletRootPub,
+ providers,
+ };
+}
+
+/**
+ * Get backup recovery information, including the wallet's
+ * private key.
+ */
+export async function getBackupRecovery(
+ wex: WalletExecutionContext,
+): Promise<BackupRecovery> {
+ const bs = await provideBackupState(wex);
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+ return {
+ providers: providers
+ .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
+ .map((x) => {
+ return {
+ name: x.name,
+ url: x.baseUrl,
+ };
+ }),
+ walletRootPriv: bs.walletRootPriv,
+ };
+}
+
+async function backupRecoveryTheirs(
+ wex: WalletExecutionContext,
+ br: BackupRecovery,
+) {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders", "config"] },
+ async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ checkDbInvariant(!!backupStateEntry);
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ backupStateEntry.value.lastBackupNonce = undefined;
+ backupStateEntry.value.lastBackupTimestamp = undefined;
+ backupStateEntry.value.lastBackupCheckTimestamp = undefined;
+ backupStateEntry.value.lastBackupPlainHash = undefined;
+ backupStateEntry.value.walletRootPriv = br.walletRootPriv;
+ backupStateEntry.value.walletRootPub = encodeCrock(
+ eddsaGetPublic(decodeCrock(br.walletRootPriv)),
+ );
+ await tx.config.put(backupStateEntry);
+ for (const prov of br.providers) {
+ const existingProv = await tx.backupProviders.get(prov.url);
+ if (!existingProv) {
+ await tx.backupProviders.put({
+ baseUrl: prov.url,
+ name: prov.name,
+ paymentProposalIds: [],
+ shouldRetryFreshProposal: false,
+ state: {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ },
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ }
+ }
+ const providers = await tx.backupProviders.iter().toArray();
+ for (const prov of providers) {
+ prov.lastBackupCycleTimestamp = undefined;
+ prov.lastBackupHash = undefined;
+ await tx.backupProviders.put(prov);
+ }
+ },
+ );
+}
+
+async function backupRecoveryOurs(
+ wex: WalletExecutionContext,
+ br: BackupRecovery,
+) {
+ throw Error("not implemented");
+}
+
+export async function loadBackupRecovery(
+ wex: WalletExecutionContext,
+ br: RecoveryLoadRequest,
+): Promise<void> {
+ const bs = await provideBackupState(wex);
+ const providers = await wex.db.runReadOnlyTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+ let strategy = br.strategy;
+ if (
+ br.recovery.walletRootPriv != bs.walletRootPriv &&
+ providers.length > 0 &&
+ !strategy
+ ) {
+ throw Error(
+ "recovery load strategy must be specified for wallet with existing providers",
+ );
+ } else if (!strategy) {
+ // Default to using the new key if we don't have providers yet.
+ strategy = RecoveryMergeStrategy.Theirs;
+ }
+ if (strategy === RecoveryMergeStrategy.Theirs) {
+ return backupRecoveryTheirs(wex, br.recovery);
+ } else {
+ return backupRecoveryOurs(wex, br.recovery);
+ }
+}
+
+export async function decryptBackup(
+ backupConfig: WalletBackupConfState,
+ data: Uint8Array,
+): Promise<any> {
+ const rMagic = bytesToString(data.slice(0, 8));
+ if (rMagic != magic) {
+ throw Error("invalid backup file (magic tag mismatch)");
+ }
+
+ const nonce = data.slice(8, 8 + 24);
+ const box = data.slice(8 + 24);
+ const secret = deriveBlobSecret(backupConfig);
+ const dataCompressed = secretbox_open(box, nonce, secret);
+ if (!dataCompressed) {
+ throw Error("decryption failed");
+ }
+ return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
+}
+
+export async function provideBackupState(
+ wex: WalletExecutionContext,
+): Promise<WalletBackupConfState> {
+ const bs: ConfigRecord | undefined = await wex.db.runReadOnlyTx(
+ { storeNames: ["config"] },
+ async (tx) => {
+ return await tx.config.get(ConfigRecordKey.WalletBackupState);
+ },
+ );
+ if (bs) {
+ checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
+ return bs.value;
+ }
+ // We need to generate the key outside of the transaction
+ // due to how IndexedDB works.
+ const k = await wex.cryptoApi.createEddsaKeypair({});
+ const d = getRandomBytes(5);
+ // FIXME: device ID should be configured when wallet is initialized
+ // and be based on hostname
+ const deviceId = `wallet-core-${encodeCrock(d)}`;
+ return await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (!backupStateEntry) {
+ backupStateEntry = {
+ key: ConfigRecordKey.WalletBackupState,
+ value: {
+ deviceId,
+ walletRootPub: k.pub,
+ walletRootPriv: k.priv,
+ lastBackupPlainHash: undefined,
+ },
+ };
+ await tx.config.put(backupStateEntry);
+ }
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ return backupStateEntry.value;
+ });
+}
+
+export async function getWalletBackupState(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<["config"]>,
+): Promise<WalletBackupConfState> {
+ const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
+ checkDbInvariant(!!bs, "wallet backup state should be in DB");
+ checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
+ return bs.value;
+}
+
+export async function setWalletDeviceId(
+ wex: WalletExecutionContext,
+ deviceId: string,
+): Promise<void> {
+ await provideBackupState(wex);
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (
+ !backupStateEntry ||
+ backupStateEntry.key !== ConfigRecordKey.WalletBackupState
+ ) {
+ return;
+ }
+ backupStateEntry.value.deviceId = deviceId;
+ await tx.config.put(backupStateEntry);
+ });
+}
+
+export async function getWalletDeviceId(
+ wex: WalletExecutionContext,
+): Promise<string> {
+ const bs = await provideBackupState(wex);
+ return bs.deviceId;
+}
diff --git a/packages/taler-wallet-core/src/util/assertUnreachable.ts b/packages/taler-wallet-core/src/backup/state.ts
index ffdf88f04..72f850b25 100644
--- a/packages/taler-wallet-core/src/util/assertUnreachable.ts
+++ b/packages/taler-wallet-core/src/backup/state.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
+ (C) 2020 Taler Systems SA
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
@@ -13,7 +13,3 @@
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");
-}
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
new file mode 100644
index 000000000..5a805b477
--- /dev/null
+++ b/packages/taler-wallet-core/src/balance.ts
@@ -0,0 +1,772 @@
+/*
+ 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/>
+ */
+
+/**
+ * Functions to compute the wallet's balance.
+ *
+ * There are multiple definition of the wallet's balance.
+ * We use the following terminology:
+ *
+ * - "available": Balance that is available
+ * for spending from transactions in their final state and
+ * expected to be available from pending refreshes.
+ *
+ * - "pending-incoming": Expected (positive!) delta
+ * to the available balance that we expect to have
+ * after pending operations reach the "done" state.
+ *
+ * - "pending-outgoing": Amount that is currently allocated
+ * to be spent, but the spend operation could still be aborted
+ * and part of the pending-outgoing amount could be recovered.
+ *
+ * - "material": Balance that the wallet believes it could spend *right now*,
+ * without waiting for any operations to complete.
+ * This balance type is important when showing "insufficient balance" error messages.
+ *
+ * - "age-acceptable": Subset of the material balance that can be spent
+ * with age restrictions applied.
+ *
+ * - "merchant-acceptable": Subset of the material balance that can be spent with a particular
+ * merchant (restricted via min age, exchange, auditor, wire_method).
+ *
+ * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
+ * can accept via their supported wire methods.
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AmountJson,
+ AmountLike,
+ Amounts,
+ assertUnreachable,
+ BalanceFlag,
+ BalancesResponse,
+ GetBalanceDetailRequest,
+ j2s,
+ Logger,
+ parsePaytoUri,
+ ScopeInfo,
+ ScopeType,
+} from "@gnu-taler/taler-util";
+import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js";
+import {
+ DepositOperationStatus,
+ ExchangeEntryDbRecordStatus,
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ PeerPushDebitStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ WalletDbReadOnlyTransaction,
+ WithdrawalGroupStatus,
+} from "./db.js";
+import {
+ getExchangeScopeInfo,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("operations/balance.ts");
+
+interface WalletBalance {
+ scopeInfo: ScopeInfo;
+ available: AmountJson;
+ pendingIncoming: AmountJson;
+ pendingOutgoing: AmountJson;
+ flagIncomingKyc: boolean;
+ flagIncomingAml: boolean;
+ flagIncomingConfirmation: boolean;
+ flagOutgoingKyc: boolean;
+}
+
+/**
+ * Compute the available amount that the wallet expects to get
+ * out of a refresh group.
+ */
+function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
+ // Don't count finished refreshes, since the refresh already resulted
+ // in coins being added to the wallet.
+ let available = Amounts.zeroOfCurrency(r.currency);
+ if (r.timestampFinished) {
+ return available;
+ }
+ for (let i = 0; i < r.oldCoinPubs.length; i++) {
+ available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount;
+ }
+ return available;
+}
+
+function getBalanceKey(scopeInfo: ScopeInfo): string {
+ switch (scopeInfo.type) {
+ case ScopeType.Auditor:
+ return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
+ case ScopeType.Exchange:
+ return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
+ case ScopeType.Global:
+ return `${scopeInfo.type};${scopeInfo.currency}`;
+ }
+}
+
+class BalancesStore {
+ private exchangeScopeCache: Record<string, ScopeInfo> = {};
+ private balanceStore: Record<string, WalletBalance> = {};
+
+ constructor(
+ private wex: WalletExecutionContext,
+ private tx: WalletDbReadOnlyTransaction<
+ [
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+ ) {}
+
+ /**
+ * Add amount to a balance field, both for
+ * the slicing by exchange and currency.
+ */
+ private async initBalance(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<WalletBalance> {
+ let scopeInfo: ScopeInfo | undefined =
+ this.exchangeScopeCache[exchangeBaseUrl];
+ if (!scopeInfo) {
+ scopeInfo = await getExchangeScopeInfo(
+ this.tx,
+ exchangeBaseUrl,
+ currency,
+ );
+ this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo;
+ }
+ const balanceKey = getBalanceKey(scopeInfo);
+ const b = this.balanceStore[balanceKey];
+ if (!b) {
+ const zero = Amounts.zeroOfCurrency(currency);
+ this.balanceStore[balanceKey] = {
+ scopeInfo,
+ available: zero,
+ pendingIncoming: zero,
+ pendingOutgoing: zero,
+ flagIncomingAml: false,
+ flagIncomingConfirmation: false,
+ flagIncomingKyc: false,
+ flagOutgoingKyc: false,
+ };
+ }
+ return this.balanceStore[balanceKey];
+ }
+
+ async addZero(currency: string, exchangeBaseUrl: string): Promise<void> {
+ await this.initBalance(currency, exchangeBaseUrl);
+ }
+
+ async addAvailable(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.available = Amounts.add(b.available, amount).amount;
+ }
+
+ async addPendingIncoming(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.pendingIncoming = Amounts.add(b.pendingIncoming, amount).amount;
+ }
+
+ async addPendingOutgoing(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.pendingOutgoing = Amounts.add(b.pendingOutgoing, amount).amount;
+ }
+
+ async setFlagIncomingAml(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingAml = true;
+ }
+
+ async setFlagIncomingKyc(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingKyc = true;
+ }
+
+ async setFlagIncomingConfirmation(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingConfirmation = true;
+ }
+
+ async setFlagOutgoingKyc(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagOutgoingKyc = true;
+ }
+
+ toBalancesResponse(): BalancesResponse {
+ const balancesResponse: BalancesResponse = {
+ balances: [],
+ };
+
+ const balanceStore = this.balanceStore;
+
+ Object.keys(balanceStore)
+ .sort()
+ .forEach((c) => {
+ const v = balanceStore[c];
+ const flags: BalanceFlag[] = [];
+ if (v.flagIncomingAml) {
+ flags.push(BalanceFlag.IncomingAml);
+ }
+ if (v.flagIncomingKyc) {
+ flags.push(BalanceFlag.IncomingKyc);
+ }
+ if (v.flagIncomingConfirmation) {
+ flags.push(BalanceFlag.IncomingConfirmation);
+ }
+ if (v.flagOutgoingKyc) {
+ flags.push(BalanceFlag.OutgoingKyc);
+ }
+ balancesResponse.balances.push({
+ scopeInfo: v.scopeInfo,
+ available: Amounts.stringify(v.available),
+ pendingIncoming: Amounts.stringify(v.pendingIncoming),
+ pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
+ // FIXME: This field is basically not implemented, do we even need it?
+ hasPendingTransactions: false,
+ // FIXME: This field is basically not implemented, do we even need it?
+ requiresUserInput: false,
+ flags,
+ });
+ });
+ return balancesResponse;
+ }
+}
+
+/**
+ * Get balance information.
+ */
+export async function getBalancesInsideTransaction(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "coinAvailability",
+ "refreshGroups",
+ "depositGroups",
+ "withdrawalGroups",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "peerPushDebit",
+ ]
+ >,
+): Promise<BalancesResponse> {
+ const balanceStore: BalancesStore = new BalancesStore(wex, tx);
+
+ const keyRangeActive = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+
+ await tx.exchanges.iter().forEachAsync(async (ex) => {
+ if (
+ ex.entryStatus === ExchangeEntryDbRecordStatus.Used ||
+ ex.tosAcceptedTimestamp != null
+ ) {
+ const det = await getExchangeWireDetailsInTx(tx, ex.baseUrl);
+ if (det) {
+ await balanceStore.addZero(det.currency, ex.baseUrl);
+ }
+ }
+ });
+
+ await tx.coinAvailability.iter().forEachAsync(async (ca) => {
+ const count = ca.visibleCoinCount ?? 0;
+ await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl);
+ for (let i = 0; i < count; i++) {
+ await balanceStore.addAvailable(
+ ca.currency,
+ ca.exchangeBaseUrl,
+ ca.value,
+ );
+ }
+ });
+
+ await tx.refreshGroups.iter().forEachAsync(async (r) => {
+ switch (r.operationStatus) {
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended:
+ break;
+ default:
+ return;
+ }
+ const perExchange = r.infoPerExchange;
+ if (!perExchange) {
+ return;
+ }
+ for (const [e, x] of Object.entries(perExchange)) {
+ await balanceStore.addAvailable(r.currency, e, x.outputEffective);
+ }
+ });
+
+ await tx.withdrawalGroups.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (wgRecord) => {
+ const currency = Amounts.currencyOf(wgRecord.denomsSel.totalCoinValue);
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ case WithdrawalGroupStatus.DialogProposed:
+ case WithdrawalGroupStatus.Done:
+ // Does not count as pendingIncoming
+ return;
+ case WithdrawalGroupStatus.PendingReady:
+ case WithdrawalGroupStatus.AbortingBank:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ // Pending, but no special flag.
+ break;
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.PendingKyc:
+ await balanceStore.setFlagIncomingKyc(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.SuspendedAml:
+ await balanceStore.setFlagIncomingAml(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ await balanceStore.setFlagIncomingConfirmation(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ default:
+ assertUnreachable(wgRecord.status);
+ }
+ await balanceStore.addPendingIncoming(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ wgRecord.denomsSel.totalCoinValue,
+ );
+ });
+
+ await tx.peerPushDebit.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (ppdRecord) => {
+ switch (ppdRecord.status) {
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.SuspendedCreatePurse: {
+ const currency = Amounts.currencyOf(ppdRecord.amount);
+ await balanceStore.addPendingOutgoing(
+ currency,
+ ppdRecord.exchangeBaseUrl,
+ ppdRecord.totalCost,
+ );
+ break;
+ }
+ }
+ });
+
+ await tx.depositGroups.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (dgRecord) => {
+ const perExchange = dgRecord.infoPerExchange;
+ if (!perExchange) {
+ return;
+ }
+ for (const [e, x] of Object.entries(perExchange)) {
+ const currency = Amounts.currencyOf(dgRecord.amount);
+ switch (dgRecord.operationStatus) {
+ case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.PendingKyc:
+ await balanceStore.setFlagOutgoingKyc(currency, e);
+ }
+
+ switch (dgRecord.operationStatus) {
+ case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.PendingKyc:
+ case DepositOperationStatus.PendingTrack:
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.SuspendedDeposit:
+ case DepositOperationStatus.SuspendedTrack:
+ case DepositOperationStatus.PendingDeposit: {
+ const perExchange = dgRecord.infoPerExchange;
+ if (perExchange) {
+ for (const [e, v] of Object.entries(perExchange)) {
+ await balanceStore.addPendingOutgoing(
+ currency,
+ e,
+ v.amountEffective,
+ );
+ }
+ }
+ }
+ }
+ }
+ });
+
+ return balanceStore.toBalancesResponse();
+}
+
+/**
+ * Get detailed balance information, sliced by exchange and by currency.
+ */
+export async function getBalances(
+ wex: WalletExecutionContext,
+): Promise<BalancesResponse> {
+ logger.trace("starting to compute balance");
+
+ const wbal = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coinAvailability",
+ "coins",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "purchases",
+ "refreshGroups",
+ "withdrawalGroups",
+ "peerPushDebit",
+ ],
+ },
+ async (tx) => {
+ return getBalancesInsideTransaction(wex, tx);
+ },
+ );
+
+ logger.trace("finished computing wallet balance");
+
+ return wbal;
+}
+
+export interface PaymentRestrictionsForBalance {
+ currency: string;
+ minAge: number;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ restrictWireMethods: string[] | undefined;
+ depositPaytoUri: string | undefined;
+}
+
+export interface AcceptableExchanges {
+ /**
+ * Exchanges accepted by the merchant, but wire method might not match.
+ */
+ acceptableExchanges: string[];
+
+ /**
+ * Exchanges accepted by the merchant, including a matching
+ * wire method, i.e. the merchant can deposit coins there.
+ */
+ depositableExchanges: string[];
+}
+
+export interface PaymentBalanceDetails {
+ /**
+ * Balance of type "available" (see balance.ts for definition).
+ */
+ balanceAvailable: AmountJson;
+
+ /**
+ * Balance of type "material" (see balance.ts for definition).
+ */
+ balanceMaterial: AmountJson;
+
+ /**
+ * Balance of type "age-acceptable" (see balance.ts for definition).
+ */
+ balanceAgeAcceptable: AmountJson;
+
+ /**
+ * Balance of type "merchant-acceptable" (see balance.ts for definition).
+ */
+ balanceReceiverAcceptable: AmountJson;
+
+ /**
+ * Balance of type "merchant-depositable" (see balance.ts for definition).
+ */
+ balanceReceiverDepositable: AmountJson;
+
+ /**
+ * Balance that's depositable with the exchange.
+ * This balance is reduced by the exchange's debit restrictions
+ * and wire fee configuration.
+ */
+ balanceExchangeDepositable: AmountJson;
+
+ maxEffectiveSpendAmount: AmountJson;
+}
+
+export async function getPaymentBalanceDetails(
+ wex: WalletExecutionContext,
+ req: PaymentRestrictionsForBalance,
+): Promise<PaymentBalanceDetails> {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "coinAvailability",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ ],
+ },
+ async (tx) => {
+ return getPaymentBalanceDetailsInTx(wex, tx, req);
+ },
+ );
+}
+
+export async function getPaymentBalanceDetailsInTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ ]
+ >,
+ req: PaymentRestrictionsForBalance,
+): Promise<PaymentBalanceDetails> {
+ const d: PaymentBalanceDetails = {
+ balanceAvailable: Amounts.zeroOfCurrency(req.currency),
+ balanceMaterial: Amounts.zeroOfCurrency(req.currency),
+ balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
+ balanceReceiverAcceptable: Amounts.zeroOfCurrency(req.currency),
+ balanceReceiverDepositable: Amounts.zeroOfCurrency(req.currency),
+ maxEffectiveSpendAmount: Amounts.zeroOfCurrency(req.currency),
+ balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency),
+ };
+
+ logger.info(`computing balance details for ${j2s(req)}`);
+
+ const availableCoins = await tx.coinAvailability.getAll();
+
+ for (const ca of availableCoins) {
+ if (ca.currency != req.currency) {
+ continue;
+ }
+
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ ca.exchangeBaseUrl,
+ ca.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+
+ const wireDetails = await getExchangeWireDetailsInTx(
+ tx,
+ ca.exchangeBaseUrl,
+ );
+ if (!wireDetails) {
+ continue;
+ }
+
+ const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
+ const coinAmount: AmountJson = Amounts.mult(
+ singleCoinAmount,
+ ca.freshCoinCount,
+ ).amount;
+
+ let wireOkay = false;
+ if (req.restrictWireMethods == null) {
+ wireOkay = true;
+ } else {
+ for (const wm of req.restrictWireMethods) {
+ const wmf = findMatchingWire(wm, req.depositPaytoUri, wireDetails);
+ if (wmf) {
+ wireOkay = true;
+ break;
+ }
+ }
+ }
+
+ if (wireOkay) {
+ d.balanceExchangeDepositable = Amounts.add(
+ d.balanceExchangeDepositable,
+ coinAmount,
+ ).amount;
+ }
+
+ let ageOkay = ca.maxAge === 0 || ca.maxAge > req.minAge;
+
+ let merchantExchangeAcceptable = false;
+
+ if (!req.restrictExchanges) {
+ merchantExchangeAcceptable = true;
+ } else {
+ for (const ex of req.restrictExchanges.exchanges) {
+ if (ex.exchangeBaseUrl === ca.exchangeBaseUrl) {
+ merchantExchangeAcceptable = true;
+ break;
+ }
+ }
+ for (const acceptedAuditor of req.restrictExchanges.auditors) {
+ for (const exchangeAuditor of wireDetails.auditors) {
+ if (acceptedAuditor.auditorBaseUrl === exchangeAuditor.auditor_url) {
+ merchantExchangeAcceptable = true;
+ break;
+ }
+ }
+ }
+ }
+
+ const merchantExchangeDepositable = merchantExchangeAcceptable && wireOkay;
+
+ d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
+ d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
+ if (ageOkay) {
+ d.balanceAgeAcceptable = Amounts.add(
+ d.balanceAgeAcceptable,
+ coinAmount,
+ ).amount;
+ if (merchantExchangeAcceptable) {
+ d.balanceReceiverAcceptable = Amounts.add(
+ d.balanceReceiverAcceptable,
+ coinAmount,
+ ).amount;
+ if (merchantExchangeDepositable) {
+ d.balanceReceiverDepositable = Amounts.add(
+ d.balanceReceiverDepositable,
+ coinAmount,
+ ).amount;
+ }
+ }
+ }
+
+ if (
+ ageOkay &&
+ wireOkay &&
+ merchantExchangeAcceptable &&
+ merchantExchangeDepositable
+ ) {
+ d.maxEffectiveSpendAmount = Amounts.add(
+ d.maxEffectiveSpendAmount,
+ Amounts.mult(ca.value, ca.freshCoinCount).amount,
+ ).amount;
+
+ d.maxEffectiveSpendAmount = Amounts.sub(
+ d.maxEffectiveSpendAmount,
+ Amounts.mult(denom.feeDeposit, ca.freshCoinCount).amount,
+ ).amount;
+ }
+ }
+
+ await tx.refreshGroups.iter().forEach((r) => {
+ if (r.currency != req.currency) {
+ return;
+ }
+ d.balanceAvailable = Amounts.add(
+ d.balanceAvailable,
+ computeRefreshGroupAvailableAmount(r),
+ ).amount;
+ });
+
+ return d;
+}
+
+export async function getBalanceDetail(
+ wex: WalletExecutionContext,
+ req: GetBalanceDetailRequest,
+): Promise<PaymentBalanceDetails> {
+ const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
+ const wires = new Array<string>();
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || req.currency !== details.currency) {
+ continue;
+ }
+ details.wireInfo.accounts.forEach((a) => {
+ const payto = parsePaytoUri(a.payto_uri);
+ if (payto && !wires.includes(payto.targetType)) {
+ wires.push(payto.targetType);
+ }
+ });
+ exchanges.push({
+ exchangePub: details.masterPublicKey,
+ exchangeBaseUrl: e.baseUrl,
+ });
+ }
+ },
+ );
+
+ return await getPaymentBalanceDetails(wex, {
+ currency: req.currency,
+ restrictExchanges: {
+ auditors: [],
+ exchanges,
+ },
+ restrictWireMethods: wires,
+ minAge: 0,
+ depositPaytoUri: undefined,
+ });
+}
diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts
new file mode 100644
index 000000000..c7cb2857e
--- /dev/null
+++ b/packages/taler-wallet-core/src/coinSelection.test.ts
@@ -0,0 +1,281 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ 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 {
+ AbsoluteTime,
+ AmountString,
+ Amounts,
+ DenomKeyType,
+ Duration,
+ j2s,
+} from "@gnu-taler/taler-util";
+import test from "ava";
+import {
+ AvailableDenom,
+ CoinSelectionTally,
+ emptyTallyForPeerPayment,
+ testing_selectGreedy,
+} from "./coinSelection.js";
+
+const inTheDistantFuture = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ hours: 1 })),
+);
+
+const inThePast = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.subtractDuraction(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+);
+
+test("p2p: should select the coin", (t) => {
+ const instructedAmount = Amounts.parseOrThrow("LOCAL:2");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ t.log(`tally before: ${j2s(tally)}`);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ createCandidates([
+ {
+ amount: "LOCAL:10" as AmountString,
+ numAvailable: 5,
+ depositFee: "LOCAL:0.1" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ },
+ ]),
+ tally,
+ );
+
+ t.log(`coins: ${j2s(coins)}`);
+ t.log(`tally: ${j2s(tally)}`);
+
+ t.assert(coins != null);
+
+ t.deepEqual(coins, {
+ "hash0;32;http://exchange.localhost/": {
+ exchangeBaseUrl: "http://exchange.localhost/",
+ denomPubHash: "hash0",
+ maxAge: 32,
+ contributions: [Amounts.parseOrThrow("LOCAL:2.1")],
+ },
+ });
+});
+
+test("p2p: should select 3 coins", (t) => {
+ const instructedAmount = Amounts.parseOrThrow("LOCAL:20");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ createCandidates([
+ {
+ amount: "LOCAL:10" as AmountString,
+ numAvailable: 5,
+ depositFee: "LOCAL:0.1" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ },
+ ]),
+ tally,
+ );
+
+ t.deepEqual(coins, {
+ "hash0;32;http://exchange.localhost/": {
+ exchangeBaseUrl: "http://exchange.localhost/",
+ denomPubHash: "hash0",
+ maxAge: 32,
+ contributions: [
+ Amounts.parseOrThrow("LOCAL:10"),
+ Amounts.parseOrThrow("LOCAL:10"),
+ Amounts.parseOrThrow("LOCAL:0.3"),
+ ],
+ },
+ });
+});
+
+test("p2p: can't select since the instructed amount is too high", (t) => {
+ const instructedAmount = Amounts.parseOrThrow("LOCAL:60");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ createCandidates([
+ {
+ amount: "LOCAL:10" as AmountString,
+ numAvailable: 5,
+ depositFee: "LOCAL:0.1" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ },
+ ]),
+ tally,
+ );
+
+ t.is(coins, undefined);
+});
+
+test("pay: select one coin to pay with fee", (t) => {
+ const payment = Amounts.parseOrThrow("LOCAL:2");
+ const exchangeWireFee = Amounts.parseOrThrow("LOCAL:0.1");
+ const zero = Amounts.zeroOfCurrency(payment.currency);
+ const tally = {
+ amountPayRemaining: payment,
+ amountDepositFeeLimitRemaining: zero,
+ customerDepositFees: zero,
+ customerWireFees: zero,
+ wireFeeCoveredForExchange: new Set<string>(),
+ lastDepositFee: zero,
+ } satisfies CoinSelectionTally;
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: { "http://exchange.localhost/": exchangeWireFee },
+ },
+ createCandidates([
+ {
+ amount: "LOCAL:10" as AmountString,
+ numAvailable: 5,
+ depositFee: "LOCAL:0.1" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ },
+ ]),
+ tally,
+ );
+
+ t.deepEqual(coins, {
+ "hash0;32;http://exchange.localhost/": {
+ exchangeBaseUrl: "http://exchange.localhost/",
+ denomPubHash: "hash0",
+ maxAge: 32,
+ contributions: [Amounts.parseOrThrow("LOCAL:2.2")],
+ },
+ });
+
+ t.deepEqual(tally, {
+ amountPayRemaining: Amounts.parseOrThrow("LOCAL:0"),
+ amountDepositFeeLimitRemaining: zero,
+ customerDepositFees: Amounts.parse("LOCAL:0.1"),
+ customerWireFees: Amounts.parse("LOCAL:0.1"),
+ wireFeeCoveredForExchange: new Set(["http://exchange.localhost/"]),
+ lastDepositFee: Amounts.parse("LOCAL:0.1"),
+ });
+});
+
+function createCandidates(
+ ar: {
+ amount: AmountString;
+ depositFee: AmountString;
+ numAvailable: number;
+ fromExchange: string;
+ }[],
+): AvailableDenom[] {
+ return ar.map((r, idx) => {
+ return {
+ denomPub: {
+ age_mask: 0,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: "PPP",
+ },
+ denomPubHash: `hash${idx}`,
+ value: r.amount,
+ feeDeposit: r.depositFee,
+ feeRefresh: "LOCAL:0" as AmountString,
+ feeRefund: "LOCAL:0" as AmountString,
+ feeWithdraw: "LOCAL:0" as AmountString,
+ stampExpireDeposit: inTheDistantFuture,
+ stampExpireLegal: inTheDistantFuture,
+ stampExpireWithdraw: inTheDistantFuture,
+ stampStart: inThePast,
+ exchangeBaseUrl: r.fromExchange,
+ numAvailable: r.numAvailable,
+ maxAge: 32,
+ };
+ });
+}
+
+test("p2p: regression STATER", (t) => {
+ const candidates = [
+ {
+ denomPub: {
+ age_mask: 349441,
+ cipher: "RSA",
+ rsa_public_key:
+ "040000WTR9ERP6FYDM4581C1WY4DX6EA6ZP0RKDEY1VCEG1HGZQDB1E1MT0HSPWKVWYY8GN99YG8JV2BQHCV608V3AP00HZ44M4R2RDK3MEG1HY3H5VP2YESFDXC8C2J0BT6E662JJYN4MCFR8Q8ZFD7ZCA8HGBNVG4JMTS5MBDTF9CX3JC25H702K1FG2C54HR48767D18F2H11HMVK7EEF51QRGE08T704VRCNZ6WTM3Z73Z5DW4W26GBEWTDZZ4HX94HRJEH8YENXAW5T5E39TQQN7MZ7HEPB59BQWB0DDMM8MAE274BV3HC2AJVCSXFJSKBAK1B9HKERPWF7Z5556VJG6YJ9236G5SFM3RC22PJM2SXHYBWFV1WBAYF1F2026C0CM5Q3RPQETHCWZTEX8KJ2J1K904002",
+ },
+ denomPubHash:
+ "TF5S4VJ8P3NN0SM5R1KW5MP665KEFMGAT2RPR70BMG0WQ5A72J53GDDE0YSCTWEXHRW8FMMX3X27RQK4D1VH69GVJBYR5RSJY3X5FS8",
+ feeDeposit: "STATER:1",
+ feeRefresh: "STATER:0",
+ feeRefund: "STATER:0",
+ feeWithdraw: "STATER:0",
+ stampExpireDeposit: {
+ t_s: 1772722025,
+ },
+ stampExpireLegal: {
+ t_s: 1961938025,
+ },
+ stampExpireWithdraw: {
+ t_s: 1709650025,
+ },
+ stampStart: {
+ t_s: 1709045225,
+ },
+ value: "STATER:2",
+ exchangeBaseUrl: "https://exchange.taler.grothoff.org/",
+ numAvailable: 6,
+ maxAge: 32,
+ },
+ {
+ denomPub: {
+ age_mask: 349441,
+ cipher: "RSA",
+ rsa_public_key:
+ "040000Y84BTTQCZ28AS2KZ867V05WES3YPN34X51DNF14ADGW2HNG9YFXCCNVQ2JA9ZT3KSBD17ZN9Y71KGWAWEFYMHE0S61DW63WN58VWRXQ92440V1JSZDD7FDTYEVNGG8ZVARVZ4GGF1RCDM93R28M067S5CPRZFCCQBRFFM9YDK2W06WDXE96BDCB8MZEYPHSGK5CTDY6XJE18EMRWYRBAG0H8P6QGQS73REXX66PTJ3MRX3AK3ARZF8417QKMZZPNS1JV5EYPAC7X8R1F9G1GWAQXVVQ2XTA5NMVMNJDJ0KEM93AXD4W2C7XMVJFSQN8RVB9KZ8JXWGN1YJQK7P6476HV896THKQ05QK4F0C65P4HA7QDX84C91F42PZVMH8AMYMA2NBXEYXS0EV8NXZHMZ30JF04002",
+ },
+ denomPubHash:
+ "WCMKBGR8ZKJ62YZXCRNT3EHPFQQ2M0B5CGZXW0PYA76G8PPXJMXZ7Q3WBP2DA3Z4BF21K3X9AG769RYCC39C3PT0R1DCTJA2PRTSHSR",
+ feeDeposit: "STATER:1",
+ feeRefresh: "STATER:0",
+ feeRefund: "STATER:0",
+ feeWithdraw: "STATER:0",
+ stampExpireDeposit: {
+ t_s: 1772722025,
+ },
+ stampExpireLegal: {
+ t_s: 1961938025,
+ },
+ stampExpireWithdraw: {
+ t_s: 1709650025,
+ },
+ stampStart: {
+ t_s: 1709045225,
+ },
+ value: "STATER:1",
+ exchangeBaseUrl: "https://exchange.taler.grothoff.org/",
+ numAvailable: 1,
+ maxAge: 32,
+ },
+ ];
+ const instructedAmount = Amounts.parseOrThrow("STATER:1");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const res = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ candidates as any,
+ tally,
+ );
+ t.assert(!!res);
+});
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
new file mode 100644
index 000000000..a60e41ecd
--- /dev/null
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -0,0 +1,1258 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Selection of coins for payments.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AccountRestriction,
+ AgeRestriction,
+ AllowedAuditorInfo,
+ AllowedExchangeInfo,
+ AmountJson,
+ Amounts,
+ checkDbInvariant,
+ checkLogicInvariant,
+ CoinStatus,
+ DenominationInfo,
+ ExchangeGlobalFees,
+ ForcedCoinSel,
+ InternationalizedString,
+ j2s,
+ Logger,
+ parsePaytoUri,
+ PayCoinSelection,
+ PaymentInsufficientBalanceDetails,
+ ProspectivePayCoinSelection,
+ SelectedCoin,
+ SelectedProspectiveCoin,
+ strcmp,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { getPaymentBalanceDetailsInTx } from "./balance.js";
+import { getAutoRefreshExecuteThreshold } from "./common.js";
+import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js";
+import {
+ ExchangeWireDetails,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("coinSelection.ts");
+
+export type PreviousPayCoins = {
+ coinPub: string;
+ contribution: AmountJson;
+}[];
+
+export interface ExchangeRestrictionSpec {
+ exchanges: AllowedExchangeInfo[];
+ auditors: AllowedAuditorInfo[];
+}
+
+export interface CoinSelectionTally {
+ /**
+ * Amount that still needs to be paid.
+ * May increase during the computation when fees need to be covered.
+ */
+ amountPayRemaining: AmountJson;
+
+ /**
+ * Allowance given by the merchant towards deposit fees
+ * (and wire fees after wire fee limit is exhausted)
+ */
+ amountDepositFeeLimitRemaining: AmountJson;
+
+ customerDepositFees: AmountJson;
+
+ customerWireFees: AmountJson;
+
+ wireFeeCoveredForExchange: Set<string>;
+
+ lastDepositFee: AmountJson;
+}
+
+/**
+ * Account for the fees of spending a coin.
+ */
+function tallyFees(
+ tally: CoinSelectionTally,
+ wireFeesPerExchange: Record<string, AmountJson>,
+ exchangeBaseUrl: string,
+ feeDeposit: AmountJson,
+): void {
+ const currency = tally.amountPayRemaining.currency;
+
+ if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
+ const wf =
+ wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency);
+ // The remaining, amortized amount needs to be paid by the
+ // wallet or covered by the deposit fee allowance.
+ let wfRemaining = wf;
+ // This is the amount forgiven via the deposit fee allowance.
+ const wfDepositForgiven = Amounts.min(
+ tally.amountDepositFeeLimitRemaining,
+ wfRemaining,
+ );
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
+ wfDepositForgiven,
+ ).amount;
+ wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
+ tally.customerWireFees = Amounts.add(
+ tally.customerWireFees,
+ wfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ wfRemaining,
+ ).amount;
+ tally.wireFeeCoveredForExchange.add(exchangeBaseUrl);
+ }
+
+ const dfForgiven = Amounts.min(
+ feeDeposit,
+ tally.amountDepositFeeLimitRemaining,
+ );
+
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
+ dfForgiven,
+ ).amount;
+
+ // How much does the user spend on deposit fees for this coin?
+ const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount;
+ tally.customerDepositFees = Amounts.add(
+ tally.customerDepositFees,
+ dfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ dfRemaining,
+ ).amount;
+ tally.lastDepositFee = feeDeposit;
+}
+
+export type SelectPayCoinsResult =
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ }
+ | { type: "prospective"; result: ProspectivePayCoinSelection }
+ | { type: "success"; coinSel: PayCoinSelection };
+
+async function internalSelectPayCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ]
+ >,
+ req: SelectPayCoinRequestNg,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally }
+ | undefined
+> {
+ const { contractTermsAmount, depositFeeLimit } = req;
+ const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ restrictWireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: req.requiredMinimumAge,
+ includePendingCoins,
+ },
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`,
+ );
+ logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`);
+ logger.trace(`candidates: ${j2s(candidateDenoms)}`);
+ }
+
+ const coinRes: SelectedCoin[] = [];
+ const currency = contractTermsAmount.currency;
+
+ let tally: CoinSelectionTally = {
+ amountPayRemaining: contractTermsAmount,
+ amountDepositFeeLimitRemaining: depositFeeLimit,
+ customerDepositFees: Amounts.zeroOfCurrency(currency),
+ customerWireFees: Amounts.zeroOfCurrency(currency),
+ wireFeeCoveredForExchange: new Set(),
+ lastDepositFee: Amounts.zeroOfCurrency(currency),
+ };
+
+ await maybeRepairCoinSelection(
+ wex,
+ tx,
+ req.prevPayCoins ?? [],
+ coinRes,
+ tally,
+ {
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ );
+
+ let selectedDenom: SelResult | undefined;
+ if (req.forcedSelection) {
+ selectedDenom = selectForced(req, candidateDenoms);
+ } else {
+ // FIXME: Here, we should select coins in a smarter way.
+ // Instead of always spending the next-largest coin,
+ // we should try to find the smallest coin that covers the
+ // amount.
+ selectedDenom = selectGreedy(
+ {
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ candidateDenoms,
+ tally,
+ );
+ }
+
+ if (!selectedDenom) {
+ return undefined;
+ }
+ return {
+ sel: selectedDenom,
+ coinRes,
+ tally,
+ };
+}
+
+/**
+ * Select coins to spend under the merchant's constraints.
+ *
+ * The prevPayCoins can be specified to "repair" a coin selection
+ * by adding additional coins, after a broken (e.g. double-spent) coin
+ * has been removed from the selection.
+ */
+export async function selectPayCoins(
+ wex: WalletExecutionContext,
+ req: SelectPayCoinRequestNg,
+): Promise<SelectPayCoinsResult> {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selecting coins for ${j2s(req)}`);
+ }
+
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const materialAvSel = await internalSelectPayCoins(wex, tx, req, false);
+
+ if (!materialAvSel) {
+ const prospectiveAvSel = await internalSelectPayCoins(
+ wex,
+ tx,
+ req,
+ true,
+ );
+
+ if (prospectiveAvSel) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvSel.sel)) {
+ const mySel = prospectiveAvSel.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ customerDepositFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerDepositFees,
+ ),
+ customerWireFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerWireFees,
+ ),
+ },
+ } satisfies SelectPayCoinsResult;
+ }
+
+ return {
+ type: "failure",
+ insufficientBalanceDetails: await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ requiredMinimumAge: req.requiredMinimumAge,
+ wireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ },
+ ),
+ } satisfies SelectPayCoinsResult;
+ }
+
+ const coinSel = await assembleSelectPayCoinsSuccessResult(
+ tx,
+ materialAvSel.sel,
+ materialAvSel.coinRes,
+ materialAvSel.tally,
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`coin selection: ${j2s(coinSel)}`);
+ }
+
+ return {
+ type: "success",
+ coinSel,
+ };
+ },
+ );
+}
+
+async function maybeRepairCoinSelection(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ prevPayCoins: PreviousPayCoins,
+ coinRes: SelectedCoin[],
+ tally: CoinSelectionTally,
+ feeInfo: {
+ wireFeesPerExchange: Record<string, AmountJson>;
+ },
+): Promise<void> {
+ // Look at existing pay coin selection and tally up
+ for (const prev of prevPayCoins) {
+ const coin = await tx.coins.get(prev.coinPub);
+ if (!coin) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+ tallyFees(
+ tally,
+ feeInfo.wireFeesPerExchange,
+ coin.exchangeBaseUrl,
+ Amounts.parseOrThrow(denom.feeDeposit),
+ );
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ prev.contribution,
+ ).amount;
+
+ coinRes.push({
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ denomPubHash: coin.denomPubHash,
+ coinPub: prev.coinPub,
+ contribution: Amounts.stringify(prev.contribution),
+ });
+ }
+}
+
+/**
+ * Returns undefined if the success response could not be assembled,
+ * as not enough coins are actually available.
+ */
+async function assembleSelectPayCoinsSuccessResult(
+ tx: WalletDbReadOnlyTransaction<["coins"]>,
+ finalSel: SelResult,
+ coinRes: SelectedCoin[],
+ tally: CoinSelectionTally,
+): Promise<PayCoinSelection> {
+ for (const dph of Object.keys(finalSel)) {
+ const selInfo = finalSel[dph];
+ const numRequested = selInfo.contributions.length;
+ const query = [
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ selInfo.maxAge,
+ CoinStatus.Fresh,
+ ];
+ logger.trace(`query: ${j2s(query)}`);
+ const coins =
+ await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
+ query,
+ numRequested,
+ );
+ if (coins.length != numRequested) {
+ throw Error(
+ `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
+ );
+ }
+
+ for (let i = 0; i < selInfo.contributions.length; i++) {
+ coinRes.push({
+ denomPubHash: coins[i].denomPubHash,
+ coinPub: coins[i].coinPub,
+ contribution: Amounts.stringify(selInfo.contributions[i]),
+ exchangeBaseUrl: coins[i].exchangeBaseUrl,
+ });
+ }
+ }
+
+ return {
+ coins: coinRes,
+ customerDepositFees: Amounts.stringify(tally.customerDepositFees),
+ customerWireFees: Amounts.stringify(tally.customerWireFees),
+ };
+}
+
+interface ReportInsufficientBalanceRequest {
+ instructedAmount: AmountJson;
+ requiredMinimumAge: number | undefined;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ wireMethod: string | undefined;
+ depositPaytoUri: string | undefined;
+}
+
+export async function reportInsufficientBalanceDetails(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "exchanges",
+ "exchangeDetails",
+ "refreshGroups",
+ "denominations",
+ ]
+ >,
+ req: ReportInsufficientBalanceRequest,
+): Promise<PaymentInsufficientBalanceDetails> {
+ const details = await getPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: req.restrictExchanges,
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ });
+ const perExchange: PaymentInsufficientBalanceDetails["perExchange"] = {};
+ const exchanges = await tx.exchanges.getAll();
+
+ for (const exch of exchanges) {
+ if (!exch.detailsPointer) {
+ continue;
+ }
+ let missingGlobalFees = false;
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ missingGlobalFees = true;
+ } else {
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ missingGlobalFees = true;
+ }
+ }
+ const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: {
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.baseUrl,
+ exchangePub: exch.detailsPointer?.masterPublicKey,
+ },
+ ],
+ auditors: [],
+ },
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : [],
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ });
+ perExchange[exch.baseUrl] = {
+ balanceAvailable: Amounts.stringify(exchDet.balanceAvailable),
+ balanceMaterial: Amounts.stringify(exchDet.balanceMaterial),
+ balanceExchangeDepositable: Amounts.stringify(
+ exchDet.balanceExchangeDepositable,
+ ),
+ balanceAgeAcceptable: Amounts.stringify(exchDet.balanceAgeAcceptable),
+ balanceReceiverAcceptable: Amounts.stringify(
+ exchDet.balanceReceiverAcceptable,
+ ),
+ balanceReceiverDepositable: Amounts.stringify(
+ exchDet.balanceReceiverDepositable,
+ ),
+ maxEffectiveSpendAmount: Amounts.stringify(
+ exchDet.maxEffectiveSpendAmount,
+ ),
+ missingGlobalFees,
+ };
+ }
+
+ return {
+ amountRequested: Amounts.stringify(req.instructedAmount),
+ balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
+ balanceAvailable: Amounts.stringify(details.balanceAvailable),
+ balanceMaterial: Amounts.stringify(details.balanceMaterial),
+ balanceReceiverAcceptable: Amounts.stringify(
+ details.balanceReceiverAcceptable,
+ ),
+ balanceExchangeDepositable: Amounts.stringify(
+ details.balanceExchangeDepositable,
+ ),
+ balanceReceiverDepositable: Amounts.stringify(
+ details.balanceReceiverDepositable,
+ ),
+ maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount),
+ perExchange,
+ };
+}
+
+function makeAvailabilityKey(
+ exchangeBaseUrl: string,
+ denomPubHash: string,
+ maxAge: number,
+): string {
+ return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
+}
+
+/**
+ * Selection result.
+ */
+interface SelResult {
+ /**
+ * Map from an availability key
+ * to an array of contributions.
+ */
+ [avKey: string]: {
+ exchangeBaseUrl: string;
+ denomPubHash: string;
+ maxAge: number;
+ contributions: AmountJson[];
+ };
+}
+
+export function testing_selectGreedy(
+ ...args: Parameters<typeof selectGreedy>
+): ReturnType<typeof selectGreedy> {
+ return selectGreedy(...args);
+}
+
+export interface SelectGreedyRequest {
+ wireFeesPerExchange: Record<string, AmountJson>;
+}
+
+function selectGreedy(
+ req: SelectGreedyRequest,
+ candidateDenoms: AvailableDenom[],
+ tally: CoinSelectionTally,
+): SelResult | undefined {
+ const selectedDenom: SelResult = {};
+ for (const denom of candidateDenoms) {
+ const contributions: AmountJson[] = [];
+
+ // Don't use this coin if depositing it is more expensive than
+ // the amount it would give the merchant.
+ if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) {
+ tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit);
+ continue;
+ }
+
+ for (
+ let i = 0;
+ i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining);
+ i++
+ ) {
+ tallyFees(
+ tally,
+ req.wireFeesPerExchange,
+ denom.exchangeBaseUrl,
+ Amounts.parseOrThrow(denom.feeDeposit),
+ );
+
+ const coinSpend = Amounts.max(
+ Amounts.min(tally.amountPayRemaining, denom.value),
+ denom.feeDeposit,
+ );
+
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ coinSpend,
+ ).amount;
+
+ contributions.push(coinSpend);
+ }
+
+ if (contributions.length) {
+ const avKey = makeAvailabilityKey(
+ denom.exchangeBaseUrl,
+ denom.denomPubHash,
+ denom.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = {
+ contributions: [],
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ maxAge: denom.maxAge,
+ };
+ }
+ sd.contributions.push(...contributions);
+ selectedDenom[avKey] = sd;
+ }
+ }
+ return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined;
+}
+
+function selectForced(
+ req: SelectPayCoinRequestNg,
+ candidateDenoms: AvailableDenom[],
+): SelResult | undefined {
+ const selectedDenom: SelResult = {};
+
+ const forcedSelection = req.forcedSelection;
+ checkLogicInvariant(!!forcedSelection);
+
+ for (const forcedCoin of forcedSelection.coins) {
+ let found = false;
+ for (const aci of candidateDenoms) {
+ if (aci.numAvailable <= 0) {
+ continue;
+ }
+ if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
+ aci.numAvailable--;
+ const avKey = makeAvailabilityKey(
+ aci.exchangeBaseUrl,
+ aci.denomPubHash,
+ aci.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = {
+ contributions: [],
+ denomPubHash: aci.denomPubHash,
+ exchangeBaseUrl: aci.exchangeBaseUrl,
+ maxAge: aci.maxAge,
+ };
+ }
+ sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
+ selectedDenom[avKey] = sd;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ throw Error("can't find coin for forced coin selection");
+ }
+ }
+ return selectedDenom;
+}
+
+export function checkAccountRestriction(
+ paytoUri: string,
+ restrictions: AccountRestriction[],
+): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } {
+ for (const myRestriction of restrictions) {
+ switch (myRestriction.type) {
+ case "deny":
+ return { ok: false };
+ case "regex":
+ const regex = new RegExp(myRestriction.payto_regex);
+ if (!regex.test(paytoUri)) {
+ return {
+ ok: false,
+ hint: myRestriction.human_hint,
+ hintI18n: myRestriction.human_hint_i18n,
+ };
+ }
+ }
+ }
+ return {
+ ok: true,
+ };
+}
+
+export interface SelectPayCoinRequestNg {
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ restrictWireMethod: string;
+ contractTermsAmount: AmountJson;
+ depositFeeLimit: AmountJson;
+ prevPayCoins?: PreviousPayCoins;
+ requiredMinimumAge?: number;
+ forcedSelection?: ForcedCoinSel;
+
+ /**
+ * Deposit payto URI, in case we already know the account that
+ * will be deposited into.
+ *
+ * That is typically the case when the wallet does a deposit to
+ * return funds to the user's own bank account.
+ */
+ depositPaytoUri?: string;
+}
+
+export type AvailableDenom = DenominationInfo & {
+ maxAge: number;
+ numAvailable: number;
+};
+
+export function findMatchingWire(
+ wireMethod: string,
+ depositPaytoUri: string | undefined,
+ exchangeWireDetails: ExchangeWireDetails,
+): { wireFee: AmountJson } | undefined {
+ for (const acc of exchangeWireDetails.wireInfo.accounts) {
+ const pp = parsePaytoUri(acc.payto_uri);
+ checkLogicInvariant(!!pp);
+ if (pp.targetType !== wireMethod) {
+ continue;
+ }
+ const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[
+ wireMethod
+ ]?.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+
+ if (!wireFeeStr) {
+ continue;
+ }
+
+ let debitAccountCheckOk = false;
+ if (depositPaytoUri) {
+ // FIXME: We should somehow propagate the hint here!
+ const checkResult = checkAccountRestriction(
+ depositPaytoUri,
+ acc.debit_restrictions,
+ );
+ if (checkResult.ok) {
+ debitAccountCheckOk = true;
+ }
+ } else {
+ debitAccountCheckOk = true;
+ }
+
+ if (!debitAccountCheckOk) {
+ continue;
+ }
+
+ return {
+ wireFee: Amounts.parseOrThrow(wireFeeStr),
+ };
+ }
+ return undefined;
+}
+
+function checkExchangeAccepted(
+ exchangeDetails: ExchangeWireDetails,
+ exchangeRestrictions: ExchangeRestrictionSpec | undefined,
+): boolean {
+ if (!exchangeRestrictions) {
+ return true;
+ }
+ let accepted = false;
+ for (const allowedExchange of exchangeRestrictions.exchanges) {
+ if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
+ accepted = true;
+ break;
+ }
+ }
+ for (const allowedAuditor of exchangeRestrictions.auditors) {
+ for (const providedAuditor of exchangeDetails.auditors) {
+ if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
+ accepted = true;
+ break;
+ }
+ }
+ }
+ return accepted;
+}
+
+interface SelectPayCandidatesRequest {
+ instructedAmount: AmountJson;
+ restrictWireMethod: string | undefined;
+ depositPaytoUri?: string;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ requiredMinimumAge?: number;
+
+ /**
+ * If set to true, the coin selection will also use coins that are not
+ * materially available yet, but that are expected to become available
+ * as the output of a refresh operation.
+ */
+ includePendingCoins: boolean;
+}
+
+async function selectPayCandidates(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ ["exchanges", "coinAvailability", "exchangeDetails", "denominations"]
+ >,
+ req: SelectPayCandidatesRequest,
+): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
+ // FIXME: Use the existing helper (from balance.ts) to
+ // get acceptable exchanges.
+ logger.shouldLogTrace() &&
+ logger.trace(`selecting available coin candidates for ${j2s(req)}`);
+ const denoms: AvailableDenom[] = [];
+ const exchanges = await tx.exchanges.iter().toArray();
+ const wfPerExchange: Record<string, AmountJson> = {};
+ for (const exchange of exchanges) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchange.baseUrl,
+ );
+ // 1. exchange has same currency
+ if (exchangeDetails?.currency !== req.instructedAmount.currency) {
+ logger.shouldLogTrace() &&
+ logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`);
+ continue;
+ }
+
+ // 2. Exchange supports wire method (only for pay/deposit)
+ if (req.restrictWireMethod) {
+ const wire = findMatchingWire(
+ req.restrictWireMethod,
+ req.depositPaytoUri,
+ exchangeDetails,
+ );
+ if (!wire) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `skipping ${exchange.baseUrl} due to missing wire info mismatch`,
+ );
+ }
+ continue;
+ }
+ wfPerExchange[exchange.baseUrl] = wire.wireFee;
+ }
+
+ // 3. exchange is trusted in the exchange list or auditor list
+ let accepted = checkExchangeAccepted(
+ exchangeDetails,
+ req.restrictExchanges,
+ );
+ if (!accepted) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`skipping ${exchange.baseUrl} due to unacceptability`);
+ }
+ continue;
+ }
+
+ // 4. filter coins restricted by age
+ let ageLower = 0;
+ let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+ if (req.requiredMinimumAge) {
+ ageLower = req.requiredMinimumAge;
+ }
+
+ const myExchangeCoins =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [exchangeDetails.exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER],
+ ),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `exchange ${exchange.baseUrl} has ${myExchangeCoins.length} candidate records`,
+ );
+ }
+
+ let numUsable = 0;
+
+ // 5. save denoms with how many coins are available
+ // FIXME: Check that the individual denomination is audited!
+ // FIXME: Should we exclude denominations that are
+ // not spendable anymore?
+ for (const coinAvail of myExchangeCoins) {
+ const denom = await tx.denominations.get([
+ coinAvail.exchangeBaseUrl,
+ coinAvail.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ if (denom.isRevoked) {
+ logger.trace("denom is revoked");
+ continue;
+ }
+ if (!denom.isOffered) {
+ logger.trace("denom is unoffered");
+ continue;
+ }
+ numUsable++;
+ let numAvailable = coinAvail.freshCoinCount ?? 0;
+ if (req.includePendingCoins) {
+ numAvailable += coinAvail.pendingRefreshOutputCount ?? 0;
+ }
+ denoms.push({
+ ...DenominationRecord.toDenomInfo(denom),
+ numAvailable,
+ maxAge: coinAvail.maxAge,
+ });
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `exchange ${exchange.baseUrl} has ${numUsable} candidate records with usable denominations`,
+ );
+ }
+ }
+ // Sort by available amount (descending), deposit fee (ascending) and
+ // denomPub (ascending) if deposit fee is the same
+ // (to guarantee deterministic results)
+ denoms.sort(
+ (o1, o2) =>
+ -Amounts.cmp(o1.value, o2.value) ||
+ Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
+ strcmp(o1.denomPubHash, o2.denomPubHash),
+ );
+ return [denoms, wfPerExchange];
+}
+
+export interface PeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ /**
+ * Info of Coins that were selected.
+ */
+ coins: SelectedCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export interface ProspectivePeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ prospectiveCoins: SelectedProspectiveCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export type SelectPeerCoinsResult =
+ | { type: "success"; result: PeerCoinSelectionDetails }
+ // Successful, but using coins that are not materially available yet.
+ | { type: "prospective"; result: ProspectivePeerCoinSelectionDetails }
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ };
+
+export interface PeerCoinSelectionRequest {
+ instructedAmount: AmountJson;
+
+ /**
+ * Instruct the coin selection to repair this coin
+ * selection instead of selecting completely new coins.
+ */
+ repair?: PreviousPayCoins;
+}
+
+export async function computeCoinSelMaxExpirationDate(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ selectedDenom: SelResult,
+): Promise<TalerProtocolTimestamp> {
+ let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never();
+ for (const dph of Object.keys(selectedDenom)) {
+ const selInfo = selectedDenom[dph];
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+ // Compute earliest time that a selected denom
+ // would have its coins auto-refreshed.
+ minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min(
+ minAutorefreshExecuteThreshold,
+ AbsoluteTime.toProtocolTimestamp(
+ getAutoRefreshExecuteThreshold({
+ stampExpireDeposit: denom.stampExpireDeposit,
+ stampExpireWithdraw: denom.stampExpireWithdraw,
+ }),
+ ),
+ );
+ }
+ return minAutorefreshExecuteThreshold;
+}
+
+export function emptyTallyForPeerPayment(
+ instructedAmount: AmountJson,
+): CoinSelectionTally {
+ const currency = instructedAmount.currency;
+ const zero = Amounts.zeroOfCurrency(currency);
+ return {
+ amountPayRemaining: instructedAmount,
+ customerDepositFees: zero,
+ lastDepositFee: zero,
+ amountDepositFeeLimitRemaining: zero,
+ customerWireFees: zero,
+ wireFeeCoveredForExchange: new Set(),
+ };
+}
+
+function getGlobalFees(
+ wireDetails: ExchangeWireDetails,
+): ExchangeGlobalFees | undefined {
+ const now = AbsoluteTime.now();
+ for (let gf of wireDetails.globalFees) {
+ const isActive = AbsoluteTime.isBetween(
+ now,
+ AbsoluteTime.fromProtocolTimestamp(gf.startDate),
+ AbsoluteTime.fromProtocolTimestamp(gf.endDate),
+ );
+ if (!isActive) {
+ continue;
+ }
+ return gf;
+ }
+ return undefined;
+}
+
+async function internalSelectPeerCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ]
+ >,
+ req: PeerCoinSelectionRequest,
+ exch: ExchangeWireDetails,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; tally: CoinSelectionTally; resCoins: SelectedCoin[] }
+ | undefined
+> {
+ const candidatesRes = await selectPayCandidates(wex, tx, {
+ instructedAmount: req.instructedAmount,
+ restrictExchanges: {
+ auditors: [],
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ exchangePub: exch.masterPublicKey,
+ },
+ ],
+ },
+ restrictWireMethod: undefined,
+ includePendingCoins,
+ });
+ const candidates = candidatesRes[0];
+ if (logger.shouldLogTrace()) {
+ logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
+ }
+ const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const resCoins: SelectedCoin[] = [];
+
+ await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, {
+ wireFeesPerExchange: {},
+ });
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`candidates: ${j2s(candidates)}`);
+ logger.trace(`instructedAmount: ${j2s(req.instructedAmount)}`);
+ logger.trace(`tally: ${j2s(tally)}`);
+ }
+
+ const selRes = selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ candidates,
+ tally,
+ );
+ if (!selRes) {
+ return undefined;
+ }
+
+ return {
+ sel: selRes,
+ tally,
+ resCoins,
+ };
+}
+
+export async function selectPeerCoins(
+ wex: WalletExecutionContext,
+ req: PeerCoinSelectionRequest,
+): Promise<SelectPeerCoinsResult> {
+ const instructedAmount = req.instructedAmount;
+ if (Amounts.isZero(instructedAmount)) {
+ // Other parts of the code assume that we have at least
+ // one coin to spend.
+ throw new Error("amount of zero not allowed");
+ }
+
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ],
+ },
+ async (tx): Promise<SelectPeerCoinsResult> => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ const currency = Amounts.currencyOf(instructedAmount);
+ for (const exch of exchanges) {
+ if (exch.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ continue;
+ }
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ continue;
+ }
+
+ const avRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ false,
+ );
+
+ if (!avRes) {
+ // Try to see if we can do a prospective selection
+ const prospectiveAvRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ true,
+ );
+ if (prospectiveAvRes) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvRes.sel)) {
+ const mySel = prospectiveAvRes.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ prospectiveAvRes.sel,
+ );
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ depositFees: prospectiveAvRes.tally.customerDepositFees,
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ } else if (avRes) {
+ const r = await assembleSelectPayCoinsSuccessResult(
+ tx,
+ avRes.sel,
+ avRes.resCoins,
+ avRes.tally,
+ );
+
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ avRes.sel,
+ );
+
+ return {
+ type: "success",
+ result: {
+ coins: r.coins,
+ depositFees: Amounts.parseOrThrow(r.customerDepositFees),
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ }
+ const insufficientBalanceDetails = await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: undefined,
+ instructedAmount: req.instructedAmount,
+ requiredMinimumAge: undefined,
+ wireMethod: undefined,
+ depositPaytoUri: undefined,
+ },
+ );
+ return {
+ type: "failure",
+ insufficientBalanceDetails,
+ };
+ },
+ );
+}
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
index dd8542def..edaba5ba4 100644
--- a/packages/taler-wallet-core/src/common.ts
+++ b/packages/taler-wallet-core/src/common.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
+ (C) 2022 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
@@ -15,142 +15,809 @@
*/
/**
- * Common interface of the internal wallet state. This object is passed
- * to the various operations (exchange management, withdrawal, refresh, reserve
- * management, etc.).
- *
- * Some operations can be accessed via this state object. This allows mutual
- * recursion between operations, without having cycling dependencies between
- * the respective TypeScript files.
- *
- * (You can think of this as a "header file" for the wallet implementation.)
- */
-
-/**
* Imports.
*/
-import { WalletNotification, BalancesResponse } from "@gnu-taler/taler-util";
-import { CryptoApi } from "./crypto/workers/cryptoApi.js";
-import { ExchangeDetailsRecord, ExchangeRecord, WalletStoresV1 } from "./db.js";
-import { PendingOperationsResponse } from "./pending-types.js";
-import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
-import { HttpRequestLibrary } from "./util/http.js";
-import { AsyncCondition } from "./util/promiseUtils.js";
import {
- DbAccess,
- GetReadOnlyAccess,
- GetReadWriteAccess,
-} from "./util/query.js";
-import { TimerGroup } from "./util/timer.js";
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ AsyncFlag,
+ CoinRefreshRequest,
+ CoinStatus,
+ Duration,
+ ExchangeEntryState,
+ ExchangeEntryStatus,
+ ExchangeTosStatus,
+ ExchangeUpdateStatus,
+ Logger,
+ RefreshReason,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TombstoneIdStr,
+ TransactionIdStr,
+ WalletNotification,
+ assertUnreachable,
+ checkDbInvariant,
+ checkLogicInvariant,
+ durationMul,
+} from "@gnu-taler/taler-util";
+import {
+ BackupProviderRecord,
+ CoinRecord,
+ DbPreciseTimestamp,
+ DepositGroupRecord,
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ ExchangeEntryRecord,
+ PeerPullCreditRecord,
+ PeerPullPaymentIncomingRecord,
+ PeerPushDebitRecord,
+ PeerPushPaymentIncomingRecord,
+ PurchaseRecord,
+ RecoupGroupRecord,
+ RefreshGroupRecord,
+ RewardRecord,
+ WalletDbReadWriteTransaction,
+ WithdrawalGroupRecord,
+ timestampPreciseToDb,
+} from "./db.js";
+import { createRefreshGroup } from "./refresh.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+const logger = new Logger("operations/common.ts");
+
+export interface CoinsSpendInfo {
+ coinPubs: string[];
+ contributions: AmountJson[];
+ refreshReason: RefreshReason;
+ /**
+ * Identifier for what the coin has been spent for.
+ */
+ allocationId: TransactionIdStr;
+}
+
+export async function makeCoinsVisible(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>,
+ transactionId: string,
+): Promise<void> {
+ const coins =
+ await tx.coins.indexes.bySourceTransactionId.getAll(transactionId);
+ for (const coinRecord of coins) {
+ if (!coinRecord.visible) {
+ coinRecord.visible = 1;
+ await tx.coins.put(coinRecord);
+ const ageRestriction = coinRecord.maxAge;
+ const car = await tx.coinAvailability.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ logger.error("missing coin availability record");
+ continue;
+ }
+ const visCount = car.visibleCoinCount ?? 0;
+ car.visibleCoinCount = visCount + 1;
+ await tx.coinAvailability.put(car);
+ }
+ }
+}
-export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
-export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";
+export async function makeCoinAvailable(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coins", "coinAvailability", "denominations"]
+ >,
+ coinRecord: CoinRecord,
+): Promise<void> {
+ checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
+ const existingCoin = await tx.coins.get(coinRecord.coinPub);
+ if (existingCoin) {
+ return;
+ }
+ const denom = await tx.denominations.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ const ageRestriction = coinRecord.maxAge;
+ let car = await tx.coinAvailability.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ car = {
+ maxAge: ageRestriction,
+ value: denom.value,
+ currency: denom.currency,
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ freshCoinCount: 0,
+ visibleCoinCount: 0,
+ };
+ }
+ car.freshCoinCount++;
+ await tx.coins.put(coinRecord);
+ await tx.coinAvailability.put(car);
+}
+
+export async function spendCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ ]
+ >,
+ csi: CoinsSpendInfo,
+): Promise<void> {
+ if (csi.coinPubs.length != csi.contributions.length) {
+ throw Error("assertion failed");
+ }
+ if (csi.coinPubs.length === 0) {
+ return;
+ }
+ let refreshCoinPubs: CoinRefreshRequest[] = [];
+ for (let i = 0; i < csi.coinPubs.length; i++) {
+ const coin = await tx.coins.get(csi.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin allocated for payment doesn't exist anymore");
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ const coinAvailability = await tx.coinAvailability.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ coin.maxAge,
+ ]);
+ checkDbInvariant(!!coinAvailability);
+ const contrib = csi.contributions[i];
+ if (coin.status !== CoinStatus.Fresh) {
+ const alloc = coin.spendAllocation;
+ if (!alloc) {
+ continue;
+ }
+ if (alloc.id !== csi.allocationId) {
+ // FIXME: assign error code
+ logger.info("conflicting coin allocation ID");
+ logger.info(`old ID: ${alloc.id}, new ID: ${csi.allocationId}`);
+ throw Error("conflicting coin allocation (id)");
+ }
+ if (0 !== Amounts.cmp(alloc.amount, contrib)) {
+ // FIXME: assign error code
+ throw Error("conflicting coin allocation (contrib)");
+ }
+ continue;
+ }
+ coin.status = CoinStatus.Dormant;
+ coin.spendAllocation = {
+ id: csi.allocationId,
+ amount: Amounts.stringify(contrib),
+ };
+ const remaining = Amounts.sub(denom.value, contrib);
+ if (remaining.saturated) {
+ throw Error("not enough remaining balance on coin for payment");
+ }
+ refreshCoinPubs.push({
+ amount: Amounts.stringify(remaining.amount),
+ coinPub: coin.coinPub,
+ });
+ checkDbInvariant(!!coinAvailability);
+ if (coinAvailability.freshCoinCount === 0) {
+ throw Error(
+ `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+ );
+ }
+ coinAvailability.freshCoinCount--;
+ if (coin.visible) {
+ if (!coinAvailability.visibleCoinCount) {
+ logger.error("coin availability inconsistent");
+ } else {
+ coinAvailability.visibleCoinCount--;
+ }
+ }
+ await tx.coins.put(coin);
+ await tx.coinAvailability.put(coinAvailability);
+ }
+
+ await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(csi.contributions[0]),
+ refreshCoinPubs,
+ csi.refreshReason,
+ csi.allocationId,
+ );
+}
-export interface TrustInfo {
- isTrusted: boolean;
- isAudited: boolean;
+export enum TombstoneTag {
+ DeleteWithdrawalGroup = "delete-withdrawal-group",
+ DeleteReserve = "delete-reserve",
+ DeletePayment = "delete-payment",
+ DeleteReward = "delete-reward",
+ DeleteRefreshGroup = "delete-refresh-group",
+ DeleteDepositGroup = "delete-deposit-group",
+ DeleteRefund = "delete-refund",
+ DeletePeerPullDebit = "delete-peer-pull-debit",
+ DeletePeerPushDebit = "delete-peer-push-debit",
+ DeletePeerPullCredit = "delete-peer-pull-credit",
+ DeletePeerPushCredit = "delete-peer-push-credit",
+}
+
+export function getExchangeTosStatusFromRecord(
+ exchange: ExchangeEntryRecord,
+): ExchangeTosStatus {
+ if (!exchange.tosAcceptedEtag) {
+ return ExchangeTosStatus.Proposed;
+ }
+ if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) {
+ return ExchangeTosStatus.Accepted;
+ }
+ return ExchangeTosStatus.Proposed;
+}
+
+export function getExchangeUpdateStatusFromRecord(
+ r: ExchangeEntryRecord,
+): ExchangeUpdateStatus {
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ return ExchangeUpdateStatus.UnavailableUpdate;
+ case ExchangeEntryDbUpdateStatus.Initial:
+ return ExchangeUpdateStatus.Initial;
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ return ExchangeUpdateStatus.InitialUpdate;
+ case ExchangeEntryDbUpdateStatus.Ready:
+ return ExchangeUpdateStatus.Ready;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ return ExchangeUpdateStatus.ReadyUpdate;
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ return ExchangeUpdateStatus.Suspended;
+ default:
+ assertUnreachable(r.updateStatus);
+ }
+}
+
+export function getExchangeEntryStatusFromRecord(
+ r: ExchangeEntryRecord,
+): ExchangeEntryStatus {
+ switch (r.entryStatus) {
+ case ExchangeEntryDbRecordStatus.Ephemeral:
+ return ExchangeEntryStatus.Ephemeral;
+ case ExchangeEntryDbRecordStatus.Preset:
+ return ExchangeEntryStatus.Preset;
+ case ExchangeEntryDbRecordStatus.Used:
+ return ExchangeEntryStatus.Used;
+ default:
+ assertUnreachable(r.entryStatus);
+ }
}
/**
- * Interface for exchange-related operations.
+ * Compute the state of an exchange entry from the DB
+ * record.
*/
-export interface ExchangeOperations {
- // FIXME: Should other operations maybe always use
- // updateExchangeFromUrl?
- getExchangeDetails(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- exchangeBaseUrl: string,
- ): Promise<ExchangeDetailsRecord | undefined>;
- getExchangeTrust(
- ws: InternalWalletState,
- exchangeInfo: ExchangeRecord,
- ): Promise<TrustInfo>;
- updateExchangeFromUrl(
- ws: InternalWalletState,
- baseUrl: string,
- acceptedFormat?: string[],
- forceNow?: boolean,
- ): Promise<{
- exchange: ExchangeRecord;
- exchangeDetails: ExchangeDetailsRecord;
- }>;
-}
-
-export interface RecoupOperations {
- createRecoupGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
- coinPubs: string[],
- ): Promise<string>;
- processRecoupGroup(
- ws: InternalWalletState,
- recoupGroupId: string,
- forceNow?: boolean,
- ): Promise<void>;
-}
-
-export type NotificationListener = (n: WalletNotification) => void;
+export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState {
+ return {
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+ tosStatus: getExchangeTosStatusFromRecord(r),
+ };
+}
+
+export type ParsedTombstone =
+ | {
+ tag: TombstoneTag.DeleteWithdrawalGroup;
+ withdrawalGroupId: string;
+ }
+ | { tag: TombstoneTag.DeleteRefund; refundGroupId: string }
+ | { tag: TombstoneTag.DeleteReserve; reservePub: string }
+ | { tag: TombstoneTag.DeleteRefreshGroup; refreshGroupId: string }
+ | { tag: TombstoneTag.DeleteReward; walletTipId: string }
+ | { tag: TombstoneTag.DeletePayment; proposalId: string };
+
+export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
+ switch (p.tag) {
+ case TombstoneTag.DeleteWithdrawalGroup:
+ return `tmb:${p.tag}:${p.withdrawalGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteRefund:
+ return `tmb:${p.tag}:${p.refundGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteReserve:
+ return `tmb:${p.tag}:${p.reservePub}` as TombstoneIdStr;
+ case TombstoneTag.DeletePayment:
+ return `tmb:${p.tag}:${p.proposalId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteRefreshGroup:
+ return `tmb:${p.tag}:${p.refreshGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteReward:
+ return `tmb:${p.tag}:${p.walletTipId}` as TombstoneIdStr;
+ default:
+ assertUnreachable(p);
+ }
+}
/**
- * Internal, shard wallet state that is used by the implementation
- * of wallet operations.
- *
- * FIXME: This should not be exported anywhere from the taler-wallet-core package,
- * as it's an opaque implementation detail.
+ * Uniform interface for a particular wallet transaction.
*/
-export interface InternalWalletState {
- memoProcessReserve: AsyncOpMemoMap<void>;
- memoMakePlanchet: AsyncOpMemoMap<void>;
- memoGetPending: AsyncOpMemoSingle<PendingOperationsResponse>;
- memoGetBalance: AsyncOpMemoSingle<BalancesResponse>;
- memoProcessRefresh: AsyncOpMemoMap<void>;
- memoProcessRecoup: AsyncOpMemoMap<void>;
- memoProcessDeposit: AsyncOpMemoMap<void>;
- cryptoApi: CryptoApi;
-
- timerGroup: TimerGroup;
- stopped: boolean;
+export interface TransactionManager {
+ get taskId(): TaskIdStr;
+ get transactionId(): TransactionIdStr;
+ fail(): Promise<void>;
+ abort(): Promise<void>;
+ suspend(): Promise<void>;
+ resume(): Promise<void>;
+ process(): Promise<TaskRunResult>;
+}
+export enum TaskRunResultType {
+ Finished = "finished",
+ Backoff = "backoff",
+ Progress = "progress",
+ Error = "error",
+ LongpollReturnedPending = "longpoll-returned-pending",
+ ScheduleLater = "schedule-later",
+}
+
+export type TaskRunResult =
+ | TaskRunFinishedResult
+ | TaskRunErrorResult
+ | TaskRunBackoffResult
+ | TaskRunProgressResult
+ | TaskRunLongpollReturnedPendingResult
+ | TaskRunScheduleLaterResult;
+
+export namespace TaskRunResult {
/**
- * Asynchronous condition to interrupt the sleep of the
- * retry loop.
- *
- * Used to allow processing of new work faster.
+ * Task is finished and does not need to be processed again.
*/
- latch: AsyncCondition;
+ export function finished(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Finished,
+ };
+ }
+ /**
+ * Task is waiting for something, should be invoked
+ * again with exponentiall back-off until some other
+ * result is returned.
+ */
+ export function backoff(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Backoff,
+ };
+ }
+ /**
+ * Task made progress and should be processed again.
+ */
+ export function progress(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Progress,
+ };
+ }
+ /**
+ * Run the task again at a fixed time in the future.
+ */
+ export function runAgainAt(runAt: AbsoluteTime): TaskRunResult {
+ return {
+ type: TaskRunResultType.ScheduleLater,
+ runAt,
+ };
+ }
+ /**
+ * Longpolling returned, but what we're waiting for
+ * is still pending on the other side.
+ */
+ export function longpollReturnedPending(): TaskRunLongpollReturnedPendingResult {
+ return {
+ type: TaskRunResultType.LongpollReturnedPending,
+ };
+ }
+}
- listeners: NotificationListener[];
+export interface TaskRunFinishedResult {
+ type: TaskRunResultType.Finished;
+}
- initCalled: boolean;
+export interface TaskRunBackoffResult {
+ type: TaskRunResultType.Backoff;
+}
- exchangeOps: ExchangeOperations;
- recoupOps: RecoupOperations;
+export interface TaskRunProgressResult {
+ type: TaskRunResultType.Progress;
+}
- db: DbAccess<typeof WalletStoresV1>;
- http: HttpRequestLibrary;
+export interface TaskRunScheduleLaterResult {
+ type: TaskRunResultType.ScheduleLater;
+ runAt: AbsoluteTime;
+}
- notify(n: WalletNotification): void;
+export interface TaskRunLongpollReturnedPendingResult {
+ type: TaskRunResultType.LongpollReturnedPending;
+}
- addNotificationListener(f: (n: WalletNotification) => void): void;
+export interface TaskRunErrorResult {
+ type: TaskRunResultType.Error;
+ errorDetail: TalerErrorDetail;
+}
- /**
- * Stop ongoing processing.
- */
- stop(): void;
+export interface DbRetryInfo {
+ firstTry: DbPreciseTimestamp;
+ nextRetry: DbPreciseTimestamp;
+ retryCounter: number;
+}
- /**
- * Run an async function after acquiring a list of locks, identified
- * by string tokens.
- */
- runSequentialized<T>(tokens: string[], f: () => Promise<T>): Promise<T>;
+export interface RetryPolicy {
+ readonly backoffDelta: Duration;
+ readonly backoffBase: number;
+ readonly maxTimeout: Duration;
+}
+
+const defaultRetryPolicy: RetryPolicy = {
+ backoffBase: 1.5,
+ backoffDelta: Duration.fromSpec({ seconds: 1 }),
+ maxTimeout: Duration.fromSpec({ minutes: 2 }),
+};
+
+function updateTimeout(
+ r: DbRetryInfo,
+ p: RetryPolicy = defaultRetryPolicy,
+): void {
+ const now = AbsoluteTime.now();
+ if (now.t_ms === "never") {
+ throw Error("assertion failed");
+ }
+ if (p.backoffDelta.d_ms === "forever") {
+ r.nextRetry = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ );
+ return;
+ }
+
+ const nextIncrement =
+ p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
+
+ const t =
+ now.t_ms +
+ (p.maxTimeout.d_ms === "forever"
+ ? nextIncrement
+ : Math.min(p.maxTimeout.d_ms, nextIncrement));
+ r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
+}
+
+export function computeDbBackoff(retryCounter: number): DbPreciseTimestamp {
+ const now = AbsoluteTime.now();
+ if (now.t_ms === "never") {
+ throw Error("assertion failed");
+ }
+ const p = defaultRetryPolicy;
+ if (p.backoffDelta.d_ms === "forever") {
+ throw Error("assertion failed");
+ }
+
+ const nextIncrement =
+ p.backoffDelta.d_ms * Math.pow(p.backoffBase, retryCounter);
+
+ const t =
+ now.t_ms +
+ (p.maxTimeout.d_ms === "forever"
+ ? nextIncrement
+ : Math.min(p.maxTimeout.d_ms, nextIncrement));
+ return timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
+}
+
+export namespace DbRetryInfo {
+ export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo {
+ const now = TalerPreciseTimestamp.now();
+ const info: DbRetryInfo = {
+ firstTry: timestampPreciseToDb(now),
+ nextRetry: timestampPreciseToDb(now),
+ retryCounter: 0,
+ };
+ updateTimeout(info, p);
+ return info;
+ }
+
+ export function increment(
+ r: DbRetryInfo | undefined,
+ p: RetryPolicy = defaultRetryPolicy,
+ ): DbRetryInfo {
+ if (!r) {
+ return reset(p);
+ }
+ const r2 = { ...r };
+ r2.retryCounter++;
+ updateTimeout(r2, p);
+ return r2;
+ }
+}
+
+/**
+ * Timestamp after which the wallet would do an auto-refresh.
+ */
+export function getAutoRefreshExecuteThreshold(d: {
+ stampExpireWithdraw: TalerProtocolTimestamp;
+ stampExpireDeposit: TalerProtocolTimestamp;
+}): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ d.stampExpireWithdraw,
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ d.stampExpireDeposit,
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.5);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
+ * Parsed representation of task identifiers.
+ */
+export type ParsedTaskIdentifier =
+ | {
+ tag: PendingTaskType.Withdraw;
+ withdrawalGroupId: string;
+ }
+ | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
+ | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
+ | { tag: PendingTaskType.Deposit; depositGroupId: string }
+ | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string }
+ | { tag: PendingTaskType.PeerPullCredit; pursePub: string }
+ | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string }
+ | { tag: PendingTaskType.PeerPushDebit; pursePub: string }
+ | { tag: PendingTaskType.Purchase; proposalId: string }
+ | { tag: PendingTaskType.Recoup; recoupGroupId: string }
+ | { tag: PendingTaskType.RewardPickup; walletRewardId: string }
+ | { tag: PendingTaskType.Refresh; refreshGroupId: string };
+
+export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
+ const task = x.split(":");
+
+ if (task.length < 2) {
+ throw Error("task id should have al least 2 parts separated by ':'");
+ }
+
+ const [type, ...rest] = task;
+ switch (type) {
+ case PendingTaskType.Backup:
+ return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) };
+ case PendingTaskType.Deposit:
+ return { tag: type, depositGroupId: rest[0] };
+ case PendingTaskType.ExchangeUpdate:
+ return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
+ case PendingTaskType.PeerPullCredit:
+ return { tag: type, pursePub: rest[0] };
+ case PendingTaskType.PeerPullDebit:
+ return { tag: type, peerPullDebitId: rest[0] };
+ case PendingTaskType.PeerPushCredit:
+ return { tag: type, peerPushCreditId: rest[0] };
+ case PendingTaskType.PeerPushDebit:
+ return { tag: type, pursePub: rest[0] };
+ case PendingTaskType.Purchase:
+ return { tag: type, proposalId: rest[0] };
+ case PendingTaskType.Recoup:
+ return { tag: type, recoupGroupId: rest[0] };
+ case PendingTaskType.Refresh:
+ return { tag: type, refreshGroupId: rest[0] };
+ case PendingTaskType.RewardPickup:
+ return { tag: type, walletRewardId: rest[0] };
+ case PendingTaskType.Withdraw:
+ return { tag: type, withdrawalGroupId: rest[0] };
+ default:
+ throw Error("invalid task identifier");
+ }
+}
+
+export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr {
+ switch (p.tag) {
+ case PendingTaskType.Backup:
+ return `${p.tag}:${p.backupProviderBaseUrl}` as TaskIdStr;
+ case PendingTaskType.Deposit:
+ return `${p.tag}:${p.depositGroupId}` as TaskIdStr;
+ case PendingTaskType.ExchangeUpdate:
+ return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
+ case PendingTaskType.PeerPullDebit:
+ return `${p.tag}:${p.peerPullDebitId}` as TaskIdStr;
+ case PendingTaskType.PeerPushCredit:
+ return `${p.tag}:${p.peerPushCreditId}` as TaskIdStr;
+ case PendingTaskType.PeerPullCredit:
+ return `${p.tag}:${p.pursePub}` as TaskIdStr;
+ case PendingTaskType.PeerPushDebit:
+ return `${p.tag}:${p.pursePub}` as TaskIdStr;
+ case PendingTaskType.Purchase:
+ return `${p.tag}:${p.proposalId}` as TaskIdStr;
+ case PendingTaskType.Recoup:
+ return `${p.tag}:${p.recoupGroupId}` as TaskIdStr;
+ case PendingTaskType.Refresh:
+ return `${p.tag}:${p.refreshGroupId}` as TaskIdStr;
+ case PendingTaskType.RewardPickup:
+ return `${p.tag}:${p.walletRewardId}` as TaskIdStr;
+ case PendingTaskType.Withdraw:
+ return `${p.tag}:${p.withdrawalGroupId}` as TaskIdStr;
+ default:
+ assertUnreachable(p);
+ }
+}
+
+export namespace TaskIdentifiers {
+ export function forWithdrawal(wg: WithdrawalGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskIdStr;
+ }
+ export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskIdStr {
+ return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
+ exch.baseUrl,
+ )}` as TaskIdStr;
+ }
+ export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskIdStr {
+ return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
+ exchBaseUrl,
+ )}` as TaskIdStr;
+ }
+ export function forTipPickup(tipRecord: RewardRecord): TaskIdStr {
+ return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskIdStr;
+ }
+ export function forRefresh(
+ refreshGroupRecord: RefreshGroupRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskIdStr;
+ }
+ export function forPay(purchaseRecord: PurchaseRecord): TaskIdStr {
+ return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskIdStr;
+ }
+ export function forRecoup(recoupRecord: RecoupGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskIdStr;
+ }
+ export function forDeposit(depositRecord: DepositGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskIdStr;
+ }
+ export function forBackup(backupRecord: BackupProviderRecord): TaskIdStr {
+ return `${PendingTaskType.Backup}:${encodeURIComponent(
+ backupRecord.baseUrl,
+ )}` as TaskIdStr;
+ }
+ export function forPeerPushPaymentInitiation(
+ ppi: PeerPushDebitRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskIdStr;
+ }
+ export function forPeerPullPaymentInitiation(
+ ppi: PeerPullCreditRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskIdStr;
+ }
+ export function forPeerPullPaymentDebit(
+ ppi: PeerPullPaymentIncomingRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskIdStr;
+ }
+ export function forPeerPushCredit(
+ ppi: PeerPushPaymentIncomingRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskIdStr;
+ }
+}
+
+/**
+ * Result of a transaction transition.
+ */
+export enum TransitionResultType {
+ Transition = 1,
+ Stay = 2,
+ Delete = 3,
+}
+
+export type TransitionResult<R> =
+ | { type: TransitionResultType.Stay }
+ | { type: TransitionResultType.Transition; rec: R }
+ | { type: TransitionResultType.Delete };
+
+export const TransitionResult = {
+ stay<T>(): TransitionResult<T> {
+ return { type: TransitionResultType.Stay };
+ },
+ delete<T>(): TransitionResult<T> {
+ return { type: TransitionResultType.Delete };
+ },
+ transition<T>(rec: T): TransitionResult<T> {
+ return {
+ type: TransitionResultType.Transition,
+ rec,
+ };
+ },
+};
+
+/**
+ * Transaction context.
+ * Uniform interface to all transactions.
+ */
+export interface TransactionContext {
+ get taskId(): TaskIdStr | undefined;
+ get transactionId(): TransactionIdStr;
+ abortTransaction(): Promise<void>;
+ suspendTransaction(): Promise<void>;
+ resumeTransaction(): Promise<void>;
+ failTransaction(): Promise<void>;
+ deleteTransaction(): Promise<void>;
+}
+
+/**
+ * Type and schema definitions for pending tasks in the wallet.
+ *
+ * These are only used internally, and are not part of the stable public
+ * interface to the wallet.
+ */
+
+export enum PendingTaskType {
+ ExchangeUpdate = "exchange-update",
+ Purchase = "purchase",
+ Refresh = "refresh",
+ Recoup = "recoup",
+ RewardPickup = "reward-pickup",
+ Withdraw = "withdraw",
+ Deposit = "deposit",
+ Backup = "backup",
+ PeerPushDebit = "peer-push-debit",
+ PeerPullCredit = "peer-pull-credit",
+ PeerPushCredit = "peer-push-credit",
+ PeerPullDebit = "peer-pull-debit",
+}
+
+declare const __taskIdStr: unique symbol;
+export type TaskIdStr = string & { [__taskIdStr]: true };
+
+/**
+ * Wait until the wallet is in a particular state.
+ *
+ * Two functions must be provided:
+ * 1. checkState, which checks if the wallet is in the
+ * desired state.
+ * 2. filterNotification, which checks whether a notification
+ * might have lead to a state change.
+ */
+export async function genericWaitForState(
+ wex: WalletExecutionContext,
+ args: {
+ checkState: () => Promise<boolean>;
+ filterNotification: (notif: WalletNotification) => boolean;
+ },
+): Promise<void> {
+ await wex.taskScheduler.ensureRunning();
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const flag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (args.filterNotification(notif)) {
+ flag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ flag.raise();
+ });
- runUntilDone(req?: { maxRetries?: number }): Promise<void>;
+ try {
+ while (true) {
+ if (wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+ if (await args.checkState()) {
+ return;
+ }
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
}
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
new file mode 100644
index 000000000..2a2958a71
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -0,0 +1,1787 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of crypto-related high-level functions for the Taler wallet.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+
+import {
+ AgeCommitmentProof,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ AmountString,
+ amountToBuffer,
+ BlindedDenominationSignature,
+ bufferForUint32,
+ bufferForUint64,
+ buildSigPS,
+ canonicalJson,
+ CoinDepositPermission,
+ CoinEnvelope,
+ createHashContext,
+ decodeCrock,
+ decryptContractForDeposit,
+ decryptContractForMerge,
+ DenomKeyType,
+ DepositInfo,
+ durationRoundedToBuffer,
+ ecdhGetPublic,
+ eddsaGetPublic,
+ EddsaPublicKeyString,
+ eddsaSign,
+ eddsaVerify,
+ encodeCrock,
+ encryptContractForDeposit,
+ encryptContractForMerge,
+ ExchangeProtocolVersion,
+ getRandomBytes,
+ GlobalFees,
+ hash,
+ HashCodeString,
+ hashCoinEv,
+ hashCoinEvInner,
+ hashCoinPub,
+ hashDenomPub,
+ hashTruncate32,
+ kdf,
+ kdfKw,
+ keyExchangeEcdhEddsa,
+ Logger,
+ MakeSyncSignatureRequest,
+ PlanchetCreationRequest,
+ PlanchetUnblindInfo,
+ PurseDeposit,
+ RecoupRefreshRequest,
+ RecoupRequest,
+ RefreshPlanchetInfo,
+ rsaBlind,
+ rsaUnblind,
+ rsaVerify,
+ setupTipPlanchet,
+ stringToBytes,
+ TalerProtocolTimestamp,
+ TalerSignaturePurpose,
+ timestampRoundedToBuffer,
+ UnblindedSignature,
+ WireFee,
+ WithdrawalPlanchet,
+} from "@gnu-taler/taler-util";
+// FIXME: Crypto should not use DB Types!
+import { DenominationRecord, timestampProtocolFromDb } from "../db.js";
+import {
+ CreateRecoupRefreshReqRequest,
+ CreateRecoupReqRequest,
+ DecryptContractForDepositRequest,
+ DecryptContractForDepositResponse,
+ DecryptContractRequest,
+ DecryptContractResponse,
+ DerivedRefreshSession,
+ DerivedTipPlanchet,
+ DeriveRefreshSessionRequest,
+ DeriveTipRequest,
+ EncryptContractForDepositRequest,
+ EncryptContractForDepositResponse,
+ EncryptContractRequest,
+ EncryptContractResponse,
+ SignCoinHistoryRequest,
+ SignCoinHistoryResponse,
+ SignDeletePurseRequest,
+ SignDeletePurseResponse,
+ SignPurseMergeRequest,
+ SignPurseMergeResponse,
+ SignRefundRequest,
+ SignRefundResponse,
+ SignReservePurseCreateRequest,
+ SignReservePurseCreateResponse,
+ SignTrackTransactionRequest,
+} from "./cryptoTypes.js";
+
+const logger = new Logger("cryptoImplementation.ts");
+
+/**
+ * Interface for (asynchronous) cryptographic operations that
+ * Taler uses.
+ */
+export interface TalerCryptoInterface {
+ /**
+ * Create a pre-coin of the given denomination to be withdrawn from then given
+ * reserve.
+ */
+ createPlanchet(req: PlanchetCreationRequest): Promise<WithdrawalPlanchet>;
+
+ eddsaSign(req: EddsaSignRequest): Promise<EddsaSignResponse>;
+
+ /**
+ * Create a planchet used for tipping, including the private keys.
+ */
+ createTipPlanchet(req: DeriveTipRequest): Promise<DerivedTipPlanchet>;
+
+ signTrackTransaction(
+ req: SignTrackTransactionRequest,
+ ): Promise<EddsaSigningResult>;
+
+ createRecoupRequest(req: CreateRecoupReqRequest): Promise<RecoupRequest>;
+
+ createRecoupRefreshRequest(
+ req: CreateRecoupRefreshReqRequest,
+ ): Promise<RecoupRefreshRequest>;
+
+ isValidPaymentSignature(
+ req: PaymentSignatureValidationRequest,
+ ): Promise<ValidationResult>;
+
+ isValidWireFee(req: WireFeeValidationRequest): Promise<ValidationResult>;
+
+ isValidGlobalFees(
+ req: GlobalFeesValidationRequest,
+ ): Promise<ValidationResult>;
+
+ isValidDenom(req: DenominationValidationRequest): Promise<ValidationResult>;
+
+ isValidWireAccount(
+ req: WireAccountValidationRequest,
+ ): Promise<ValidationResult>;
+
+ isValidContractTermsSignature(
+ req: ContractTermsValidationRequest,
+ ): Promise<ValidationResult>;
+
+ createEddsaKeypair(req: {}): Promise<EddsaKeypair>;
+
+ eddsaGetPublic(req: EddsaGetPublicRequest): Promise<EddsaGetPublicResponse>;
+
+ unblindDenominationSignature(
+ req: UnblindDenominationSignatureRequest,
+ ): Promise<UnblindedSignature>;
+
+ rsaUnblind(req: RsaUnblindRequest): Promise<RsaUnblindResponse>;
+
+ rsaVerify(req: RsaVerificationRequest): Promise<ValidationResult>;
+
+ rsaBlind(req: RsaBlindRequest): Promise<RsaBlindResponse>;
+
+ signDepositPermission(
+ depositInfo: DepositInfo,
+ ): Promise<CoinDepositPermission>;
+
+ deriveRefreshSession(
+ req: DeriveRefreshSessionRequest,
+ ): Promise<DerivedRefreshSession>;
+
+ hashString(req: HashStringRequest): Promise<HashStringResult>;
+
+ signCoinLink(req: SignCoinLinkRequest): Promise<EddsaSigningResult>;
+
+ makeSyncSignature(req: MakeSyncSignatureRequest): Promise<EddsaSigningResult>;
+
+ setupRefreshPlanchet(
+ req: SetupRefreshPlanchetRequest,
+ ): Promise<FreshCoinEncoded>;
+
+ setupWithdrawalPlanchet(
+ req: SetupWithdrawalPlanchetRequest,
+ ): Promise<FreshCoinEncoded>;
+
+ keyExchangeEcdheEddsa(
+ req: KeyExchangeEcdheEddsaRequest,
+ ): Promise<KeyExchangeResult>;
+
+ ecdheGetPublic(req: EddsaGetPublicRequest): Promise<EddsaGetPublicResponse>;
+
+ setupRefreshTransferPub(
+ req: SetupRefreshTransferPubRequest,
+ ): Promise<TransferPubResponse>;
+
+ signPurseCreation(req: SignPurseCreationRequest): Promise<EddsaSigningResult>;
+
+ signReserveHistoryReq(
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse>;
+
+ signPurseDeposits(
+ req: SignPurseDepositsRequest,
+ ): Promise<SignPurseDepositsResponse>;
+
+ encryptContractForMerge(
+ req: EncryptContractRequest,
+ ): Promise<EncryptContractResponse>;
+
+ decryptContractForMerge(
+ req: DecryptContractRequest,
+ ): Promise<DecryptContractResponse>;
+
+ encryptContractForDeposit(
+ req: EncryptContractForDepositRequest,
+ ): Promise<EncryptContractForDepositResponse>;
+
+ decryptContractForDeposit(
+ req: DecryptContractForDepositRequest,
+ ): Promise<DecryptContractForDepositResponse>;
+
+ signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>;
+
+ signReservePurseCreate(
+ req: SignReservePurseCreateRequest,
+ ): Promise<SignReservePurseCreateResponse>;
+
+ signRefund(req: SignRefundRequest): Promise<SignRefundResponse>;
+
+ signDeletePurse(
+ req: SignDeletePurseRequest,
+ ): Promise<SignDeletePurseResponse>;
+
+ signCoinHistoryRequest(
+ req: SignCoinHistoryRequest,
+ ): Promise<SignCoinHistoryResponse>;
+}
+
+/**
+ * Implementation of the Taler crypto interface where every function
+ * always throws. Only useful in practice as a way to iterate through
+ * all possible crypto functions.
+ *
+ * (This list can be easily auto-generated by your favorite IDE).
+ */
+export const nullCrypto: TalerCryptoInterface = {
+ createPlanchet: function (
+ req: PlanchetCreationRequest,
+ ): Promise<WithdrawalPlanchet> {
+ throw new Error("Function not implemented.");
+ },
+ eddsaSign: function (req: EddsaSignRequest): Promise<EddsaSignResponse> {
+ throw new Error("Function not implemented.");
+ },
+ createTipPlanchet: function (
+ req: DeriveTipRequest,
+ ): Promise<DerivedTipPlanchet> {
+ throw new Error("Function not implemented.");
+ },
+ signTrackTransaction: function (
+ req: SignTrackTransactionRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
+ createRecoupRequest: function (
+ req: CreateRecoupReqRequest,
+ ): Promise<RecoupRequest> {
+ throw new Error("Function not implemented.");
+ },
+ createRecoupRefreshRequest: function (
+ req: CreateRecoupRefreshReqRequest,
+ ): Promise<RecoupRefreshRequest> {
+ throw new Error("Function not implemented.");
+ },
+ isValidPaymentSignature: function (
+ req: PaymentSignatureValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidWireFee: function (
+ req: WireFeeValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidDenom: function (
+ req: DenominationValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidWireAccount: function (
+ req: WireAccountValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidGlobalFees: function (
+ req: GlobalFeesValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidContractTermsSignature: function (
+ req: ContractTermsValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ createEddsaKeypair: function (req: unknown): Promise<EddsaKeypair> {
+ throw new Error("Function not implemented.");
+ },
+ eddsaGetPublic: function (req: EddsaGetPublicRequest): Promise<EddsaKeypair> {
+ throw new Error("Function not implemented.");
+ },
+ unblindDenominationSignature: function (
+ req: UnblindDenominationSignatureRequest,
+ ): Promise<UnblindedSignature> {
+ throw new Error("Function not implemented.");
+ },
+ rsaUnblind: function (req: RsaUnblindRequest): Promise<RsaUnblindResponse> {
+ throw new Error("Function not implemented.");
+ },
+ rsaVerify: function (req: RsaVerificationRequest): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ signDepositPermission: function (
+ depositInfo: DepositInfo,
+ ): Promise<CoinDepositPermission> {
+ throw new Error("Function not implemented.");
+ },
+ deriveRefreshSession: function (
+ req: DeriveRefreshSessionRequest,
+ ): Promise<DerivedRefreshSession> {
+ throw new Error("Function not implemented.");
+ },
+ hashString: function (req: HashStringRequest): Promise<HashStringResult> {
+ throw new Error("Function not implemented.");
+ },
+ signCoinLink: function (
+ req: SignCoinLinkRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
+ makeSyncSignature: function (
+ req: MakeSyncSignatureRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
+ setupRefreshPlanchet: function (
+ req: SetupRefreshPlanchetRequest,
+ ): Promise<FreshCoinEncoded> {
+ throw new Error("Function not implemented.");
+ },
+ rsaBlind: function (req: RsaBlindRequest): Promise<RsaBlindResponse> {
+ throw new Error("Function not implemented.");
+ },
+ keyExchangeEcdheEddsa: function (
+ req: KeyExchangeEcdheEddsaRequest,
+ ): Promise<KeyExchangeResult> {
+ throw new Error("Function not implemented.");
+ },
+ setupWithdrawalPlanchet: function (
+ req: SetupWithdrawalPlanchetRequest,
+ ): Promise<FreshCoinEncoded> {
+ throw new Error("Function not implemented.");
+ },
+ ecdheGetPublic: function (
+ req: EddsaGetPublicRequest,
+ ): Promise<EddsaGetPublicResponse> {
+ throw new Error("Function not implemented.");
+ },
+ setupRefreshTransferPub: function (
+ req: SetupRefreshTransferPubRequest,
+ ): Promise<TransferPubResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signPurseCreation: function (
+ req: SignPurseCreationRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
+ signPurseDeposits: function (
+ req: SignPurseDepositsRequest,
+ ): Promise<SignPurseDepositsResponse> {
+ throw new Error("Function not implemented.");
+ },
+ encryptContractForMerge: function (
+ req: EncryptContractRequest,
+ ): Promise<EncryptContractResponse> {
+ throw new Error("Function not implemented.");
+ },
+ decryptContractForMerge: function (
+ req: DecryptContractRequest,
+ ): Promise<DecryptContractResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signPurseMerge: function (
+ req: SignPurseMergeRequest,
+ ): Promise<SignPurseMergeResponse> {
+ throw new Error("Function not implemented.");
+ },
+ encryptContractForDeposit: function (
+ req: EncryptContractForDepositRequest,
+ ): Promise<EncryptContractForDepositResponse> {
+ throw new Error("Function not implemented.");
+ },
+ decryptContractForDeposit: function (
+ req: DecryptContractForDepositRequest,
+ ): Promise<DecryptContractForDepositResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signReservePurseCreate: function (
+ req: SignReservePurseCreateRequest,
+ ): Promise<SignReservePurseCreateResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signRefund: function (req: SignRefundRequest): Promise<SignRefundResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signDeletePurse: function (
+ req: SignDeletePurseRequest,
+ ): Promise<SignDeletePurseResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signCoinHistoryRequest: function (
+ req: SignCoinHistoryRequest,
+ ): Promise<SignCoinHistoryResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signReserveHistoryReq: function (
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse> {
+ throw new Error("Function not implemented.");
+ },
+};
+
+export type WithArg<X> = X extends (req: infer T) => infer R
+ ? (tci: TalerCryptoInterfaceR, req: T) => R
+ : never;
+
+export type TalerCryptoInterfaceR = {
+ [x in keyof TalerCryptoInterface]: WithArg<TalerCryptoInterface[x]>;
+};
+
+export interface SignCoinLinkRequest {
+ oldCoinPriv: string;
+ newDenomHash: string;
+ oldCoinPub: string;
+ transferPub: string;
+ coinEv: CoinEnvelope;
+}
+
+export interface SetupRefreshPlanchetRequest {
+ transferSecret: string;
+ coinNumber: number;
+}
+
+export interface SetupWithdrawalPlanchetRequest {
+ secretSeed: string;
+ coinNumber: number;
+}
+
+export interface SignPurseCreationRequest {
+ pursePriv: string;
+ purseExpiration: TalerProtocolTimestamp;
+ purseAmount: AmountString;
+ hContractTerms: HashCodeString;
+ mergePub: EddsaPublicKeyString;
+ minAge: number;
+}
+
+export interface SignReserveHistoryReqRequest {
+ reservePriv: string;
+ startOffset: number;
+}
+
+export interface SignReserveHistoryReqResponse {
+ sig: string;
+}
+
+export interface SpendCoinDetails {
+ coinPub: string;
+ coinPriv: string;
+ contribution: AmountString;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
+}
+
+export interface SignPurseDepositsRequest {
+ pursePub: string;
+ exchangeBaseUrl: string;
+ coins: SpendCoinDetails[];
+}
+
+export interface SignPurseDepositsResponse {
+ deposits: PurseDeposit[];
+}
+
+export interface RsaVerificationRequest {
+ hm: string;
+ sig: string;
+ pk: string;
+}
+
+export interface RsaBlindRequest {
+ hm: string;
+ bks: string;
+ pub: string;
+}
+
+export interface EddsaSigningResult {
+ sig: string;
+}
+
+export interface ValidationResult {
+ valid: boolean;
+}
+
+export interface HashStringRequest {
+ str: string;
+}
+
+export interface HashStringResult {
+ h: string;
+}
+
+export interface WireFeeValidationRequest {
+ type: string;
+ wf: WireFee;
+ masterPub: string;
+}
+
+export interface GlobalFeesValidationRequest {
+ gf: GlobalFees;
+ masterPub: string;
+}
+
+export interface DenominationValidationRequest {
+ denom: DenominationRecord;
+ masterPub: string;
+}
+
+export interface PaymentSignatureValidationRequest {
+ sig: string;
+ contractHash: string;
+ merchantPub: string;
+}
+
+export interface ContractTermsValidationRequest {
+ contractTermsHash: string;
+ sig: string;
+ merchantPub: string;
+}
+
+export interface WireAccountValidationRequest {
+ versionCurrent: ExchangeProtocolVersion;
+ paytoUri: string;
+ sig: string;
+ masterPub: string;
+ conversionUrl?: string;
+ debitRestrictions?: any[];
+ creditRestrictions?: any[];
+}
+
+export interface EddsaKeypair {
+ priv: string;
+ pub: string;
+}
+
+export interface EddsaGetPublicRequest {
+ priv: string;
+}
+
+export interface EddsaGetPublicResponse {
+ pub: string;
+}
+
+export interface EcdheGetPublicRequest {
+ priv: string;
+}
+
+export interface EcdheGetPublicResponse {
+ pub: string;
+}
+
+export interface UnblindDenominationSignatureRequest {
+ planchet: PlanchetUnblindInfo;
+ evSig: BlindedDenominationSignature;
+}
+
+export interface FreshCoinEncoded {
+ coinPub: string;
+ coinPriv: string;
+ bks: string;
+}
+
+export interface RsaUnblindRequest {
+ blindedSig: string;
+ bk: string;
+ pk: string;
+}
+
+export interface RsaBlindResponse {
+ blinded: string;
+}
+
+export interface RsaUnblindResponse {
+ sig: string;
+}
+
+export interface KeyExchangeEcdheEddsaRequest {
+ ecdhePriv: string;
+ eddsaPub: string;
+}
+
+export interface KeyExchangeResult {
+ h: string;
+}
+
+export interface SetupRefreshTransferPubRequest {
+ secretSeed: string;
+ transferPubIndex: number;
+}
+
+export interface TransferPubResponse {
+ transferPub: string;
+ transferPriv: string;
+}
+
+/**
+ * JS-native implementation of the Taler crypto worker operations.
+ */
+export const nativeCryptoR: TalerCryptoInterfaceR = {
+ async eddsaSign(
+ tci: TalerCryptoInterfaceR,
+ req: EddsaSignRequest,
+ ): Promise<EddsaSignResponse> {
+ return {
+ sig: encodeCrock(eddsaSign(decodeCrock(req.msg), decodeCrock(req.priv))),
+ };
+ },
+
+ async rsaBlind(
+ tci: TalerCryptoInterfaceR,
+ req: RsaBlindRequest,
+ ): Promise<RsaBlindResponse> {
+ const res = rsaBlind(
+ decodeCrock(req.hm),
+ decodeCrock(req.bks),
+ decodeCrock(req.pub),
+ );
+ return {
+ blinded: encodeCrock(res),
+ };
+ },
+
+ async setupRefreshPlanchet(
+ tci: TalerCryptoInterfaceR,
+ req: SetupRefreshPlanchetRequest,
+ ): Promise<FreshCoinEncoded> {
+ const transferSecret = decodeCrock(req.transferSecret);
+ const coinNumber = req.coinNumber;
+ // See TALER_transfer_secret_to_planchet_secret in C impl
+ const planchetMasterSecret = kdfKw({
+ ikm: transferSecret,
+ outputLength: 32,
+ salt: bufferForUint32(coinNumber),
+ info: stringToBytes("taler-coin-derivation"),
+ });
+
+ const coinPriv = kdfKw({
+ ikm: planchetMasterSecret,
+ outputLength: 32,
+ salt: stringToBytes("coin"),
+ });
+
+ const bks = kdfKw({
+ ikm: planchetMasterSecret,
+ outputLength: 32,
+ salt: stringToBytes("bks"),
+ });
+
+ const coinPrivEnc = encodeCrock(coinPriv);
+ const coinPubRes = await tci.eddsaGetPublic(tci, {
+ priv: coinPrivEnc,
+ });
+
+ return {
+ bks: encodeCrock(bks),
+ coinPriv: coinPrivEnc,
+ coinPub: coinPubRes.pub,
+ };
+ },
+
+ async setupWithdrawalPlanchet(
+ tci: TalerCryptoInterfaceR,
+ req: SetupWithdrawalPlanchetRequest,
+ ): Promise<FreshCoinEncoded> {
+ const info = stringToBytes("taler-withdrawal-coin-derivation");
+ const saltArrBuf = new ArrayBuffer(4);
+ const salt = new Uint8Array(saltArrBuf);
+ const saltDataView = new DataView(saltArrBuf);
+ saltDataView.setUint32(0, req.coinNumber);
+ const secretSeedDec = decodeCrock(req.secretSeed);
+ const out = kdf(64, secretSeedDec, salt, info);
+ const coinPriv = out.slice(0, 32);
+ const bks = out.slice(32, 64);
+ const coinPrivEnc = encodeCrock(coinPriv);
+ const coinPubRes = await tci.eddsaGetPublic(tci, {
+ priv: coinPrivEnc,
+ });
+ return {
+ bks: encodeCrock(bks),
+ coinPriv: coinPrivEnc,
+ coinPub: coinPubRes.pub,
+ };
+ },
+
+ async createPlanchet(
+ tci: TalerCryptoInterfaceR,
+ req: PlanchetCreationRequest,
+ ): Promise<WithdrawalPlanchet> {
+ const denomPub = req.denomPub;
+ if (denomPub.cipher === DenomKeyType.Rsa) {
+ const reservePub = decodeCrock(req.reservePub);
+ const derivedPlanchet = await tci.setupWithdrawalPlanchet(tci, {
+ coinNumber: req.coinIndex,
+ secretSeed: req.secretSeed,
+ });
+
+ let maybeAcp: AgeCommitmentProof | undefined = undefined;
+ let maybeAgeCommitmentHash: string | undefined = undefined;
+ if (denomPub.age_mask) {
+ const age = req.restrictAge || AgeRestriction.AGE_UNRESTRICTED;
+ logger.info(`creating age-restricted planchet (age ${age})`);
+ maybeAcp = await AgeRestriction.restrictionCommitSeeded(
+ denomPub.age_mask,
+ age,
+ stringToBytes(req.secretSeed),
+ );
+ maybeAgeCommitmentHash = AgeRestriction.hashCommitment(
+ maybeAcp.commitment,
+ );
+ }
+
+ const coinPubHash = hashCoinPub(
+ derivedPlanchet.coinPub,
+ maybeAgeCommitmentHash,
+ );
+
+ const blindResp = await tci.rsaBlind(tci, {
+ bks: derivedPlanchet.bks,
+ hm: encodeCrock(coinPubHash),
+ pub: denomPub.rsa_public_key,
+ });
+ const coinEv: CoinEnvelope = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: blindResp.blinded,
+ };
+ const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount;
+ const denomPubHash = hashDenomPub(req.denomPub);
+ const evHash = hashCoinEv(coinEv, encodeCrock(denomPubHash));
+ const withdrawRequest = buildSigPS(
+ TalerSignaturePurpose.WALLET_RESERVE_WITHDRAW,
+ )
+ .put(amountToBuffer(amountWithFee))
+ .put(denomPubHash)
+ .put(evHash)
+ .build();
+
+ const sigResult = await tci.eddsaSign(tci, {
+ msg: encodeCrock(withdrawRequest),
+ priv: req.reservePriv,
+ });
+
+ const planchet: WithdrawalPlanchet = {
+ blindingKey: derivedPlanchet.bks,
+ coinEv,
+ coinPriv: derivedPlanchet.coinPriv,
+ coinPub: derivedPlanchet.coinPub,
+ coinValue: req.value,
+ denomPub,
+ denomPubHash: encodeCrock(denomPubHash),
+ reservePub: encodeCrock(reservePub),
+ withdrawSig: sigResult.sig,
+ coinEvHash: encodeCrock(evHash),
+ ageCommitmentProof: maybeAcp,
+ };
+ return planchet;
+ } else {
+ throw Error("unsupported cipher, unable to create planchet");
+ }
+ },
+
+ async createTipPlanchet(
+ tci: TalerCryptoInterfaceR,
+ req: DeriveTipRequest,
+ ): Promise<DerivedTipPlanchet> {
+ if (req.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error(`unsupported cipher (${req.denomPub.cipher})`);
+ }
+ const fc = await setupTipPlanchet(
+ decodeCrock(req.secretSeed),
+ req.denomPub,
+ req.planchetIndex,
+ );
+ const maybeAch = fc.ageCommitmentProof
+ ? AgeRestriction.hashCommitment(fc.ageCommitmentProof.commitment)
+ : undefined;
+ const denomPub = decodeCrock(req.denomPub.rsa_public_key);
+ const coinPubHash = hashCoinPub(encodeCrock(fc.coinPub), maybeAch);
+ const blindResp = await tci.rsaBlind(tci, {
+ bks: encodeCrock(fc.bks),
+ hm: encodeCrock(coinPubHash),
+ pub: encodeCrock(denomPub),
+ });
+ const coinEv = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: blindResp.blinded,
+ };
+ const tipPlanchet: DerivedTipPlanchet = {
+ blindingKey: encodeCrock(fc.bks),
+ coinEv,
+ coinEvHash: encodeCrock(
+ hashCoinEv(coinEv, encodeCrock(hashDenomPub(req.denomPub))),
+ ),
+ coinPriv: encodeCrock(fc.coinPriv),
+ coinPub: encodeCrock(fc.coinPub),
+ ageCommitmentProof: fc.ageCommitmentProof,
+ };
+ return tipPlanchet;
+ },
+
+ async signTrackTransaction(
+ tci: TalerCryptoInterfaceR,
+ req: SignTrackTransactionRequest,
+ ): Promise<EddsaSigningResult> {
+ const p = buildSigPS(TalerSignaturePurpose.MERCHANT_TRACK_TRANSACTION)
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.wireHash))
+ .put(decodeCrock(req.coinPub))
+ .build();
+ return { sig: encodeCrock(eddsaSign(p, decodeCrock(req.merchantPriv))) };
+ },
+
+ /**
+ * Create and sign a message to recoup a coin.
+ */
+ async createRecoupRequest(
+ tci: TalerCryptoInterfaceR,
+ req: CreateRecoupReqRequest,
+ ): Promise<RecoupRequest> {
+ const p = buildSigPS(TalerSignaturePurpose.WALLET_COIN_RECOUP)
+ .put(decodeCrock(req.denomPubHash))
+ .put(decodeCrock(req.blindingKey))
+ .build();
+
+ const coinPriv = decodeCrock(req.coinPriv);
+ const coinSig = eddsaSign(p, coinPriv);
+ if (req.denomPub.cipher === DenomKeyType.Rsa) {
+ const paybackRequest: RecoupRequest = {
+ coin_blind_key_secret: req.blindingKey,
+ coin_sig: encodeCrock(coinSig),
+ denom_pub_hash: req.denomPubHash,
+ denom_sig: req.denomSig,
+ // FIXME!
+ ewv: {
+ cipher: "RSA",
+ },
+ };
+ return paybackRequest;
+ } else {
+ throw new Error();
+ }
+ },
+
+ /**
+ * Create and sign a message to recoup a coin.
+ */
+ async createRecoupRefreshRequest(
+ tci: TalerCryptoInterfaceR,
+ req: CreateRecoupRefreshReqRequest,
+ ): Promise<RecoupRefreshRequest> {
+ const p = buildSigPS(TalerSignaturePurpose.WALLET_COIN_RECOUP_REFRESH)
+ .put(decodeCrock(req.denomPubHash))
+ .put(decodeCrock(req.blindingKey))
+ .build();
+
+ const coinPriv = decodeCrock(req.coinPriv);
+ const coinSig = eddsaSign(p, coinPriv);
+ if (req.denomPub.cipher === DenomKeyType.Rsa) {
+ const recoupRequest: RecoupRefreshRequest = {
+ coin_blind_key_secret: req.blindingKey,
+ coin_sig: encodeCrock(coinSig),
+ denom_pub_hash: req.denomPubHash,
+ denom_sig: req.denomSig,
+ // FIXME!
+ ewv: {
+ cipher: "RSA",
+ },
+ };
+ return recoupRequest;
+ } else {
+ throw new Error();
+ }
+ },
+
+ /**
+ * Check if a payment signature is valid.
+ */
+ async isValidPaymentSignature(
+ tci: TalerCryptoInterfaceR,
+ req: PaymentSignatureValidationRequest,
+ ): Promise<ValidationResult> {
+ const { contractHash, sig, merchantPub } = req;
+ const p = buildSigPS(TalerSignaturePurpose.MERCHANT_PAYMENT_OK)
+ .put(decodeCrock(contractHash))
+ .build();
+ const sigBytes = decodeCrock(sig);
+ const pubBytes = decodeCrock(merchantPub);
+ return { valid: eddsaVerify(p, sigBytes, pubBytes) };
+ },
+
+ /**
+ * Check if a wire fee is correctly signed.
+ */
+ async isValidWireFee(
+ tci: TalerCryptoInterfaceR,
+ req: WireFeeValidationRequest,
+ ): Promise<ValidationResult> {
+ const { type, wf, masterPub } = req;
+ const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_FEES)
+ .put(hash(stringToBytes(type + "\0")))
+ .put(timestampRoundedToBuffer(wf.startStamp))
+ .put(timestampRoundedToBuffer(wf.endStamp))
+ .put(amountToBuffer(wf.wireFee))
+ .put(amountToBuffer(wf.closingFee))
+ .build();
+ const sig = decodeCrock(wf.sig);
+ const pub = decodeCrock(masterPub);
+ return { valid: eddsaVerify(p, sig, pub) };
+ },
+
+ /**
+ * Check if a global fee is correctly signed.
+ */
+ async isValidGlobalFees(
+ tci: TalerCryptoInterfaceR,
+ req: GlobalFeesValidationRequest,
+ ): Promise<ValidationResult> {
+ const { gf, masterPub } = req;
+ const p = buildSigPS(TalerSignaturePurpose.GLOBAL_FEES)
+ .put(timestampRoundedToBuffer(gf.start_date))
+ .put(timestampRoundedToBuffer(gf.end_date))
+ .put(durationRoundedToBuffer(gf.purse_timeout))
+ .put(durationRoundedToBuffer(gf.history_expiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(gf.history_fee)))
+ .put(amountToBuffer(Amounts.parseOrThrow(gf.account_fee)))
+ .put(amountToBuffer(Amounts.parseOrThrow(gf.purse_fee)))
+ .put(bufferForUint32(gf.purse_account_limit))
+ .build();
+ const sig = decodeCrock(gf.master_sig);
+ const pub = decodeCrock(masterPub);
+ return { valid: eddsaVerify(p, sig, pub) };
+ },
+
+ /**
+ * Check if the signature of a denomination is valid.
+ */
+ async isValidDenom(
+ tci: TalerCryptoInterfaceR,
+ req: DenominationValidationRequest,
+ ): Promise<ValidationResult> {
+ const { masterPub, denom } = req;
+ const value: AmountJson = Amounts.parseOrThrow(denom.value);
+ const p = buildSigPS(TalerSignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY)
+ .put(decodeCrock(masterPub))
+ .put(timestampRoundedToBuffer(timestampProtocolFromDb(denom.stampStart)))
+ .put(
+ timestampRoundedToBuffer(
+ timestampProtocolFromDb(denom.stampExpireWithdraw),
+ ),
+ )
+ .put(
+ timestampRoundedToBuffer(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ ),
+ )
+ .put(
+ timestampRoundedToBuffer(
+ timestampProtocolFromDb(denom.stampExpireLegal),
+ ),
+ )
+ .put(amountToBuffer(value))
+ .put(amountToBuffer(denom.fees.feeWithdraw))
+ .put(amountToBuffer(denom.fees.feeDeposit))
+ .put(amountToBuffer(denom.fees.feeRefresh))
+ .put(amountToBuffer(denom.fees.feeRefund))
+ .put(decodeCrock(denom.denomPubHash))
+ .build();
+ const sig = decodeCrock(denom.masterSig);
+ const pub = decodeCrock(masterPub);
+ const res = eddsaVerify(p, sig, pub);
+ return { valid: res };
+ },
+
+ async isValidWireAccount(
+ tci: TalerCryptoInterfaceR,
+ req: WireAccountValidationRequest,
+ ): Promise<ValidationResult> {
+ const { sig, masterPub, paytoUri } = req;
+ const paytoHash = hashTruncate32(stringToBytes(paytoUri + "\0"));
+ const pb = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS);
+ pb.put(paytoHash);
+ if (req.versionCurrent >= 15) {
+ let conversionUrlHash;
+ if (!req.conversionUrl) {
+ conversionUrlHash = new Uint8Array(64);
+ } else {
+ conversionUrlHash = hash(stringToBytes(req.conversionUrl + "\0"));
+ }
+ pb.put(conversionUrlHash);
+ pb.put(hash(stringToBytes(canonicalJson(req.debitRestrictions) + "\0")));
+ pb.put(hash(stringToBytes(canonicalJson(req.creditRestrictions) + "\0")));
+ }
+ const p = pb.build();
+ return { valid: eddsaVerify(p, decodeCrock(sig), decodeCrock(masterPub)) };
+ },
+
+ async isValidContractTermsSignature(
+ tci: TalerCryptoInterfaceR,
+ req: ContractTermsValidationRequest,
+ ): Promise<ValidationResult> {
+ const cthDec = decodeCrock(req.contractTermsHash);
+ const p = buildSigPS(TalerSignaturePurpose.MERCHANT_CONTRACT)
+ .put(cthDec)
+ .build();
+ return {
+ valid: eddsaVerify(p, decodeCrock(req.sig), decodeCrock(req.merchantPub)),
+ };
+ },
+
+ /**
+ * Create a new EdDSA key pair.
+ */
+ async createEddsaKeypair(tci: TalerCryptoInterfaceR): Promise<EddsaKeypair> {
+ const eddsaPriv = encodeCrock(getRandomBytes(32));
+ const eddsaPubRes = await tci.eddsaGetPublic(tci, {
+ priv: eddsaPriv,
+ });
+ return {
+ priv: eddsaPriv,
+ pub: eddsaPubRes.pub,
+ };
+ },
+
+ async eddsaGetPublic(
+ tci: TalerCryptoInterfaceR,
+ req: EddsaGetPublicRequest,
+ ): Promise<EddsaKeypair> {
+ return {
+ priv: req.priv,
+ pub: encodeCrock(eddsaGetPublic(decodeCrock(req.priv))),
+ };
+ },
+
+ async unblindDenominationSignature(
+ tci: TalerCryptoInterfaceR,
+ req: UnblindDenominationSignatureRequest,
+ ): Promise<UnblindedSignature> {
+ if (req.evSig.cipher === DenomKeyType.Rsa) {
+ if (req.planchet.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw new Error(
+ "planchet cipher does not match blind signature cipher",
+ );
+ }
+ const denomSig = rsaUnblind(
+ decodeCrock(req.evSig.blinded_rsa_signature),
+ decodeCrock(req.planchet.denomPub.rsa_public_key),
+ decodeCrock(req.planchet.blindingKey),
+ );
+ return {
+ cipher: DenomKeyType.Rsa,
+ rsa_signature: encodeCrock(denomSig),
+ };
+ } else {
+ throw Error(`unblinding for cipher ${req.evSig.cipher} not implemented`);
+ }
+ },
+
+ /**
+ * Unblind a blindly signed value.
+ */
+ async rsaUnblind(
+ tci: TalerCryptoInterfaceR,
+ req: RsaUnblindRequest,
+ ): Promise<RsaUnblindResponse> {
+ const denomSig = rsaUnblind(
+ decodeCrock(req.blindedSig),
+ decodeCrock(req.pk),
+ decodeCrock(req.bk),
+ );
+ return { sig: encodeCrock(denomSig) };
+ },
+
+ /**
+ * Unblind a blindly signed value.
+ */
+ async rsaVerify(
+ tci: TalerCryptoInterfaceR,
+ req: RsaVerificationRequest,
+ ): Promise<ValidationResult> {
+ return {
+ valid: rsaVerify(
+ hash(decodeCrock(req.hm)),
+ decodeCrock(req.sig),
+ decodeCrock(req.pk),
+ ),
+ };
+ },
+
+ /**
+ * Generate updated coins (to store in the database)
+ * and deposit permissions for each given coin.
+ */
+ async signDepositPermission(
+ tci: TalerCryptoInterfaceR,
+ depositInfo: DepositInfo,
+ ): Promise<CoinDepositPermission> {
+ // FIXME: put extensions here if used
+ const hExt = new Uint8Array(64);
+ let hAgeCommitment: Uint8Array;
+ let minimumAgeSig: string | undefined = undefined;
+ if (depositInfo.ageCommitmentProof) {
+ const ach = AgeRestriction.hashCommitment(
+ depositInfo.ageCommitmentProof.commitment,
+ );
+ hAgeCommitment = decodeCrock(ach);
+ if (depositInfo.requiredMinimumAge) {
+ minimumAgeSig = encodeCrock(
+ AgeRestriction.commitmentAttest(
+ depositInfo.ageCommitmentProof,
+ depositInfo.requiredMinimumAge,
+ ),
+ );
+ }
+ } else {
+ // All zeros.
+ hAgeCommitment = new Uint8Array(32);
+ }
+ // FIXME: Actually allow passing user data here!
+ const walletDataHash = new Uint8Array(64);
+ let d: Uint8Array;
+ if (depositInfo.denomKeyType === DenomKeyType.Rsa) {
+ d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT)
+ .put(decodeCrock(depositInfo.contractTermsHash))
+ .put(hAgeCommitment)
+ .put(hExt)
+ .put(decodeCrock(depositInfo.wireInfoHash))
+ .put(decodeCrock(depositInfo.denomPubHash))
+ .put(timestampRoundedToBuffer(depositInfo.timestamp))
+ .put(timestampRoundedToBuffer(depositInfo.refundDeadline))
+ .put(amountToBuffer(depositInfo.spendAmount))
+ .put(amountToBuffer(depositInfo.feeDeposit))
+ .put(decodeCrock(depositInfo.merchantPub))
+ .put(walletDataHash)
+ .build();
+ } else {
+ throw Error("unsupported exchange protocol version");
+ }
+ const coinSigRes = await this.eddsaSign(tci, {
+ msg: encodeCrock(d),
+ priv: depositInfo.coinPriv,
+ });
+
+ if (depositInfo.denomKeyType === DenomKeyType.Rsa) {
+ const s: CoinDepositPermission = {
+ coin_pub: depositInfo.coinPub,
+ coin_sig: coinSigRes.sig,
+ contribution: Amounts.stringify(depositInfo.spendAmount),
+ h_denom: depositInfo.denomPubHash,
+ exchange_url: depositInfo.exchangeBaseUrl,
+ ub_sig: {
+ cipher: DenomKeyType.Rsa,
+ rsa_signature: depositInfo.denomSig.rsa_signature,
+ },
+ };
+
+ if (depositInfo.requiredMinimumAge) {
+ // These are only required by the merchant
+ s.minimum_age_sig = minimumAgeSig;
+ s.age_commitment =
+ depositInfo.ageCommitmentProof?.commitment.publicKeys;
+ } else if (depositInfo.ageCommitmentProof) {
+ s.h_age_commitment = encodeCrock(hAgeCommitment);
+ }
+
+ return s;
+ } else {
+ throw Error(
+ `unsupported denomination cipher (${depositInfo.denomKeyType})`,
+ );
+ }
+ },
+
+ async deriveRefreshSession(
+ tci: TalerCryptoInterfaceR,
+ req: DeriveRefreshSessionRequest,
+ ): Promise<DerivedRefreshSession> {
+ const {
+ newCoinDenoms,
+ feeRefresh: meltFee,
+ kappa,
+ meltCoinDenomPubHash,
+ meltCoinPriv,
+ meltCoinPub,
+ sessionSecretSeed: refreshSessionSecretSeed,
+ } = req;
+
+ const currency = Amounts.currencyOf(newCoinDenoms[0].value);
+ let valueWithFee = Amounts.zeroOfCurrency(currency);
+
+ for (const ncd of newCoinDenoms) {
+ const t = Amounts.add(ncd.value, ncd.feeWithdraw).amount;
+ valueWithFee = Amounts.add(
+ valueWithFee,
+ Amounts.mult(t, ncd.count).amount,
+ ).amount;
+ }
+
+ // melt fee
+ valueWithFee = Amounts.add(valueWithFee, meltFee).amount;
+
+ const sessionHc = createHashContext();
+
+ const transferPubs: string[] = [];
+ const transferPrivs: string[] = [];
+
+ const planchetsForGammas: RefreshPlanchetInfo[][] = [];
+
+ for (let i = 0; i < kappa; i++) {
+ const transferKeyPair = await tci.setupRefreshTransferPub(tci, {
+ secretSeed: refreshSessionSecretSeed,
+ transferPubIndex: i,
+ });
+ sessionHc.update(decodeCrock(transferKeyPair.transferPub));
+ transferPrivs.push(transferKeyPair.transferPriv);
+ transferPubs.push(transferKeyPair.transferPub);
+ }
+
+ for (const denomSel of newCoinDenoms) {
+ for (let i = 0; i < denomSel.count; i++) {
+ if (denomSel.denomPub.cipher === DenomKeyType.Rsa) {
+ const denomPubHash = hashDenomPub(denomSel.denomPub);
+ sessionHc.update(denomPubHash);
+ } else {
+ throw new Error();
+ }
+ }
+ }
+
+ sessionHc.update(decodeCrock(meltCoinPub));
+ sessionHc.update(amountToBuffer(valueWithFee));
+
+ for (let i = 0; i < kappa; i++) {
+ const planchets: RefreshPlanchetInfo[] = [];
+ for (let j = 0; j < newCoinDenoms.length; j++) {
+ const denomSel = newCoinDenoms[j];
+ for (let k = 0; k < denomSel.count; k++) {
+ const coinIndex = planchets.length;
+ const transferSecretRes = await tci.keyExchangeEcdheEddsa(tci, {
+ ecdhePriv: transferPrivs[i],
+ eddsaPub: meltCoinPub,
+ });
+ let coinPub: Uint8Array;
+ let coinPriv: Uint8Array;
+ let blindingFactor: Uint8Array;
+ let fresh: FreshCoinEncoded = await tci.setupRefreshPlanchet(tci, {
+ coinNumber: coinIndex,
+ transferSecret: transferSecretRes.h,
+ });
+ let newAc: AgeCommitmentProof | undefined = undefined;
+ let newAch: HashCodeString | undefined = undefined;
+ if (req.meltCoinAgeCommitmentProof) {
+ newAc = await AgeRestriction.commitmentDerive(
+ req.meltCoinAgeCommitmentProof,
+ decodeCrock(transferSecretRes.h),
+ );
+ newAch = AgeRestriction.hashCommitment(newAc.commitment);
+ }
+ coinPriv = decodeCrock(fresh.coinPriv);
+ coinPub = decodeCrock(fresh.coinPub);
+ blindingFactor = decodeCrock(fresh.bks);
+ const coinPubHash = hashCoinPub(fresh.coinPub, newAch);
+ if (denomSel.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error("unsupported cipher, can't create refresh session");
+ }
+ const blindResult = await tci.rsaBlind(tci, {
+ bks: encodeCrock(blindingFactor),
+ hm: encodeCrock(coinPubHash),
+ pub: denomSel.denomPub.rsa_public_key,
+ });
+ const coinEv: CoinEnvelope = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: blindResult.blinded,
+ };
+ const coinEvHash = hashCoinEv(
+ coinEv,
+ encodeCrock(hashDenomPub(denomSel.denomPub)),
+ );
+ const planchet: RefreshPlanchetInfo = {
+ blindingKey: encodeCrock(blindingFactor),
+ coinEv,
+ coinPriv: encodeCrock(coinPriv),
+ coinPub: encodeCrock(coinPub),
+ coinEvHash: encodeCrock(coinEvHash),
+ maxAge: req.meltCoinMaxAge,
+ ageCommitmentProof: newAc,
+ };
+ planchets.push(planchet);
+ hashCoinEvInner(coinEv, sessionHc);
+ }
+ }
+ planchetsForGammas.push(planchets);
+ }
+
+ const sessionHash = sessionHc.finish();
+ let confirmData: Uint8Array;
+ let hAgeCommitment: Uint8Array;
+ if (req.meltCoinAgeCommitmentProof) {
+ hAgeCommitment = decodeCrock(
+ AgeRestriction.hashCommitment(
+ req.meltCoinAgeCommitmentProof.commitment,
+ ),
+ );
+ } else {
+ hAgeCommitment = new Uint8Array(32);
+ }
+ confirmData = buildSigPS(TalerSignaturePurpose.WALLET_COIN_MELT)
+ .put(sessionHash)
+ .put(decodeCrock(meltCoinDenomPubHash))
+ .put(hAgeCommitment)
+ .put(amountToBuffer(valueWithFee))
+ .put(amountToBuffer(meltFee))
+ .build();
+
+ const confirmSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(confirmData),
+ priv: meltCoinPriv,
+ });
+
+ const refreshSession: DerivedRefreshSession = {
+ confirmSig: confirmSigResp.sig,
+ hash: encodeCrock(sessionHash),
+ meltCoinPub: meltCoinPub,
+ planchetsForGammas: planchetsForGammas,
+ transferPrivs,
+ transferPubs,
+ meltValueWithFee: valueWithFee,
+ };
+
+ return refreshSession;
+ },
+
+ /**
+ * Hash a string including the zero terminator.
+ */
+ async hashString(
+ tci: TalerCryptoInterfaceR,
+ req: HashStringRequest,
+ ): Promise<HashStringResult> {
+ const b = stringToBytes(req.str + "\0");
+ return { h: encodeCrock(hash(b)) };
+ },
+
+ async signCoinLink(
+ tci: TalerCryptoInterfaceR,
+ req: SignCoinLinkRequest,
+ ): Promise<EddsaSigningResult> {
+ const coinEvHash = hashCoinEv(req.coinEv, req.newDenomHash);
+ // FIXME: fill in
+ const hAgeCommitment = new Uint8Array(32);
+ const coinLink = buildSigPS(TalerSignaturePurpose.WALLET_COIN_LINK)
+ .put(decodeCrock(req.newDenomHash))
+ .put(decodeCrock(req.transferPub))
+ .put(hAgeCommitment)
+ .put(coinEvHash)
+ .build();
+ return tci.eddsaSign(tci, {
+ msg: encodeCrock(coinLink),
+ priv: req.oldCoinPriv,
+ });
+ },
+
+ async makeSyncSignature(
+ tci: TalerCryptoInterfaceR,
+ req: MakeSyncSignatureRequest,
+ ): Promise<EddsaSigningResult> {
+ const hNew = decodeCrock(req.newHash);
+ let hOld: Uint8Array;
+ if (req.oldHash) {
+ hOld = decodeCrock(req.oldHash);
+ } else {
+ hOld = new Uint8Array(64);
+ }
+ const sigBlob = buildSigPS(TalerSignaturePurpose.SYNC_BACKUP_UPLOAD)
+ .put(hOld)
+ .put(hNew)
+ .build();
+ const uploadSig = eddsaSign(sigBlob, decodeCrock(req.accountPriv));
+ return { sig: encodeCrock(uploadSig) };
+ },
+ async keyExchangeEcdheEddsa(
+ tci: TalerCryptoInterfaceR,
+ req: KeyExchangeEcdheEddsaRequest,
+ ): Promise<KeyExchangeResult> {
+ return {
+ h: encodeCrock(
+ keyExchangeEcdhEddsa(
+ decodeCrock(req.ecdhePriv),
+ decodeCrock(req.eddsaPub),
+ ),
+ ),
+ };
+ },
+ async ecdheGetPublic(
+ tci: TalerCryptoInterfaceR,
+ req: EcdheGetPublicRequest,
+ ): Promise<EcdheGetPublicResponse> {
+ return {
+ pub: encodeCrock(ecdhGetPublic(decodeCrock(req.priv))),
+ };
+ },
+ async setupRefreshTransferPub(
+ tci: TalerCryptoInterfaceR,
+ req: SetupRefreshTransferPubRequest,
+ ): Promise<TransferPubResponse> {
+ const info = stringToBytes("taler-transfer-pub-derivation");
+ const saltArrBuf = new ArrayBuffer(4);
+ const salt = new Uint8Array(saltArrBuf);
+ const saltDataView = new DataView(saltArrBuf);
+ saltDataView.setUint32(0, req.transferPubIndex);
+ const out = kdf(32, decodeCrock(req.secretSeed), salt, info);
+ const transferPriv = encodeCrock(out);
+ return {
+ transferPriv,
+ transferPub: (await tci.ecdheGetPublic(tci, { priv: transferPriv })).pub,
+ };
+ },
+ async signPurseCreation(
+ tci: TalerCryptoInterfaceR,
+ req: SignPurseCreationRequest,
+ ): Promise<EddsaSigningResult> {
+ const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_CREATE)
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+ .put(decodeCrock(req.hContractTerms))
+ .put(decodeCrock(req.mergePub))
+ .put(bufferForUint32(req.minAge))
+ .build();
+ return await tci.eddsaSign(tci, {
+ msg: encodeCrock(sigBlob),
+ priv: req.pursePriv,
+ });
+ },
+ async signPurseDeposits(
+ tci: TalerCryptoInterfaceR,
+ req: SignPurseDepositsRequest,
+ ): Promise<SignPurseDepositsResponse> {
+ const hExchangeBaseUrl = hash(stringToBytes(req.exchangeBaseUrl + "\0"));
+ const deposits: PurseDeposit[] = [];
+ for (const c of req.coins) {
+ let maybeAch: Uint8Array;
+ if (c.ageCommitmentProof) {
+ maybeAch = decodeCrock(
+ AgeRestriction.hashCommitment(c.ageCommitmentProof.commitment),
+ );
+ } else {
+ maybeAch = new Uint8Array(32);
+ }
+ const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT)
+ .put(amountToBuffer(Amounts.parseOrThrow(c.contribution)))
+ .put(decodeCrock(c.denomPubHash))
+ .put(maybeAch)
+ .put(decodeCrock(req.pursePub))
+ .put(hExchangeBaseUrl)
+ .build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(sigBlob),
+ priv: c.coinPriv,
+ });
+ deposits.push({
+ amount: c.contribution,
+ coin_pub: c.coinPub,
+ coin_sig: sigResp.sig,
+ denom_pub_hash: c.denomPubHash,
+ ub_sig: c.denomSig,
+ age_commitment: c.ageCommitmentProof
+ ? c.ageCommitmentProof.commitment.publicKeys
+ : undefined,
+ });
+ }
+ return {
+ deposits,
+ };
+ },
+ async encryptContractForMerge(
+ tci: TalerCryptoInterfaceR,
+ req: EncryptContractRequest,
+ ): Promise<EncryptContractResponse> {
+ const enc = await encryptContractForMerge(
+ decodeCrock(req.pursePub),
+ decodeCrock(req.contractPriv),
+ decodeCrock(req.mergePriv),
+ req.contractTerms,
+ decodeCrock(req.nonce),
+ );
+ const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_ECONTRACT)
+ .put(hash(enc))
+ .put(decodeCrock(req.contractPub))
+ .build();
+ const sig = eddsaSign(sigBlob, decodeCrock(req.pursePriv));
+ return {
+ econtract: {
+ contract_pub: req.contractPub,
+ econtract: encodeCrock(enc),
+ econtract_sig: encodeCrock(sig),
+ },
+ };
+ },
+ async decryptContractForMerge(
+ tci: TalerCryptoInterfaceR,
+ req: DecryptContractRequest,
+ ): Promise<DecryptContractResponse> {
+ const res = await decryptContractForMerge(
+ decodeCrock(req.ciphertext),
+ decodeCrock(req.pursePub),
+ decodeCrock(req.contractPriv),
+ );
+ return {
+ contractTerms: res.contractTerms,
+ mergePriv: encodeCrock(res.mergePriv),
+ };
+ },
+ async encryptContractForDeposit(
+ tci: TalerCryptoInterfaceR,
+ req: EncryptContractForDepositRequest,
+ ): Promise<EncryptContractForDepositResponse> {
+ const enc = await encryptContractForDeposit(
+ decodeCrock(req.pursePub),
+ decodeCrock(req.contractPriv),
+ req.contractTerms,
+ decodeCrock(req.nonce),
+ );
+ const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_ECONTRACT)
+ .put(hash(enc))
+ .put(decodeCrock(req.contractPub))
+ .build();
+ const sig = eddsaSign(sigBlob, decodeCrock(req.pursePriv));
+ return {
+ econtract: {
+ contract_pub: req.contractPub,
+ econtract: encodeCrock(enc),
+ econtract_sig: encodeCrock(sig),
+ },
+ };
+ },
+ async decryptContractForDeposit(
+ tci: TalerCryptoInterfaceR,
+ req: DecryptContractForDepositRequest,
+ ): Promise<DecryptContractForDepositResponse> {
+ const res = await decryptContractForDeposit(
+ decodeCrock(req.ciphertext),
+ decodeCrock(req.pursePub),
+ decodeCrock(req.contractPriv),
+ );
+ return {
+ contractTerms: res.contractTerms,
+ };
+ },
+ async signPurseMerge(
+ tci: TalerCryptoInterfaceR,
+ req: SignPurseMergeRequest,
+ ): Promise<SignPurseMergeResponse> {
+ const mergeSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE)
+ .put(timestampRoundedToBuffer(req.mergeTimestamp))
+ .put(decodeCrock(req.pursePub))
+ .put(hashTruncate32(stringToBytes(req.reservePayto + "\0")))
+ .build();
+ const mergeSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(mergeSigBlob),
+ priv: req.mergePriv,
+ });
+
+ const reserveSigBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_ACCOUNT_MERGE,
+ )
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseFee)))
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.pursePub))
+ .put(timestampRoundedToBuffer(req.mergeTimestamp))
+ // FIXME: put in min_age
+ .put(bufferForUint32(0))
+ .put(bufferForUint32(req.flags))
+ .build();
+
+ logger.info(
+ `signing WALLET_ACCOUNT_MERGE over ${encodeCrock(reserveSigBlob)}`,
+ );
+
+ const reserveSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(reserveSigBlob),
+ priv: req.reservePriv,
+ });
+
+ return {
+ mergeSig: mergeSigResp.sig,
+ accountSig: reserveSigResp.sig,
+ };
+ },
+ async signReservePurseCreate(
+ tci: TalerCryptoInterfaceR,
+ req: SignReservePurseCreateRequest,
+ ): Promise<SignReservePurseCreateResponse> {
+ const mergeSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE)
+ .put(timestampRoundedToBuffer(req.mergeTimestamp))
+ .put(decodeCrock(req.pursePub))
+ .put(hashTruncate32(stringToBytes(req.reservePayto + "\0")))
+ .build();
+ const mergeSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(mergeSigBlob),
+ priv: req.mergePriv,
+ });
+
+ logger.info(`payto URI: ${req.reservePayto}`);
+ logger.info(`signing WALLET_PURSE_MERGE over ${encodeCrock(mergeSigBlob)}`);
+
+ const reserveSigBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_ACCOUNT_MERGE,
+ )
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseFee)))
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.pursePub))
+ .put(timestampRoundedToBuffer(req.mergeTimestamp))
+ // FIXME: put in min_age
+ .put(bufferForUint32(0))
+ .put(bufferForUint32(req.flags))
+ .build();
+
+ logger.info(
+ `signing WALLET_ACCOUNT_MERGE over ${encodeCrock(reserveSigBlob)}`,
+ );
+
+ const reserveSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(reserveSigBlob),
+ priv: req.reservePriv,
+ });
+
+ const mergePub = encodeCrock(eddsaGetPublic(decodeCrock(req.mergePriv)));
+
+ const purseSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_CREATE)
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(mergePub))
+ // FIXME: add age!
+ .put(bufferForUint32(0))
+ .build();
+
+ const purseSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(purseSigBlob),
+ priv: req.pursePriv,
+ });
+
+ return {
+ mergeSig: mergeSigResp.sig,
+ accountSig: reserveSigResp.sig,
+ purseSig: purseSigResp.sig,
+ };
+ },
+ async signRefund(
+ tci: TalerCryptoInterfaceR,
+ req: SignRefundRequest,
+ ): Promise<SignRefundResponse> {
+ const refundSigBlob = buildSigPS(TalerSignaturePurpose.MERCHANT_REFUND)
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.coinPub))
+ .put(bufferForUint64(req.rtransactionId))
+ .put(amountToBuffer(req.refundAmount))
+ .build();
+ const refundSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(refundSigBlob),
+ priv: req.merchantPriv,
+ });
+ return {
+ sig: refundSigResp.sig,
+ };
+ },
+ async signDeletePurse(
+ tci: TalerCryptoInterfaceR,
+ req: SignDeletePurseRequest,
+ ): Promise<SignDeletePurseResponse> {
+ const deleteSigBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_PURSE_DELETE,
+ ).build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(deleteSigBlob),
+ priv: req.pursePriv,
+ });
+ return {
+ sig: sigResp.sig,
+ };
+ },
+ async signCoinHistoryRequest(
+ tci: TalerCryptoInterfaceR,
+ req: SignCoinHistoryRequest,
+ ): Promise<SignCoinHistoryResponse> {
+ const coinHistorySigBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_COIN_HISTORY,
+ )
+ .put(bufferForUint64(req.startOffset))
+ .build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(coinHistorySigBlob),
+ priv: req.coinPriv,
+ });
+ return {
+ sig: sigResp.sig,
+ };
+ },
+ async signReserveHistoryReq(
+ tci: TalerCryptoInterfaceR,
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse> {
+ const reserveHistoryBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_RESERVE_HISTORY,
+ )
+ .put(bufferForUint64(req.startOffset))
+ .build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(reserveHistoryBlob),
+ priv: req.reservePriv,
+ });
+ return {
+ sig: sigResp.sig,
+ };
+ },
+};
+
+export interface EddsaSignRequest {
+ msg: string;
+ priv: string;
+}
+
+export interface EddsaSignResponse {
+ sig: string;
+}
+
+export const nativeCrypto: TalerCryptoInterface = Object.fromEntries(
+ Object.keys(nativeCryptoR).map((name) => {
+ return [
+ name,
+ (req: any) =>
+ nativeCryptoR[name as keyof TalerCryptoInterfaceR](nativeCryptoR, req),
+ ];
+ }),
+) as any;
diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
index 922fbbfac..df25b87e4 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
@@ -27,13 +27,27 @@
/**
* Imports.
*/
-import { AmountJson } from "@gnu-taler/taler-util";
+import {
+ AgeCommitmentProof,
+ AmountJson,
+ AmountString,
+ CoinEnvelope,
+ DenominationPubKey,
+ EddsaPublicKeyString,
+ EddsaSignatureString,
+ ExchangeProtocolVersion,
+ RefreshPlanchetInfo,
+ TalerProtocolTimestamp,
+ UnblindedSignature,
+ WalletAccountMergeFlags,
+} from "@gnu-taler/taler-util";
export interface RefreshNewDenomInfo {
count: number;
- value: AmountJson;
- feeWithdraw: AmountJson;
- denomPub: string;
+ value: AmountString;
+ feeWithdraw: AmountString;
+ denomPub: DenominationPubKey;
+ denomPubHash: string;
}
/**
@@ -41,11 +55,14 @@ export interface RefreshNewDenomInfo {
* secret seed.
*/
export interface DeriveRefreshSessionRequest {
+ exchangeProtocolVersion: ExchangeProtocolVersion;
sessionSecretSeed: string;
kappa: number;
meltCoinPub: string;
meltCoinPriv: string;
meltCoinDenomPubHash: string;
+ meltCoinMaxAge: number;
+ meltCoinAgeCommitmentProof?: AgeCommitmentProof;
newCoinDenoms: RefreshNewDenomInfo[];
feeRefresh: AmountJson;
}
@@ -67,32 +84,7 @@ export interface DerivedRefreshSession {
/**
* Planchets for each cut-and-choose instance.
*/
- planchetsForGammas: {
- /**
- * Public key for the coin.
- */
- publicKey: string;
-
- /**
- * Private key for the coin.
- */
- privateKey: string;
-
- /**
- * Blinded public key.
- */
- coinEv: string;
-
- /**
- * Hash of the blinded public key.
- */
- coinEvHash: string;
-
- /**
- * Blinding key used.
- */
- blindingKey: string;
- }[][];
+ planchetsForGammas: RefreshPlanchetInfo[][];
/**
* The transfer keys, kappa of them.
@@ -117,7 +109,7 @@ export interface DerivedRefreshSession {
export interface DeriveTipRequest {
secretSeed: string;
- denomPub: string;
+ denomPub: DenominationPubKey;
planchetIndex: number;
}
@@ -126,10 +118,11 @@ export interface DeriveTipRequest {
*/
export interface DerivedTipPlanchet {
blindingKey: string;
- coinEv: string;
+ coinEv: CoinEnvelope;
coinEvHash: string;
coinPriv: string;
coinPub: string;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
}
export interface SignTrackTransactionRequest {
@@ -139,3 +132,200 @@ export interface SignTrackTransactionRequest {
merchantPriv: string;
merchantPub: string;
}
+
+/**
+ * Request to create a recoup request payload.
+ */
+export interface CreateRecoupReqRequest {
+ coinPub: string;
+ coinPriv: string;
+ blindingKey: string;
+ denomPub: DenominationPubKey;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+}
+
+/**
+ * Request to create a recoup-refresh request payload.
+ */
+export interface CreateRecoupRefreshReqRequest {
+ coinPub: string;
+ coinPriv: string;
+ blindingKey: string;
+ denomPub: DenominationPubKey;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+}
+
+export interface EncryptedContract {
+ /**
+ * Encrypted contract.
+ */
+ econtract: string;
+
+ /**
+ * Signature over the (encrypted) contract.
+ */
+ econtract_sig: EddsaSignatureString;
+
+ /**
+ * Ephemeral public key for the DH operation to decrypt the encrypted contract.
+ */
+ contract_pub: EddsaPublicKeyString;
+}
+
+export interface EncryptContractRequest {
+ contractTerms: any;
+ contractPriv: string;
+ contractPub: string;
+ pursePub: string;
+ pursePriv: string;
+ mergePriv: string;
+ nonce: string;
+}
+
+export interface EncryptContractResponse {
+ econtract: EncryptedContract;
+}
+
+export interface EncryptContractForDepositRequest {
+ contractTerms: any;
+
+ contractPriv: string;
+ contractPub: string;
+
+ pursePub: string;
+ pursePriv: string;
+
+ nonce: string;
+}
+
+export interface EncryptContractForDepositResponse {
+ econtract: EncryptedContract;
+}
+
+export interface DecryptContractRequest {
+ ciphertext: string;
+ pursePub: string;
+ contractPriv: string;
+}
+
+export interface DecryptContractResponse {
+ contractTerms: any;
+ mergePriv: string;
+}
+
+export interface DecryptContractForDepositRequest {
+ ciphertext: string;
+ pursePub: string;
+ contractPriv: string;
+}
+
+export interface DecryptContractForDepositResponse {
+ contractTerms: any;
+}
+
+export interface SignPurseMergeRequest {
+ mergeTimestamp: TalerProtocolTimestamp;
+
+ pursePub: string;
+
+ reservePayto: string;
+
+ reservePriv: string;
+
+ mergePriv: string;
+
+ purseExpiration: TalerProtocolTimestamp;
+
+ purseAmount: AmountString;
+ purseFee: AmountString;
+
+ contractTermsHash: string;
+
+ /**
+ * Flags.
+ */
+ flags: WalletAccountMergeFlags;
+}
+
+export interface SignPurseMergeResponse {
+ /**
+ * Signature made by the purse's merge private key.
+ */
+ mergeSig: string;
+
+ accountSig: string;
+}
+
+export interface SignRefundRequest {
+ merchantPriv: string;
+ merchantPub: string;
+ contractTermsHash: string;
+ coinPub: string;
+ rtransactionId: number;
+ refundAmount: AmountString;
+}
+
+export interface SignRefundResponse {
+ sig: string;
+}
+
+export interface SignDeletePurseRequest {
+ pursePriv: string;
+}
+
+export interface SignDeletePurseResponse {
+ sig: EddsaSignatureString;
+}
+
+export interface SignCoinHistoryRequest {
+ coinPub: string;
+ coinPriv: string;
+ startOffset: number;
+}
+
+export interface SignCoinHistoryResponse {
+ sig: EddsaSignatureString;
+}
+
+export interface SignReservePurseCreateRequest {
+ mergeTimestamp: TalerProtocolTimestamp;
+
+ pursePub: string;
+
+ pursePriv: string;
+
+ reservePayto: string;
+
+ reservePriv: string;
+
+ mergePriv: string;
+
+ purseExpiration: TalerProtocolTimestamp;
+
+ purseAmount: AmountString;
+ purseFee: AmountString;
+
+ contractTermsHash: string;
+
+ /**
+ * Flags.
+ */
+ flags: WalletAccountMergeFlags;
+}
+
+/**
+ * Response with signatures needed for creation of a purse
+ * from a reserve for a PULL payment.
+ */
+export interface SignReservePurseCreateResponse {
+ /**
+ * Signature made by the purse's merge private key.
+ */
+ mergeSig: string;
+
+ accountSig: string;
+
+ purseSig: string;
+}
diff --git a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.test.ts b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.test.ts
new file mode 100644
index 000000000..96e2ee735
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.test.ts
@@ -0,0 +1,128 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ 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 { AbsoluteTime, TalerErrorCode } from "@gnu-taler/taler-util";
+import test from "ava";
+import { CryptoDispatcher, CryptoWorkerFactory } from "./crypto-dispatcher.js";
+import {
+ CryptoWorker,
+ CryptoWorkerResponseMessage,
+} from "./cryptoWorkerInterface.js";
+
+export class MyCryptoWorker implements CryptoWorker {
+ /**
+ * Function to be called when we receive a message from the worker thread.
+ */
+ onmessage: undefined | ((m: any) => void) = undefined;
+
+ /**
+ * Function to be called when we receive an error from the worker thread.
+ */
+ onerror: undefined | ((m: any) => void) = undefined;
+
+ /**
+ * Add an event listener for either an "error" or "message" event.
+ */
+ addEventListener(event: "message" | "error", fn: (x: any) => void): void {
+ switch (event) {
+ case "message":
+ this.onmessage = fn;
+ break;
+ case "error":
+ this.onerror = fn;
+ break;
+ }
+ }
+
+ private dispatchMessage(msg: any): void {
+ if (this.onmessage) {
+ this.onmessage(msg);
+ }
+ }
+
+ /**
+ * Send a message to the worker thread.
+ */
+ postMessage(msg: any): void {
+ const handleRequest = async () => {
+ let responseMsg: CryptoWorkerResponseMessage;
+ if (msg.operation === "testSuccess") {
+ responseMsg = {
+ id: msg.id,
+ type: "success",
+ result: {
+ testResult: 42,
+ },
+ };
+ } else if (msg.operation === "testError") {
+ responseMsg = {
+ id: msg.id,
+ type: "error",
+ error: {
+ code: TalerErrorCode.ANASTASIS_EMAIL_INVALID,
+ when: AbsoluteTime.now(),
+ hint: "bla",
+ },
+ };
+ } else if (msg.operation === "testTimeout") {
+ // Don't respond
+ return;
+ }
+ try {
+ setTimeout(() => this.dispatchMessage(responseMsg), 0);
+ } catch (e) {
+ console.error("got error during dispatch", e);
+ }
+ };
+ handleRequest().catch((e) => {
+ console.error("Error while handling crypto request:", e);
+ });
+ }
+
+ /**
+ * Forcibly terminate the worker thread.
+ */
+ terminate(): void {
+ // This is a no-op.
+ }
+}
+
+export class MyCryptoWorkerFactory implements CryptoWorkerFactory {
+ startWorker(): CryptoWorker {
+ return new MyCryptoWorker();
+ }
+
+ getConcurrency(): number {
+ return 1;
+ }
+}
+
+test("continues after error", async (t) => {
+ const cryptoDisp = new CryptoDispatcher(new MyCryptoWorkerFactory());
+ const resp1 = await cryptoDisp.doRpc("testSuccess", 0, {});
+ t.assert((resp1 as any).testResult === 42);
+ const exc = await t.throwsAsync(async () => {
+ const resp2 = await cryptoDisp.doRpc("testError", 0, {});
+ });
+
+ // Check that it still works after one error.
+ const resp2 = await cryptoDisp.doRpc("testSuccess", 0, {});
+ t.assert((resp2 as any).testResult === 42);
+
+ // Check that it still works after timeout.
+ const resp3 = await cryptoDisp.doRpc("testSuccess", 0, {});
+ t.assert((resp3 as any).testResult === 42);
+});
diff --git a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts
new file mode 100644
index 000000000..f86163723
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts
@@ -0,0 +1,386 @@
+/*
+ This file is part of GNU Taler
+ (C) 2016 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/>
+ */
+
+/**
+ * API to access the Taler crypto worker.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ j2s,
+ Logger,
+ openPromise,
+ performanceNow,
+ TalerError,
+ TalerErrorCode,
+ timer,
+ TimerHandle,
+} from "@gnu-taler/taler-util";
+import { nullCrypto, TalerCryptoInterface } from "../cryptoImplementation.js";
+import { CryptoWorker } from "./cryptoWorkerInterface.js";
+
+const logger = new Logger("cryptoDispatcher.ts");
+
+/**
+ * State of a crypto worker.
+ */
+interface WorkerInfo {
+ /**
+ * The actual worker thread.
+ */
+ w: CryptoWorker | null;
+
+ /**
+ * Work we're currently executing or null if not busy.
+ */
+ currentWorkItem: WorkItem | null;
+
+ /**
+ * Timer to terminate the worker if it's not busy enough.
+ */
+ idleTimeoutHandle: TimerHandle | null;
+}
+
+interface WorkItem {
+ operation: string;
+ req: unknown;
+ resolve: any;
+ reject: any;
+
+ /**
+ * Serial id to identify a matching response.
+ */
+ rpcId: number;
+
+ /**
+ * Time when the work was submitted to a (non-busy) worker thread.
+ */
+ startTime: BigInt;
+
+ state: WorkItemState;
+}
+
+/**
+ * Number of different priorities. Each priority p
+ * must be 0 <= p < NUM_PRIO.
+ */
+const NUM_PRIO = 5;
+
+/**
+ * A crypto worker factory is responsible for creating new
+ * crypto workers on-demand.
+ */
+export interface CryptoWorkerFactory {
+ /**
+ * Start a new worker.
+ */
+ startWorker(): CryptoWorker;
+
+ /**
+ * Query the number of workers that should be
+ * run at the same time.
+ */
+ getConcurrency(): number;
+}
+
+export class CryptoApiStoppedError extends Error {
+ constructor() {
+ super("Crypto API stopped");
+ Object.setPrototypeOf(this, CryptoApiStoppedError.prototype);
+ }
+}
+
+export enum WorkItemState {
+ Pending = 1,
+ Running = 2,
+ Finished = 3,
+}
+
+/**
+ * Dispatcher for cryptographic operations to underlying crypto workers.
+ */
+export class CryptoDispatcher {
+ private nextRpcId = 1;
+ private workers: WorkerInfo[];
+ private workQueues: WorkItem[][];
+
+ private workerFactory: CryptoWorkerFactory;
+
+ /**
+ * Number of busy workers.
+ */
+ private numBusy = 0;
+
+ /**
+ * Did we stop accepting new requests?
+ */
+ private stopped = false;
+
+ /**
+ * Terminate all worker threads.
+ */
+ terminateWorkers(): void {
+ for (const worker of this.workers) {
+ if (worker.idleTimeoutHandle) {
+ worker.idleTimeoutHandle.clear();
+ worker.idleTimeoutHandle = null;
+ }
+ if (worker.currentWorkItem) {
+ worker.currentWorkItem.reject(new CryptoApiStoppedError());
+ worker.currentWorkItem = null;
+ }
+ if (worker.w) {
+ logger.trace("terminating worker");
+ worker.w.terminate();
+ worker.w = null;
+ }
+ }
+ }
+
+ stop(): void {
+ this.stopped = true;
+ this.terminateWorkers();
+ }
+
+ /**
+ * Start a worker (if not started) and set as busy.
+ */
+ wake(ws: WorkerInfo, work: WorkItem): void {
+ if (this.stopped) {
+ return;
+ }
+ if (ws.currentWorkItem !== null) {
+ throw Error("assertion failed");
+ }
+ ws.currentWorkItem = work;
+ this.numBusy++;
+ let worker: CryptoWorker;
+ if (!ws.w) {
+ worker = this.workerFactory.startWorker();
+ worker.onmessage = (m: any) => this.handleWorkerMessage(ws, m);
+ worker.onerror = (e: any) => this.handleWorkerError(ws, e);
+ ws.w = worker;
+ } else {
+ worker = ws.w;
+ }
+
+ const msg: any = {
+ req: work.req,
+ id: work.rpcId,
+ operation: work.operation,
+ };
+ this.resetWorkerTimeout(ws);
+ work.startTime = performanceNow();
+ work.state = WorkItemState.Running;
+ timer.after(0, () => worker.postMessage(msg));
+ }
+
+ resetWorkerTimeout(ws: WorkerInfo): void {
+ if (ws.idleTimeoutHandle !== null) {
+ ws.idleTimeoutHandle.clear();
+ ws.idleTimeoutHandle = null;
+ }
+ const destroy = (): void => {
+ logger.trace("destroying crypto worker after idle timeout");
+ // terminate worker if it's idle
+ if (ws.w && ws.currentWorkItem === null) {
+ ws.w.terminate();
+ ws.w = null;
+ }
+ };
+ ws.idleTimeoutHandle = timer.after(15 * 1000, destroy);
+ ws.idleTimeoutHandle.unref();
+ }
+
+ private resetWorker(ws: WorkerInfo, e: any): void {
+ try {
+ if (ws.w) {
+ ws.w.terminate();
+ ws.w = null;
+ }
+ } catch (e) {
+ logger.error(e as string);
+ }
+ if (ws.currentWorkItem !== null) {
+ ws.currentWorkItem.state = WorkItemState.Finished;
+ ws.currentWorkItem.reject(e);
+ ws.currentWorkItem = null;
+ this.numBusy--;
+ }
+ this.findWork(ws);
+ }
+
+ handleWorkerError(ws: WorkerInfo, e: any): void {
+ if (ws.currentWorkItem) {
+ logger.error(`error in worker during ${ws.currentWorkItem.operation}`, e);
+ } else {
+ logger.error("error in worker", e);
+ }
+ logger.error(e.message);
+ this.resetWorker(ws, e);
+ }
+
+ private findWork(ws: WorkerInfo): void {
+ // try to find more work for this worker
+ for (let i = 0; i < NUM_PRIO; i++) {
+ const q = this.workQueues[NUM_PRIO - i - 1];
+ if (q.length !== 0) {
+ const work: WorkItem | undefined = q.shift();
+ if (!work) {
+ continue;
+ }
+ this.wake(ws, work);
+ return;
+ }
+ }
+ }
+
+ handleWorkerMessage(ws: WorkerInfo, msg: any): void {
+ const id = msg.id;
+ if (typeof id !== "number") {
+ logger.error("rpc id must be number");
+ return;
+ }
+ const currentWorkItem = ws.currentWorkItem;
+ ws.currentWorkItem = null;
+ if (!currentWorkItem) {
+ logger.error("unsolicited response from worker");
+ return;
+ }
+ if (id !== currentWorkItem.rpcId) {
+ logger.error(`RPC with id ${id} has no registry entry`);
+ return;
+ }
+ if (currentWorkItem.state === WorkItemState.Running) {
+ this.numBusy--;
+ currentWorkItem.state = WorkItemState.Finished;
+ if (msg.type === "success") {
+ currentWorkItem.resolve(msg.result);
+ } else if (msg.type === "error") {
+ currentWorkItem.reject(
+ TalerError.fromDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_ERROR, {
+ innerError: msg.error,
+ }),
+ );
+ } else {
+ logger.warn(`bad message: ${j2s(msg)}`);
+ currentWorkItem.reject(new Error("bad message from crypto worker"));
+ }
+ }
+ this.findWork(ws);
+ }
+
+ cryptoApi: TalerCryptoInterface;
+
+ constructor(workerFactory: CryptoWorkerFactory) {
+ const fns: any = {};
+ for (const name of Object.keys(nullCrypto)) {
+ fns[name] = (x: any) => this.doRpc(name, 0, x);
+ }
+
+ this.cryptoApi = fns;
+
+ this.workerFactory = workerFactory;
+ this.workers = new Array<WorkerInfo>(workerFactory.getConcurrency());
+
+ for (let i = 0; i < this.workers.length; i++) {
+ this.workers[i] = {
+ currentWorkItem: null,
+ idleTimeoutHandle: null,
+ w: null,
+ };
+ }
+
+ this.workQueues = [];
+ for (let i = 0; i < NUM_PRIO; i++) {
+ this.workQueues.push([]);
+ }
+ }
+
+ doRpc<T>(operation: string, priority: number, req: unknown): Promise<T> {
+ if (this.stopped) {
+ throw new CryptoApiStoppedError();
+ }
+ const rpcId = this.nextRpcId++;
+ const myProm = openPromise<T>();
+ const workItem: WorkItem = {
+ operation,
+ req,
+ resolve: myProm.resolve,
+ reject: myProm.reject,
+ rpcId,
+ startTime: BigInt(0),
+ state: WorkItemState.Pending,
+ };
+ let scheduled = false;
+ if (this.numBusy === this.workers.length) {
+ // All workers are busy, queue work item
+ const q = this.workQueues[priority];
+ if (!q) {
+ throw Error("assertion failed");
+ }
+ this.workQueues[priority].push(workItem);
+ scheduled = true;
+ }
+ if (!scheduled) {
+ for (const ws of this.workers) {
+ if (ws.currentWorkItem !== null) {
+ continue;
+ }
+ this.wake(ws, workItem);
+ scheduled = true;
+ break;
+ }
+ }
+
+ if (!scheduled) {
+ // Could not schedule work.
+ throw Error("assertion failed");
+ }
+
+ // Make sure that we wait for the result while a timer is active
+ // to prevent the event loop from dying, as just waiting for a promise
+ // does not keep the process alive in Node.
+ // (The worker child process won't keep us alive either, because we un-ref
+ // it to make sure it doesn't keep us alive if there is no work.)
+ return new Promise<T>((resolve, reject) => {
+ let timeoutHandle: TimerHandle | undefined = undefined;
+ const timeoutMs = 5000;
+ const onTimeout = () => {
+ // FIXME: Maybe destroy and re-init worker if request is in processing
+ // state and really taking too long?
+ logger.warn(
+ `crypto RPC call ('${operation}') has been queued for a long time`,
+ );
+ timeoutHandle = timer.after(timeoutMs, onTimeout);
+ };
+ myProm.promise
+ .then((x) => {
+ timeoutHandle?.clear();
+ resolve(x);
+ })
+ .catch((x) => {
+ logger.info(`crypto RPC call ${operation} threw`);
+ timeoutHandle?.clear();
+ reject(x);
+ });
+ });
+ }
+}
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
deleted file mode 100644
index 6bace01a3..000000000
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
+++ /dev/null
@@ -1,457 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2016 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/>
- */
-
-/**
- * API to access the Taler crypto worker thread.
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import { CoinRecord, DenominationRecord, WireFee } from "../../db.js";
-
-import { CryptoWorker } from "./cryptoWorker.js";
-
-import { RecoupRequest, CoinDepositPermission } from "@gnu-taler/taler-util";
-
-import {
- BenchmarkResult,
- PlanchetCreationResult,
- PlanchetCreationRequest,
- DepositInfo,
- MakeSyncSignatureRequest,
-} from "@gnu-taler/taler-util";
-
-import * as timer from "../../util/timer.js";
-import { Logger } from "@gnu-taler/taler-util";
-import {
- DerivedRefreshSession,
- DerivedTipPlanchet,
- DeriveRefreshSessionRequest,
- DeriveTipRequest,
- SignTrackTransactionRequest,
-} from "../cryptoTypes.js";
-
-const logger = new Logger("cryptoApi.ts");
-
-/**
- * State of a crypto worker.
- */
-interface WorkerState {
- /**
- * The actual worker thread.
- */
- w: CryptoWorker | null;
-
- /**
- * Work we're currently executing or null if not busy.
- */
- currentWorkItem: WorkItem | null;
-
- /**
- * Timer to terminate the worker if it's not busy enough.
- */
- terminationTimerHandle: timer.TimerHandle | null;
-}
-
-interface WorkItem {
- operation: string;
- args: any[];
- resolve: any;
- reject: any;
-
- /**
- * Serial id to identify a matching response.
- */
- rpcId: number;
-
- /**
- * Time when the work was submitted to a (non-busy) worker thread.
- */
- startTime: BigInt;
-}
-
-/**
- * Number of different priorities. Each priority p
- * must be 0 <= p < NUM_PRIO.
- */
-const NUM_PRIO = 5;
-
-export interface CryptoWorkerFactory {
- /**
- * Start a new worker.
- */
- startWorker(): CryptoWorker;
-
- /**
- * Query the number of workers that should be
- * run at the same time.
- */
- getConcurrency(): number;
-}
-
-/**
- * Crypto API that interfaces manages a background crypto thread
- * for the execution of expensive operations.
- */
-export class CryptoApi {
- private nextRpcId = 1;
- private workers: WorkerState[];
- private workQueues: WorkItem[][];
-
- private workerFactory: CryptoWorkerFactory;
-
- /**
- * Number of busy workers.
- */
- private numBusy = 0;
-
- /**
- * Did we stop accepting new requests?
- */
- private stopped = false;
-
- /**
- * Terminate all worker threads.
- */
- terminateWorkers(): void {
- for (const worker of this.workers) {
- if (worker.w) {
- logger.trace("terminating worker");
- worker.w.terminate();
- if (worker.terminationTimerHandle) {
- worker.terminationTimerHandle.clear();
- worker.terminationTimerHandle = null;
- }
- if (worker.currentWorkItem) {
- worker.currentWorkItem.reject(Error("explicitly terminated"));
- worker.currentWorkItem = null;
- }
- worker.w = null;
- }
- }
- }
-
- stop(): void {
- this.terminateWorkers();
- this.stopped = true;
- }
-
- /**
- * Start a worker (if not started) and set as busy.
- */
- wake(ws: WorkerState, work: WorkItem): void {
- if (this.stopped) {
- logger.trace("cryptoApi is stopped");
- return;
- }
- if (ws.currentWorkItem !== null) {
- throw Error("assertion failed");
- }
- ws.currentWorkItem = work;
- this.numBusy++;
- let worker: CryptoWorker;
- if (!ws.w) {
- worker = this.workerFactory.startWorker();
- worker.onmessage = (m: any) => this.handleWorkerMessage(ws, m);
- worker.onerror = (e: any) => this.handleWorkerError(ws, e);
- ws.w = worker;
- } else {
- worker = ws.w;
- }
-
- const msg: any = {
- args: work.args,
- id: work.rpcId,
- operation: work.operation,
- };
- this.resetWorkerTimeout(ws);
- work.startTime = timer.performanceNow();
- timer.after(0, () => worker.postMessage(msg));
- }
-
- resetWorkerTimeout(ws: WorkerState): void {
- if (ws.terminationTimerHandle !== null) {
- ws.terminationTimerHandle.clear();
- ws.terminationTimerHandle = null;
- }
- const destroy = (): void => {
- // terminate worker if it's idle
- if (ws.w && ws.currentWorkItem === null) {
- ws.w.terminate();
- ws.w = null;
- }
- };
- ws.terminationTimerHandle = timer.after(15 * 1000, destroy);
- //ws.terminationTimerHandle.unref();
- }
-
- handleWorkerError(ws: WorkerState, e: any): void {
- if (ws.currentWorkItem) {
- logger.error(`error in worker during ${ws.currentWorkItem.operation}`, e);
- } else {
- logger.error("error in worker", e);
- }
- logger.error(e.message);
- try {
- if (ws.w) {
- ws.w.terminate();
- ws.w = null;
- }
- } catch (e) {
- logger.error(e as string);
- }
- if (ws.currentWorkItem !== null) {
- ws.currentWorkItem.reject(e);
- ws.currentWorkItem = null;
- this.numBusy--;
- }
- this.findWork(ws);
- }
-
- private findWork(ws: WorkerState): void {
- // try to find more work for this worker
- for (let i = 0; i < NUM_PRIO; i++) {
- const q = this.workQueues[NUM_PRIO - i - 1];
- if (q.length !== 0) {
- const work: WorkItem | undefined = q.shift();
- if (!work) {
- continue;
- }
- this.wake(ws, work);
- return;
- }
- }
- }
-
- handleWorkerMessage(ws: WorkerState, msg: any): void {
- const id = msg.data.id;
- if (typeof id !== "number") {
- console.error("rpc id must be number");
- return;
- }
- const currentWorkItem = ws.currentWorkItem;
- ws.currentWorkItem = null;
- this.numBusy--;
- this.findWork(ws);
- if (!currentWorkItem) {
- console.error("unsolicited response from worker");
- return;
- }
- if (id !== currentWorkItem.rpcId) {
- console.error(`RPC with id ${id} has no registry entry`);
- return;
- }
-
- currentWorkItem.resolve(msg.data.result);
- }
-
- constructor(workerFactory: CryptoWorkerFactory) {
- this.workerFactory = workerFactory;
- this.workers = new Array<WorkerState>(workerFactory.getConcurrency());
-
- for (let i = 0; i < this.workers.length; i++) {
- this.workers[i] = {
- currentWorkItem: null,
- terminationTimerHandle: null,
- w: null,
- };
- }
-
- this.workQueues = [];
- for (let i = 0; i < NUM_PRIO; i++) {
- this.workQueues.push([]);
- }
- }
-
- private doRpc<T>(
- operation: string,
- priority: number,
- ...args: any[]
- ): Promise<T> {
- const p: Promise<T> = new Promise<T>((resolve, reject) => {
- const rpcId = this.nextRpcId++;
- const workItem: WorkItem = {
- operation,
- args,
- resolve,
- reject,
- rpcId,
- startTime: BigInt(0),
- };
-
- if (this.numBusy === this.workers.length) {
- const q = this.workQueues[priority];
- if (!q) {
- throw Error("assertion failed");
- }
- this.workQueues[priority].push(workItem);
- return;
- }
-
- for (const ws of this.workers) {
- if (ws.currentWorkItem !== null) {
- continue;
- }
- this.wake(ws, workItem);
- return;
- }
-
- throw Error("assertion failed");
- });
-
- return p;
- }
-
- createPlanchet(
- req: PlanchetCreationRequest,
- ): Promise<PlanchetCreationResult> {
- return this.doRpc<PlanchetCreationResult>("createPlanchet", 1, req);
- }
-
- createTipPlanchet(req: DeriveTipRequest): Promise<DerivedTipPlanchet> {
- return this.doRpc<DerivedTipPlanchet>("createTipPlanchet", 1, req);
- }
-
- signTrackTransaction(req: SignTrackTransactionRequest): Promise<string> {
- return this.doRpc<string>("signTrackTransaction", 1, req);
- }
-
- hashString(str: string): Promise<string> {
- return this.doRpc<string>("hashString", 1, str);
- }
-
- hashEncoded(encodedBytes: string): Promise<string> {
- return this.doRpc<string>("hashEncoded", 1, encodedBytes);
- }
-
- isValidDenom(denom: DenominationRecord, masterPub: string): Promise<boolean> {
- return this.doRpc<boolean>("isValidDenom", 2, denom, masterPub);
- }
-
- isValidWireFee(
- type: string,
- wf: WireFee,
- masterPub: string,
- ): Promise<boolean> {
- return this.doRpc<boolean>("isValidWireFee", 2, type, wf, masterPub);
- }
-
- isValidPaymentSignature(
- sig: string,
- contractHash: string,
- merchantPub: string,
- ): Promise<boolean> {
- return this.doRpc<boolean>(
- "isValidPaymentSignature",
- 1,
- sig,
- contractHash,
- merchantPub,
- );
- }
-
- signDepositPermission(
- depositInfo: DepositInfo,
- ): Promise<CoinDepositPermission> {
- return this.doRpc<CoinDepositPermission>(
- "signDepositPermission",
- 3,
- depositInfo,
- );
- }
-
- createEddsaKeypair(): Promise<{ priv: string; pub: string }> {
- return this.doRpc<{ priv: string; pub: string }>("createEddsaKeypair", 1);
- }
-
- eddsaGetPublic(key: string): Promise<{ priv: string; pub: string }> {
- return this.doRpc<{ priv: string; pub: string }>("eddsaGetPublic", 1, key);
- }
-
- rsaUnblind(sig: string, bk: string, pk: string): Promise<string> {
- return this.doRpc<string>("rsaUnblind", 4, sig, bk, pk);
- }
-
- rsaVerify(hm: string, sig: string, pk: string): Promise<boolean> {
- return this.doRpc<boolean>("rsaVerify", 4, hm, sig, pk);
- }
-
- isValidWireAccount(
- paytoUri: string,
- sig: string,
- masterPub: string,
- ): Promise<boolean> {
- return this.doRpc<boolean>(
- "isValidWireAccount",
- 4,
- paytoUri,
- sig,
- masterPub,
- );
- }
-
- isValidContractTermsSignature(
- contractTermsHash: string,
- sig: string,
- merchantPub: string,
- ): Promise<boolean> {
- return this.doRpc<boolean>(
- "isValidContractTermsSignature",
- 4,
- contractTermsHash,
- sig,
- merchantPub,
- );
- }
-
- createRecoupRequest(coin: CoinRecord): Promise<RecoupRequest> {
- return this.doRpc<RecoupRequest>("createRecoupRequest", 1, coin);
- }
-
- deriveRefreshSession(
- req: DeriveRefreshSessionRequest,
- ): Promise<DerivedRefreshSession> {
- return this.doRpc<DerivedRefreshSession>("deriveRefreshSession", 4, req);
- }
-
- signCoinLink(
- oldCoinPriv: string,
- newDenomHash: string,
- oldCoinPub: string,
- transferPub: string,
- coinEv: string,
- ): Promise<string> {
- return this.doRpc<string>(
- "signCoinLink",
- 4,
- oldCoinPriv,
- newDenomHash,
- oldCoinPub,
- transferPub,
- coinEv,
- );
- }
-
- benchmark(repetitions: number): Promise<BenchmarkResult> {
- return this.doRpc<BenchmarkResult>("benchmark", 1, repetitions);
- }
-
- makeSyncSignature(req: MakeSyncSignatureRequest): Promise<string> {
- return this.doRpc<string>("makeSyncSignature", 3, req);
- }
-}
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts
deleted file mode 100644
index c42ece778..000000000
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts
+++ /dev/null
@@ -1,593 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2020 Taler Systems SA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Synchronous implementation of crypto-related functions for the wallet.
- *
- * The functionality is parameterized over an Emscripten environment.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-
-// FIXME: Crypto should not use DB Types!
-import {
- CoinRecord,
- DenominationRecord,
- WireFee,
- CoinSourceType,
-} from "../../db.js";
-
-import {
- buildSigPS,
- CoinDepositPermission,
- RecoupRequest,
- RefreshPlanchetInfo,
- SignaturePurposeBuilder,
- TalerSignaturePurpose,
-} from "@gnu-taler/taler-util";
-// FIXME: These types should be internal to the wallet!
-import {
- BenchmarkResult,
- PlanchetCreationResult,
- PlanchetCreationRequest,
- DepositInfo,
- MakeSyncSignatureRequest,
-} from "@gnu-taler/taler-util";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import * as timer from "../../util/timer.js";
-import {
- encodeCrock,
- decodeCrock,
- createEddsaKeyPair,
- hash,
- rsaBlind,
- eddsaVerify,
- eddsaSign,
- rsaUnblind,
- stringToBytes,
- createHashContext,
- keyExchangeEcdheEddsa,
- setupRefreshPlanchet,
- rsaVerify,
- setupRefreshTransferPub,
- setupTipPlanchet,
- setupWithdrawPlanchet,
- eddsaGetPublic,
-} from "@gnu-taler/taler-util";
-import { randomBytes } from "@gnu-taler/taler-util";
-import { kdf } from "@gnu-taler/taler-util";
-import { Timestamp, timestampTruncateToSecond } from "@gnu-taler/taler-util";
-
-import { Logger } from "@gnu-taler/taler-util";
-import {
- DerivedRefreshSession,
- DerivedTipPlanchet,
- DeriveRefreshSessionRequest,
- DeriveTipRequest,
- SignTrackTransactionRequest,
-} from "../cryptoTypes.js";
-import bigint from "big-integer";
-
-const logger = new Logger("cryptoImplementation.ts");
-
-function amountToBuffer(amount: AmountJson): Uint8Array {
- const buffer = new ArrayBuffer(8 + 4 + 12);
- const dvbuf = new DataView(buffer);
- const u8buf = new Uint8Array(buffer);
- const curr = stringToBytes(amount.currency);
- if (typeof dvbuf.setBigUint64 !== "undefined") {
- dvbuf.setBigUint64(0, BigInt(amount.value));
- } else {
- const arr = bigint(amount.value).toArray(2 ** 8).value;
- let offset = 8 - arr.length;
- for (let i = 0; i < arr.length; i++) {
- dvbuf.setUint8(offset++, arr[i]);
- }
- }
- dvbuf.setUint32(8, amount.fraction);
- u8buf.set(curr, 8 + 4);
-
- return u8buf;
-}
-
-function timestampRoundedToBuffer(ts: Timestamp): Uint8Array {
- const b = new ArrayBuffer(8);
- const v = new DataView(b);
- const tsRounded = timestampTruncateToSecond(ts);
- if (typeof v.setBigUint64 !== "undefined") {
- const s = BigInt(tsRounded.t_ms) * BigInt(1000);
- v.setBigUint64(0, s);
- } else {
- const s =
- tsRounded.t_ms === "never"
- ? bigint.zero
- : bigint(tsRounded.t_ms).times(1000);
- const arr = s.toArray(2 ** 8).value;
- let offset = 8 - arr.length;
- for (let i = 0; i < arr.length; i++) {
- v.setUint8(offset++, arr[i]);
- }
- }
- return new Uint8Array(b);
-}
-
-export class CryptoImplementation {
- static enableTracing = false;
-
- /**
- * Create a pre-coin of the given denomination to be withdrawn from then given
- * reserve.
- */
- createPlanchet(req: PlanchetCreationRequest): PlanchetCreationResult {
- const reservePub = decodeCrock(req.reservePub);
- const reservePriv = decodeCrock(req.reservePriv);
- const denomPub = decodeCrock(req.denomPub);
- const derivedPlanchet = setupWithdrawPlanchet(
- decodeCrock(req.secretSeed),
- req.coinIndex,
- );
- const coinPubHash = hash(derivedPlanchet.coinPub);
- const ev = rsaBlind(coinPubHash, derivedPlanchet.bks, denomPub);
- const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount;
- const denomPubHash = hash(denomPub);
- const evHash = hash(ev);
-
- const withdrawRequest = buildSigPS(
- TalerSignaturePurpose.WALLET_RESERVE_WITHDRAW,
- )
- .put(reservePub)
- .put(amountToBuffer(amountWithFee))
- .put(denomPubHash)
- .put(evHash)
- .build();
-
- const sig = eddsaSign(withdrawRequest, reservePriv);
-
- const planchet: PlanchetCreationResult = {
- blindingKey: encodeCrock(derivedPlanchet.bks),
- coinEv: encodeCrock(ev),
- coinPriv: encodeCrock(derivedPlanchet.coinPriv),
- coinPub: encodeCrock(derivedPlanchet.coinPub),
- coinValue: req.value,
- denomPub: encodeCrock(denomPub),
- denomPubHash: encodeCrock(denomPubHash),
- reservePub: encodeCrock(reservePub),
- withdrawSig: encodeCrock(sig),
- coinEvHash: encodeCrock(evHash),
- };
- return planchet;
- }
-
- /**
- * Create a planchet used for tipping, including the private keys.
- */
- createTipPlanchet(req: DeriveTipRequest): DerivedTipPlanchet {
- const fc = setupTipPlanchet(decodeCrock(req.secretSeed), req.planchetIndex);
- const denomPub = decodeCrock(req.denomPub);
- const coinPubHash = hash(fc.coinPub);
- const ev = rsaBlind(coinPubHash, fc.bks, denomPub);
-
- const tipPlanchet: DerivedTipPlanchet = {
- blindingKey: encodeCrock(fc.bks),
- coinEv: encodeCrock(ev),
- coinEvHash: encodeCrock(hash(ev)),
- coinPriv: encodeCrock(fc.coinPriv),
- coinPub: encodeCrock(fc.coinPub),
- };
- return tipPlanchet;
- }
-
- signTrackTransaction(req: SignTrackTransactionRequest): string {
- const p = buildSigPS(TalerSignaturePurpose.MERCHANT_TRACK_TRANSACTION)
- .put(decodeCrock(req.contractTermsHash))
- .put(decodeCrock(req.wireHash))
- .put(decodeCrock(req.merchantPub))
- .put(decodeCrock(req.coinPub))
- .build();
- return encodeCrock(eddsaSign(p, decodeCrock(req.merchantPriv)));
- }
-
- /**
- * Create and sign a message to recoup a coin.
- */
- createRecoupRequest(coin: CoinRecord): RecoupRequest {
- const p = buildSigPS(TalerSignaturePurpose.WALLET_COIN_RECOUP)
- .put(decodeCrock(coin.coinPub))
- .put(decodeCrock(coin.denomPubHash))
- .put(decodeCrock(coin.blindingKey))
- .build();
-
- const coinPriv = decodeCrock(coin.coinPriv);
- const coinSig = eddsaSign(p, coinPriv);
- const paybackRequest: RecoupRequest = {
- coin_blind_key_secret: coin.blindingKey,
- coin_pub: coin.coinPub,
- coin_sig: encodeCrock(coinSig),
- denom_pub_hash: coin.denomPubHash,
- denom_sig: coin.denomSig,
- refreshed: coin.coinSource.type === CoinSourceType.Refresh,
- };
- return paybackRequest;
- }
-
- /**
- * Check if a payment signature is valid.
- */
- isValidPaymentSignature(
- sig: string,
- contractHash: string,
- merchantPub: string,
- ): boolean {
- const p = buildSigPS(TalerSignaturePurpose.MERCHANT_PAYMENT_OK)
- .put(decodeCrock(contractHash))
- .build();
- const sigBytes = decodeCrock(sig);
- const pubBytes = decodeCrock(merchantPub);
- return eddsaVerify(p, sigBytes, pubBytes);
- }
-
- /**
- * Check if a wire fee is correctly signed.
- */
- isValidWireFee(type: string, wf: WireFee, masterPub: string): boolean {
- const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_FEES)
- .put(hash(stringToBytes(type + "\0")))
- .put(timestampRoundedToBuffer(wf.startStamp))
- .put(timestampRoundedToBuffer(wf.endStamp))
- .put(amountToBuffer(wf.wireFee))
- .put(amountToBuffer(wf.closingFee))
- .build();
- const sig = decodeCrock(wf.sig);
- const pub = decodeCrock(masterPub);
- return eddsaVerify(p, sig, pub);
- }
-
- /**
- * Check if the signature of a denomination is valid.
- */
- isValidDenom(denom: DenominationRecord, masterPub: string): boolean {
- const p = buildSigPS(TalerSignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY)
- .put(decodeCrock(masterPub))
- .put(timestampRoundedToBuffer(denom.stampStart))
- .put(timestampRoundedToBuffer(denom.stampExpireWithdraw))
- .put(timestampRoundedToBuffer(denom.stampExpireDeposit))
- .put(timestampRoundedToBuffer(denom.stampExpireLegal))
- .put(amountToBuffer(denom.value))
- .put(amountToBuffer(denom.feeWithdraw))
- .put(amountToBuffer(denom.feeDeposit))
- .put(amountToBuffer(denom.feeRefresh))
- .put(amountToBuffer(denom.feeRefund))
- .put(decodeCrock(denom.denomPubHash))
- .build();
- const sig = decodeCrock(denom.masterSig);
- const pub = decodeCrock(masterPub);
- const res = eddsaVerify(p, sig, pub);
- return res;
- }
-
- isValidWireAccount(
- paytoUri: string,
- sig: string,
- masterPub: string,
- ): boolean {
- const h = kdf(
- 64,
- stringToBytes("exchange-wire-signature"),
- stringToBytes(paytoUri + "\0"),
- new Uint8Array(0),
- );
- const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS)
- .put(h)
- .build();
- return eddsaVerify(p, decodeCrock(sig), decodeCrock(masterPub));
- }
-
- isValidContractTermsSignature(
- contractTermsHash: string,
- sig: string,
- merchantPub: string,
- ): boolean {
- const cthDec = decodeCrock(contractTermsHash);
- const p = buildSigPS(TalerSignaturePurpose.MERCHANT_CONTRACT)
- .put(cthDec)
- .build();
- return eddsaVerify(p, decodeCrock(sig), decodeCrock(merchantPub));
- }
-
- /**
- * Create a new EdDSA key pair.
- */
- createEddsaKeypair(): { priv: string; pub: string } {
- const pair = createEddsaKeyPair();
- return {
- priv: encodeCrock(pair.eddsaPriv),
- pub: encodeCrock(pair.eddsaPub),
- };
- }
-
- eddsaGetPublic(key: string): { priv: string; pub: string } {
- return {
- priv: key,
- pub: encodeCrock(eddsaGetPublic(decodeCrock(key))),
- };
- }
-
- /**
- * Unblind a blindly signed value.
- */
- rsaUnblind(blindedSig: string, bk: string, pk: string): string {
- const denomSig = rsaUnblind(
- decodeCrock(blindedSig),
- decodeCrock(pk),
- decodeCrock(bk),
- );
- return encodeCrock(denomSig);
- }
-
- /**
- * Unblind a blindly signed value.
- */
- rsaVerify(hm: string, sig: string, pk: string): boolean {
- return rsaVerify(hash(decodeCrock(hm)), decodeCrock(sig), decodeCrock(pk));
- }
-
- /**
- * Generate updated coins (to store in the database)
- * and deposit permissions for each given coin.
- */
- signDepositPermission(depositInfo: DepositInfo): CoinDepositPermission {
- const d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT)
- .put(decodeCrock(depositInfo.contractTermsHash))
- .put(decodeCrock(depositInfo.wireInfoHash))
- .put(decodeCrock(depositInfo.denomPubHash))
- .put(timestampRoundedToBuffer(depositInfo.timestamp))
- .put(timestampRoundedToBuffer(depositInfo.refundDeadline))
- .put(amountToBuffer(depositInfo.spendAmount))
- .put(amountToBuffer(depositInfo.feeDeposit))
- .put(decodeCrock(depositInfo.merchantPub))
- .put(decodeCrock(depositInfo.coinPub))
- .build();
- const coinSig = eddsaSign(d, decodeCrock(depositInfo.coinPriv));
-
- const s: CoinDepositPermission = {
- coin_pub: depositInfo.coinPub,
- coin_sig: encodeCrock(coinSig),
- contribution: Amounts.stringify(depositInfo.spendAmount),
- h_denom: depositInfo.denomPubHash,
- exchange_url: depositInfo.exchangeBaseUrl,
- ub_sig: depositInfo.denomSig,
- };
- return s;
- }
-
- deriveRefreshSession(
- req: DeriveRefreshSessionRequest,
- ): DerivedRefreshSession {
- const {
- newCoinDenoms,
- feeRefresh: meltFee,
- kappa,
- meltCoinDenomPubHash,
- meltCoinPriv,
- meltCoinPub,
- sessionSecretSeed: refreshSessionSecretSeed,
- } = req;
-
- const currency = newCoinDenoms[0].value.currency;
- let valueWithFee = Amounts.getZero(currency);
-
- for (const ncd of newCoinDenoms) {
- const t = Amounts.add(ncd.value, ncd.feeWithdraw).amount;
- valueWithFee = Amounts.add(
- valueWithFee,
- Amounts.mult(t, ncd.count).amount,
- ).amount;
- }
-
- // melt fee
- valueWithFee = Amounts.add(valueWithFee, meltFee).amount;
-
- const sessionHc = createHashContext();
-
- const transferPubs: string[] = [];
- const transferPrivs: string[] = [];
-
- const planchetsForGammas: RefreshPlanchetInfo[][] = [];
-
- for (let i = 0; i < kappa; i++) {
- const transferKeyPair = setupRefreshTransferPub(
- decodeCrock(refreshSessionSecretSeed),
- i,
- );
- sessionHc.update(transferKeyPair.ecdhePub);
- transferPrivs.push(encodeCrock(transferKeyPair.ecdhePriv));
- transferPubs.push(encodeCrock(transferKeyPair.ecdhePub));
- }
-
- for (const denomSel of newCoinDenoms) {
- for (let i = 0; i < denomSel.count; i++) {
- const r = decodeCrock(denomSel.denomPub);
- sessionHc.update(r);
- }
- }
-
- sessionHc.update(decodeCrock(meltCoinPub));
- sessionHc.update(amountToBuffer(valueWithFee));
- for (let i = 0; i < kappa; i++) {
- const planchets: RefreshPlanchetInfo[] = [];
- for (let j = 0; j < newCoinDenoms.length; j++) {
- const denomSel = newCoinDenoms[j];
- for (let k = 0; k < denomSel.count; k++) {
- const coinNumber = planchets.length;
- const transferPriv = decodeCrock(transferPrivs[i]);
- const oldCoinPub = decodeCrock(meltCoinPub);
- const transferSecret = keyExchangeEcdheEddsa(
- transferPriv,
- oldCoinPub,
- );
- const fresh = setupRefreshPlanchet(transferSecret, coinNumber);
- const coinPriv = fresh.coinPriv;
- const coinPub = fresh.coinPub;
- const blindingFactor = fresh.bks;
- const pubHash = hash(coinPub);
- const denomPub = decodeCrock(denomSel.denomPub);
- const ev = rsaBlind(pubHash, blindingFactor, denomPub);
- const planchet: RefreshPlanchetInfo = {
- blindingKey: encodeCrock(blindingFactor),
- coinEv: encodeCrock(ev),
- privateKey: encodeCrock(coinPriv),
- publicKey: encodeCrock(coinPub),
- coinEvHash: encodeCrock(hash(ev)),
- };
- planchets.push(planchet);
- sessionHc.update(ev);
- }
- }
- planchetsForGammas.push(planchets);
- }
-
- const sessionHash = sessionHc.finish();
- const confirmData = buildSigPS(TalerSignaturePurpose.WALLET_COIN_MELT)
- .put(sessionHash)
- .put(decodeCrock(meltCoinDenomPubHash))
- .put(amountToBuffer(valueWithFee))
- .put(amountToBuffer(meltFee))
- .put(decodeCrock(meltCoinPub))
- .build();
-
- const confirmSig = eddsaSign(confirmData, decodeCrock(meltCoinPriv));
-
- const refreshSession: DerivedRefreshSession = {
- confirmSig: encodeCrock(confirmSig),
- hash: encodeCrock(sessionHash),
- meltCoinPub: meltCoinPub,
- planchetsForGammas: planchetsForGammas,
- transferPrivs,
- transferPubs,
- meltValueWithFee: valueWithFee,
- };
-
- return refreshSession;
- }
-
- /**
- * Hash a string including the zero terminator.
- */
- hashString(str: string): string {
- const b = stringToBytes(str + "\0");
- return encodeCrock(hash(b));
- }
-
- /**
- * Hash a crockford encoded value.
- */
- hashEncoded(encodedBytes: string): string {
- return encodeCrock(hash(decodeCrock(encodedBytes)));
- }
-
- signCoinLink(
- oldCoinPriv: string,
- newDenomHash: string,
- oldCoinPub: string,
- transferPub: string,
- coinEv: string,
- ): string {
- const coinEvHash = hash(decodeCrock(coinEv));
- const coinLink = buildSigPS(TalerSignaturePurpose.WALLET_COIN_LINK)
- .put(decodeCrock(newDenomHash))
- .put(decodeCrock(transferPub))
- .put(coinEvHash)
- .build();
- const coinPriv = decodeCrock(oldCoinPriv);
- const sig = eddsaSign(coinLink, coinPriv);
- return encodeCrock(sig);
- }
-
- benchmark(repetitions: number): BenchmarkResult {
- let time_hash = BigInt(0);
- for (let i = 0; i < repetitions; i++) {
- const start = timer.performanceNow();
- this.hashString("hello world");
- time_hash += timer.performanceNow() - start;
- }
-
- let time_hash_big = BigInt(0);
- for (let i = 0; i < repetitions; i++) {
- const ba = randomBytes(4096);
- const start = timer.performanceNow();
- hash(ba);
- time_hash_big += timer.performanceNow() - start;
- }
-
- let time_eddsa_create = BigInt(0);
- for (let i = 0; i < repetitions; i++) {
- const start = timer.performanceNow();
- createEddsaKeyPair();
- time_eddsa_create += timer.performanceNow() - start;
- }
-
- let time_eddsa_sign = BigInt(0);
- const p = randomBytes(4096);
-
- const pair = createEddsaKeyPair();
-
- for (let i = 0; i < repetitions; i++) {
- const start = timer.performanceNow();
- eddsaSign(p, pair.eddsaPriv);
- time_eddsa_sign += timer.performanceNow() - start;
- }
-
- const sig = eddsaSign(p, pair.eddsaPriv);
-
- let time_eddsa_verify = BigInt(0);
- for (let i = 0; i < repetitions; i++) {
- const start = timer.performanceNow();
- eddsaVerify(p, sig, pair.eddsaPub);
- time_eddsa_verify += timer.performanceNow() - start;
- }
-
- return {
- repetitions,
- time: {
- hash_small: Number(time_hash),
- hash_big: Number(time_hash_big),
- eddsa_create: Number(time_eddsa_create),
- eddsa_sign: Number(time_eddsa_sign),
- eddsa_verify: Number(time_eddsa_verify),
- },
- };
- }
-
- makeSyncSignature(req: MakeSyncSignatureRequest): string {
- const hNew = decodeCrock(req.newHash);
- let hOld: Uint8Array;
- if (req.oldHash) {
- hOld = decodeCrock(req.oldHash);
- } else {
- hOld = new Uint8Array(64);
- }
- const sigBlob = buildSigPS(TalerSignaturePurpose.SYNC_BACKUP_UPLOAD)
- .put(hOld)
- .put(hNew)
- .build();
- const uploadSig = eddsaSign(sigBlob, decodeCrock(req.accountPriv));
- return encodeCrock(uploadSig);
- }
-}
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts
deleted file mode 100644
index 9f3ee6f50..000000000
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export interface CryptoWorker {
- postMessage(message: any): void;
-
- terminate(): void;
-
- onmessage: ((m: any) => void) | undefined;
- onerror: ((m: any) => void) | undefined;
-}
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts
new file mode 100644
index 000000000..b3620e950
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ 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 { TalerErrorDetail } from "@gnu-taler/taler-util";
+
+/**
+ * Common interface for all crypto workers.
+ */
+export interface CryptoWorker {
+ postMessage(message: any): void;
+ terminate(): void;
+ onmessage: ((m: any) => void) | undefined;
+ onerror: ((m: any) => void) | undefined;
+}
+
+/**
+ * Type of requests sent to the crypto worker.
+ */
+export type CryptoWorkerRequestMessage = {
+ /**
+ * Operation ID to correlate request with the response.
+ */
+ id: number;
+
+ /**
+ * Operation to execute.
+ */
+ operation: string;
+
+ /**
+ * Operation-specific request payload.
+ */
+ req: any;
+};
+
+/**
+ * Type of messages sent back by the crypto worker.
+ */
+export type CryptoWorkerResponseMessage =
+ | {
+ type: "success";
+ id: number;
+ result: any;
+ }
+ | {
+ type: "error";
+ id?: number;
+ error: TalerErrorDetail;
+ };
diff --git a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
index 3f7f9e170..eaa0108bb 100644
--- a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
@@ -17,15 +17,19 @@
/**
* Imports
*/
-import { CryptoWorkerFactory } from "./cryptoApi.js";
-import { CryptoWorker } from "./cryptoWorker.js";
-import os from "os";
-import { CryptoImplementation } from "./cryptoImplementation.js";
import { Logger } from "@gnu-taler/taler-util";
+import os from "os";
+import url from "url";
+import { nativeCryptoR } from "../cryptoImplementation.js";
+import { CryptoWorkerFactory } from "./crypto-dispatcher.js";
+import { CryptoWorker } from "./cryptoWorkerInterface.js";
+import { processRequestWithImpl } from "./worker-common.js";
const logger = new Logger("nodeThreadWorker.ts");
-const f = __filename;
+const f = import.meta.url
+ ? url.fileURLToPath(import.meta.url)
+ : "__not_available__";
const workerCode = `
// Try loading the glue library for embedded
@@ -69,59 +73,31 @@ const workerCode = `
* a message.
*/
export function handleWorkerMessage(msg: any): void {
- const args = msg.args;
- if (!Array.isArray(args)) {
- console.error("args must be array");
- return;
- }
- const id = msg.id;
- if (typeof id !== "number") {
- console.error("RPC id must be number");
- return;
- }
- const operation = msg.operation;
- if (typeof operation !== "string") {
- console.error("RPC operation must be string");
- return;
- }
-
const handleRequest = async (): Promise<void> => {
- const impl = new CryptoImplementation();
-
- if (!(operation in impl)) {
- console.error(`crypto operation '${operation}' not found`);
- return;
- }
-
+ const responseMsg = await processRequestWithImpl(msg, nativeCryptoR);
try {
- const result = (impl as any)[operation](...args);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const _r = "require";
- const worker_threads: typeof import("worker_threads") = module[_r](
- "worker_threads",
- );
+ const worker_threads: typeof import("worker_threads") =
+ module[_r]("worker_threads");
// const worker_threads = require("worker_threads");
-
const p = worker_threads.parentPort;
- worker_threads.parentPort?.postMessage;
if (p) {
- p.postMessage({ data: { result, id } });
+ p.postMessage(responseMsg);
} else {
- console.error("parent port not available (not running in thread?");
+ logger.error("parent port not available (not running in thread?");
}
- } catch (e) {
- console.error("error during operation", e);
+ } catch (e: any) {
+ logger.error(`error in node worker: ${e.stack ?? e.toString()}`);
return;
}
};
- handleRequest().catch((e) => {
- console.error("error in node worker", e);
- });
+ handleRequest();
}
export function handleWorkerError(e: Error): void {
- console.log("got error from worker", e);
+ logger.error(`got error from worker: ${e.stack ?? e.toString()}`);
}
export class NodeThreadCryptoWorkerFactory implements CryptoWorkerFactory {
@@ -162,7 +138,7 @@ class NodeThreadCryptoWorker implements CryptoWorker {
this.nodeWorker = new worker_threads.Worker(workerCode, { eval: true });
this.nodeWorker.on("error", (err: Error) => {
- console.error("error in node worker:", err);
+ logger.error("error in node worker:", err);
if (this.onerror) {
this.onerror(err);
}
@@ -175,7 +151,7 @@ class NodeThreadCryptoWorker implements CryptoWorker {
this.onmessage(v);
}
});
- this.nodeWorker.unref();
+ //this.nodeWorker.unref();
}
/**
diff --git a/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts b/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts
deleted file mode 100644
index f6b8ac5d7..000000000
--- a/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- 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 { CryptoImplementation } from "./cryptoImplementation.js";
-
-import { CryptoWorkerFactory } from "./cryptoApi.js";
-import { CryptoWorker } from "./cryptoWorker.js";
-
-/**
- * The synchronous crypto worker produced by this factory doesn't run in the
- * background, but actually blocks the caller until the operation is done.
- */
-export class SynchronousCryptoWorkerFactory implements CryptoWorkerFactory {
- startWorker(): CryptoWorker {
- if (typeof require === "undefined") {
- throw Error("cannot make worker, require(...) not defined");
- }
- return new SynchronousCryptoWorker();
- }
-
- getConcurrency(): number {
- return 1;
- }
-}
-
-/**
- * Worker implementation that uses node subprocesses.
- */
-export class SynchronousCryptoWorker {
- /**
- * Function to be called when we receive a message from the worker thread.
- */
- onmessage: undefined | ((m: any) => void);
-
- /**
- * Function to be called when we receive an error from the worker thread.
- */
- onerror: undefined | ((m: any) => void);
-
- constructor() {
- this.onerror = undefined;
- this.onmessage = undefined;
- }
-
- /**
- * Add an event listener for either an "error" or "message" event.
- */
- addEventListener(event: "message" | "error", fn: (x: any) => void): void {
- switch (event) {
- case "message":
- this.onmessage = fn;
- break;
- case "error":
- this.onerror = fn;
- break;
- }
- }
-
- private dispatchMessage(msg: any): void {
- if (this.onmessage) {
- this.onmessage({ data: msg });
- }
- }
-
- private async handleRequest(
- operation: string,
- id: number,
- args: string[],
- ): Promise<void> {
- const impl = new CryptoImplementation();
-
- if (!(operation in impl)) {
- console.error(`crypto operation '${operation}' not found`);
- return;
- }
-
- let result: any;
- try {
- result = (impl as any)[operation](...args);
- } catch (e) {
- console.log("error during operation", e);
- return;
- }
-
- try {
- setTimeout(() => this.dispatchMessage({ result, id }), 0);
- } catch (e) {
- console.log("got error during dispatch", e);
- }
- }
-
- /**
- * Send a message to the worker thread.
- */
- postMessage(msg: any): void {
- const args = msg.args;
- if (!Array.isArray(args)) {
- console.error("args must be array");
- return;
- }
- const id = msg.id;
- if (typeof id !== "number") {
- console.error("RPC id must be number");
- return;
- }
- const operation = msg.operation;
- if (typeof operation !== "string") {
- console.error("RPC operation must be string");
- return;
- }
-
- this.handleRequest(operation, id, args).catch((e) => {
- console.error("Error while handling crypto request:", e);
- });
- }
-
- /**
- * Forcibly terminate the worker thread.
- */
- terminate(): void {
- // This is a no-op.
- }
-}
diff --git a/packages/taler-wallet-core/src/util/invariants.ts b/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerFactoryPlain.ts
index b788d044e..66381bc0e 100644
--- a/packages/taler-wallet-core/src/util/invariants.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerFactoryPlain.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
+ (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
@@ -15,25 +15,24 @@
*/
/**
- * Helpers for invariants.
+ * Imports.
*/
+import { CryptoWorkerFactory } from "./crypto-dispatcher.js";
+import { CryptoWorker } from "./cryptoWorkerInterface.js";
+import { SynchronousCryptoWorkerPlain } from "./synchronousWorkerPlain.js";
-export function checkDbInvariant(b: boolean, m?: string): asserts b {
- if (!b) {
- if (m) {
- throw Error(`BUG: database invariant failed (${m})`);
- } else {
- throw Error("BUG: database invariant failed");
- }
+/**
+ * The synchronous crypto worker produced by this factory doesn't run in the
+ * background, but actually blocks the caller until the operation is done.
+ */
+export class SynchronousCryptoWorkerFactoryPlain
+ implements CryptoWorkerFactory
+{
+ startWorker(): CryptoWorker {
+ return new SynchronousCryptoWorkerPlain();
}
-}
-export function checkLogicInvariant(b: boolean, m?: string): asserts b {
- if (!b) {
- if (m) {
- throw Error(`BUG: logic invariant failed (${m})`);
- } else {
- throw Error("BUG: logic invariant failed");
- }
+ getConcurrency(): number {
+ return 1;
}
}
diff --git a/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerPlain.ts b/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerPlain.ts
new file mode 100644
index 000000000..c80f2f58f
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerPlain.ts
@@ -0,0 +1,98 @@
+/*
+ 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 { j2s, Logger } from "@gnu-taler/taler-util";
+import {
+ nativeCryptoR,
+ TalerCryptoInterfaceR,
+} from "../cryptoImplementation.js";
+import { CryptoWorker } from "./cryptoWorkerInterface.js";
+import { processRequestWithImpl } from "./worker-common.js";
+
+const logger = new Logger("synchronousWorker.ts");
+
+/**
+ * Worker implementation that synchronously executes cryptographic
+ * operations.
+ */
+export class SynchronousCryptoWorkerPlain implements CryptoWorker {
+ /**
+ * Function to be called when we receive a message from the worker thread.
+ */
+ onmessage: undefined | ((m: any) => void);
+
+ /**
+ * Function to be called when we receive an error from the worker thread.
+ */
+ onerror: undefined | ((m: any) => void);
+
+ cryptoImplR: TalerCryptoInterfaceR;
+
+ constructor() {
+ this.onerror = undefined;
+ this.onmessage = undefined;
+ this.cryptoImplR = { ...nativeCryptoR };
+ }
+
+ /**
+ * Add an event listener for either an "error" or "message" event.
+ */
+ addEventListener(event: "message" | "error", fn: (x: any) => void): void {
+ switch (event) {
+ case "message":
+ this.onmessage = fn;
+ break;
+ case "error":
+ this.onerror = fn;
+ break;
+ }
+ }
+
+ private dispatchMessage(msg: any): void {
+ if (this.onmessage) {
+ this.onmessage(msg);
+ }
+ }
+
+ /**
+ * Send a message to the worker thread.
+ */
+ postMessage(msg: any): void {
+ const handleRequest = async () => {
+ const responseMsg = await processRequestWithImpl(msg, this.cryptoImplR);
+ try {
+ setTimeout(() => this.dispatchMessage(responseMsg), 0);
+ } catch (e) {
+ logger.error("got error during dispatch", e);
+ }
+ };
+ handleRequest().catch((e) => {
+ logger.error("Error while handling crypto request:", e);
+ logger.error("Stack:", e.stack);
+ logger.error(`request was ${j2s(msg)}`);
+ });
+ }
+
+ /**
+ * Forcibly terminate the worker thread.
+ */
+ terminate(): void {
+ // This is a no-op.
+ }
+}
diff --git a/packages/taler-wallet-core/src/crypto/workers/worker-common.ts b/packages/taler-wallet-core/src/crypto/workers/worker-common.ts
new file mode 100644
index 000000000..63147ce92
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/worker-common.ts
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ 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 {
+ j2s,
+ Logger,
+ stringifyError as safeStringifyError,
+ TalerErrorCode,
+} from "@gnu-taler/taler-util";
+import {
+ getErrorDetailFromException,
+ makeErrorDetail,
+} from "@gnu-taler/taler-util";
+import { TalerCryptoInterfaceR } from "../cryptoImplementation.js";
+import {
+ CryptoWorkerRequestMessage,
+ CryptoWorkerResponseMessage,
+} from "./cryptoWorkerInterface.js";
+
+const logger = new Logger("worker-common.ts");
+
+/**
+ * Process a crypto worker request by calling into the table
+ * of supported operations.
+ *
+ * Does not throw, but returns an error response instead.
+ */
+export async function processRequestWithImpl(
+ reqMsg: CryptoWorkerRequestMessage,
+ impl: TalerCryptoInterfaceR,
+): Promise<CryptoWorkerResponseMessage> {
+ if (typeof reqMsg !== "object") {
+ logger.error("request must be an object");
+ return {
+ type: "error",
+ error: makeErrorDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST, {
+ detail: "",
+ }),
+ };
+ }
+ const id = reqMsg.id;
+ if (typeof id !== "number") {
+ const msg = "RPC id must be number";
+ logger.error(msg);
+ return {
+ type: "error",
+ error: makeErrorDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST, {
+ detail: msg,
+ }),
+ };
+ }
+ const operation = reqMsg.operation;
+ if (typeof operation !== "string") {
+ const msg = "RPC operation must be string";
+ logger.error(msg);
+ return {
+ type: "error",
+ id,
+ error: makeErrorDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST, {
+ detail: msg,
+ }),
+ };
+ }
+
+ if (!(operation in impl)) {
+ const msg = `crypto operation '${operation}' not found`;
+ logger.error(msg);
+ return {
+ type: "error",
+ id,
+ error: makeErrorDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST, {
+ detail: msg,
+ }),
+ };
+ }
+
+ let responseMsg: CryptoWorkerResponseMessage;
+
+ try {
+ const result = await (impl as any)[operation](impl, reqMsg.req);
+ responseMsg = { type: "success", result, id };
+ } catch (e: any) {
+ logger.error(`error during operation: ${safeStringifyError(e)}`);
+ responseMsg = {
+ type: "error",
+ error: getErrorDetailFromException(e),
+ id,
+ };
+ }
+ return responseMsg;
+}
diff --git a/packages/taler-wallet-core/src/db-utils.ts b/packages/taler-wallet-core/src/db-utils.ts
deleted file mode 100644
index 075bddde5..000000000
--- a/packages/taler-wallet-core/src/db-utils.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- 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 { IDBDatabase, IDBFactory, IDBTransaction } from "@gnu-taler/idb-bridge";
-import { Logger } from "@gnu-taler/taler-util";
-import {
- CURRENT_DB_CONFIG_KEY,
- TALER_DB_NAME,
- TALER_META_DB_NAME,
- walletMetadataStore,
- WalletStoresV1,
- WALLET_DB_MINOR_VERSION,
-} from "./db.js";
-import {
- DbAccess,
- IndexDescriptor,
- openDatabase,
- StoreDescriptor,
- StoreWithIndexes,
-} from "./util/query.js";
-
-const logger = new Logger("db-utils.ts");
-
-function upgradeFromStoreMap(
- storeMap: any,
- db: IDBDatabase,
- oldVersion: number,
- newVersion: number,
- upgradeTransaction: IDBTransaction,
-): void {
- if (oldVersion === 0) {
- for (const n in storeMap) {
- const swi: StoreWithIndexes<StoreDescriptor<unknown>, any> = storeMap[n];
- const storeDesc: StoreDescriptor<unknown> = swi.store;
- const s = db.createObjectStore(storeDesc.name, {
- autoIncrement: storeDesc.autoIncrement,
- keyPath: storeDesc.keyPath,
- });
- for (const indexName in swi.indexMap as any) {
- const indexDesc: IndexDescriptor = swi.indexMap[indexName];
- s.createIndex(indexDesc.name, indexDesc.keyPath, {
- multiEntry: indexDesc.multiEntry,
- });
- }
- }
- return;
- }
- if (oldVersion === newVersion) {
- return;
- }
- logger.info(`upgrading database from ${oldVersion} to ${newVersion}`);
- throw Error("upgrade not supported");
-}
-
-function onTalerDbUpgradeNeeded(
- db: IDBDatabase,
- oldVersion: number,
- newVersion: number,
- upgradeTransaction: IDBTransaction,
-) {
- upgradeFromStoreMap(
- WalletStoresV1,
- db,
- oldVersion,
- newVersion,
- upgradeTransaction,
- );
-}
-
-function onMetaDbUpgradeNeeded(
- db: IDBDatabase,
- oldVersion: number,
- newVersion: number,
- upgradeTransaction: IDBTransaction,
-) {
- upgradeFromStoreMap(
- walletMetadataStore,
- db,
- oldVersion,
- newVersion,
- upgradeTransaction,
- );
-}
-
-/**
- * Return a promise that resolves
- * to the taler wallet db.
- */
-export async function openTalerDatabase(
- idbFactory: IDBFactory,
- onVersionChange: () => void,
-): Promise<DbAccess<typeof WalletStoresV1>> {
- const metaDbHandle = await openDatabase(
- idbFactory,
- TALER_META_DB_NAME,
- 1,
- () => {},
- onMetaDbUpgradeNeeded,
- );
-
- const metaDb = new DbAccess(metaDbHandle, walletMetadataStore);
- let currentMainVersion: string | undefined;
- await metaDb
- .mktx((x) => ({
- metaConfig: x.metaConfig,
- }))
- .runReadWrite(async (tx) => {
- const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
- if (!dbVersionRecord) {
- currentMainVersion = TALER_DB_NAME;
- await tx.metaConfig.put({
- key: CURRENT_DB_CONFIG_KEY,
- value: TALER_DB_NAME,
- });
- } else {
- currentMainVersion = dbVersionRecord.value;
- }
- });
-
- if (currentMainVersion !== TALER_DB_NAME) {
- switch (currentMainVersion) {
- case "taler-wallet-main-v2":
- // We consider this a pre-release
- // development version, no migration is done.
- await metaDb
- .mktx((x) => ({
- metaConfig: x.metaConfig,
- }))
- .runReadWrite(async (tx) => {
- await tx.metaConfig.put({
- key: CURRENT_DB_CONFIG_KEY,
- value: TALER_DB_NAME,
- });
- });
- break;
- default:
- throw Error(
- `migration from database ${currentMainVersion} not supported`,
- );
- }
- }
-
- const mainDbHandle = await openDatabase(
- idbFactory,
- TALER_DB_NAME,
- WALLET_DB_MINOR_VERSION,
- onVersionChange,
- onTalerDbUpgradeNeeded,
- );
-
- return new DbAccess(mainDbHandle, WalletStoresV1);
-}
-
-export function deleteTalerDatabase(idbFactory: IDBFactory): void {
- idbFactory.deleteDatabase(TALER_DB_NAME);
-}
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
index 902f749cf..b75e48c39 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2021-2024 Taler Systems S.A.
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
@@ -18,27 +18,101 @@
* Imports.
*/
import {
- describeStore,
- describeContents,
- describeIndex,
-} from "./util/query.js";
+ Event,
+ IDBDatabase,
+ IDBFactory,
+ IDBObjectStore,
+ IDBRequest,
+ IDBTransaction,
+ structuredEncapsulate,
+ structuredRevive,
+} from "@gnu-taler/idb-bridge";
import {
- AmountJson,
+ AbsoluteTime,
+ AgeCommitmentProof,
AmountString,
- Auditor,
- CoinDepositPermission,
- ContractTerms,
- Duration,
- ExchangeSignKeyJson,
- InternationalizedString,
- MerchantInfo,
- Product,
+ Amounts,
+ AttentionInfo,
+ BackupProviderTerms,
+ CancellationToken,
+ Codec,
+ CoinEnvelope,
+ CoinPublicKeyString,
+ CoinRefreshRequest,
+ CoinStatus,
+ DenomLossEventType,
+ DenomSelectionState,
+ DenominationInfo,
+ DenominationPubKey,
+ EddsaPublicKeyString,
+ EddsaSignatureString,
+ ExchangeAuditor,
+ ExchangeGlobalFees,
+ HashCodeString,
+ Logger,
RefreshReason,
- TalerErrorDetails,
- Timestamp,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ Transaction,
+ TransactionIdStr,
+ UnblindedSignature,
+ WireInfo,
+ WithdrawalExchangeAccountDetails,
+ codecForAny,
} from "@gnu-taler/taler-util";
-import { RetryInfo } from "./util/retries.js";
-import { PayCoinSelection } from "./util/coinSelection.js";
+import { DbRetryInfo, TaskIdentifiers } from "./common.js";
+import {
+ DbAccess,
+ DbAccessImpl,
+ DbReadOnlyTransaction,
+ DbReadWriteTransaction,
+ IndexDescriptor,
+ StoreDescriptor,
+ StoreNames,
+ StoreWithIndexes,
+ describeContents,
+ describeIndex,
+ describeStore,
+ describeStoreV2,
+ openDatabase,
+} from "./query.js";
+
+/**
+ * This file contains the database schema of the Taler wallet together
+ * with some helper functions.
+ *
+ * Some design considerations:
+ * - By convention, each object store must have a corresponding "<Name>Record"
+ * interface defined for it.
+ * - For records that represent operations, there should be exactly
+ * one top-level enum field that indicates the status of the operation.
+ * This field should be present even if redundant, because the field
+ * will have an index.
+ * - Amounts are stored as strings, except when they are needed for
+ * indexing.
+ * - Every record that has a corresponding transaction item must have
+ * an index for a mandatory timestamp field.
+ * - Optional fields should be avoided, use "T | undefined" instead.
+ * - Do all records have some obvious, indexed field that can
+ * be used for range queries?
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ FIXMEs:
+ - Contract terms can be quite large. We currently tend to read the
+ full contract terms from the DB quite often.
+ Instead, we should probably extract what we need into a separate object
+ store.
+ - More object stores should have an "id" primary key,
+ as this makes referencing less expensive.
+ - Coin selections should probably go into a separate object store.
+ - Some records should be split up into an extra "details" record
+ that we don't always need to iterate over.
+ */
/**
* Name of the Taler database. This is effectively the major
@@ -46,7 +120,7 @@ import { PayCoinSelection } from "./util/coinSelection.js";
* for all previous versions must be written, which should be
* avoided.
*/
-export const TALER_DB_NAME = "taler-wallet-main-v3";
+export const TALER_WALLET_MAIN_DB_NAME = "taler-wallet-main-v10";
/**
* Name of the metadata database. This database is used
@@ -54,8 +128,20 @@ export const TALER_DB_NAME = "taler-wallet-main-v3";
*
* (Minor migrations are handled via upgrade transactions.)
*/
-export const TALER_META_DB_NAME = "taler-wallet-meta";
+export const TALER_WALLET_META_DB_NAME = "taler-wallet-meta";
+
+/**
+ * Name of the "stored backups" database.
+ * Stored backups are created before manually importing a backup.
+ * We use IndexedDB for this purpose, since we don't have file system
+ * access on some platforms.
+ */
+export const TALER_WALLET_STORED_BACKUPS_DB_NAME =
+ "taler-wallet-stored-backups";
+/**
+ * Name of the "meta config" database.
+ */
export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
/**
@@ -65,237 +151,290 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
* backwards-compatible way or object stores and indices
* are added.
*/
-export const WALLET_DB_MINOR_VERSION = 1;
+export const WALLET_DB_MINOR_VERSION = 10;
-export enum ReserveRecordStatus {
- /**
- * Reserve must be registered with the bank.
- */
- REGISTERING_BANK = "registering-bank",
+declare const symDbProtocolTimestamp: unique symbol;
- /**
- * We've registered reserve's information with the bank
- * and are now waiting for the user to confirm the withdraw
- * with the bank (typically 2nd factor auth).
- */
- WAIT_CONFIRM_BANK = "wait-confirm-bank",
+declare const symDbPreciseTimestamp: unique symbol;
- /**
- * Querying reserve status with the exchange.
- */
- QUERYING_STATUS = "querying-status",
+/**
+ * Timestamp, stored as microseconds.
+ *
+ * Always rounded to a full second.
+ */
+export type DbProtocolTimestamp = number & { [symDbProtocolTimestamp]: true };
- /**
- * The corresponding withdraw record has been created.
- * No further processing is done, unless explicitly requested
- * by the user.
- */
- DORMANT = "dormant",
+/**
+ * Timestamp, stored as microseconds.
+ */
+export type DbPreciseTimestamp = number & { [symDbPreciseTimestamp]: true };
- /**
- * The bank aborted the withdrawal.
- */
- BANK_ABORTED = "bank-aborted",
+const DB_TIMESTAMP_FOREVER = Number.MAX_SAFE_INTEGER;
+
+export function timestampPreciseFromDb(
+ dbTs: DbPreciseTimestamp,
+): TalerPreciseTimestamp {
+ return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
}
-/**
- * Extra info about a reserve that is used
- * with a bank-integrated withdrawal.
- */
-export interface ReserveBankInfo {
- /**
- * Status URL that the wallet will use to query the status
- * of the Taler withdrawal operation on the bank's side.
- */
- statusUrl: string;
+export function timestampOptionalPreciseFromDb(
+ dbTs: DbPreciseTimestamp | undefined,
+): TalerPreciseTimestamp | undefined {
+ if (!dbTs) {
+ return undefined;
+ }
+ return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
+}
- /**
- * URL that the user can be redirected to, and allows
- * them to confirm (or abort) the bank-integrated withdrawal.
- */
- confirmUrl?: string;
+export function timestampPreciseToDb(
+ stamp: TalerPreciseTimestamp,
+): DbPreciseTimestamp {
+ if (stamp.t_s === "never") {
+ return DB_TIMESTAMP_FOREVER as DbPreciseTimestamp;
+ } else {
+ let tUs = stamp.t_s * 1000000;
+ if (stamp.off_us) {
+ tUs += stamp.off_us;
+ }
+ return tUs as DbPreciseTimestamp;
+ }
+}
- /**
- * Exchange payto URI that the bank will use to fund the reserve.
- */
- exchangePaytoUri: string;
+export function timestampProtocolToDb(
+ stamp: TalerProtocolTimestamp,
+): DbProtocolTimestamp {
+ if (stamp.t_s === "never") {
+ return DB_TIMESTAMP_FOREVER as DbProtocolTimestamp;
+ } else {
+ let tUs = stamp.t_s * 1000000;
+ return tUs as DbProtocolTimestamp;
+ }
+}
+
+export function timestampProtocolFromDb(
+ stamp: DbProtocolTimestamp,
+): TalerProtocolTimestamp {
+ return TalerProtocolTimestamp.fromSeconds(Math.floor(stamp / 1000000));
+}
+
+export function timestampAbsoluteFromDb(
+ stamp: DbProtocolTimestamp | DbPreciseTimestamp,
+): AbsoluteTime {
+ if (stamp >= DB_TIMESTAMP_FOREVER) {
+ return AbsoluteTime.never();
+ }
+ return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000));
+}
+
+export function timestampOptionalAbsoluteFromDb(
+ stamp: DbProtocolTimestamp | DbPreciseTimestamp | undefined,
+): AbsoluteTime | undefined {
+ if (stamp == null) {
+ return undefined;
+ }
+ if (stamp >= DB_TIMESTAMP_FOREVER) {
+ return AbsoluteTime.never();
+ }
+ return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000));
}
/**
- * A reserve record as stored in the wallet's database.
+ * Format of the operation status code: 0x0abc_nnnn
+
+ * a=1: active
+ * 0x0100_nnnn: pending
+ * 0x0101_nnnn: dialog
+ * 0x0102_nnnn: (reserved)
+ * 0x0103_nnnn: aborting
+ * 0x0110_nnnn: suspended
+ * 0x0113_nnnn: suspended-aborting
+ * a=5: final
+ * 0x0500_nnnn: done
+ * 0x0501_nnnn: failed
+ * 0x0502_nnnn: expired
+ * 0x0503_nnnn: aborted
+ *
+ * nnnn=0000 should always be the most generic minor state for the major state
*/
-export interface ReserveRecord {
- /**
- * The reserve public key.
- */
- reservePub: string;
- /**
- * The reserve private key.
- */
- reservePriv: string;
+/**
+ * First possible operation status in the active range (inclusive).
+ */
+export const OPERATION_STATUS_ACTIVE_FIRST = 0x0100_0000;
+
+/**
+ * LAST possible operation status in the active range (inclusive).
+ */
+export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_ffff;
+/**
+ * Status of a withdrawal.
+ */
+export enum WithdrawalGroupStatus {
/**
- * The exchange base URL.
+ * Reserve must be registered with the bank.
*/
- exchangeBaseUrl: string;
+ PendingRegisteringBank = 0x0100_0001,
+ SuspendedRegisteringBank = 0x0110_0001,
/**
- * Currency of the reserve.
+ * We've registered reserve's information with the bank
+ * and are now waiting for the user to confirm the withdraw
+ * with the bank (typically 2nd factor auth).
*/
- currency: string;
+ PendingWaitConfirmBank = 0x0100_0002,
+ SuspendedWaitConfirmBank = 0x0110_0002,
/**
- * Time when the reserve was created.
+ * Querying reserve status with the exchange.
*/
- timestampCreated: Timestamp;
+ PendingQueryingStatus = 0x0100_0003,
+ SuspendedQueryingStatus = 0x0110_0003,
/**
- * Time when the information about this reserve was posted to the bank.
- *
- * Only applies if bankWithdrawStatusUrl is defined.
- *
- * Set to 0 if that hasn't happened yet.
+ * Ready for withdrawal.
*/
- timestampReserveInfoPosted: Timestamp | undefined;
+ PendingReady = 0x0100_0004,
+ SuspendedReady = 0x0110_0004,
/**
- * Time when the reserve was confirmed by the bank.
- *
- * Set to undefined if not confirmed yet.
+ * Proposed to the user, has can choose to accept/refuse.
*/
- timestampBankConfirmed: Timestamp | undefined;
+ DialogProposed = 0x0101_0000,
/**
- * Wire information (as payto URI) for the bank account that
- * transferred funds for this reserve.
+ * We are telling the bank that we don't want to complete
+ * the withdrawal!
*/
- senderWire?: string;
+ AbortingBank = 0x0103_0001,
+ SuspendedAbortingBank = 0x0113_0001,
/**
- * Amount that was sent by the user to fund the reserve.
+ * Exchange wants KYC info from the user.
*/
- instructedAmount: AmountJson;
+ PendingKyc = 0x0100_0005,
+ SuspendedKyc = 0x0110_005,
/**
- * Extra state for when this is a withdrawal involving
- * a Taler-integrated bank.
+ * Exchange is doing AML checks.
*/
- bankInfo?: ReserveBankInfo;
-
- initialWithdrawalGroupId: string;
+ PendingAml = 0x0100_0006,
+ SuspendedAml = 0x0110_0006,
/**
- * Did we start the first withdrawal for this reserve?
- *
- * We only report a pending withdrawal for the reserve before
- * the first withdrawal has started.
+ * The corresponding withdraw record has been created.
+ * No further processing is done, unless explicitly requested
+ * by the user.
*/
- initialWithdrawalStarted: boolean;
+ Done = 0x0500_0000,
/**
- * Initial denomination selection, stored here so that
- * we can show this information in the transactions/balances
- * before we have a withdrawal group.
+ * The bank aborted the withdrawal.
*/
- initialDenomSel: DenomSelectionState;
+ FailedBankAborted = 0x0501_0001,
- reserveStatus: ReserveRecordStatus;
+ FailedAbortingBank = 0x0501_0002,
/**
- * Was a reserve query requested? If so, query again instead
- * of going into dormant status.
+ * Aborted in a state where we were supposed to
+ * talk to the exchange. Money might have been
+ * wired or not.
*/
- requestedQuery: boolean;
+ AbortedExchange = 0x0503_0001,
- /**
- * Time of the last successful status query.
- */
- lastSuccessfulStatusQuery: Timestamp | undefined;
+ AbortedBank = 0x0503_0002,
/**
- * Retry info. This field is present even if no retry is scheduled,
- * because we need it to be present for the index on the object store
- * to work.
+ * User didn't refused the withdrawal.
*/
- retryInfo: RetryInfo;
+ AbortedUserRefused = 0x0503_0003,
/**
- * Last error that happened in a reserve operation
- * (either talking to the bank or the exchange).
+ * Another wallet confirmed the withdrawal
+ * (by POSTing the reserve pub to the bank)
+ * before we had the chance.
+ *
+ * In this situation, we'll let the other wallet continue
+ * and give up ourselves.
*/
- lastError: TalerErrorDetails | undefined;
+ AbortedOtherWallet = 0x0503_0004,
}
/**
- * Record that indicates the wallet trusts
- * a particular auditor.
+ * Extra info about a withdrawal that is used
+ * with a bank-integrated withdrawal.
*/
-export interface AuditorTrustRecord {
+export interface ReserveBankInfo {
+ talerWithdrawUri: string;
+
/**
- * Currency that we trust this auditor for.
+ * URL that the user can be redirected to, and allows
+ * them to confirm (or abort) the bank-integrated withdrawal.
*/
- currency: string;
+ confirmUrl: string | undefined;
/**
- * Base URL of the auditor.
+ * Exchange payto URI that the bank will use to fund the reserve.
*/
- auditorBaseUrl: string;
+ exchangePaytoUri: string;
/**
- * Public key of the auditor.
+ * Time when the information about this reserve was posted to the bank.
+ *
+ * Only applies if bankWithdrawStatusUrl is defined.
+ *
+ * Set to undefined if that hasn't happened yet.
*/
- auditorPub: string;
+ timestampReserveInfoPosted: DbPreciseTimestamp | undefined;
/**
- * UIDs for the operation of adding this auditor
- * as a trusted auditor.
+ * Time when the reserve was confirmed by the bank.
+ *
+ * Set to undefined if not confirmed yet.
*/
- uids: string[];
+ timestampBankConfirmed: DbPreciseTimestamp | undefined;
}
/**
- * Record to indicate trust for a particular exchange.
+ * Status of a denomination.
*/
-export interface ExchangeTrustRecord {
+export enum DenominationVerificationStatus {
/**
- * Currency that we trust this exchange for.
+ * Verification was delayed (pending).
*/
- currency: string;
+ Unverified = 0x0100_0000,
/**
- * Canonicalized exchange base URL.
+ * Verified as valid.
*/
- exchangeBaseUrl: string;
+ VerifiedGood = 0x0500_0000,
/**
- * Master public key of the exchange.
+ * Verified as invalid.
*/
- exchangeMasterPub: string;
+ VerifiedBad = 0x0501_0000,
+}
+export interface DenomFees {
/**
- * UIDs for the operation of adding this exchange
- * as trusted.
+ * Fee for withdrawing.
*/
- uids: string[];
-}
+ feeWithdraw: AmountString;
-/**
- * Status of a denomination.
- */
-export enum DenominationVerificationStatus {
/**
- * Verification was delayed.
+ * Fee for depositing.
*/
- Unverified = "unverified",
+ feeDeposit: AmountString;
+
/**
- * Verified as valid.
+ * Fee for refreshing.
*/
- VerifiedGood = "verified-good",
+ feeRefresh: AmountString;
+
/**
- * Verified as invalid.
+ * Fee for refunding.
*/
- VerifiedBad = "verified-bad",
+ feeRefund: AmountString;
}
/**
@@ -303,14 +442,18 @@ export enum DenominationVerificationStatus {
*/
export interface DenominationRecord {
/**
- * Value of one coin of the denomination.
+ * Currency of the denomination.
+ *
+ * Stored separately as we have an index on it.
*/
- value: AmountJson;
+ currency: string;
+
+ value: AmountString;
/**
* The denomination public key.
*/
- denomPub: string;
+ denomPub: DenominationPubKey;
/**
* Hash of the denomination public key.
@@ -318,45 +461,27 @@ export interface DenominationRecord {
*/
denomPubHash: string;
- /**
- * Fee for withdrawing.
- */
- feeWithdraw: AmountJson;
-
- /**
- * Fee for depositing.
- */
- feeDeposit: AmountJson;
-
- /**
- * Fee for refreshing.
- */
- feeRefresh: AmountJson;
-
- /**
- * Fee for refunding.
- */
- feeRefund: AmountJson;
+ fees: DenomFees;
/**
* Validity start date of the denomination.
*/
- stampStart: Timestamp;
+ stampStart: DbProtocolTimestamp;
/**
* Date after which the currency can't be withdrawn anymore.
*/
- stampExpireWithdraw: Timestamp;
+ stampExpireWithdraw: DbProtocolTimestamp;
/**
* Date after the denomination officially doesn't exist anymore.
*/
- stampExpireLegal: Timestamp;
+ stampExpireLegal: DbProtocolTimestamp;
/**
* Data after which coins of this denomination can't be deposited anymore.
*/
- stampExpireDeposit: Timestamp;
+ stampExpireDeposit: DbProtocolTimestamp;
/**
* Signature by the exchange's master key over the denomination
@@ -384,6 +509,13 @@ export interface DenominationRecord {
isRevoked: boolean;
/**
+ * If set to true, the exchange announced that the private key for this
+ * denomination is lost. Thus it can't be used to sign new coins
+ * during withdrawal/refresh/..., but the coins can still be spent.
+ */
+ isLost?: boolean;
+
+ /**
* Base URL of the exchange.
*/
exchangeBaseUrl: string;
@@ -393,23 +525,47 @@ export interface DenominationRecord {
* on the denomination.
*/
exchangeMasterPub: string;
+}
+
+export namespace DenominationRecord {
+ export function toDenomInfo(d: DenominationRecord): DenominationInfo {
+ return {
+ denomPub: d.denomPub,
+ denomPubHash: d.denomPubHash,
+ feeDeposit: Amounts.stringify(d.fees.feeDeposit),
+ feeRefresh: Amounts.stringify(d.fees.feeRefresh),
+ feeRefund: Amounts.stringify(d.fees.feeRefund),
+ feeWithdraw: Amounts.stringify(d.fees.feeWithdraw),
+ stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
+ stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal),
+ stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
+ stampStart: timestampProtocolFromDb(d.stampStart),
+ value: Amounts.stringify(d.value),
+ exchangeBaseUrl: d.exchangeBaseUrl,
+ };
+ }
+}
+
+export interface ExchangeSignkeysRecord {
+ stampStart: DbProtocolTimestamp;
+ stampExpire: DbProtocolTimestamp;
+ stampEnd: DbProtocolTimestamp;
+ signkeyPub: EddsaPublicKeyString;
+ masterSig: EddsaSignatureString;
/**
- * Latest list issue date of the "/keys" response
- * that includes this denomination.
+ * Exchange details that thiis signkeys record belongs to.
*/
- listIssueDate: Timestamp;
+ exchangeDetailsRowId: number;
}
/**
- * Information about one of the exchange's bank accounts.
+ * Exchange details for a particular
+ * (exchangeBaseUrl, masterPublicKey, currency) tuple.
*/
-export interface ExchangeBankAccount {
- payto_uri: string;
- master_sig: string;
-}
-
export interface ExchangeDetailsRecord {
+ rowId?: number;
+
/**
* Master public key of the exchange.
*/
@@ -425,102 +581,123 @@ export interface ExchangeDetailsRecord {
/**
* Auditors (partially) auditing the exchange.
*/
- auditors: Auditor[];
+ auditors: ExchangeAuditor[];
/**
* Last observed protocol version.
*/
- protocolVersion: string;
-
- reserveClosingDelay: Duration;
-
- /**
- * Signing keys we got from the exchange, can also contain
- * older signing keys that are not returned by /keys anymore.
- *
- * FIXME: Should this be put into a separate object store?
- */
- signingKeys: ExchangeSignKeyJson[];
+ protocolVersionRange: string;
- /**
- * Terms of service text or undefined if not downloaded yet.
- *
- * This is just used as a cache of the last downloaded ToS.
- */
- termsOfServiceText: string | undefined;
+ reserveClosingDelay: TalerProtocolDuration;
/**
- * content-type of the last downloaded termsOfServiceText.
+ * Fees for exchange services
*/
- termsOfServiceContentType: string | undefined;
+ globalFees: ExchangeGlobalFees[];
- /**
- * ETag for last terms of service download.
- */
- termsOfServiceLastEtag: string | undefined;
+ wireInfo: WireInfo;
/**
- * ETag for last terms of service accepted.
+ * Age restrictions supported by the exchange (bitmask).
*/
- termsOfServiceAcceptedEtag: string | undefined;
-
- /**
- * Timestamp when the ToS was accepted.
- *
- * Used during backup merging.
- */
- termsOfServiceAcceptedTimestamp: Timestamp | undefined;
-
- wireInfo: WireInfo;
-}
-
-export interface WireInfo {
- feesForType: { [wireMethod: string]: WireFee[] };
-
- accounts: ExchangeBankAccount[];
+ ageMask?: number;
}
export interface ExchangeDetailsPointer {
masterPublicKey: string;
+
currency: string;
/**
* Timestamp when the (masterPublicKey, currency) pointer
* has been updated.
*/
- updateClock: Timestamp;
+ updateClock: DbPreciseTimestamp;
+}
+
+export enum ExchangeEntryDbRecordStatus {
+ Preset = 1,
+ Ephemeral = 2,
+ Used = 3,
+}
+
+// FIXME: Use status ranges for this as well?
+export enum ExchangeEntryDbUpdateStatus {
+ Initial = 1,
+ InitialUpdate = 2,
+ Suspended = 3,
+ UnavailableUpdate = 4,
+ // Reserved 5 for backwards compatibility.
+ Ready = 6,
+ ReadyUpdate = 7,
}
/**
* Exchange record as stored in the wallet's database.
*/
-export interface ExchangeRecord {
+export interface ExchangeEntryRecord {
/**
* Base url of the exchange.
*/
baseUrl: string;
/**
+ * Currency hint for a preset exchange, relevant
+ * when we didn't contact a preset exchange yet.
+ */
+ presetCurrencyHint?: string;
+
+ /**
+ * When did we confirm the last withdrawal from this exchange?
+ *
+ * Used mostly in the UI to suggest exchanges.
+ */
+ lastWithdrawal?: DbPreciseTimestamp;
+
+ /**
* Pointer to the current exchange details.
+ *
+ * Should usually not change. Only changes when the
+ * exchange advertises a different master public key and/or
+ * currency.
+ *
+ * We could use a rowID here, but having the currency in the
+ * details pointer lets us do fewer DB queries
*/
detailsPointer: ExchangeDetailsPointer | undefined;
+ entryStatus: ExchangeEntryDbRecordStatus;
+
+ updateStatus: ExchangeEntryDbUpdateStatus;
+
/**
- * Is this a permanent or temporary exchange record?
+ * If set to true, the next update to the exchange
+ * status will request /keys with no-cache headers set.
*/
- permanent: boolean;
+ cachebreakNextUpdate?: boolean;
+
+ /**
+ * Etag of the current ToS of the exchange.
+ */
+ tosCurrentEtag: string | undefined;
+
+ tosAcceptedEtag: string | undefined;
+
+ tosAcceptedTimestamp: DbPreciseTimestamp | undefined;
/**
- * Last time when the exchange was updated.
+ * Last time when the exchange /keys info was updated.
*/
- lastUpdate: Timestamp | undefined;
+ lastUpdate: DbPreciseTimestamp | undefined;
/**
* Next scheduled update for the exchange.
- *
- * (This field must always be present, so we can index on the timestamp.)
*/
- nextUpdate: Timestamp;
+ nextUpdateStamp: DbPreciseTimestamp;
+
+ updateRetryCounter?: number;
+
+ lastKeysEtag: string | undefined;
/**
* Next time that we should check if coins need to be refreshed.
@@ -528,14 +705,30 @@ export interface ExchangeRecord {
* Updated whenever the exchange's denominations are updated or when
* the refresh check has been done.
*/
- nextRefreshCheck: Timestamp;
+ nextRefreshCheckStamp: DbPreciseTimestamp;
- lastError?: TalerErrorDetails;
+ /**
+ * Public key of the reserve that we're currently using for
+ * receiving P2P payments.
+ */
+ currentMergeReserveRowId?: number;
+
+ /**
+ * Defaults to false.
+ */
+ peerPaymentsDisabled?: boolean;
/**
- * Retry status for fetching updated information about the exchange.
+ * Defaults to false.
*/
- retryInfo: RetryInfo;
+ noFees?: boolean;
+}
+
+export enum PlanchetStatus {
+ Pending = 0x0100_0000,
+ KycRequired = 0x0100_0001,
+ WithdrawalDone = 0x0500_000,
+ AbortedReplaced = 0x0503_0001,
}
/**
@@ -563,54 +756,27 @@ export interface PlanchetRecord {
*/
coinIdx: number;
- withdrawalDone: boolean;
+ planchetStatus: PlanchetStatus;
- lastError: TalerErrorDetails | undefined;
-
- /**
- * Public key of the reserve that this planchet
- * is being withdrawn from.
- *
- * Can be the empty string (non-null/undefined for DB indexing)
- * if this is a tipping reserve.
- */
- reservePub: string;
+ lastError: TalerErrorDetail | undefined;
denomPubHash: string;
- denomPub: string;
-
blindingKey: string;
withdrawSig: string;
- coinEv: string;
+ coinEv: CoinEnvelope;
coinEvHash: string;
- coinValue: AmountJson;
-
- isFromTip: boolean;
-}
-
-/**
- * Status of a coin.
- */
-export enum CoinStatus {
- /**
- * Withdrawn and never shown to anybody.
- */
- Fresh = "fresh",
- /**
- * A coin that has been spent and refreshed.
- */
- Dormant = "dormant",
+ ageCommitmentProof?: AgeCommitmentProof;
}
export enum CoinSourceType {
Withdraw = "withdraw",
Refresh = "refresh",
- Tip = "tip",
+ Reward = "reward",
}
export interface WithdrawCoinSource {
@@ -634,16 +800,20 @@ export interface WithdrawCoinSource {
export interface RefreshCoinSource {
type: CoinSourceType.Refresh;
+ refreshGroupId: string;
oldCoinPub: string;
}
-export interface TipCoinSource {
- type: CoinSourceType.Tip;
- walletTipId: string;
+export interface RewardCoinSource {
+ type: CoinSourceType.Reward;
+ walletRewardId: string;
coinIndex: number;
}
-export type CoinSource = WithdrawCoinSource | RefreshCoinSource | TipCoinSource;
+export type CoinSource =
+ | WithdrawCoinSource
+ | RefreshCoinSource
+ | RewardCoinSource;
/**
* CoinRecord as stored in the "coins" data store
@@ -656,6 +826,14 @@ export interface CoinRecord {
coinSource: CoinSource;
/**
+ * Source transaction ID of the coin.
+ *
+ * Used to make the coin visible after the transaction
+ * has entered a final state.
+ */
+ sourceTransactionId?: string;
+
+ /**
* Public key of the coin.
*/
coinPub: string;
@@ -666,11 +844,6 @@ export interface CoinRecord {
coinPriv: string;
/**
- * Key used by the exchange used to sign the coin.
- */
- denomPub: string;
-
- /**
* Hash of the public key that signs the coin.
*/
denomPubHash: string;
@@ -678,12 +851,7 @@ export interface CoinRecord {
/**
* Unblinded signature by the exchange.
*/
- denomSig: string;
-
- /**
- * Amount that's left on the coin.
- */
- currentAmount: AmountJson;
+ denomSig: UnblindedSignature;
/**
* Base URL that identifies the exchange from which we got the
@@ -692,11 +860,6 @@ export interface CoinRecord {
exchangeBaseUrl: string;
/**
- * The coin is currently suspended, and will not be used for payments.
- */
- suspended: boolean;
-
- /**
* Blinding key used when withdrawing the coin.
* Potentionally used again during payback.
*/
@@ -716,130 +879,67 @@ export interface CoinRecord {
status: CoinStatus;
/**
- * Information about what the coin has been allocated for.
- * Used to prevent allocation of the same coin for two different payments.
+ * Non-zero for visible.
+ *
+ * A coin is visible when it is fresh and the
+ * source transaction is in a final state.
*/
- allocation?: CoinAllocation;
-}
+ visible?: number;
-export interface CoinAllocation {
- id: string;
- amount: AmountString;
-}
-
-export enum ProposalStatus {
- /**
- * Not downloaded yet.
- */
- DOWNLOADING = "downloading",
- /**
- * Proposal downloaded, but the user needs to accept/reject it.
- */
- PROPOSED = "proposed",
/**
- * The user has accepted the proposal.
- */
- ACCEPTED = "accepted",
- /**
- * The user has rejected the proposal.
- */
- REFUSED = "refused",
- /**
- * Downloading or processing the proposal has failed permanently.
- */
- PERMANENTLY_FAILED = "permanently-failed",
- /**
- * Downloaded proposal was detected as a re-purchase.
+ * Information about what the coin has been allocated for.
+ *
+ * Used for:
+ * - Diagnostics
+ * - Idempotency of applying a coin selection (e.g. after re-selection)
*/
- REPURCHASE = "repurchase",
-}
+ spendAllocation: CoinAllocation | undefined;
-export interface ProposalDownload {
/**
- * The contract that was offered by the merchant.
+ * Maximum age of purchases that can be made with this coin.
+ *
+ * (Used for indexing, redundant with {@link ageCommitmentProof}).
*/
- contractTermsRaw: any;
+ maxAge: number;
- contractData: WalletContractData;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
}
/**
- * Record for a downloaded order, stored in the wallet's database.
+ * Coin allocation, i.e. what a coin has been used for.
*/
-export interface ProposalRecord {
- orderId: string;
-
- merchantBaseUrl: string;
-
- /**
- * Downloaded data from the merchant.
- */
- download: ProposalDownload | undefined;
-
- /**
- * Unique ID when the order is stored in the wallet DB.
- */
- proposalId: string;
-
- /**
- * Timestamp (in ms) of when the record
- * was created.
- */
- timestamp: Timestamp;
-
- /**
- * Private key for the nonce.
- */
- noncePriv: string;
-
- /**
- * Public key for the nonce.
- */
- noncePub: string;
-
- claimToken: string | undefined;
-
- proposalStatus: ProposalStatus;
-
- repurchaseProposalId: string | undefined;
-
- /**
- * Session ID we got when downloading the contract.
- */
- downloadSessionId?: string;
-
+export interface CoinAllocation {
/**
- * Retry info, even present when the operation isn't active to allow indexing
- * on the next retry timestamp.
+ * ID of the allocation, should be the ID of the transaction that
*/
- retryInfo?: RetryInfo;
-
- lastError: TalerErrorDetails | undefined;
+ id: TransactionIdStr;
+ amount: AmountString;
}
/**
- * Status of a tip we got from a merchant.
+ * Status of a reward we got from a merchant.
*/
-export interface TipRecord {
- lastError: TalerErrorDetails | undefined;
-
+export interface RewardRecord {
/**
* Has the user accepted the tip? Only after the tip has been accepted coins
* withdrawn from the tip may be used.
*/
- acceptedTimestamp: Timestamp | undefined;
+ acceptedTimestamp: DbPreciseTimestamp | undefined;
/**
* The tipped amount.
*/
- tipAmountRaw: AmountJson;
+ rewardAmountRaw: AmountString;
- tipAmountEffective: AmountJson;
+ /**
+ * Effect on the balance (including fees etc).
+ */
+ rewardAmountEffective: AmountString;
/**
* Timestamp, the tip can't be picked up anymore after this deadline.
*/
- tipExpiration: Timestamp;
+ rewardExpiration: DbProtocolTimestamp;
/**
* The exchange that will sign our coins, chosen by the merchant.
@@ -854,6 +954,9 @@ export interface TipRecord {
/**
* Denomination selection made by the wallet for picking up
* this tip.
+ *
+ * FIXME: Put this into some DenomSelectionCacheRecord instead of
+ * storing it here!
*/
denomsSel: DenomSelectionState;
@@ -862,7 +965,7 @@ export interface TipRecord {
/**
* Tip ID chosen by the wallet.
*/
- walletTipId: string;
+ walletRewardId: string;
/**
* Secret seed used to derive planchets for this tip.
@@ -870,46 +973,83 @@ export interface TipRecord {
secretSeed: string;
/**
- * The merchant's identifier for this tip.
+ * The merchant's identifier for this reward.
*/
- merchantTipId: string;
+ merchantRewardId: string;
- createdTimestamp: Timestamp;
+ createdTimestamp: DbPreciseTimestamp;
/**
- * Timestamp for when the wallet finished picking up the tip
- * from the merchant.
+ * The url to be redirected after the tip is accepted.
*/
- pickedUpTimestamp: Timestamp | undefined;
+ next_url: string | undefined;
/**
- * Retry info, even present when the operation isn't active to allow indexing
- * on the next retry timestamp.
+ * Timestamp for when the wallet finished picking up the tip
+ * from the merchant.
*/
- retryInfo: RetryInfo;
+ pickedUpTimestamp: DbPreciseTimestamp | undefined;
+
+ status: RewardRecordStatus;
+}
+
+export enum RewardRecordStatus {
+ PendingPickup = 0x0100_0000,
+ SuspendedPickup = 0x0110_0000,
+ DialogAccept = 0x0101_0000,
+ Done = 0x0500_0000,
+ Aborted = 0x0500_0000,
+ Failed = 0x0501_000,
}
export enum RefreshCoinStatus {
- Pending = "pending",
- Finished = "finished",
+ Pending = 0x0100_0000,
+ Finished = 0x0500_0000,
/**
* The refresh for this coin has been frozen, because of a permanent error.
* More info in lastErrorPerCoin.
*/
- Frozen = "frozen",
+ Failed = 0x0501_000,
}
-export interface RefreshGroupRecord {
+export enum RefreshOperationStatus {
+ Pending = 0x0100_0000,
+ Suspended = 0x0110_0000,
+
+ Finished = 0x0500_000,
+ Failed = 0x0501_000,
+}
+
+/**
+ * Status of a single element of a deposit group.
+ */
+export enum DepositElementStatus {
+ DepositPending = 0x0100_0000,
/**
- * Retry info, even present when the operation isn't active to allow indexing
- * on the next retry timestamp.
+ * Accepted, but tracking.
*/
- retryInfo: RetryInfo;
+ Tracking = 0x0100_0001,
+ KycRequired = 0x0100_0002,
+ Wired = 0x0500_0000,
+ RefundSuccess = 0x0503_0000,
+ RefundFailed = 0x0501_0000,
+}
- lastError: TalerErrorDetails | undefined;
+export interface RefreshGroupPerExchangeInfo {
+ /**
+ * (Expected) output once the refresh group succeeded.
+ */
+ outputEffective: AmountString;
+}
- lastErrorPerCoin: { [coinIndex: number]: TalerErrorDetails };
+/**
+ * Group of refresh operations. The refreshed coins do not
+ * have to belong to the same exchange, but must have the same
+ * currency.
+ */
+export interface RefreshGroupRecord {
+ operationStatus: RefreshOperationStatus;
/**
* Unique, randomly generated identifier for this group of
@@ -918,19 +1058,24 @@ export interface RefreshGroupRecord {
refreshGroupId: string;
/**
+ * Currency of this refresh group.
+ */
+ currency: string;
+
+ /**
* Reason why this refresh group has been created.
*/
reason: RefreshReason;
+ originatingTransactionId?: string;
+
oldCoinPubs: string[];
- // FIXME: Should this go into a separate
- // object store for faster updates?
- refreshSessionPerCoin: (RefreshSessionRecord | undefined)[];
+ inputPerCoin: AmountString[];
- inputPerCoin: AmountJson[];
+ expectedOutputPerCoin: AmountString[];
- estimatedOutputPerCoin: AmountJson[];
+ infoPerExchange?: Record<string, RefreshGroupPerExchangeInfo>;
/**
* Flag for each coin whether refreshing finished.
@@ -940,23 +1085,25 @@ export interface RefreshGroupRecord {
*/
statusPerCoin: RefreshCoinStatus[];
- timestampCreated: Timestamp;
+ timestampCreated: DbPreciseTimestamp;
/**
* Timestamp when the refresh session finished.
*/
- timestampFinished: Timestamp | undefined;
-
- /**
- * No coins are pending, but at least one is frozen.
- */
- frozen?: boolean;
+ timestampFinished: DbPreciseTimestamp | undefined;
}
/**
* Ongoing refresh
*/
export interface RefreshSessionRecord {
+ refreshGroupId: string;
+
+ /**
+ * Index of the coin in the refresh group.
+ */
+ coinIndex: number;
+
/**
* 512-bit secret that can be used to derive
* the other cryptographic material for the refresh session.
@@ -967,7 +1114,7 @@ export interface RefreshSessionRecord {
* Sum of the value of denominations we want
* to withdraw in this session, without fees.
*/
- amountRefreshOutput: AmountJson;
+ amountRefreshOutput: AmountString;
/**
* Hashed denominations of the newly requested coins.
@@ -981,173 +1128,178 @@ export interface RefreshSessionRecord {
* The no-reveal-index after we've done the melting.
*/
norevealIndex?: number;
+
+ lastError?: TalerErrorDetail;
}
-/**
- * Wire fee for one wire method as stored in the
- * wallet's database.
- */
-export interface WireFee {
+export enum RefundReason {
/**
- * Fee for wire transfers.
+ * Normal refund given by the merchant.
*/
- wireFee: AmountJson;
-
+ NormalRefund = "normal-refund",
/**
- * Fees to close and refund a reserve.
+ * Refund from an aborted payment.
*/
- closingFee: AmountJson;
+ AbortRefund = "abort-pay-refund",
+}
+export enum PurchaseStatus {
/**
- * Start date of the fee.
+ * Not downloaded yet.
*/
- startStamp: Timestamp;
+ PendingDownloadingProposal = 0x0100_0000,
+ SuspendedDownloadingProposal = 0x0110_0000,
/**
- * End date of the fee.
+ * The user has accepted the proposal.
*/
- endStamp: Timestamp;
+ PendingPaying = 0x0100_0001,
+ SuspendedPaying = 0x0110_0001,
/**
- * Signature made by the exchange master key.
+ * Currently in the process of aborting with a refund.
*/
- sig: string;
-}
+ AbortingWithRefund = 0x0103_0000,
+ SuspendedAbortingWithRefund = 0x0113_0000,
-export enum RefundState {
- Failed = "failed",
- Applied = "applied",
- Pending = "pending",
-}
-
-/**
- * State of one refund from the merchant, maintained by the wallet.
- */
-export type WalletRefundItem =
- | WalletRefundFailedItem
- | WalletRefundPendingItem
- | WalletRefundAppliedItem;
+ /**
+ * Paying a second time, likely with different session ID
+ */
+ PendingPayingReplay = 0x0100_0002,
+ SuspendedPayingReplay = 0x0110_0002,
-export interface WalletRefundItemCommon {
- // Execution time as claimed by the merchant
- executionTime: Timestamp;
+ /**
+ * Query for refunds (until query succeeds).
+ */
+ PendingQueryingRefund = 0x0100_0003,
+ SuspendedQueryingRefund = 0x0110_0003,
/**
- * Time when the wallet became aware of the refund.
+ * Query for refund (until auto-refund deadline is reached).
*/
- obtainedTime: Timestamp;
+ PendingQueryingAutoRefund = 0x0100_0004,
+ SuspendedQueryingAutoRefund = 0x0110_0004,
- refundAmount: AmountJson;
+ PendingAcceptRefund = 0x0100_0005,
+ SuspendedPendingAcceptRefund = 0x0110_0005,
- refundFee: AmountJson;
+ /**
+ * Proposal downloaded, but the user needs to accept/reject it.
+ */
+ DialogProposed = 0x0101_0000,
/**
- * Upper bound on the refresh cost incurred by
- * applying this refund.
- *
- * Might be lower in practice when two refunds on the same
- * coin are refreshed in the same refresh operation.
+ * Proposal shared to other wallet or read from other wallet
+ * the user needs to accept/reject it.
*/
- totalRefreshCostBound: AmountJson;
+ DialogShared = 0x0101_0001,
- coinPub: string;
+ /**
+ * The user has rejected the proposal.
+ */
+ AbortedProposalRefused = 0x0503_0000,
- rtransactionId: number;
-}
+ /**
+ * Downloading or processing the proposal has failed permanently.
+ */
+ FailedClaim = 0x0501_0000,
-/**
- * Failed refund, either because the merchant did
- * something wrong or it expired.
- */
-export interface WalletRefundFailedItem extends WalletRefundItemCommon {
- type: RefundState.Failed;
-}
+ /**
+ * Tried to abort, but aborting failed or was cancelled.
+ */
+ FailedAbort = 0x0501_0001,
-export interface WalletRefundPendingItem extends WalletRefundItemCommon {
- type: RefundState.Pending;
-}
+ FailedPaidByOther = 0x0501_0002,
-export interface WalletRefundAppliedItem extends WalletRefundItemCommon {
- type: RefundState.Applied;
-}
+ /**
+ * Payment was successful.
+ */
+ Done = 0x0500_0000,
-export enum RefundReason {
/**
- * Normal refund given by the merchant.
+ * Downloaded proposal was detected as a re-purchase.
*/
- NormalRefund = "normal-refund",
+ DoneRepurchaseDetected = 0x0500_0001,
+
/**
- * Refund from an aborted payment.
+ * The payment has been aborted.
*/
- AbortRefund = "abort-pay-refund",
-}
+ AbortedIncompletePayment = 0x0503_0000,
-export interface AllowedAuditorInfo {
- auditorBaseUrl: string;
- auditorPub: string;
-}
+ AbortedRefunded = 0x0503_0001,
-export interface AllowedExchangeInfo {
- exchangeBaseUrl: string;
- exchangePub: string;
+ AbortedOrderDeleted = 0x0503_0002,
}
/**
- * Data extracted from the contract terms that is relevant for payment
- * processing in the wallet.
+ * Partial information about the downloaded proposal.
+ * Only contains data that is relevant for indexing on the
+ * "purchases" object stores.
*/
-export interface WalletContractData {
- products?: Product[];
- summaryI18n: { [lang_tag: string]: string } | undefined;
+export interface ProposalDownloadInfo {
+ contractTermsHash: string;
+ fulfillmentUrl?: string;
+ currency: string;
+ contractTermsMerchantSig: string;
+}
+export interface DbCoinSelection {
+ coinPubs: string[];
+ coinContributions: AmountString[];
+}
+
+export interface PurchasePayInfo {
/**
- * Fulfillment URL, or the empty string if the order has no fulfillment URL.
- *
- * Stored as a non-nullable string as we use this field for IndexedDB indexing.
+ * Undefined if payment is blocked by a pending refund.
*/
- fulfillmentUrl: string;
-
- contractTermsHash: string;
- fulfillmentMessage?: string;
- fulfillmentMessageI18n?: InternationalizedString;
- merchantSig: string;
- merchantPub: string;
- merchant: MerchantInfo;
- amount: AmountJson;
- orderId: string;
- merchantBaseUrl: string;
- summary: string;
- autoRefund: Duration | undefined;
- maxWireFee: AmountJson;
- wireFeeAmortization: number;
- payDeadline: Timestamp;
- refundDeadline: Timestamp;
- allowedAuditors: AllowedAuditorInfo[];
- allowedExchanges: AllowedExchangeInfo[];
- timestamp: Timestamp;
- wireMethod: string;
- wireInfoHash: string;
- maxDepositFee: AmountJson;
-}
-
-export enum AbortStatus {
- None = "none",
- AbortRefund = "abort-refund",
- AbortFinished = "abort-finished",
+ payCoinSelection?: DbCoinSelection;
+ /**
+ * Undefined if payment is blocked by a pending refund.
+ */
+ payCoinSelectionUid?: string;
+ totalPayCost: AmountString;
}
/**
* Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable.
+ *
+ * Key: {@link proposalId}
+ * Operation status: {@link purchaseStatus}
*/
export interface PurchaseRecord {
/**
* Proposal ID for this purchase. Uniquely identifies the
* purchase and the proposal.
+ * Assigned by the wallet.
*/
proposalId: string;
/**
+ * Order ID, assigned by the merchant.
+ */
+ orderId: string;
+
+ merchantBaseUrl: string;
+
+ /**
+ * Claim token used when downloading the contract terms.
+ */
+ claimToken: string | undefined;
+
+ /**
+ * Session ID we got when downloading the contract.
+ */
+ downloadSessionId: string | undefined;
+
+ /**
+ * If this purchase is a repurchase, this field identifies the original purchase.
+ */
+ repurchaseProposalId: string | undefined;
+
+ purchaseStatus: PurchaseStatus;
+
+ /**
* Private key for the nonce.
*/
noncePriv: string;
@@ -1159,22 +1311,10 @@ export interface PurchaseRecord {
/**
* Downloaded and parsed proposal data.
- *
- * FIXME: Move this into another object store,
- * to improve read/write perf on purchases.
*/
- download: ProposalDownload;
+ download: ProposalDownloadInfo | undefined;
- /**
- * Deposit permissions, available once the user has accepted the payment.
- *
- * This value is cached and derived from payCoinSelection.
- */
- coinDepositPermissions: CoinDepositPermission[] | undefined;
-
- payCoinSelection: PayCoinSelection;
-
- payCoinSelectionUid: string;
+ payInfo: PurchasePayInfo | undefined;
/**
* Pending removals from pay coin selection.
@@ -1186,79 +1326,65 @@ export interface PurchaseRecord {
*/
pendingRemovedCoinPubs?: string[];
- totalPayCost: AmountJson;
-
/**
* Timestamp of the first time that sending a payment to the merchant
* for this purchase was successful.
*/
- timestampFirstSuccessfulPay: Timestamp | undefined;
+ timestampFirstSuccessfulPay: DbPreciseTimestamp | undefined;
merchantPaySig: string | undefined;
- /**
- * When was the purchase made?
- * Refers to the time that the user accepted.
- */
- timestampAccept: Timestamp;
+ posConfirmation: string | undefined;
/**
- * Pending refunds for the purchase. A refund is pending
- * when the merchant reports a transient error from the exchange.
- */
- refunds: { [refundKey: string]: WalletRefundItem };
-
- /**
- * When was the last refund made?
- * Set to 0 if no refund was made on the purchase.
+ * This purchase was created by reading
+ * a payment share or the wallet
+ * the nonce public by a payment share
*/
- timestampLastRefundStatus: Timestamp | undefined;
+ shared: boolean;
/**
- * Last session signature that we submitted to /pay (if any).
+ * When was the purchase record created?
*/
- lastSessionId: string | undefined;
+ timestamp: DbPreciseTimestamp;
/**
- * Set for the first payment, or on re-plays.
+ * When was the purchase made?
+ * Refers to the time that the user accepted.
*/
- paymentSubmitPending: boolean;
+ timestampAccept: DbPreciseTimestamp | undefined;
/**
- * Do we need to query the merchant for the refund status
- * of the payment?
- */
- refundQueryRequested: boolean;
-
- abortStatus: AbortStatus;
-
- payRetryInfo?: RetryInfo;
-
- lastPayError: TalerErrorDetails | undefined;
-
- /**
- * Retry information for querying the refund status with the merchant.
+ * When was the last refund made?
+ * Set to 0 if no refund was made on the purchase.
*/
- refundStatusRetryInfo: RetryInfo;
+ timestampLastRefundStatus: DbPreciseTimestamp | undefined;
/**
- * Last error (or undefined) for querying the refund status with the merchant.
+ * Last session signature that we submitted to /pay (if any).
*/
- lastRefundStatusError: TalerErrorDetails | undefined;
+ lastSessionId: string | undefined;
/**
* Continue querying the refund status until this deadline has expired.
*/
- autoRefundDeadline: Timestamp | undefined;
+ autoRefundDeadline: DbProtocolTimestamp | undefined;
/**
- * Is the payment frozen? I.e. did we encounter
- * an error where it doesn't make sense to retry.
+ * How much merchant has refund to be taken but the wallet
+ * did not picked up yet
*/
- payFrozen?: boolean;
+ refundAmountAwaiting: AmountString | undefined;
}
-export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
+export enum ConfigRecordKey {
+ WalletBackupState = "walletBackupState",
+ CurrencyDefaultsApplied = "currencyDefaultsApplied",
+ DevMode = "devMode",
+ // Only for testing, do not use!
+ TestLoopTx = "testTxLoop",
+ LastInitInfo = "lastInitInfo",
+}
/**
* Configuration key/value entries to configure
@@ -1266,10 +1392,12 @@ export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
*/
export type ConfigRecord =
| {
- key: typeof WALLET_BACKUP_STATE_KEY;
+ key: ConfigRecordKey.WalletBackupState;
value: WalletBackupConfState;
}
- | { key: "currencyDefaultsApplied"; value: boolean };
+ | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean }
+ | { key: ConfigRecordKey.TestLoopTx; value: number }
+ | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp };
export interface WalletBackupConfState {
deviceId: string;
@@ -1284,73 +1412,196 @@ export interface WalletBackupConfState {
/**
* Timestamp stored in the last backup.
*/
- lastBackupTimestamp?: Timestamp;
+ lastBackupTimestamp?: DbPreciseTimestamp;
/**
* Last time we tried to do a backup.
*/
- lastBackupCheckTimestamp?: Timestamp;
+ lastBackupCheckTimestamp?: DbPreciseTimestamp;
lastBackupNonce?: string;
}
-/**
- * Selected denominations withn some extra info.
- */
-export interface DenomSelectionState {
- totalCoinValue: AmountJson;
- totalWithdrawCost: AmountJson;
- selectedDenoms: {
- denomPubHash: string;
- count: number;
- }[];
+// FIXME: Should these be numeric codes?
+export const enum WithdrawalRecordType {
+ BankManual = "bank-manual",
+ BankIntegrated = "bank-integrated",
+ PeerPullCredit = "peer-pull-credit",
+ PeerPushCredit = "peer-push-credit",
+ Recoup = "recoup",
}
+export interface WgInfoBankIntegrated {
+ withdrawalType: WithdrawalRecordType.BankIntegrated;
+ /**
+ * Extra state for when this is a withdrawal involving
+ * a Taler-integrated bank.
+ */
+ bankInfo: ReserveBankInfo;
+ /**
+ * Info about withdrawal accounts, possibly including currency conversion.
+ */
+ exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[];
+}
+
+export interface WgInfoBankManual {
+ withdrawalType: WithdrawalRecordType.BankManual;
+
+ /**
+ * Info about withdrawal accounts, possibly including currency conversion.
+ */
+ exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[];
+}
+
+export interface WgInfoBankPeerPull {
+ withdrawalType: WithdrawalRecordType.PeerPullCredit;
+
+ // FIXME: include a transaction ID here?
+
+ /**
+ * Needed to quickly construct the taler:// URI for the counterparty
+ * without a join.
+ */
+ contractPriv: string;
+}
+
+export interface WgInfoBankPeerPush {
+ withdrawalType: WithdrawalRecordType.PeerPushCredit;
+
+ // FIXME: include a transaction ID here?
+}
+
+export interface WgInfoBankRecoup {
+ withdrawalType: WithdrawalRecordType.Recoup;
+}
+
+export type WgInfo =
+ | WgInfoBankIntegrated
+ | WgInfoBankManual
+ | WgInfoBankPeerPull
+ | WgInfoBankPeerPush
+ | WgInfoBankRecoup;
+
+export type KycUserType = "individual" | "business";
+
+export interface KycPendingInfo {
+ paytoHash: string;
+ requirementRow: number;
+}
/**
* Group of withdrawal operations that need to be executed.
- * (Either for a normal withdrawal or from a tip.)
+ * (Either for a normal withdrawal or from a reward.)
*
* The withdrawal group record is only created after we know
* the coin selection we want to withdraw.
*/
export interface WithdrawalGroupRecord {
+ /**
+ * Unique identifier for the withdrawal group.
+ */
withdrawalGroupId: string;
+ wgInfo: WgInfo;
+
+ kycPending?: KycPendingInfo;
+
+ kycUrl?: string;
+
/**
* Secret seed used to derive planchets.
+ * Stored since planchets are created lazily.
*/
secretSeed: string;
+ /**
+ * Public key of the reserve that we're withdrawing from.
+ */
reservePub: string;
+ /**
+ * The reserve private key.
+ *
+ * FIXME: Already in the reserves object store, redundant!
+ */
+ reservePriv: string;
+
+ /**
+ * The exchange base URL that we're withdrawing from.
+ * (Redundantly stored, as the reserve record also has this info.)
+ */
exchangeBaseUrl: string;
/**
* When was the withdrawal operation started started?
* Timestamp in milliseconds.
*/
- timestampStart: Timestamp;
+ timestampStart: DbPreciseTimestamp;
/**
* When was the withdrawal operation completed?
*/
- timestampFinish?: Timestamp;
+ timestampFinish?: DbPreciseTimestamp;
+
+ /**
+ * Current status of the reserve.
+ */
+ status: WithdrawalGroupStatus;
+
+ /**
+ * Wire information (as payto URI) for the bank account that
+ * transferred funds for this reserve.
+ *
+ * FIXME: Doesn't this belong to the bankAccounts object store?
+ */
+ senderWire?: string;
+
+ /**
+ * Restrict withdrawals from this reserve to this age.
+ */
+ restrictAge?: number;
+
+ /**
+ * Amount that was sent by the user to fund the reserve.
+ */
+ instructedAmount: AmountString;
+
+ /**
+ * Amount that was observed when querying the reserve that
+ * we are withdrawing from.
+ *
+ * Useful for diagnostics.
+ */
+ reserveBalanceAmount?: AmountString;
/**
* Amount including fees (i.e. the amount subtracted from the
* reserve to withdraw all coins in this withdrawal session).
+ *
+ * (Initial amount confirmed by the user, might differ with denomSel
+ * on reselection.)
*/
- rawWithdrawalAmount: AmountJson;
+ rawWithdrawalAmount: AmountString;
- denomsSel: DenomSelectionState;
-
- denomSelUid: string;
+ /**
+ * Amount that will be added to the balance when the withdrawal succeeds.
+ *
+ * (Initial amount confirmed by the user, might differ with denomSel
+ * on reselection.)
+ */
+ effectiveWithdrawalAmount: AmountString;
/**
- * Retry info, always present even on completed operations so that indexing works.
+ * Denominations selected for withdrawal.
*/
- retryInfo: RetryInfo;
+ denomsSel: DenomSelectionState;
- lastError: TalerErrorDetails | undefined;
+ /**
+ * UID of the denomination selection.
+ *
+ * Used for merging backups.
+ *
+ * FIXME: Should this not also include a timestamp for more logical merging?
+ */
+ denomSelUid: string;
}
export interface BankWithdrawUriRecord {
@@ -1365,6 +1616,14 @@ export interface BankWithdrawUriRecord {
reservePub: string;
}
+export enum RecoupOperationStatus {
+ Pending = 0x0100_0000,
+ Suspended = 0x0110_0000,
+
+ Finished = 0x0500_000,
+ Failed = 0x0501_000,
+}
+
/**
* Status of recoup operations that were grouped together.
*
@@ -1377,9 +1636,13 @@ export interface RecoupGroupRecord {
*/
recoupGroupId: string;
- timestampStarted: Timestamp;
+ exchangeBaseUrl: string;
+
+ operationStatus: RecoupOperationStatus;
- timestampFinished: Timestamp | undefined;
+ timestampStarted: DbPreciseTimestamp;
+
+ timestampFinished: DbPreciseTimestamp | undefined;
/**
* Public keys that identify the coins being recouped
@@ -1395,27 +1658,10 @@ export interface RecoupGroupRecord {
recoupFinishedPerCoin: boolean[];
/**
- * We store old amount (i.e. before recoup) of recouped coins here,
- * as the balance of a recouped coin is set to zero when the
- * recoup group is created.
- */
- oldAmountPerCoin: AmountJson[];
-
- /**
* Public keys of coins that should be scheduled for refreshing
* after all individual recoups are done.
*/
- scheduleRefreshCoins: string[];
-
- /**
- * Retry info.
- */
- retryInfo: RetryInfo;
-
- /**
- * Last error that occurred, if any.
- */
- lastError: TalerErrorDetails | undefined;
+ scheduleRefreshCoins: CoinRefreshRequest[];
}
export enum BackupProviderStateTag {
@@ -1430,20 +1676,12 @@ export type BackupProviderState =
}
| {
tag: BackupProviderStateTag.Ready;
- nextBackupTimestamp: Timestamp;
+ nextBackupTimestamp: DbPreciseTimestamp;
}
| {
tag: BackupProviderStateTag.Retrying;
- retryInfo: RetryInfo;
- lastError?: TalerErrorDetails;
};
-export interface BackupProviderTerms {
- supportedProtocolVersion: string;
- annualFee: AmountString;
- storageLimitInMegabytes: number;
-}
-
export interface BackupProviderRecord {
/**
* Base URL of the provider.
@@ -1477,7 +1715,7 @@ export interface BackupProviderRecord {
* Does NOT correspond to the timestamp of the backup,
* which only changes when the backup content changes.
*/
- lastBackupCycleTimestamp?: Timestamp;
+ lastBackupCycleTimestamp?: DbPreciseTimestamp;
/**
* Proposal that we're currently trying to pay for.
@@ -1488,6 +1726,8 @@ export interface BackupProviderRecord {
*/
currentPaymentProposalId?: string;
+ shouldRetryFreshProposal: boolean;
+
/**
* Proposals that were used to pay (or attempt to pay) the provider.
*
@@ -1504,12 +1744,60 @@ export interface BackupProviderRecord {
uids: string[];
}
+export enum DepositOperationStatus {
+ PendingDeposit = 0x0100_0000,
+ PendingTrack = 0x0100_0001,
+ PendingKyc = 0x0100_0002,
+
+ Aborting = 0x0103_0000,
+
+ SuspendedDeposit = 0x0110_0000,
+ SuspendedTrack = 0x0110_0001,
+ SuspendedKyc = 0x0110_0002,
+
+ SuspendedAborting = 0x0113_0000,
+
+ Finished = 0x0500_0000,
+ Failed = 0x0501_0000,
+ Aborted = 0x0503_0000,
+}
+
+export interface DepositTrackingInfo {
+ // Raw wire transfer identifier of the deposit.
+ wireTransferId: string;
+ // When was the wire transfer given to the bank.
+ timestampExecuted: DbProtocolTimestamp;
+ // Total amount transfer for this wtid (including fees)
+ amountRaw: AmountString;
+ // Wire fee amount for this exchange
+ wireFee: AmountString;
+
+ exchangePub: string;
+}
+
+export interface DepositInfoPerExchange {
+ /**
+ * Expected effective amount that will be deposited
+ * from coins of this exchange.
+ */
+ amountEffective: AmountString;
+}
+
/**
* Group of deposits made by the wallet.
*/
export interface DepositGroupRecord {
depositGroupId: string;
+ currency: string;
+
+ /**
+ * Instructed amount.
+ */
+ amount: AmountString;
+
+ wireTransferDeadline: DbProtocolTimestamp;
+
merchantPub: string;
merchantPriv: string;
@@ -1525,106 +1813,729 @@ export interface DepositGroupRecord {
salt: string;
};
+ contractTermsHash: string;
+
+ payCoinSelection?: DbCoinSelection;
+
+ payCoinSelectionUid?: string;
+
+ totalPayCost: AmountString;
+
+ /**
+ * The counterparty effective deposit amount.
+ */
+ counterpartyEffectiveDepositAmount: AmountString;
+
+ timestampCreated: DbPreciseTimestamp;
+
+ timestampFinished: DbPreciseTimestamp | undefined;
+
+ operationStatus: DepositOperationStatus;
+
+ statusPerCoin?: DepositElementStatus[];
+
+ infoPerExchange?: Record<string, DepositInfoPerExchange>;
+
+ /**
+ * When the deposit transaction was aborted and
+ * refreshes were tried, we create a refresh
+ * group and store the ID here.
+ */
+ abortRefreshGroupId?: string;
+
+ kycInfo?: DepositKycInfo;
+
+ // FIXME: Do we need this and should it be in this object store?
+ trackingState?: {
+ [signature: string]: DepositTrackingInfo;
+ };
+}
+
+export interface DepositKycInfo {
+ kycUrl: string;
+ requirementRow: number;
+ paytoHash: string;
+ exchangeBaseUrl: string;
+}
+
+export interface TombstoneRecord {
+ /**
+ * Tombstone ID, with the syntax "tmb:<type>:<key>".
+ */
+ id: string;
+}
+
+export enum PeerPushDebitStatus {
+ /**
+ * Initiated, but no purse created yet.
+ */
+ PendingCreatePurse = 0x0100_0000 /* ACTIVE_START */,
+ PendingReady = 0x0100_0001,
+ AbortingDeletePurse = 0x0103_0000,
+ /**
+ * Refresh after the purse got deleted by the wallet.
+ */
+ AbortingRefreshDeleted = 0x0103_0001,
+ /**
+ * Refresh after the purse expired.
+ */
+ AbortingRefreshExpired = 0x0103_0002,
+
+ SuspendedCreatePurse = 0x0110_0000,
+ SuspendedReady = 0x0110_0001,
+ SuspendedAbortingDeletePurse = 0x0113_0000,
+ SuspendedAbortingRefreshDeleted = 0x0113_0001,
+ SuspendedAbortingRefreshExpired = 0x0113_0002,
+
+ Done = 0x0500_0000,
+ Aborted = 0x0503_0000,
+ Failed = 0x0501_0000,
+ Expired = 0x0502_0000,
+}
+
+export interface DbPeerPushPaymentCoinSelection {
+ contributions: AmountString[];
+ coinPubs: CoinPublicKeyString[];
+}
+
+/**
+ * Record for a push P2P payment that this wallet initiated.
+ */
+export interface PeerPushDebitRecord {
+ /**
+ * What exchange are funds coming from?
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Instructed amount.
+ */
+ amount: AmountString;
+
+ totalCost: AmountString;
+
+ coinSel?: DbPeerPushPaymentCoinSelection;
+
+ contractTermsHash: HashCodeString;
+
+ /**
+ * Purse public key. Used as the primary key to look
+ * up this record.
+ */
+ pursePub: string;
+
+ /**
+ * Purse private key.
+ */
+ pursePriv: string;
+
/**
- * Verbatim contract terms.
+ * Public key of the merge capability of the purse.
*/
- contractTermsRaw: ContractTerms;
+ mergePub: string;
+
+ /**
+ * Private key of the merge capability of the purse.
+ */
+ mergePriv: string;
+
+ contractPriv: string;
+ contractPub: string;
+
+ /**
+ * 24 byte nonce.
+ */
+ contractEncNonce: string;
+
+ purseExpiration: DbProtocolTimestamp;
+ timestampCreated: DbPreciseTimestamp;
+
+ abortRefreshGroupId?: string;
+
+ /**
+ * Status of the peer push payment initiation.
+ */
+ status: PeerPushDebitStatus;
+}
+
+export enum PeerPullPaymentCreditStatus {
+ PendingCreatePurse = 0x0100_0000,
+ /**
+ * Purse created, waiting for the other party to accept the
+ * invoice and deposit money into it.
+ */
+ PendingReady = 0x0100_0001,
+ PendingMergeKycRequired = 0x0100_0002,
+ PendingWithdrawing = 0x0100_0003,
+
+ AbortingDeletePurse = 0x0103_0000,
+
+ SuspendedCreatePurse = 0x0110_0000,
+ SuspendedReady = 0x0110_0001,
+ SuspendedMergeKycRequired = 0x0110_0002,
+ SuspendedWithdrawing = 0x0110_0000,
+
+ SuspendedAbortingDeletePurse = 0x0113_0000,
+
+ Done = 0x0500_0000,
+ Failed = 0x0501_0000,
+ Expired = 0x0502_0000,
+ Aborted = 0x0503_0000,
+}
+
+export interface PeerPullCreditRecord {
+ /**
+ * What exchange are we using for the payment request?
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Amount requested.
+ * FIXME: What type of instructed amount is i?
+ */
+ amount: AmountString;
+
+ estimatedAmountEffective: AmountString;
+
+ /**
+ * Purse public key. Used as the primary key to look
+ * up this record.
+ */
+ pursePub: string;
+
+ /**
+ * Purse private key.
+ */
+ pursePriv: string;
+
+ /**
+ * Hash of the contract terms. Also
+ * used to look up the contract terms in the DB.
+ */
contractTermsHash: string;
- payCoinSelection: PayCoinSelection;
+ mergePub: string;
+ mergePriv: string;
- payCoinSelectionUid: string;
+ contractPub: string;
+ contractPriv: string;
- totalPayCost: AmountJson;
+ contractEncNonce: string;
- effectiveDepositAmount: AmountJson;
+ mergeTimestamp: DbPreciseTimestamp;
- depositedPerCoin: boolean[];
+ mergeReserveRowId: number;
- timestampCreated: Timestamp;
+ /**
+ * Status of the peer pull payment initiation.
+ */
+ status: PeerPullPaymentCreditStatus;
- timestampFinished: Timestamp | undefined;
+ kycInfo?: KycPendingInfo;
- lastError: TalerErrorDetails | undefined;
+ kycUrl?: string;
+
+ withdrawalGroupId: string | undefined;
+}
+export enum PeerPushCreditStatus {
+ PendingMerge = 0x0100_0000,
+ PendingMergeKycRequired = 0x0100_0001,
/**
- * Retry info.
+ * Merge was successful and withdrawal group has been created, now
+ * everything is in the hand of the withdrawal group.
*/
- retryInfo?: RetryInfo;
+ PendingWithdrawing = 0x0100_0002,
+
+ SuspendedMerge = 0x0110_0000,
+ SuspendedMergeKycRequired = 0x0110_0001,
+ SuspendedWithdrawing = 0x0110_0002,
+
+ DialogProposed = 0x0101_0000,
+
+ Done = 0x0500_0000,
+ Aborted = 0x0503_0000,
+ Failed = 0x0501_0000,
}
/**
- * Record for a deposits that the wallet observed
- * as a result of double spending, but which is not
- * present in the wallet's own database otherwise.
+ * Record for a push P2P payment that this wallet was offered.
+ *
+ * Unique: (exchangeBaseUrl, pursePub)
*/
-export interface GhostDepositGroupRecord {
+export interface PeerPushPaymentIncomingRecord {
+ peerPushCreditId: string;
+
+ exchangeBaseUrl: string;
+
+ pursePub: string;
+
+ mergePriv: string;
+
+ contractPriv: string;
+
+ timestamp: DbPreciseTimestamp;
+
+ estimatedAmountEffective: AmountString;
+
+ /**
+ * Hash of the contract terms. Also
+ * used to look up the contract terms in the DB.
+ */
+ contractTermsHash: string;
+
+ /**
+ * Status of the peer push payment incoming initiation.
+ */
+ status: PeerPushCreditStatus;
+
+ /**
+ * Associated withdrawal group.
+ */
+ withdrawalGroupId: string | undefined;
+
+ /**
+ * Currency of the peer push payment credit transaction.
+ *
+ * Mandatory in current schema version, optional for compatibility
+ * with older (ver_minor<4) DB versions.
+ */
+ currency: string | undefined;
+
+ kycInfo?: KycPendingInfo;
+
+ kycUrl?: string;
+}
+
+export enum PeerPullDebitRecordStatus {
+ PendingDeposit = 0x0100_0001,
+ AbortingRefresh = 0x0103_0001,
+
+ SuspendedDeposit = 0x0110_0001,
+ SuspendedAbortingRefresh = 0x0113_0001,
+
+ DialogProposed = 0x0101_0001,
+
+ Done = 0x0500_0000,
+ Aborted = 0x0503_0000,
+ Failed = 0x0501_0000,
+}
+
+export interface PeerPullPaymentCoinSelection {
+ contributions: AmountString[];
+ coinPubs: CoinPublicKeyString[];
+
/**
- * When multiple deposits for the same contract terms hash
- * have a different timestamp, we choose the earliest one.
+ * Total cost based on the coin selection.
+ * Non undefined after status === "Accepted"
*/
- timestamp: Timestamp;
+ totalCost: AmountString | undefined;
+}
+
+/**
+ * AKA PeerPullDebit.
+ */
+export interface PeerPullPaymentIncomingRecord {
+ peerPullDebitId: string;
+
+ pursePub: string;
+
+ exchangeBaseUrl: string;
+
+ amount: AmountString;
contractTermsHash: string;
- deposits: {
- coinPub: string;
- amount: AmountString;
- timestamp: Timestamp;
- depositFee: AmountString;
- merchantPub: string;
- coinSig: string;
- wireHash: string;
- }[];
+ timestampCreated: DbPreciseTimestamp;
+
+ /**
+ * Contract priv that we got from the other party.
+ */
+ contractPriv: string;
+
+ /**
+ * Status of the peer push payment incoming initiation.
+ */
+ status: PeerPullDebitRecordStatus;
+
+ /**
+ * Estimated total cost when the record was created.
+ */
+ totalCostEstimated: AmountString;
+
+ abortRefreshGroupId?: string;
+
+ coinSel?: PeerPullPaymentCoinSelection;
}
-export interface TombstoneRecord {
+/**
+ * Store for extra information about a reserve.
+ *
+ * Mostly used to store the private key for a reserve and to allow
+ * other records to reference the reserve key pair via a small row ID.
+ *
+ * In the future, we might also store KYC info about a reserve here.
+ */
+export interface ReserveRecord {
+ rowId?: number;
+ reservePub: string;
+ reservePriv: string;
+}
+
+export interface OperationRetryRecord {
/**
- * Tombstone ID, with the syntax "<type>:<key>".
+ * Unique identifier for the operation. Typically of
+ * the format `${opType}-${opUniqueKey}`
+ *
+ * @see {@link TaskIdentifiers}
*/
id: string;
+
+ lastError?: TalerErrorDetail;
+
+ retryInfo: DbRetryInfo;
+}
+
+/**
+ * Availability of coins of a given denomination (and age restriction!).
+ *
+ * We can't store this information with the denomination record, as one denomination
+ * can be withdrawn with multiple age restrictions.
+ */
+export interface CoinAvailabilityRecord {
+ currency: string;
+ value: AmountString;
+ denomPubHash: string;
+ exchangeBaseUrl: string;
+
+ /**
+ * Age restriction on the coin, or 0 for no age restriction (or
+ * denomination without age restriction support).
+ */
+ maxAge: number;
+
+ /**
+ * Number of fresh coins of this denomination that are available.
+ */
+ freshCoinCount: number;
+
+ /**
+ * Number of fresh coins that are available
+ * and visible, i.e. the source transaction is in
+ * a final state.
+ */
+ visibleCoinCount: number;
+
+ /**
+ * Number of coins that we expect to obtain via a pending refresh.
+ */
+ pendingRefreshOutputCount?: number;
}
+export interface ContractTermsRecord {
+ /**
+ * Contract terms hash.
+ */
+ h: string;
+
+ /**
+ * Contract terms JSON.
+ */
+ contractTermsRaw: any;
+}
+
+export interface UserAttentionRecord {
+ info: AttentionInfo;
+
+ entityId: string;
+
+ /**
+ * When the notification was created.
+ */
+ created: DbPreciseTimestamp;
+
+ /**
+ * When the user mark this notification as read.
+ */
+ read: DbPreciseTimestamp | undefined;
+}
+
+export interface DbExchangeHandle {
+ url: string;
+ exchangeMasterPub: string;
+}
+
+export interface DbAuditorHandle {
+ url: string;
+ auditorPub: string;
+}
+
+export enum RefundGroupStatus {
+ Pending = 0x0100_0000,
+ Done = 0x0500_0000,
+ Failed = 0x0501_0000,
+ Aborted = 0x0503_0000,
+ Expired = 0x0502_0000,
+}
+
+/**
+ * Metadata about a group of refunds with the merchant.
+ */
+export interface RefundGroupRecord {
+ status: RefundGroupStatus;
+
+ /**
+ * Timestamp when the refund group was created.
+ */
+ timestampCreated: DbPreciseTimestamp;
+
+ proposalId: string;
+
+ refundGroupId: string;
+
+ refreshGroupId?: string;
+
+ amountRaw: AmountString;
+
+ /**
+ * Estimated effective amount, based on
+ * refund fees and refresh costs.
+ */
+ amountEffective: AmountString;
+}
+
+export enum RefundItemStatus {
+ /**
+ * Intermittent error that the merchant is
+ * reporting from the exchange.
+ *
+ * We'll try again!
+ */
+ Pending = 0x0100_0000,
+ /**
+ * Refund was obtained successfully.
+ */
+ Done = 0x0500_0000,
+ /**
+ * Permanent error reported by the exchange
+ * for the refund.
+ */
+ Failed = 0x0501_0000,
+}
+
+/**
+ * Refund for a single coin in a payment with a merchant.
+ */
+export interface RefundItemRecord {
+ /**
+ * Auto-increment DB record ID.
+ */
+ id?: number;
+
+ status: RefundItemStatus;
+
+ refundGroupId: string;
+
+ /**
+ * Execution time as claimed by the merchant
+ */
+ executionTime: DbProtocolTimestamp;
+
+ /**
+ * Time when the wallet became aware of the refund.
+ */
+ obtainedTime: DbPreciseTimestamp;
+
+ refundAmount: AmountString;
+
+ coinPub: string;
+
+ rtxid: number;
+}
+
+export function passthroughCodec<T>(): Codec<T> {
+ return codecForAny();
+}
+
+export interface GlobalCurrencyAuditorRecord {
+ id?: number;
+ currency: string;
+ auditorBaseUrl: string;
+ auditorPub: string;
+}
+
+export interface GlobalCurrencyExchangeRecord {
+ id?: number;
+ currency: string;
+ exchangeBaseUrl: string;
+ exchangeMasterPub: string;
+}
+
+/**
+ * Primary key: transactionItem.transactionId
+ */
+export interface TransactionRecord {
+ /**
+ * Transaction item returned to the client.
+ */
+ transactionItem: Transaction;
+
+ /**
+ * Exchanges involved in the transaction.
+ */
+ exchanges: string[];
+
+ currency: string;
+}
+
+export enum DenomLossStatus {
+ /**
+ * Done indicates that the loss happened.
+ */
+ Done = 0x0500_0000,
+
+ /**
+ * Aborted in the sense that the loss was reversed.
+ */
+ Aborted = 0x0503_0001,
+}
+
+export interface DenomLossEventRecord {
+ denomLossEventId: string;
+ currency: string;
+ denomPubHashes: string[];
+ status: DenomLossStatus;
+ timestampCreated: DbPreciseTimestamp;
+ amount: string;
+ eventType: DenomLossEventType;
+ exchangeBaseUrl: string;
+}
+
+/**
+ * Schema definition for the IndexedDB
+ * wallet database.
+ */
export const WalletStoresV1 = {
+ denomLossEvents: describeStoreV2({
+ recordCodec: passthroughCodec<DenomLossEventRecord>(),
+ storeName: "denomLossEvents",
+ keyPath: "denomLossEventId",
+ versionAdded: 9,
+ indexes: {
+ byCurrency: describeIndex("byCurrency", "currency", {
+ versionAdded: 9,
+ }),
+ byStatus: describeIndex("byStatus", "status", {
+ versionAdded: 10,
+ }),
+ },
+ }),
+ transactions: describeStoreV2({
+ recordCodec: passthroughCodec<TransactionRecord>(),
+ storeName: "transactions",
+ keyPath: "transactionItem.transactionId",
+ versionAdded: 7,
+ indexes: {
+ byCurrency: describeIndex("byCurrency", "currency", {
+ versionAdded: 7,
+ }),
+ byExchange: describeIndex("byExchange", "exchanges", {
+ versionAdded: 7,
+ multiEntry: true,
+ }),
+ },
+ }),
+ globalCurrencyAuditors: describeStoreV2({
+ recordCodec: passthroughCodec<GlobalCurrencyAuditorRecord>(),
+ storeName: "globalCurrencyAuditors",
+ keyPath: "id",
+ autoIncrement: true,
+ versionAdded: 3,
+ indexes: {
+ byCurrencyAndUrlAndPub: describeIndex(
+ "byCurrencyAndUrlAndPub",
+ ["currency", "auditorBaseUrl", "auditorPub"],
+ {
+ unique: true,
+ versionAdded: 4,
+ },
+ ),
+ },
+ }),
+ globalCurrencyExchanges: describeStoreV2({
+ recordCodec: passthroughCodec<GlobalCurrencyExchangeRecord>(),
+ storeName: "globalCurrencyExchanges",
+ keyPath: "id",
+ autoIncrement: true,
+ versionAdded: 3,
+ indexes: {
+ byCurrencyAndUrlAndPub: describeIndex(
+ "byCurrencyAndUrlAndPub",
+ ["currency", "exchangeBaseUrl", "exchangeMasterPub"],
+ {
+ unique: true,
+ versionAdded: 4,
+ },
+ ),
+ },
+ }),
+ coinAvailability: describeStore(
+ "coinAvailability",
+ describeContents<CoinAvailabilityRecord>({
+ keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"],
+ }),
+ {
+ byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [
+ "exchangeBaseUrl",
+ "maxAge",
+ "freshCoinCount",
+ ]),
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 8,
+ }),
+ },
+ ),
coins: describeStore(
- describeContents<CoinRecord>("coins", {
+ "coins",
+ describeContents<CoinRecord>({
keyPath: "coinPub",
}),
{
byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"),
byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"),
+ byExchangeDenomPubHashAndAgeAndStatus: describeIndex(
+ "byExchangeDenomPubHashAndAgeAndStatus",
+ ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"],
+ ),
byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"),
+ bySourceTransactionId: describeIndex(
+ "bySourceTransactionId",
+ "sourceTransactionId",
+ {
+ versionAdded: 9,
+ },
+ ),
},
),
- config: describeStore(
- describeContents<ConfigRecord>("config", { keyPath: "key" }),
- {},
- ),
- auditorTrust: describeStore(
- describeContents<AuditorTrustRecord>("auditorTrust", {
- keyPath: ["currency", "auditorBaseUrl"],
+ reserves: describeStore(
+ "reserves",
+ describeContents<ReserveRecord>({
+ keyPath: "rowId",
+ autoIncrement: true,
}),
{
- byAuditorPub: describeIndex("byAuditorPub", "auditorPub"),
- byUid: describeIndex("byUid", "uids", {
- multiEntry: true,
- }),
+ byReservePub: describeIndex("byReservePub", "reservePub", {}),
},
),
- exchangeTrust: describeStore(
- describeContents<ExchangeTrustRecord>("exchangeTrust", {
- keyPath: ["currency", "exchangeBaseUrl"],
- }),
- {
- byExchangeMasterPub: describeIndex(
- "byExchangeMasterPub",
- "exchangeMasterPub",
- ),
- },
+ config: describeStore(
+ "config",
+ describeContents<ConfigRecord>({ keyPath: "key" }),
+ {},
),
denominations: describeStore(
- describeContents<DenominationRecord>("denominations", {
+ "denominations",
+ describeContents<DenominationRecord>({
keyPath: ["exchangeBaseUrl", "denomPubHash"],
}),
{
@@ -1632,96 +2543,145 @@ export const WalletStoresV1 = {
},
),
exchanges: describeStore(
- describeContents<ExchangeRecord>("exchanges", {
+ "exchanges",
+ describeContents<ExchangeEntryRecord>({
keyPath: "baseUrl",
}),
{},
),
exchangeDetails: describeStore(
- describeContents<ExchangeDetailsRecord>("exchangeDetails", {
- keyPath: ["exchangeBaseUrl", "currency", "masterPublicKey"],
+ "exchangeDetails",
+ describeContents<ExchangeDetailsRecord>({
+ keyPath: "rowId",
+ autoIncrement: true,
}),
- {},
+ {
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 2,
+ }),
+ byPointer: describeIndex(
+ "byDetailsPointer",
+ ["exchangeBaseUrl", "currency", "masterPublicKey"],
+ {
+ unique: true,
+ },
+ ),
+ },
),
- proposals: describeStore(
- describeContents<ProposalRecord>("proposals", { keyPath: "proposalId" }),
+ exchangeSignKeys: describeStore(
+ "exchangeSignKeys",
+ describeContents<ExchangeSignkeysRecord>({
+ keyPath: ["exchangeDetailsRowId", "signkeyPub"],
+ }),
{
- byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
- "merchantBaseUrl",
- "orderId",
+ byExchangeDetailsRowId: describeIndex("byExchangeDetailsRowId", [
+ "exchangeDetailsRowId",
]),
},
),
refreshGroups: describeStore(
- describeContents<RefreshGroupRecord>("refreshGroups", {
+ "refreshGroups",
+ describeContents<RefreshGroupRecord>({
keyPath: "refreshGroupId",
}),
+ {
+ byStatus: describeIndex("byStatus", "operationStatus"),
+ byOriginatingTransactionId: describeIndex(
+ "byOriginatingTransactionId",
+ "originatingTransactionId",
+ {
+ versionAdded: 5,
+ },
+ ),
+ },
+ ),
+ refreshSessions: describeStore(
+ "refreshSessions",
+ describeContents<RefreshSessionRecord>({
+ keyPath: ["refreshGroupId", "coinIndex"],
+ }),
{},
),
recoupGroups: describeStore(
- describeContents<RecoupGroupRecord>("recoupGroups", {
+ "recoupGroups",
+ describeContents<RecoupGroupRecord>({
keyPath: "recoupGroupId",
}),
- {},
- ),
- reserves: describeStore(
- describeContents<ReserveRecord>("reserves", { keyPath: "reservePub" }),
{
- byInitialWithdrawalGroupId: describeIndex(
- "byInitialWithdrawalGroupId",
- "initialWithdrawalGroupId",
- ),
+ byStatus: describeIndex("byStatus", "operationStatus", {
+ versionAdded: 6,
+ }),
},
),
purchases: describeStore(
- describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }),
+ "purchases",
+ describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
{
+ byStatus: describeIndex("byStatus", "purchaseStatus"),
byFulfillmentUrl: describeIndex(
"byFulfillmentUrl",
- "download.contractData.fulfillmentUrl",
+ "download.fulfillmentUrl",
),
- byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
- "download.contractData.merchantBaseUrl",
- "download.contractData.orderId",
+ byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
+ "merchantBaseUrl",
+ "orderId",
]),
},
),
- tips: describeStore(
- describeContents<TipRecord>("tips", { keyPath: "walletTipId" }),
+ rewards: describeStore(
+ "rewards",
+ describeContents<RewardRecord>({ keyPath: "walletRewardId" }),
{
- byMerchantTipIdAndBaseUrl: describeIndex("byMerchantTipIdAndBaseUrl", [
- "merchantTipId",
+ byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [
+ "merchantRewardId",
"merchantBaseUrl",
]),
+ byStatus: describeIndex("byStatus", "status", {
+ versionAdded: 8,
+ }),
},
),
withdrawalGroups: describeStore(
- describeContents<WithdrawalGroupRecord>("withdrawalGroups", {
+ "withdrawalGroups",
+ describeContents<WithdrawalGroupRecord>({
keyPath: "withdrawalGroupId",
}),
{
- byReservePub: describeIndex("byReservePub", "reservePub"),
+ byStatus: describeIndex("byStatus", "status"),
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 2,
+ }),
+ byTalerWithdrawUri: describeIndex(
+ "byTalerWithdrawUri",
+ "wgInfo.bankInfo.talerWithdrawUri",
+ ),
},
),
planchets: describeStore(
- describeContents<PlanchetRecord>("planchets", { keyPath: "coinPub" }),
+ "planchets",
+ describeContents<PlanchetRecord>({ keyPath: "coinPub" }),
{
- byGroupAndIndex: describeIndex("byGroupAndIndex", [
- "withdrawalGroupId",
- "coinIdx",
- ]),
+ byGroupAndIndex: describeIndex(
+ "byGroupAndIndex",
+ ["withdrawalGroupId", "coinIdx"],
+ {
+ unique: true,
+ },
+ ),
byGroup: describeIndex("byGroup", "withdrawalGroupId"),
byCoinEvHash: describeIndex("byCoinEv", "coinEvHash"),
},
),
bankWithdrawUris: describeStore(
- describeContents<BankWithdrawUriRecord>("bankWithdrawUris", {
+ "bankWithdrawUris",
+ describeContents<BankWithdrawUriRecord>({
keyPath: "talerWithdrawUri",
}),
{},
),
backupProviders: describeStore(
- describeContents<BackupProviderRecord>("backupProviders", {
+ "backupProviders",
+ describeContents<BackupProviderRecord>({
keyPath: "baseUrl",
}),
{
@@ -1735,23 +2695,184 @@ export const WalletStoresV1 = {
},
),
depositGroups: describeStore(
- describeContents<DepositGroupRecord>("depositGroups", {
+ "depositGroups",
+ describeContents<DepositGroupRecord>({
keyPath: "depositGroupId",
}),
- {},
+ {
+ byStatus: describeIndex("byStatus", "operationStatus"),
+ },
),
tombstones: describeStore(
- describeContents<TombstoneRecord>("tombstones", { keyPath: "id" }),
+ "tombstones",
+ describeContents<TombstoneRecord>({ keyPath: "id" }),
{},
),
- ghostDepositGroups: describeStore(
- describeContents<GhostDepositGroupRecord>("ghostDepositGroups", {
- keyPath: "contractTermsHash",
+ operationRetries: describeStore(
+ "operationRetries",
+ describeContents<OperationRetryRecord>({
+ keyPath: "id",
+ }),
+ {},
+ ),
+ peerPushCredit: describeStore(
+ "peerPushCredit",
+ describeContents<PeerPushPaymentIncomingRecord>({
+ keyPath: "peerPushCreditId",
+ }),
+ {
+ byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
+ "exchangeBaseUrl",
+ "pursePub",
+ ]),
+ byExchangeAndContractPriv: describeIndex(
+ "byExchangeAndContractPriv",
+ ["exchangeBaseUrl", "contractPriv"],
+ {
+ unique: true,
+ },
+ ),
+ byWithdrawalGroupId: describeIndex(
+ "byWithdrawalGroupId",
+ "withdrawalGroupId",
+ {},
+ ),
+ byStatus: describeIndex("byStatus", "status"),
+ },
+ ),
+ peerPullDebit: describeStore(
+ "peerPullDebit",
+ describeContents<PeerPullPaymentIncomingRecord>({
+ keyPath: "peerPullDebitId",
+ }),
+ {
+ byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
+ "exchangeBaseUrl",
+ "pursePub",
+ ]),
+ byExchangeAndContractPriv: describeIndex(
+ "byExchangeAndContractPriv",
+ ["exchangeBaseUrl", "contractPriv"],
+ {
+ unique: true,
+ },
+ ),
+ byStatus: describeIndex("byStatus", "status"),
+ },
+ ),
+ peerPullCredit: describeStore(
+ "peerPullCredit",
+ describeContents<PeerPullCreditRecord>({
+ keyPath: "pursePub",
+ }),
+ {
+ byStatus: describeIndex("byStatus", "status"),
+ byWithdrawalGroupId: describeIndex(
+ "byWithdrawalGroupId",
+ "withdrawalGroupId",
+ {},
+ ),
+ },
+ ),
+ peerPushDebit: describeStore(
+ "peerPushDebit",
+ describeContents<PeerPushDebitRecord>({
+ keyPath: "pursePub",
+ }),
+ {
+ byStatus: describeIndex("byStatus", "status"),
+ },
+ ),
+ bankAccounts: describeStore(
+ "bankAccounts",
+ describeContents<BankAccountsRecord>({
+ keyPath: "uri",
+ }),
+ {},
+ ),
+ contractTerms: describeStore(
+ "contractTerms",
+ describeContents<ContractTermsRecord>({
+ keyPath: "h",
+ }),
+ {},
+ ),
+ userAttention: describeStore(
+ "userAttention",
+ describeContents<UserAttentionRecord>({
+ keyPath: ["entityId", "info.type"],
+ }),
+ {},
+ ),
+ refundGroups: describeStore(
+ "refundGroups",
+ describeContents<RefundGroupRecord>({
+ keyPath: "refundGroupId",
+ }),
+ {
+ byProposalId: describeIndex("byProposalId", "proposalId"),
+ byStatus: describeIndex("byStatus", "status", {}),
+ },
+ ),
+ refundItems: describeStore(
+ "refundItems",
+ describeContents<RefundItemRecord>({
+ keyPath: "id",
+ autoIncrement: true,
+ }),
+ {
+ byCoinPubAndRtxid: describeIndex("byCoinPubAndRtxid", [
+ "coinPub",
+ "rtxid",
+ ]),
+ // FIXME: Why is this a list of index keys? Confusing!
+ byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]),
+ },
+ ),
+ fixups: describeStore(
+ "fixups",
+ describeContents<FixupRecord>({
+ keyPath: "fixupName",
}),
{},
),
};
+export type WalletDbStoresArr = Array<StoreNames<typeof WalletStoresV1>>;
+
+export type WalletDbReadWriteTransaction<StoresArr extends WalletDbStoresArr> =
+ DbReadWriteTransaction<typeof WalletStoresV1, StoresArr>;
+
+export type WalletDbReadOnlyTransaction<StoresArr extends WalletDbStoresArr> =
+ DbReadOnlyTransaction<typeof WalletStoresV1, StoresArr>;
+
+export type WalletDbAllStoresReadOnlyTransaction<> = DbReadOnlyTransaction<
+ typeof WalletStoresV1,
+ WalletDbStoresArr
+>;
+
+export type WalletDbAllStoresReadWriteTransaction<> = DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ WalletDbStoresArr
+>;
+
+/**
+ * An applied migration.
+ */
+export interface FixupRecord {
+ fixupName: string;
+}
+
+/**
+ * User accounts
+ */
+export interface BankAccountsRecord {
+ uri: string;
+ currency: string;
+ kycCompleted: boolean;
+ alias: string;
+}
+
export interface MetaConfigRecord {
key: string;
value: any;
@@ -1759,7 +2880,518 @@ export interface MetaConfigRecord {
export const walletMetadataStore = {
metaConfig: describeStore(
- describeContents<MetaConfigRecord>("metaConfig", { keyPath: "key" }),
+ "metaConfig",
+ describeContents<MetaConfigRecord>({ keyPath: "key" }),
{},
),
};
+
+export interface StoredBackupMeta {
+ name: string;
+}
+
+export const StoredBackupStores = {
+ backupMeta: describeStore(
+ "backupMeta",
+ describeContents<StoredBackupMeta>({ keyPath: "name" }),
+ {},
+ ),
+ backupData: describeStore("backupData", describeContents<any>({}), {}),
+};
+
+export interface DbDumpRecord {
+ /**
+ * Key, serialized with structuredEncapsulated.
+ *
+ * Only present for out-of-line keys (i.e. no key path).
+ */
+ key?: any;
+ /**
+ * Value, serialized with structuredEncapsulated.
+ */
+ value: any;
+}
+
+export interface DbIndexDump {
+ keyPath: string | string[];
+ multiEntry: boolean;
+ unique: boolean;
+}
+
+export interface DbStoreDump {
+ keyPath?: string | string[];
+ autoIncrement: boolean;
+ indexes: { [indexName: string]: DbIndexDump };
+ records: DbDumpRecord[];
+}
+
+export interface DbDumpDatabase {
+ version: number;
+ stores: { [storeName: string]: DbStoreDump };
+}
+
+export interface DbDump {
+ databases: {
+ [name: string]: DbDumpDatabase;
+ };
+}
+
+export async function exportSingleDb(
+ idb: IDBFactory,
+ dbName: string,
+): Promise<DbDumpDatabase> {
+ const myDb = await openDatabase(
+ idb,
+ dbName,
+ undefined,
+ () => {
+ logger.info(`unexpected onversionchange in exportSingleDb of ${dbName}`);
+ },
+ () => {
+ logger.info(`unexpected onupgradeneeded in exportSingleDb of ${dbName}`);
+ },
+ );
+
+ const singleDbDump: DbDumpDatabase = {
+ version: myDb.version,
+ stores: {},
+ };
+
+ return new Promise((resolve, reject) => {
+ const tx = myDb.transaction(Array.from(myDb.objectStoreNames));
+ tx.addEventListener("complete", () => {
+ //myDb.close();
+ resolve(singleDbDump);
+ });
+ // tslint:disable-next-line:prefer-for-of
+ for (let i = 0; i < myDb.objectStoreNames.length; i++) {
+ const name = myDb.objectStoreNames[i];
+ const store = tx.objectStore(name);
+ const storeDump: DbStoreDump = {
+ autoIncrement: store.autoIncrement,
+ keyPath: store.keyPath,
+ indexes: {},
+ records: [],
+ };
+ const indexNames = store.indexNames;
+ for (let j = 0; j < indexNames.length; j++) {
+ const idxName = indexNames[j];
+ const index = store.index(idxName);
+ storeDump.indexes[idxName] = {
+ keyPath: index.keyPath,
+ multiEntry: index.multiEntry,
+ unique: index.unique,
+ };
+ }
+ singleDbDump.stores[name] = storeDump;
+ store.openCursor().addEventListener("success", (e: Event) => {
+ const cursor = (e.target as any).result;
+ if (cursor) {
+ const rec: DbDumpRecord = {
+ value: structuredEncapsulate(cursor.value),
+ };
+ // Only store key if necessary, i.e. when
+ // the key is not stored as part of the object via
+ // a key path.
+ if (store.keyPath == null) {
+ rec.key = structuredEncapsulate(cursor.key);
+ }
+ storeDump.records.push(rec);
+ cursor.continue();
+ }
+ });
+ }
+ });
+}
+
+export async function exportDb(idb: IDBFactory): Promise<DbDump> {
+ const dbDump: DbDump = {
+ databases: {},
+ };
+
+ dbDump.databases[TALER_WALLET_META_DB_NAME] = await exportSingleDb(
+ idb,
+ TALER_WALLET_META_DB_NAME,
+ );
+ dbDump.databases[TALER_WALLET_MAIN_DB_NAME] = await exportSingleDb(
+ idb,
+ TALER_WALLET_MAIN_DB_NAME,
+ );
+
+ return dbDump;
+}
+
+async function recoverFromDump(
+ db: IDBDatabase,
+ dbDump: DbDumpDatabase,
+): Promise<void> {
+ const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite");
+ const txProm = promiseFromTransaction(tx);
+ const storeNames = db.objectStoreNames;
+ for (let i = 0; i < storeNames.length; i++) {
+ const name = db.objectStoreNames[i];
+ const storeDump = dbDump.stores[name];
+ if (!storeDump) continue;
+ await promiseFromRequest(tx.objectStore(name).clear());
+ logger.info(`importing ${storeDump.records.length} records into ${name}`);
+ for (let rec of storeDump.records) {
+ await promiseFromRequest(tx.objectStore(name).put(rec.value, rec.key));
+ logger.info("importing record done");
+ }
+ }
+ tx.commit();
+ return await txProm;
+}
+
+function checkDbDump(x: any): x is DbDump {
+ return "databases" in x;
+}
+
+export async function importDb(db: IDBDatabase, dumpJson: any): Promise<void> {
+ const d = structuredRevive(dumpJson);
+ if (checkDbDump(d)) {
+ const walletDb = d.databases[TALER_WALLET_MAIN_DB_NAME];
+ if (!walletDb) {
+ throw Error(
+ `unable to import, main wallet database (${TALER_WALLET_MAIN_DB_NAME}) not found`,
+ );
+ }
+ await recoverFromDump(db, walletDb);
+ } else {
+ throw Error("unable to import, doesn't look like a valid DB dump");
+ }
+}
+
+export interface FixupDescription {
+ name: string;
+ fn(
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ Array<StoreNames<typeof WalletStoresV1>>
+ >,
+ ): Promise<void>;
+}
+
+/**
+ * Manual migrations between minor versions of the DB schema.
+ */
+export const walletDbFixups: FixupDescription[] = [];
+
+const logger = new Logger("db.ts");
+
+export async function applyFixups(
+ db: DbAccess<typeof WalletStoresV1>,
+): Promise<void> {
+ logger.trace("applying fixups");
+ await db.runAllStoresReadWriteTx({}, async (tx) => {
+ for (const fixupInstruction of walletDbFixups) {
+ logger.trace(`checking fixup ${fixupInstruction.name}`);
+ const fixupRecord = await tx.fixups.get(fixupInstruction.name);
+ if (fixupRecord) {
+ continue;
+ }
+ logger.info(`applying DB fixup ${fixupInstruction.name}`);
+ await fixupInstruction.fn(tx);
+ await tx.fixups.put({
+ fixupName: fixupInstruction.name,
+ });
+ }
+ });
+}
+
+/**
+ * Upgrade an IndexedDB in an upgrade transaction.
+ *
+ * The upgrade is made based on a store map, i.e. the metadata
+ * structure that describes all the object stores and indexes.
+ */
+function upgradeFromStoreMap(
+ storeMap: any, // FIXME: nail down type
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+): void {
+ if (oldVersion === 0) {
+ for (const n in storeMap) {
+ const swi: StoreWithIndexes<
+ any,
+ StoreDescriptor<unknown>,
+ any
+ > = storeMap[n];
+ const storeDesc: StoreDescriptor<unknown> = swi.store;
+ const s = db.createObjectStore(swi.storeName, {
+ autoIncrement: storeDesc.autoIncrement,
+ keyPath: storeDesc.keyPath,
+ });
+ for (const indexName in swi.indexMap as any) {
+ const indexDesc: IndexDescriptor = swi.indexMap[indexName];
+ s.createIndex(indexDesc.name, indexDesc.keyPath, {
+ multiEntry: indexDesc.multiEntry,
+ unique: indexDesc.unique,
+ });
+ }
+ }
+ return;
+ }
+ if (oldVersion === newVersion) {
+ return;
+ }
+ logger.info(`upgrading database from ${oldVersion} to ${newVersion}`);
+ for (const n in storeMap) {
+ const swi: StoreWithIndexes<any, StoreDescriptor<unknown>, any> = storeMap[
+ n
+ ];
+ const storeDesc: StoreDescriptor<unknown> = swi.store;
+ const storeAddedVersion = storeDesc.versionAdded ?? 0;
+ let s: IDBObjectStore;
+ if (storeAddedVersion > oldVersion) {
+ // Be tolerant if object store already exists.
+ // Probably means somebody deployed without
+ // adding the "addedInVersion" attribute.
+ if (!upgradeTransaction.objectStoreNames.contains(swi.storeName)) {
+ try {
+ s = db.createObjectStore(swi.storeName, {
+ autoIncrement: storeDesc.autoIncrement,
+ keyPath: storeDesc.keyPath,
+ });
+ } catch (e) {
+ const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
+ throw new Error(
+ `Migration failed. Could not create store ${swi.storeName}.${moreInfo}`,
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
+ );
+ }
+ }
+ }
+
+ s = upgradeTransaction.objectStore(swi.storeName);
+
+ for (const indexName in swi.indexMap as any) {
+ const indexDesc: IndexDescriptor = swi.indexMap[indexName];
+ const indexAddedVersion = indexDesc.versionAdded ?? 0;
+ if (indexAddedVersion <= oldVersion) {
+ continue;
+ }
+ // Be tolerant if index already exists.
+ // Probably means somebody deployed without
+ // adding the "addedInVersion" attribute.
+ if (!s.indexNames.contains(indexDesc.name)) {
+ try {
+ s.createIndex(indexDesc.name, indexDesc.keyPath, {
+ multiEntry: indexDesc.multiEntry,
+ unique: indexDesc.unique,
+ });
+ } catch (e) {
+ const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
+ throw Error(
+ `Migration failed. Could not create index ${indexDesc.name}/${indexDesc.keyPath}. ${moreInfo}`,
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
+ );
+ }
+ }
+ }
+ }
+}
+
+function promiseFromTransaction(transaction: IDBTransaction): Promise<void> {
+ return new Promise<void>((resolve, reject) => {
+ transaction.oncomplete = () => {
+ resolve();
+ };
+ transaction.onerror = () => {
+ reject();
+ };
+ });
+}
+
+export function promiseFromRequest(request: IDBRequest): Promise<any> {
+ return new Promise((resolve, reject) => {
+ request.onsuccess = () => {
+ resolve(request.result);
+ };
+ request.onerror = () => {
+ reject(request.error);
+ };
+ });
+}
+
+/**
+ * Purge all data in the given database.
+ */
+export function clearDatabase(db: IDBDatabase): Promise<void> {
+ // db.objectStoreNames is a DOMStringList, so we need to convert
+ let stores: string[] = [];
+ for (let i = 0; i < db.objectStoreNames.length; i++) {
+ stores.push(db.objectStoreNames[i]);
+ }
+ const tx = db.transaction(stores, "readwrite");
+ for (const store of stores) {
+ tx.objectStore(store).clear();
+ }
+ return promiseFromTransaction(tx);
+}
+
+function onTalerDbUpgradeNeeded(
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+) {
+ upgradeFromStoreMap(
+ WalletStoresV1,
+ db,
+ oldVersion,
+ newVersion,
+ upgradeTransaction,
+ );
+}
+
+function onMetaDbUpgradeNeeded(
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+) {
+ upgradeFromStoreMap(
+ walletMetadataStore,
+ db,
+ oldVersion,
+ newVersion,
+ upgradeTransaction,
+ );
+}
+
+function onStoredBackupsDbUpgradeNeeded(
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+) {
+ upgradeFromStoreMap(
+ StoredBackupStores,
+ db,
+ oldVersion,
+ newVersion,
+ upgradeTransaction,
+ );
+}
+
+export async function openStoredBackupsDatabase(
+ idbFactory: IDBFactory,
+): Promise<DbAccess<typeof StoredBackupStores>> {
+ const backupsDbHandle = await openDatabase(
+ idbFactory,
+ TALER_WALLET_STORED_BACKUPS_DB_NAME,
+ 1,
+ () => {},
+ onStoredBackupsDbUpgradeNeeded,
+ );
+
+ const handle = new DbAccessImpl(
+ backupsDbHandle,
+ StoredBackupStores,
+ {},
+ CancellationToken.CONTINUE,
+ );
+ return handle;
+}
+
+/**
+ * Return a promise that resolves
+ * to the taler wallet db.
+ *
+ * @param onVersionChange Called when another client concurrenctly connects to the database
+ * with a higher version.
+ */
+export async function openTalerDatabase(
+ idbFactory: IDBFactory,
+ onVersionChange: () => void,
+): Promise<IDBDatabase> {
+ const metaDbHandle = await openDatabase(
+ idbFactory,
+ TALER_WALLET_META_DB_NAME,
+ 1,
+ () => {},
+ onMetaDbUpgradeNeeded,
+ );
+
+ const metaDb = new DbAccessImpl(
+ metaDbHandle,
+ walletMetadataStore,
+ {},
+ CancellationToken.CONTINUE,
+ );
+ let currentMainVersion: string | undefined;
+ await metaDb.runReadWriteTx({ storeNames: ["metaConfig"] }, async (tx) => {
+ const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
+ if (!dbVersionRecord) {
+ currentMainVersion = TALER_WALLET_MAIN_DB_NAME;
+ await tx.metaConfig.put({
+ key: CURRENT_DB_CONFIG_KEY,
+ value: TALER_WALLET_MAIN_DB_NAME,
+ });
+ } else {
+ currentMainVersion = dbVersionRecord.value;
+ }
+ });
+
+ if (currentMainVersion !== TALER_WALLET_MAIN_DB_NAME) {
+ switch (currentMainVersion) {
+ case "taler-wallet-main-v2":
+ case "taler-wallet-main-v3":
+ case "taler-wallet-main-v4": // temporary, we might migrate v4 later
+ case "taler-wallet-main-v5":
+ case "taler-wallet-main-v6":
+ case "taler-wallet-main-v7":
+ case "taler-wallet-main-v8":
+ case "taler-wallet-main-v9":
+ // We consider this a pre-release
+ // development version, no migration is done.
+ await metaDb.runReadWriteTx(
+ { storeNames: ["metaConfig"] },
+ async (tx) => {
+ await tx.metaConfig.put({
+ key: CURRENT_DB_CONFIG_KEY,
+ value: TALER_WALLET_MAIN_DB_NAME,
+ });
+ },
+ );
+ break;
+ default:
+ throw Error(
+ `major migration from database major=${currentMainVersion} not supported`,
+ );
+ }
+ }
+
+ const mainDbHandle = await openDatabase(
+ idbFactory,
+ TALER_WALLET_MAIN_DB_NAME,
+ WALLET_DB_MINOR_VERSION,
+ onVersionChange,
+ onTalerDbUpgradeNeeded,
+ );
+
+ const mainDbAccess = new DbAccessImpl(
+ mainDbHandle,
+ WalletStoresV1,
+ {},
+ CancellationToken.CONTINUE,
+ );
+ await applyFixups(mainDbAccess);
+
+ return mainDbHandle;
+}
+
+export async function deleteTalerDatabase(
+ idbFactory: IDBFactory,
+): Promise<void> {
+ return new Promise((resolve, reject) => {
+ const req = idbFactory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME);
+ req.onerror = () => reject(req.error);
+ req.onsuccess = () => resolve();
+ });
+}
diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts
new file mode 100644
index 000000000..dfefe6ef5
--- /dev/null
+++ b/packages/taler-wallet-core/src/dbless.ts
@@ -0,0 +1,419 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Helper functions to run wallet functionality (withdrawal, deposit, refresh)
+ * without a database or retry loop.
+ *
+ * Used for benchmarking, where we want to benchmark the exchange, but the
+ * normal wallet would be too sluggish.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ AmountString,
+ Amounts,
+ DenominationPubKey,
+ ExchangeBatchDepositRequest,
+ ExchangeBatchWithdrawRequest,
+ ExchangeMeltRequest,
+ ExchangeProtocolVersion,
+ Logger,
+ TalerCorebankApiClient,
+ UnblindedSignature,
+ codecForAny,
+ codecForBankWithdrawalOperationPostResponse,
+ codecForBatchDepositSuccess,
+ codecForExchangeMeltResponse,
+ codecForExchangeRevealResponse,
+ codecForExchangeWithdrawBatchResponse,
+ encodeCrock,
+ getRandomBytes,
+ hashWire,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
+import { DenominationRecord } from "./db.js";
+import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js";
+import { assembleRefreshRevealRequest } from "./refresh.js";
+import { isWithdrawableDenom } from "./denominations.js";
+import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js";
+
+export { downloadExchangeInfo };
+
+const logger = new Logger("dbless.ts");
+
+export interface ReserveKeypair {
+ reservePub: string;
+ reservePriv: string;
+}
+
+/**
+ * Denormalized info about a coin.
+ */
+export interface CoinInfo {
+ coinPub: string;
+ coinPriv: string;
+ exchangeBaseUrl: string;
+ denomSig: UnblindedSignature;
+ denomPub: DenominationPubKey;
+ denomPubHash: string;
+ feeDeposit: string;
+ feeRefresh: string;
+ maxAge: number;
+}
+
+/**
+ * Check the status of a reserve, use long-polling to wait
+ * until the reserve actually has been created.
+ */
+export async function checkReserve(
+ http: HttpRequestLibrary,
+ exchangeBaseUrl: string,
+ reservePub: string,
+ longpollTimeoutMs: number = 500,
+): Promise<void> {
+ const reqUrl = new URL(`reserves/${reservePub}`, exchangeBaseUrl);
+ if (longpollTimeoutMs) {
+ reqUrl.searchParams.set("timeout_ms", `${longpollTimeoutMs}`);
+ }
+ const resp = await http.fetch(reqUrl.href, { method: "GET" });
+ if (resp.status !== 200) {
+ throw new Error("reserve not okay");
+ }
+}
+
+export interface TopupReserveWithBankArgs {
+ http: HttpRequestLibrary;
+ reservePub: string;
+ corebankApiBaseUrl: string;
+ exchangeInfo: ExchangeInfo;
+ amount: AmountString;
+}
+
+export async function topupReserveWithBank(
+ args: TopupReserveWithBankArgs,
+) {
+ const { http, corebankApiBaseUrl, amount, exchangeInfo, reservePub } = args;
+ const bankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
+ const bankUser = await bankClient.createRandomBankUser();
+ const wopi = await bankClient.createWithdrawalOperation(
+ bankUser.username,
+ amount,
+ );
+ const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri);
+ const bankStatusUrl = getBankStatusUrl(wopi.taler_withdraw_uri);
+ if (!bankInfo.suggestedExchange) {
+ throw Error("no suggested exchange");
+ }
+ const plainPaytoUris =
+ exchangeInfo.keys.accounts.map((x) => x.payto_uri) ?? [];
+ if (plainPaytoUris.length <= 0) {
+ throw new Error();
+ }
+ const httpResp = await http.fetch(bankStatusUrl, {
+ method: "POST",
+ body: {
+ reserve_pub: reservePub,
+ selected_exchange: plainPaytoUris[0],
+ },
+ });
+ await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForBankWithdrawalOperationPostResponse(),
+ );
+ await bankClient.confirmWithdrawalOperation(bankUser.username, {
+ withdrawalOperationId: wopi.withdrawal_id,
+ });
+}
+
+export async function withdrawCoin(args: {
+ http: HttpRequestLibrary;
+ cryptoApi: TalerCryptoInterface;
+ reserveKeyPair: ReserveKeypair;
+ denom: DenominationRecord;
+ exchangeBaseUrl: string;
+}): Promise<CoinInfo> {
+ const { http, cryptoApi, reserveKeyPair, denom, exchangeBaseUrl } = args;
+ const planchet = await cryptoApi.createPlanchet({
+ coinIndex: 0,
+ denomPub: denom.denomPub,
+ feeWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
+ reservePriv: reserveKeyPair.reservePriv,
+ reservePub: reserveKeyPair.reservePub,
+ secretSeed: encodeCrock(getRandomBytes(32)),
+ value: Amounts.parseOrThrow(denom.value),
+ });
+
+ const reqBody: ExchangeBatchWithdrawRequest = {
+ planchets: [
+ {
+ denom_pub_hash: planchet.denomPubHash,
+ reserve_sig: planchet.withdrawSig,
+ coin_ev: planchet.coinEv,
+ },
+ ],
+ };
+ const reqUrl = new URL(
+ `reserves/${planchet.reservePub}/batch-withdraw`,
+ exchangeBaseUrl,
+ ).href;
+
+ const resp = await http.fetch(reqUrl, { method: "POST", body: reqBody });
+ const rBatch = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWithdrawBatchResponse(),
+ );
+
+ const ubSig = await cryptoApi.unblindDenominationSignature({
+ planchet,
+ evSig: rBatch.ev_sigs[0].ev_sig,
+ });
+
+ return {
+ coinPriv: planchet.coinPriv,
+ coinPub: planchet.coinPub,
+ denomSig: ubSig,
+ denomPub: denom.denomPub,
+ denomPubHash: denom.denomPubHash,
+ feeDeposit: Amounts.stringify(denom.fees.feeDeposit),
+ feeRefresh: Amounts.stringify(denom.fees.feeRefresh),
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ maxAge: AgeRestriction.AGE_UNRESTRICTED,
+ };
+}
+
+export interface FindDenomOptions {
+ denomselAllowLate?: boolean;
+}
+
+export function findDenomOrThrow(
+ exchangeInfo: ExchangeInfo,
+ amount: AmountString,
+ options: FindDenomOptions = {},
+): DenominationRecord {
+ const denomselAllowLate = options.denomselAllowLate ?? false;
+ for (const d of exchangeInfo.keys.currentDenominations) {
+ const value: AmountJson = Amounts.parseOrThrow(d.value);
+ if (
+ Amounts.cmp(value, amount) === 0 &&
+ isWithdrawableDenom(d, denomselAllowLate)
+ ) {
+ return d;
+ }
+ }
+ throw new Error("no matching denomination found");
+}
+
+export async function depositCoin(args: {
+ http: HttpRequestLibrary;
+ cryptoApi: TalerCryptoInterface;
+ exchangeBaseUrl: string;
+ coin: CoinInfo;
+ amount: AmountString;
+ depositPayto?: string;
+ merchantPub?: string;
+ contractTermsHash?: string;
+ // 16 bytes, crockford encoded
+ wireSalt?: string;
+}): Promise<void> {
+ const { coin, http, cryptoApi } = args;
+ const depositPayto =
+ args.depositPayto ?? "payto://x-taler-bank/localhost/foo?receiver-name=foo";
+ const wireSalt = args.wireSalt ?? encodeCrock(getRandomBytes(16));
+ const timestampNow = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now());
+ const contractTermsHash =
+ args.contractTermsHash ?? encodeCrock(getRandomBytes(64));
+ const depositTimestamp = timestampNow;
+ const refundDeadline = timestampNow;
+ const wireTransferDeadline = timestampNow;
+ const merchantPub = args.merchantPub ?? encodeCrock(getRandomBytes(32));
+ const dp = await cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash,
+ denomKeyType: coin.denomPub.cipher,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(coin.feeDeposit),
+ merchantPub,
+ spendAmount: Amounts.parseOrThrow(args.amount),
+ timestamp: depositTimestamp,
+ refundDeadline: refundDeadline,
+ wireInfoHash: hashWire(depositPayto, wireSalt),
+ });
+ const requestBody: ExchangeBatchDepositRequest = {
+ coins: [
+ {
+ contribution: Amounts.stringify(dp.contribution),
+ coin_pub: dp.coin_pub,
+ coin_sig: dp.coin_sig,
+ denom_pub_hash: dp.h_denom,
+ ub_sig: dp.ub_sig,
+ },
+ ],
+ merchant_payto_uri: depositPayto,
+ wire_salt: wireSalt,
+ h_contract_terms: contractTermsHash,
+ timestamp: depositTimestamp,
+ wire_transfer_deadline: wireTransferDeadline,
+ refund_deadline: refundDeadline,
+ merchant_pub: merchantPub,
+ };
+ const url = new URL(`batch-deposit`, dp.exchange_url);
+ const httpResp = await http.fetch(url.href, {
+ method: "POST",
+ body: requestBody,
+ });
+ await readSuccessResponseJsonOrThrow(httpResp, codecForBatchDepositSuccess());
+}
+
+export async function refreshCoin(req: {
+ http: HttpRequestLibrary;
+ cryptoApi: TalerCryptoInterface;
+ oldCoin: CoinInfo;
+ newDenoms: DenominationRecord[];
+}): Promise<void> {
+ const { cryptoApi, oldCoin, http } = req;
+ const refreshSessionSeed = encodeCrock(getRandomBytes(32));
+ const session = await cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion: ExchangeProtocolVersion.V12,
+ feeRefresh: Amounts.parseOrThrow(oldCoin.feeRefresh),
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ sessionSecretSeed: refreshSessionSeed,
+ newCoinDenoms: req.newDenoms.map((x) => ({
+ count: 1,
+ denomPub: x.denomPub,
+ denomPubHash: x.denomPubHash,
+ feeWithdraw: x.fees.feeWithdraw,
+ value: x.value,
+ })),
+ meltCoinMaxAge: oldCoin.maxAge,
+ });
+
+ const meltReqBody: ExchangeMeltRequest = {
+ coin_pub: oldCoin.coinPub,
+ confirm_sig: session.confirmSig,
+ denom_pub_hash: oldCoin.denomPubHash,
+ denom_sig: oldCoin.denomSig,
+ rc: session.hash,
+ value_with_fee: Amounts.stringify(session.meltValueWithFee),
+ };
+
+ logger.info("requesting melt");
+
+ const meltReqUrl = new URL(
+ `coins/${oldCoin.coinPub}/melt`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ logger.info("requesting melt done");
+
+ const meltHttpResp = await http.fetch(meltReqUrl.href, {
+ method: "POST",
+ body: meltReqBody,
+ });
+
+ const meltResponse = await readSuccessResponseJsonOrThrow(
+ meltHttpResp,
+ codecForExchangeMeltResponse(),
+ );
+
+ const norevealIndex = meltResponse.noreveal_index;
+
+ const revealRequest = await assembleRefreshRevealRequest({
+ cryptoApi,
+ derived: session,
+ newDenoms: req.newDenoms.map((x) => ({
+ count: 1,
+ denomPubHash: x.denomPubHash,
+ })),
+ norevealIndex,
+ oldCoinPriv: oldCoin.coinPriv,
+ oldCoinPub: oldCoin.coinPub,
+ });
+
+ logger.info("requesting reveal");
+ const reqUrl = new URL(
+ `refreshes/${session.hash}/reveal`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const revealResp = await http.fetch(reqUrl.href, {
+ method: "POST",
+ body: revealRequest,
+ });
+
+ logger.info("requesting reveal done");
+
+ const reveal = await readSuccessResponseJsonOrThrow(
+ revealResp,
+ codecForExchangeRevealResponse(),
+ );
+
+ // We could unblind here, but we only use this function to
+ // benchmark the exchange.
+}
+
+/**
+ * Create a reserve for testing withdrawals.
+ *
+ * The reserve is created using the test-only API "/admin/add-incoming".
+ */
+export async function createTestingReserve(args: {
+ http: HttpRequestLibrary;
+ corebankApiBaseUrl: string;
+ amount: string;
+ reservePub: string;
+ exchangeInfo: ExchangeInfo;
+}): Promise<void> {
+ const { http, corebankApiBaseUrl, amount, reservePub } = args;
+ const paytoUri = args.exchangeInfo.keys.accounts[0].payto_uri;
+ const pt = parsePaytoUri(paytoUri);
+ if (!pt) {
+ throw Error("failed to parse payto URI");
+ }
+ const components = pt.targetPath.split("/");
+ const creditorAcct = components[components.length - 1];
+ const fbReq = await http.fetch(
+ new URL(
+ `accounts/${creditorAcct}/taler-wire-gateway/admin/add-incoming`,
+ corebankApiBaseUrl,
+ ).href,
+ {
+ method: "POST",
+ body: {
+ amount,
+ reserve_pub: reservePub,
+ debit_account: "payto://x-taler-bank/localhost/testdebtor",
+ },
+ },
+ );
+ await readSuccessResponseJsonOrThrow(fbReq, codecForAny());
+}
diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts
new file mode 100644
index 000000000..ecc1fa881
--- /dev/null
+++ b/packages/taler-wallet-core/src/denomSelection.ts
@@ -0,0 +1,199 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Selection of denominations for withdrawals.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ DenomSelectionState,
+ ForcedDenomSel,
+ Logger,
+} from "@gnu-taler/taler-util";
+import { DenominationRecord, timestampAbsoluteFromDb } from "./db.js";
+import { isWithdrawableDenom } from "./denominations.js";
+
+const logger = new Logger("denomSelection.ts");
+
+/**
+ * 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 selectWithdrawalDenominations(
+ amountAvailable: AmountJson,
+ denoms: DenominationRecord[],
+ denomselAllowLate: boolean = false,
+): DenomSelectionState {
+ let remaining = Amounts.copy(amountAvailable);
+
+ const selectedDenoms: {
+ count: number;
+ denomPubHash: string;
+ }[] = [];
+
+ let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let earliestDepositExpiration: AbsoluteTime | undefined;
+ let hasDenomWithAgeRestriction = false;
+
+ denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `selecting withdrawal denoms for ${Amounts.stringify(amountAvailable)}`,
+ );
+ }
+
+ for (const d of denoms) {
+ const cost = Amounts.add(d.value, d.fees.feeWithdraw).amount;
+ const res = Amounts.divmod(remaining, cost);
+ const count = res.quotient;
+ remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount;
+ if (count > 0) {
+ totalCoinValue = Amounts.add(
+ totalCoinValue,
+ Amounts.mult(d.value, count).amount,
+ ).amount;
+ totalWithdrawCost = Amounts.add(
+ totalWithdrawCost,
+ Amounts.mult(cost, count).amount,
+ ).amount;
+ selectedDenoms.push({
+ count,
+ denomPubHash: d.denomPubHash,
+ });
+ hasDenomWithAgeRestriction =
+ hasDenomWithAgeRestriction || d.denomPub.age_mask > 0;
+ const expireDeposit = timestampAbsoluteFromDb(d.stampExpireDeposit);
+ if (!earliestDepositExpiration) {
+ earliestDepositExpiration = expireDeposit;
+ } else {
+ earliestDepositExpiration = AbsoluteTime.min(
+ expireDeposit,
+ earliestDepositExpiration,
+ );
+ }
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `denom_pub_hash=${
+ d.denomPubHash
+ }, count=${count}, val=${Amounts.stringify(
+ d.value,
+ )}, wdfee=${Amounts.stringify(d.fees.feeWithdraw)}`,
+ );
+ }
+
+ if (Amounts.isZero(remaining)) {
+ break;
+ }
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace("(end of denom selection)");
+ }
+
+ earliestDepositExpiration ??= AbsoluteTime.never();
+
+ return {
+ selectedDenoms,
+ totalCoinValue: Amounts.stringify(totalCoinValue),
+ totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ earliestDepositExpiration,
+ ),
+ hasDenomWithAgeRestriction,
+ };
+}
+
+export function selectForcedWithdrawalDenominations(
+ amountAvailable: AmountJson,
+ denoms: DenominationRecord[],
+ forcedDenomSel: ForcedDenomSel,
+ denomselAllowLate: boolean,
+): DenomSelectionState {
+ const selectedDenoms: {
+ count: number;
+ denomPubHash: string;
+ }[] = [];
+
+ let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let earliestDepositExpiration: AbsoluteTime | undefined;
+ let hasDenomWithAgeRestriction = false;
+
+ denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ for (const fds of forcedDenomSel.denoms) {
+ const count = fds.count;
+ const denom = denoms.find((x) => {
+ return Amounts.cmp(x.value, fds.value) == 0;
+ });
+ if (!denom) {
+ throw Error(
+ `unable to find denom for forced selection (value ${fds.value})`,
+ );
+ }
+ const cost = Amounts.add(denom.value, denom.fees.feeWithdraw).amount;
+ totalCoinValue = Amounts.add(
+ totalCoinValue,
+ Amounts.mult(denom.value, count).amount,
+ ).amount;
+ totalWithdrawCost = Amounts.add(
+ totalWithdrawCost,
+ Amounts.mult(cost, count).amount,
+ ).amount;
+ selectedDenoms.push({
+ count,
+ denomPubHash: denom.denomPubHash,
+ });
+ hasDenomWithAgeRestriction =
+ hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
+ const expireDeposit = timestampAbsoluteFromDb(denom.stampExpireDeposit);
+ if (!earliestDepositExpiration) {
+ earliestDepositExpiration = expireDeposit;
+ } else {
+ earliestDepositExpiration = AbsoluteTime.min(
+ expireDeposit,
+ earliestDepositExpiration,
+ );
+ }
+ }
+
+ earliestDepositExpiration ??= AbsoluteTime.never();
+
+ return {
+ selectedDenoms,
+ totalCoinValue: Amounts.stringify(totalCoinValue),
+ totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ earliestDepositExpiration,
+ ),
+ hasDenomWithAgeRestriction,
+ };
+}
diff --git a/packages/taler-wallet-core/src/denominations.test.ts b/packages/taler-wallet-core/src/denominations.test.ts
new file mode 100644
index 000000000..98af5d1a4
--- /dev/null
+++ b/packages/taler-wallet-core/src/denominations.test.ts
@@ -0,0 +1,870 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AbsoluteTime,
+ FeeDescription,
+ FeeDescriptionPair,
+ Amounts,
+ DenominationInfo,
+ AmountString,
+} from "@gnu-taler/taler-util";
+// import { expect } from "chai";
+import {
+ createPairTimeline,
+ createTimeline,
+ selectBestForOverlappingDenominations,
+} from "./denominations.js";
+import test, { ExecutionContext } from "ava";
+
+/**
+ * Create some constants to be used as reference in the tests
+ */
+const VALUES: AmountString[] = Array.from({ length: 10 }).map(
+ (undef, t) => `USD:${t}` as AmountString,
+);
+const TIMESTAMPS = Array.from({ length: 20 }).map((undef, t_s) => ({ t_s }));
+const ABS_TIME = TIMESTAMPS.map((m) => AbsoluteTime.fromProtocolTimestamp(m));
+
+function normalize(
+ list: DenominationInfo[],
+): (DenominationInfo & { group: string })[] {
+ return list.map((e, idx) => ({
+ ...e,
+ denomPubHash: `id${idx}`,
+ group: Amounts.stringifyValue(e.value),
+ }));
+}
+
+//Avoiding to make an error-prone/time-consuming refactor
+//this function calls AVA's deepEqual from a chai interface
+function expect(t: ExecutionContext, thing: any): any {
+ return {
+ deep: {
+ equal: (another: any) => t.deepEqual(thing, another),
+ equals: (another: any) => t.deepEqual(thing, another),
+ },
+ };
+}
+
+// describe("Denomination timeline creation", (t) => {
+// describe("single value example", (t) => {
+
+test("should have one row with start and exp", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[2],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[1],
+ } as FeeDescription,
+ ]);
+});
+
+test("should have two rows with the second denom in the middle if second is better", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should have two rows with the first denom in the middle if second is worse", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should add a gap when there no fee", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[2],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[3],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[3],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should have three rows when first denom is between second and second is worse", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should have one row when first denom is between second and second is better", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[4],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should only add the best1", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should only add the best2", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[5],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[5],
+ stampExpireDeposit: TIMESTAMPS[6],
+ feeDeposit: VALUES[3],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[5],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[5],
+ until: ABS_TIME[6],
+ fee: VALUES[3],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should only add the best3", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[5],
+ feeDeposit: VALUES[3],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[5],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[5],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[5],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+// })
+
+// describe("multiple value example", (t) => {
+
+//TODO: test the same start but different value
+
+test("should not merge when there is different value", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[2],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[2]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should not merge when there is different value (with duplicates)", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[2],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[2],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[2]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[]);
+});
+
+// it.skip("real world example: bitcoin exchange", (t) => {
+// const timeline = createDenominationTimeline(
+// bitcoinExchanges[0].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
+// "stampExpireDeposit", "feeDeposit");
+
+// expect(t,timeline).deep.equal([{
+// fee: Amounts.parseOrThrow('BITCOINBTC:0.00000001'),
+// from: { t_ms: 1652978648000 },
+// until: { t_ms: 1699633748000 },
+// value: Amounts.parseOrThrow('BITCOINBTC:0.01048576'),
+// }, {
+// fee: Amounts.parseOrThrow('BITCOINBTC:0.00000003'),
+// from: { t_ms: 1699633748000 },
+// until: { t_ms: 1707409448000 },
+// value: Amounts.parseOrThrow('BITCOINBTC:0.01048576'),
+// }] as FeeDescription[])
+// })
+
+// })
+
+// })
+
+// describe("Denomination timeline pair creation", (t) => {
+
+// describe("single value example", (t) => {
+
+test("should return empty", (t) => {
+ const left = [] as FeeDescription[];
+ const right = [] as FeeDescription[];
+
+ const pairs = createPairTimeline(left, right);
+
+ expect(t, pairs).deep.equals([]);
+});
+
+test("should return first element", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[];
+
+ const right = [] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ ] as FeeDescriptionPair[]);
+ }
+ {
+ const pairs = createPairTimeline(right, left);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ right: VALUES[1],
+ left: undefined,
+ },
+ ] as FeeDescriptionPair[]);
+ }
+});
+
+test("should add both to the same row", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[];
+
+ const right = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: VALUES[2],
+ },
+ ] as FeeDescriptionPair[]);
+ }
+ {
+ const pairs = createPairTimeline(right, left);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[2],
+ right: VALUES[1],
+ },
+ ] as FeeDescriptionPair[]);
+ }
+});
+
+test("should repeat the first and change the second", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[5],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[];
+
+ const right = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[3],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ fee: VALUES[3],
+ },
+ ] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: VALUES[2],
+ },
+ {
+ from: ABS_TIME[2],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ {
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: VALUES[3],
+ },
+ {
+ from: ABS_TIME[4],
+ until: ABS_TIME[5],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ ] as FeeDescriptionPair[]);
+ }
+});
+
+// })
+
+// describe("multiple value example", (t) => {
+
+test("should separate denominations of different value", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[];
+
+ const right = [
+ {
+ group: Amounts.stringifyValue(VALUES[2]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[2]),
+ left: undefined,
+ right: VALUES[2],
+ },
+ ] as FeeDescriptionPair[]);
+ }
+ {
+ const pairs = createPairTimeline(right, left);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: undefined,
+ right: VALUES[1],
+ },
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[2]),
+ left: VALUES[2],
+ right: undefined,
+ },
+ ] as FeeDescriptionPair[]);
+ }
+});
+
+test("should separate denominations of different value2", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[];
+
+ const right = [
+ {
+ group: Amounts.stringifyValue(VALUES[2]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ {
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[2],
+ right: undefined,
+ },
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[2]),
+ left: undefined,
+ right: VALUES[2],
+ },
+ ] as FeeDescriptionPair[]);
+ }
+ // {
+ // const pairs = createDenominationPairTimeline(right, left)
+ // expect(t,pairs).deep.equals([{
+ // from: moments[1],
+ // until: moments[3],
+ // value: values[1],
+ // left: undefined,
+ // right: values[1],
+ // }, {
+ // from: moments[1],
+ // until: moments[3],
+ // value: values[2],
+ // left: values[2],
+ // right: undefined,
+ // }] as FeeDescriptionPair[])
+ // }
+});
+// it.skip("should render real world", (t) => {
+// const left = createDenominationTimeline(
+// bitcoinExchanges[0].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
+// "stampExpireDeposit", "feeDeposit");
+// const right = createDenominationTimeline(
+// bitcoinExchanges[1].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
+// "stampExpireDeposit", "feeDeposit");
+
+// const pairs = createDenominationPairTimeline(left, right)
+// })
+
+// })
+// })
diff --git a/packages/taler-wallet-core/src/denominations.ts b/packages/taler-wallet-core/src/denominations.ts
new file mode 100644
index 000000000..d41307d5d
--- /dev/null
+++ b/packages/taler-wallet-core/src/denominations.ts
@@ -0,0 +1,479 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ 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 {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ AmountString,
+ DenominationInfo,
+ Duration,
+ FeeDescription,
+ FeeDescriptionPair,
+ TalerProtocolTimestamp,
+ TimePoint,
+} from "@gnu-taler/taler-util";
+import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
+
+/**
+ * Given a list of denominations with the same value and same period of time:
+ * return the one that will be used.
+ * The best denomination is the one that will minimize the fee cost.
+ *
+ * @param list denominations of same value
+ * @returns
+ */
+export function selectBestForOverlappingDenominations<
+ T extends DenominationInfo,
+>(list: T[]): T | undefined {
+ let minDeposit: DenominationInfo | undefined = undefined;
+ //TODO: improve denomination selection, this is a trivial implementation
+ list.forEach((e) => {
+ if (minDeposit === undefined) {
+ minDeposit = e;
+ return;
+ }
+ if (Amounts.cmp(minDeposit.feeDeposit, e.feeDeposit) > -1) {
+ minDeposit = e;
+ }
+ });
+ return minDeposit;
+}
+
+export function selectMinimumFee<T extends { fee: AmountString }>(
+ list: T[],
+): T | undefined {
+ let minFee: T | undefined = undefined;
+ //TODO: improve denomination selection, this is a trivial implementation
+ list.forEach((e) => {
+ if (minFee === undefined) {
+ minFee = e;
+ return;
+ }
+ if (Amounts.cmp(minFee.fee, e.fee) > -1) {
+ minFee = e;
+ }
+ });
+ return minFee;
+}
+
+type PropsWithReturnType<T extends object, F> = Exclude<
+ {
+ [K in keyof T]: T[K] extends F ? K : never;
+ }[keyof T],
+ undefined
+>;
+
+/**
+ * Takes two timelines and create one to compare them.
+ *
+ * For both lists the next condition should be true:
+ * for any element in the position "idx" then
+ * list[idx].until === list[idx+1].from
+ *
+ * @see {createTimeline}
+ *
+ * @param left list denominations @type {FeeDescription}
+ * @param right list denominations @type {FeeDescription}
+ * @returns list of pairs for the same time
+ */
+export function createPairTimeline(
+ left: FeeDescription[],
+ right: FeeDescription[],
+): FeeDescriptionPair[] {
+ //FIXME: we need to create a copy of the array because
+ //this algorithm is using splice, remove splice and
+ //remove this array duplication
+ left = [...left];
+ right = [...right];
+
+ //both list empty, discarded
+ if (left.length === 0 && right.length === 0) return [];
+
+ const pairList: FeeDescriptionPair[] = [];
+
+ let li = 0; //left list index
+ let ri = 0; //right list index
+
+ while (li < left.length && ri < right.length) {
+ const currentGroup =
+ Number.parseFloat(left[li].group) < Number.parseFloat(right[ri].group)
+ ? left[li].group
+ : right[ri].group;
+ const lgs = li; //left group start index
+ const rgs = ri; //right group start index
+
+ let lgl = 0; //left group length (until next value)
+ while (li + lgl < left.length && left[li + lgl].group === currentGroup) {
+ lgl++;
+ }
+ let rgl = 0; //right group length (until next value)
+ while (ri + rgl < right.length && right[ri + rgl].group === currentGroup) {
+ rgl++;
+ }
+ const leftGroupIsEmpty = lgl === 0;
+ const rightGroupIsEmpty = rgl === 0;
+ //check which start after, add gap so both list starts at the same time
+ // one list may be empty
+ const leftStartTime: AbsoluteTime = leftGroupIsEmpty
+ ? AbsoluteTime.never()
+ : left[li].from;
+ const rightStartTime: AbsoluteTime = rightGroupIsEmpty
+ ? AbsoluteTime.never()
+ : right[ri].from;
+
+ //first time cut is the smallest time
+ let timeCut: AbsoluteTime = leftStartTime;
+
+ if (AbsoluteTime.cmp(leftStartTime, rightStartTime) < 0) {
+ const ends = rightGroupIsEmpty ? left[li + lgl - 1].until : right[0].from;
+
+ right.splice(ri, 0, {
+ from: leftStartTime,
+ until: ends,
+ group: left[li].group,
+ });
+ rgl++;
+
+ timeCut = leftStartTime;
+ }
+ if (AbsoluteTime.cmp(leftStartTime, rightStartTime) > 0) {
+ const ends = leftGroupIsEmpty ? right[ri + rgl - 1].until : left[0].from;
+
+ left.splice(li, 0, {
+ from: rightStartTime,
+ until: ends,
+ group: right[ri].group,
+ });
+ lgl++;
+
+ timeCut = rightStartTime;
+ }
+
+ //check which ends sooner, add gap so both list ends at the same time
+ // here both list are non empty
+ const leftEndTime: AbsoluteTime = left[li + lgl - 1].until;
+ const rightEndTime: AbsoluteTime = right[ri + rgl - 1].until;
+
+ if (AbsoluteTime.cmp(leftEndTime, rightEndTime) > 0) {
+ right.splice(ri + rgl, 0, {
+ from: rightEndTime,
+ until: leftEndTime,
+ group: left[0].group,
+ });
+ rgl++;
+ }
+ if (AbsoluteTime.cmp(leftEndTime, rightEndTime) < 0) {
+ left.splice(li + lgl, 0, {
+ from: leftEndTime,
+ until: rightEndTime,
+ group: right[0].group,
+ });
+ lgl++;
+ }
+
+ //now both lists are non empty and (starts,ends) at the same time
+ while (li < lgs + lgl && ri < rgs + rgl) {
+ if (
+ AbsoluteTime.cmp(left[li].from, timeCut) !== 0 &&
+ AbsoluteTime.cmp(right[ri].from, timeCut) !== 0
+ ) {
+ // timeCut comes from the latest "until" (expiration from the previous)
+ // and this value comes from the latest left or right
+ // it should be the same as the "from" from one of the latest left or right
+ // otherwise it means that there is missing a gap object in the middle
+ // the list is not complete and the behavior is undefined
+ throw Error(
+ "one of the list is not completed: list[i].until !== list[i+1].from",
+ );
+ }
+
+ pairList.push({
+ left: left[li].fee,
+ right: right[ri].fee,
+ from: timeCut,
+ until: AbsoluteTime.never(),
+ group: currentGroup,
+ });
+
+ if (left[li].until.t_ms === right[ri].until.t_ms) {
+ timeCut = left[li].until;
+ ri++;
+ li++;
+ } else if (left[li].until.t_ms < right[ri].until.t_ms) {
+ timeCut = left[li].until;
+ li++;
+ } else if (left[li].until.t_ms > right[ri].until.t_ms) {
+ timeCut = right[ri].until;
+ ri++;
+ }
+ pairList[pairList.length - 1].until = timeCut;
+
+ // if (
+ // (li < left.length && left[li].group !== currentGroup) ||
+ // (ri < right.length && right[ri].group !== currentGroup)
+ // ) {
+ // //value changed, should break
+ // //this if will catch when both (left and right) change at the same time
+ // //if just one side changed it will catch in the while condition
+ // break;
+ // }
+ }
+ }
+ //one of the list left or right can still have elements
+ if (li < left.length) {
+ let timeCut =
+ pairList.length > 0 &&
+ pairList[pairList.length - 1].group === left[li].group
+ ? pairList[pairList.length - 1].until
+ : left[li].from;
+ while (li < left.length) {
+ pairList.push({
+ left: left[li].fee,
+ right: undefined,
+ from: timeCut,
+ until: left[li].until,
+ group: left[li].group,
+ });
+ timeCut = left[li].until;
+ li++;
+ }
+ }
+ if (ri < right.length) {
+ let timeCut =
+ pairList.length > 0 &&
+ pairList[pairList.length - 1].group === right[ri].group
+ ? pairList[pairList.length - 1].until
+ : right[ri].from;
+ while (ri < right.length) {
+ pairList.push({
+ right: right[ri].fee,
+ left: undefined,
+ from: timeCut,
+ until: right[ri].until,
+ group: right[ri].group,
+ });
+ timeCut = right[ri].until;
+ ri++;
+ }
+ }
+ return pairList;
+}
+
+/**
+ * Create a usage timeline with the entity given.
+ *
+ * If there are multiple entities that can be used in the same period,
+ * the list will contain the one that minimize the fee cost.
+ * @see selectBestForOverlappingDenominations
+ *
+ * @param list list of entities
+ * @param idProp property used for identification
+ * @param periodStartProp property of element of the list that will be used as start of the usage period
+ * @param periodEndProp property of element of the list that will be used as end of the usage period
+ * @param feeProp property of the element of the list that will be used as fee reference
+ * @param groupProp property of the element of the list that will be used for grouping
+ * @returns list of @type {FeeDescription} sorted by usage period
+ */
+export function createTimeline<Type extends object>(
+ list: Type[],
+ idProp: PropsWithReturnType<Type, string>,
+ periodStartProp: PropsWithReturnType<Type, TalerProtocolTimestamp>,
+ periodEndProp: PropsWithReturnType<Type, TalerProtocolTimestamp>,
+ feeProp: PropsWithReturnType<Type, AmountString>,
+ groupProp: PropsWithReturnType<Type, string> | undefined,
+ selectBestForOverlapping: (l: Type[]) => Type | undefined,
+): FeeDescription[] {
+ /**
+ * First we create a list with with point in the timeline sorted
+ * by time and categorized by starting or ending.
+ */
+ const sortedPointsInTime = list
+ .reduce((ps, denom) => {
+ //exclude denoms with bad configuration
+ const id = denom[idProp] as string;
+ const stampStart = denom[periodStartProp] as TalerProtocolTimestamp;
+ const stampEnd = denom[periodEndProp] as TalerProtocolTimestamp;
+ const fee = denom[feeProp] as AmountJson;
+ const group = !groupProp ? "" : (denom[groupProp] as string);
+
+ if (!id) {
+ throw Error(
+ `denomination without hash ${JSON.stringify(denom, undefined, 2)}`,
+ );
+ }
+ if (stampStart.t_s >= stampEnd.t_s) {
+ throw Error(`denom ${id} has start after the end`);
+ }
+ ps.push({
+ type: "start",
+ fee: Amounts.stringify(fee),
+ group,
+ id,
+ moment: AbsoluteTime.fromProtocolTimestamp(stampStart),
+ denom,
+ });
+ ps.push({
+ type: "end",
+ fee: Amounts.stringify(fee),
+ group,
+ id,
+ moment: AbsoluteTime.fromProtocolTimestamp(stampEnd),
+ denom,
+ });
+ return ps;
+ }, [] as TimePoint<Type>[])
+ .sort((a, b) => {
+ const v = a.group == b.group ? 0 : a.group > b.group ? 1 : -1;
+ if (v != 0) return v;
+ const t = AbsoluteTime.cmp(a.moment, b.moment);
+ if (t != 0) return t;
+ if (a.type === b.type) return 0;
+ return a.type === "start" ? 1 : -1;
+ });
+
+ const activeAtTheSameTime: Type[] = [];
+ return sortedPointsInTime.reduce((result, cursor, idx) => {
+ /**
+ * Now that we have move one step forward, we should
+ * update the previous element ending period with the
+ * current start time.
+ */
+ let prev = result.length > 0 ? result[result.length - 1] : undefined;
+ const prevHasSameValue = prev && prev.group == cursor.group;
+ if (prev) {
+ if (prevHasSameValue) {
+ prev.until = cursor.moment;
+
+ if (prev.from.t_ms === prev.until.t_ms) {
+ result.pop();
+ prev = result[result.length - 1];
+ }
+ } else {
+ // the last end adds a gap that we have to remove
+ result.pop();
+ }
+ }
+
+ /**
+ * With the current moment in the iteration we
+ * should keep updated which entities are current
+ * active in this period of time.
+ */
+ if (cursor.type === "end") {
+ const loc = activeAtTheSameTime.findIndex((v) => v[idProp] === cursor.id);
+ if (loc === -1) {
+ throw Error(`denomination ${cursor.id} has an end but no start`);
+ }
+ activeAtTheSameTime.splice(loc, 1);
+ } else if (cursor.type === "start") {
+ activeAtTheSameTime.push(cursor.denom);
+ } else {
+ const exhaustiveCheck: never = cursor.type;
+ throw new Error(`not TimePoint defined for type: ${exhaustiveCheck}`);
+ }
+
+ if (idx == sortedPointsInTime.length - 1) {
+ /**
+ * This is the last element in the list, if we continue
+ * a gap will normally be added which is not necessary.
+ * Also, the last element should be ending and the list of active
+ * element should be empty
+ */
+ if (cursor.type !== "end") {
+ throw Error(
+ `denomination ${cursor.id} starts after ending or doesn't have an ending`,
+ );
+ }
+ if (activeAtTheSameTime.length > 0) {
+ throw Error(
+ `there are ${activeAtTheSameTime.length} denominations without ending`,
+ );
+ }
+ return result;
+ }
+
+ const current = selectBestForOverlapping(activeAtTheSameTime);
+
+ if (current) {
+ /**
+ * We have a candidate to add in the list, check that we are
+ * not adding a duplicate.
+ * Next element in the list will defined the ending.
+ */
+ const currentFee = current[feeProp] as AmountJson;
+ if (
+ prev === undefined || //is the first
+ !prev.fee || //is a gap
+ Amounts.cmp(prev.fee, currentFee) !== 0 // prev has different fee
+ ) {
+ result.push({
+ group: cursor.group,
+ from: cursor.moment,
+ until: AbsoluteTime.never(), //not yet known
+ fee: Amounts.stringify(currentFee),
+ });
+ } else {
+ prev.until = cursor.moment;
+ }
+ } else {
+ /**
+ * No active element in this period of time, so we add a gap (no fee)
+ * Next element in the list will defined the ending.
+ */
+ result.push({
+ group: cursor.group,
+ from: cursor.moment,
+ until: AbsoluteTime.never(), //not yet known
+ });
+ }
+
+ return result;
+ }, [] as FeeDescription[]);
+}
+
+/**
+ * Check if a denom is withdrawable based on the expiration time,
+ * revocation and offered state.
+ */
+export function isWithdrawableDenom(
+ d: DenominationRecord,
+ denomselAllowLate?: boolean,
+): boolean {
+ const now = AbsoluteTime.now();
+ const start = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampStart),
+ );
+ const withdrawExpire = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireWithdraw),
+ );
+ const started = AbsoluteTime.cmp(now, start) >= 0;
+ let lastPossibleWithdraw: AbsoluteTime;
+ if (denomselAllowLate) {
+ lastPossibleWithdraw = start;
+ } else {
+ lastPossibleWithdraw = AbsoluteTime.subtractDuraction(
+ withdrawExpire,
+ Duration.fromSpec({ minutes: 5 }),
+ );
+ }
+ const remaining = Duration.getRemaining(lastPossibleWithdraw, now);
+ const stillOkay = remaining.d_ms !== 0;
+ return started && stillOkay && !d.isRevoked && d.isOffered && !d.isLost;
+}
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
new file mode 100644
index 000000000..c4cd98d73
--- /dev/null
+++ b/packages/taler-wallet-core/src/deposits.ts
@@ -0,0 +1,1775 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Implementation of the deposit transaction.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ BatchDepositRequestCoin,
+ CancellationToken,
+ CoinRefreshRequest,
+ CreateDepositGroupRequest,
+ CreateDepositGroupResponse,
+ DepositGroupFees,
+ Duration,
+ ExchangeBatchDepositRequest,
+ ExchangeHandle,
+ ExchangeRefundRequest,
+ HttpStatusCode,
+ Logger,
+ MerchantContractTerms,
+ NotificationType,
+ PrepareDepositRequest,
+ PrepareDepositResponse,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TrackTransaction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WireFee,
+ assertUnreachable,
+ canonicalJson,
+ checkDbInvariant,
+ checkLogicInvariant,
+ codecForBatchDepositSuccess,
+ codecForTackTransactionAccepted,
+ codecForTackTransactionWired,
+ encodeCrock,
+ getRandomBytes,
+ hashTruncate32,
+ hashWire,
+ j2s,
+ parsePaytoUri,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import { selectPayCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import {
+ DepositElementStatus,
+ DepositGroupRecord,
+ DepositInfoPerExchange,
+ DepositOperationStatus,
+ DepositTrackingInfo,
+ KycPendingInfo,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import { getExchangeWireDetailsInTx } from "./exchanges.js";
+import {
+ extractContractData,
+ generateDepositPermissions,
+ getTotalPaymentCost,
+} from "./pay-merchant.js";
+import {
+ CreateRefreshGroupResult,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("deposits.ts");
+
+export class DepositTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public depositGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const depositGroupId = this.depositGroupId;
+ const ws = this.wex;
+ // FIXME: We should check first if we are in a final state
+ // where deletion is allowed.
+ await ws.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "tombstones"] },
+ async (tx) => {
+ const tipRecord = await tx.depositGroups.get(depositGroupId);
+ if (tipRecord) {
+ await tx.depositGroups.delete(depositGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
+ });
+ }
+ },
+ );
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ let newOpStatus: DepositOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.PendingDeposit:
+ newOpStatus = DepositOperationStatus.SuspendedDeposit;
+ break;
+ case DepositOperationStatus.PendingKyc:
+ newOpStatus = DepositOperationStatus.SuspendedKyc;
+ break;
+ case DepositOperationStatus.PendingTrack:
+ newOpStatus = DepositOperationStatus.SuspendedTrack;
+ break;
+ case DepositOperationStatus.Aborting:
+ newOpStatus = DepositOperationStatus.SuspendedAborting;
+ break;
+ }
+ if (!newOpStatus) {
+ return undefined;
+ }
+ dg.operationStatus = newOpStatus;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return undefined;
+ case DepositOperationStatus.PendingDeposit:
+ case DepositOperationStatus.SuspendedDeposit: {
+ dg.operationStatus = DepositOperationStatus.Aborting;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ }
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ let newOpStatus: DepositOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.SuspendedDeposit:
+ newOpStatus = DepositOperationStatus.PendingDeposit;
+ break;
+ case DepositOperationStatus.SuspendedAborting:
+ newOpStatus = DepositOperationStatus.Aborting;
+ break;
+ case DepositOperationStatus.SuspendedKyc:
+ newOpStatus = DepositOperationStatus.PendingKyc;
+ break;
+ case DepositOperationStatus.SuspendedTrack:
+ newOpStatus = DepositOperationStatus.PendingTrack;
+ break;
+ }
+ if (!newOpStatus) {
+ return undefined;
+ }
+ dg.operationStatus = newOpStatus;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.Aborting: {
+ dg.operationStatus = DepositOperationStatus.Failed;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ }
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(taskId);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+}
+
+/**
+ * Get the (DD37-style) transaction status based on the
+ * database record of a deposit group.
+ */
+export function computeDepositTransactionStatus(
+ dg: DepositGroupRecord,
+): TransactionState {
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case DepositOperationStatus.PendingDeposit:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Deposit,
+ };
+ case DepositOperationStatus.PendingKyc:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case DepositOperationStatus.PendingTrack:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Track,
+ };
+ case DepositOperationStatus.SuspendedKyc:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case DepositOperationStatus.SuspendedTrack:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Track,
+ };
+ case DepositOperationStatus.SuspendedDeposit:
+ return {
+ major: TransactionMajorState.Suspended,
+ };
+ case DepositOperationStatus.Aborting:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ case DepositOperationStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case DepositOperationStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case DepositOperationStatus.SuspendedAborting:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ };
+ default:
+ assertUnreachable(dg.operationStatus);
+ }
+}
+
+/**
+ * Compute the possible actions possible on a deposit transaction
+ * based on the current transaction state.
+ */
+export function computeDepositTransactionActions(
+ dg: DepositGroupRecord,
+): TransactionAction[] {
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.PendingDeposit:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case DepositOperationStatus.SuspendedDeposit:
+ return [TransactionAction.Resume];
+ case DepositOperationStatus.Aborting:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case DepositOperationStatus.Aborted:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.Failed:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.SuspendedAborting:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case DepositOperationStatus.PendingKyc:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case DepositOperationStatus.PendingTrack:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case DepositOperationStatus.SuspendedKyc:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case DepositOperationStatus.SuspendedTrack:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ default:
+ assertUnreachable(dg.operationStatus);
+ }
+}
+
+async function refundDepositGroup(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const statusPerCoin = depositGroup.statusPerCoin;
+ const payCoinSelection = depositGroup.payCoinSelection;
+ if (!statusPerCoin) {
+ throw Error(
+ "unable to refund deposit group without coin selection (status missing)",
+ );
+ }
+ if (!payCoinSelection) {
+ throw Error(
+ "unable to refund deposit group without coin selection (selection missing)",
+ );
+ }
+ const newTxPerCoin = [...statusPerCoin];
+ logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`);
+ for (let i = 0; i < statusPerCoin.length; i++) {
+ const st = statusPerCoin[i];
+ switch (st) {
+ case DepositElementStatus.RefundFailed:
+ case DepositElementStatus.RefundSuccess:
+ break;
+ default: {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coinExchange = await wex.db.runReadOnlyTx(
+ { storeNames: ["coins"] },
+ async (tx) => {
+ const coinRecord = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coinRecord);
+ return coinRecord.exchangeBaseUrl;
+ },
+ );
+ const refundAmount = payCoinSelection.coinContributions[i];
+ // We use a constant refund transaction ID, since there can
+ // only be one refund.
+ const rtid = 1;
+ const sig = await wex.cryptoApi.signRefund({
+ coinPub,
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ merchantPub: depositGroup.merchantPub,
+ refundAmount: refundAmount,
+ rtransactionId: rtid,
+ });
+ const refundReq: ExchangeRefundRequest = {
+ h_contract_terms: depositGroup.contractTermsHash,
+ merchant_pub: depositGroup.merchantPub,
+ merchant_sig: sig.sig,
+ refund_amount: refundAmount,
+ rtransaction_id: rtid,
+ };
+ const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
+ const httpResp = await wex.http.fetch(refundUrl.href, {
+ method: "POST",
+ body: refundReq,
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(
+ `coin ${i} refund HTTP status for coin: ${httpResp.status}`,
+ );
+ let newStatus: DepositElementStatus;
+ if (httpResp.status === 200) {
+ // FIXME: validate response
+ newStatus = DepositElementStatus.RefundSuccess;
+ } else {
+ // FIXME: Store problem somewhere!
+ newStatus = DepositElementStatus.RefundFailed;
+ }
+ // FIXME: Handle case where refund request needs to be tried again
+ newTxPerCoin[i] = newStatus;
+ break;
+ }
+ }
+ }
+ let isDone = true;
+ for (let i = 0; i < newTxPerCoin.length; i++) {
+ if (
+ newTxPerCoin[i] != DepositElementStatus.RefundFailed &&
+ newTxPerCoin[i] != DepositElementStatus.RefundSuccess
+ ) {
+ isDone = false;
+ }
+ }
+
+ const currency = Amounts.currencyOf(depositGroup.totalPayCost);
+
+ const res = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "depositGroups",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ newDg.statusPerCoin = newTxPerCoin;
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < newTxPerCoin.length; i++) {
+ refreshCoins.push({
+ amount: payCoinSelection.coinContributions[i],
+ coinPub: payCoinSelection.coinPubs[i],
+ });
+ }
+ let refreshRes: CreateRefreshGroupResult | undefined = undefined;
+ if (isDone) {
+ refreshRes = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortDeposit,
+ constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: newDg.depositGroupId,
+ }),
+ );
+ newDg.abortRefreshGroupId = refreshRes.refreshGroupId;
+ }
+ await tx.depositGroups.put(newDg);
+ return { refreshRes };
+ },
+ );
+
+ if (res?.refreshRes) {
+ for (const notif of res.refreshRes.notifications) {
+ wex.ws.notify(notif);
+ }
+ }
+
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Check whether the refresh associated with the
+ * aborting deposit group is done.
+ *
+ * If done, mark the deposit transaction as aborted.
+ *
+ * Otherwise continue waiting.
+ *
+ * FIXME: Wait for the refresh group notifications instead of periodically
+ * checking the refresh group status.
+ * FIXME: This is just one transaction, can't we do this in the initial
+ * transaction of processDepositGroup?
+ */
+async function waitForRefreshOnDepositGroup(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: depositGroup.depositGroupId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "refreshGroups"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: DepositOperationStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into aborted.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = DepositOperationStatus.Aborted;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = DepositOperationStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = DepositOperationStatus.Aborted;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ newDg.operationStatus = newOpState;
+ const newTxState = computeDepositTransactionStatus(newDg);
+ await tx.depositGroups.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ return TaskRunResult.backoff();
+}
+
+async function processDepositGroupAborting(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ logger.info("processing deposit tx in 'aborting'");
+ const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
+ if (!abortRefreshGroupId) {
+ logger.info("refunding deposit group");
+ return refundDepositGroup(wex, depositGroup);
+ }
+ logger.info("waiting for refresh");
+ return waitForRefreshOnDepositGroup(wex, depositGroup);
+}
+
+async function processDepositGroupPendingKyc(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+
+ const kycInfo = depositGroup.kycInfo;
+ const userType = "individual";
+
+ if (!kycInfo) {
+ throw Error("invalid DB state, in pending(kyc), but no kycInfo present");
+ }
+
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ kycInfo.exchangeBaseUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const newDg = await tx.depositGroups.get(depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ newDg.operationStatus = DepositOperationStatus.PendingTrack;
+ const newTxState = computeDepositTransactionStatus(newDg);
+ await tx.depositGroups.put(newDg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Tracking information from the exchange indicated that
+ * KYC is required. We need to check the KYC info
+ * and transition the transaction to the KYC required state.
+ */
+async function transitionToKycRequired(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+ kycInfo: KycPendingInfo,
+ exchangeUrl: string,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+ const userType = "individual";
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusReq = await wex.http.fetch(url.href, {
+ method: "GET",
+ });
+ if (kycStatusReq.status === HttpStatusCode.Ok) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.backoff();
+ } else if (kycStatusReq.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusReq.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ if (dg.operationStatus !== DepositOperationStatus.PendingTrack) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ dg.kycInfo = {
+ exchangeBaseUrl: exchangeUrl,
+ kycUrl: kycStatus.kyc_url,
+ paytoHash: kycInfo.paytoHash,
+ requirementRow: kycInfo.requirementRow,
+ };
+ await tx.depositGroups.put(dg);
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
+ }
+}
+
+async function processDepositGroupPendingTrack(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const statusPerCoin = depositGroup.statusPerCoin;
+ const payCoinSelection = depositGroup.payCoinSelection;
+ if (!statusPerCoin) {
+ throw Error(
+ "unable to refund deposit group without coin selection (status missing)",
+ );
+ }
+ if (!payCoinSelection) {
+ throw Error(
+ "unable to refund deposit group without coin selection (selection missing)",
+ );
+ }
+ const { depositGroupId } = depositGroup;
+ for (let i = 0; i < statusPerCoin.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ // FIXME: Make the URL part of the coin selection?
+ const exchangeBaseUrl = await wex.db.runReadWriteTx(
+ { storeNames: ["coins"] },
+ async (tx) => {
+ const coinRecord = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`);
+ return coinRecord.exchangeBaseUrl;
+ },
+ );
+
+ let updatedTxStatus: DepositElementStatus | undefined = undefined;
+ let newWiredCoin:
+ | {
+ id: string;
+ value: DepositTrackingInfo;
+ }
+ | undefined;
+
+ if (statusPerCoin[i] !== DepositElementStatus.Wired) {
+ const track = await trackDeposit(
+ wex,
+ depositGroup,
+ coinPub,
+ exchangeBaseUrl,
+ );
+
+ if (track.type === "accepted") {
+ if (!track.kyc_ok && track.requirement_row !== undefined) {
+ const paytoHash = encodeCrock(
+ hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")),
+ );
+ const { requirement_row: requirementRow } = track;
+ const kycInfo: KycPendingInfo = {
+ paytoHash,
+ requirementRow,
+ };
+ return transitionToKycRequired(
+ wex,
+ depositGroup,
+ kycInfo,
+ exchangeBaseUrl,
+ );
+ } else {
+ updatedTxStatus = DepositElementStatus.Tracking;
+ }
+ } else if (track.type === "wired") {
+ updatedTxStatus = DepositElementStatus.Wired;
+
+ const payto = parsePaytoUri(depositGroup.wire.payto_uri);
+ if (!payto) {
+ throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`);
+ }
+
+ const fee = await getExchangeWireFee(
+ wex,
+ payto.targetType,
+ exchangeBaseUrl,
+ track.execution_time,
+ );
+ const raw = Amounts.parseOrThrow(track.coin_contribution);
+ const wireFee = Amounts.parseOrThrow(fee.wireFee);
+
+ newWiredCoin = {
+ value: {
+ amountRaw: Amounts.stringify(raw),
+ wireFee: Amounts.stringify(wireFee),
+ exchangePub: track.exchange_pub,
+ timestampExecuted: timestampProtocolToDb(track.execution_time),
+ wireTransferId: track.wtid,
+ },
+ id: track.exchange_sig,
+ };
+ } else {
+ updatedTxStatus = DepositElementStatus.DepositPending;
+ }
+ }
+
+ if (updatedTxStatus !== undefined) {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return;
+ }
+ if (!dg.statusPerCoin) {
+ return;
+ }
+ if (updatedTxStatus !== undefined) {
+ dg.statusPerCoin[i] = updatedTxStatus;
+ }
+ if (newWiredCoin) {
+ /**
+ * FIXME: if there is a new wire information from the exchange
+ * it should add up to the previous tracking states.
+ *
+ * This may loose information by overriding prev state.
+ *
+ * And: add checks to integration tests
+ */
+ if (!dg.trackingState) {
+ dg.trackingState = {};
+ }
+
+ dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
+ }
+ await tx.depositGroups.put(dg);
+ },
+ );
+ }
+ }
+
+ let allWired = true;
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ if (!dg.statusPerCoin) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ for (let i = 0; i < dg.statusPerCoin.length; i++) {
+ if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) {
+ allWired = false;
+ break;
+ }
+ }
+ if (allWired) {
+ dg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ dg.operationStatus = DepositOperationStatus.Finished;
+ await tx.depositGroups.put(dg);
+ }
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+ notifyTransition(wex, transactionId, transitionInfo);
+ if (allWired) {
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ return TaskRunResult.finished();
+ } else {
+ return TaskRunResult.longpollReturnedPending();
+ }
+}
+
+async function processDepositGroupPendingDeposit(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+ cancellationToken?: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.info("processing deposit group in pending(deposit)");
+ const depositGroupId = depositGroup.depositGroupId;
+ const contractTermsRec = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ async (tx) => {
+ return tx.contractTerms.get(depositGroup.contractTermsHash);
+ },
+ );
+ if (!contractTermsRec) {
+ throw Error("contract terms for deposit not found in database");
+ }
+ const contractTerms: MerchantContractTerms =
+ contractTermsRec.contractTermsRaw;
+ const contractData = extractContractData(
+ contractTermsRec.contractTermsRaw,
+ depositGroup.contractTermsHash,
+ "",
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+
+ // Check for cancellation before expensive operations.
+ cancellationToken?.throwIfCancelled();
+
+ if (!depositGroup.payCoinSelection) {
+ logger.info("missing coin selection for deposit group, selecting now");
+ // FIXME: Consider doing the coin selection inside the txn
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ });
+
+ switch (payCoinSel.type) {
+ case "success":
+ logger.info("coin selection success");
+ break;
+ case "failure":
+ logger.info("coin selection failure");
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ logger.info("coin selection prospective");
+ throw Error("insufficient balance (waiting on pending refresh)");
+ default:
+ assertUnreachable(payCoinSel);
+ }
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "depositGroups",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ ],
+ },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return false;
+ }
+ if (dg.statusPerCoin) {
+ return false;
+ }
+ dg.payCoinSelection = {
+ coinContributions: payCoinSel.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
+ };
+ dg.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ dg.statusPerCoin = payCoinSel.coinSel.coins.map(
+ () => DepositElementStatus.DepositPending,
+ );
+ await tx.depositGroups.put(dg);
+ await spendCoins(wex, tx, {
+ allocationId: transactionId,
+ coinPubs: dg.payCoinSelection.coinPubs,
+ contributions: dg.payCoinSelection.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayDeposit,
+ });
+ return true;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ // FIXME: Cache these!
+ const depositPermissions = await generateDepositPermissions(
+ wex,
+ depositGroup.payCoinSelection,
+ contractData,
+ );
+
+ // Exchanges involved in the deposit
+ const exchanges: Set<string> = new Set();
+
+ for (const dp of depositPermissions) {
+ exchanges.add(dp.exchange_url);
+ }
+
+ // We need to do one batch per exchange.
+ for (const exchangeUrl of exchanges.values()) {
+ const coins: BatchDepositRequestCoin[] = [];
+ const batchIndexes: number[] = [];
+
+ const batchReq: ExchangeBatchDepositRequest = {
+ coins,
+ h_contract_terms: depositGroup.contractTermsHash,
+ merchant_payto_uri: depositGroup.wire.payto_uri,
+ merchant_pub: contractTerms.merchant_pub,
+ timestamp: contractTerms.timestamp,
+ wire_salt: depositGroup.wire.salt,
+ wire_transfer_deadline: contractTerms.wire_transfer_deadline,
+ refund_deadline: contractTerms.refund_deadline,
+ };
+
+ for (let i = 0; i < depositPermissions.length; i++) {
+ const perm = depositPermissions[i];
+ if (perm.exchange_url != exchangeUrl) {
+ continue;
+ }
+ coins.push({
+ coin_pub: perm.coin_pub,
+ coin_sig: perm.coin_sig,
+ contribution: Amounts.stringify(perm.contribution),
+ denom_pub_hash: perm.h_denom,
+ ub_sig: perm.ub_sig,
+ h_age_commitment: perm.h_age_commitment,
+ });
+ batchIndexes.push(i);
+ }
+
+ // Check for cancellation before making network request.
+ cancellationToken?.throwIfCancelled();
+ const url = new URL(`batch-deposit`, exchangeUrl);
+ logger.info(`depositing to ${url.href}`);
+ logger.trace(`deposit request: ${j2s(batchReq)}`);
+ const httpResp = await wex.http.fetch(url.href, {
+ method: "POST",
+ body: batchReq,
+ cancellationToken: cancellationToken,
+ });
+ await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForBatchDepositSuccess(),
+ );
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return;
+ }
+ if (!dg.statusPerCoin) {
+ return;
+ }
+ for (const batchIndex of batchIndexes) {
+ const coinStatus = dg.statusPerCoin[batchIndex];
+ switch (coinStatus) {
+ case DepositElementStatus.DepositPending:
+ dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
+ await tx.depositGroups.put(dg);
+ }
+ }
+ },
+ );
+ }
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ dg.operationStatus = DepositOperationStatus.PendingTrack;
+ await tx.depositGroups.put(dg);
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+}
+
+/**
+ * Process a deposit group that is not in its final state yet.
+ */
+export async function processDepositGroup(
+ wex: WalletExecutionContext,
+ depositGroupId: string,
+): Promise<TaskRunResult> {
+ const depositGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ return tx.depositGroups.get(depositGroupId);
+ },
+ );
+ if (!depositGroup) {
+ logger.warn(`deposit group ${depositGroupId} not found`);
+ return TaskRunResult.finished();
+ }
+
+ switch (depositGroup.operationStatus) {
+ case DepositOperationStatus.PendingTrack:
+ return processDepositGroupPendingTrack(wex, depositGroup);
+ case DepositOperationStatus.PendingKyc:
+ return processDepositGroupPendingKyc(wex, depositGroup);
+ case DepositOperationStatus.PendingDeposit:
+ return processDepositGroupPendingDeposit(wex, depositGroup);
+ case DepositOperationStatus.Aborting:
+ return processDepositGroupAborting(wex, depositGroup);
+ }
+
+ return TaskRunResult.finished();
+}
+
+/**
+ * FIXME: Consider moving this to exchanges.ts.
+ */
+async function getExchangeWireFee(
+ wex: WalletExecutionContext,
+ wireType: string,
+ baseUrl: string,
+ time: TalerProtocolTimestamp,
+): Promise<WireFee> {
+ const exchangeDetails = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const ex = await tx.exchanges.get(baseUrl);
+ if (!ex || !ex.detailsPointer) return undefined;
+ return await tx.exchangeDetails.indexes.byPointer.get([
+ baseUrl,
+ ex.detailsPointer.currency,
+ ex.detailsPointer.masterPublicKey,
+ ]);
+ },
+ );
+
+ if (!exchangeDetails) {
+ throw Error(`exchange missing: ${baseUrl}`);
+ }
+
+ const fees = exchangeDetails.wireInfo.feesForType[wireType];
+ if (!fees || fees.length === 0) {
+ throw Error(
+ `exchange ${baseUrl} doesn't have fees for wire type ${wireType}`,
+ );
+ }
+ const fee = fees.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.fromProtocolTimestamp(time),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ });
+ if (!fee) {
+ throw Error(
+ `exchange ${exchangeDetails.exchangeBaseUrl} doesn't have fees for wire type ${wireType} at ${time.t_s}`,
+ );
+ }
+
+ return fee;
+}
+
+async function trackDeposit(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+ coinPub: string,
+ exchangeUrl: string,
+): Promise<TrackTransaction> {
+ const wireHash = hashWire(
+ depositGroup.wire.payto_uri,
+ depositGroup.wire.salt,
+ );
+
+ const url = new URL(
+ `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
+ exchangeUrl,
+ );
+ const sigResp = await wex.cryptoApi.signTrackTransaction({
+ coinPub,
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ merchantPub: depositGroup.merchantPub,
+ wireHash,
+ });
+ url.searchParams.set("merchant_sig", sigResp.sig);
+ url.searchParams.set("timeout_ms", "30000");
+ const httpResp = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.trace(`deposits response status: ${httpResp.status}`);
+ switch (httpResp.status) {
+ case HttpStatusCode.Accepted: {
+ const accepted = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForTackTransactionAccepted(),
+ );
+ return { type: "accepted", ...accepted };
+ }
+ case HttpStatusCode.Ok: {
+ const wired = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForTackTransactionWired(),
+ );
+ return { type: "wired", ...wired };
+ }
+ default: {
+ throw Error(
+ `unexpected response from track-transaction (${httpResp.status})`,
+ );
+ }
+ }
+}
+
+/**
+ * Check if creating a deposit group is possible and calculate
+ * the associated fees.
+ */
+export async function checkDepositGroup(
+ wex: WalletExecutionContext,
+ req: PrepareDepositRequest,
+): Promise<PrepareDepositResponse> {
+ const p = parsePaytoUri(req.depositPaytoUri);
+ if (!p) {
+ throw Error("invalid payto URI");
+ }
+ const amount = Amounts.parseOrThrow(req.amount);
+ const currency = Amounts.currencyOf(amount);
+
+ const exchangeInfos: ExchangeHandle[] = [];
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || amount.currency !== details.currency) {
+ continue;
+ }
+ exchangeInfos.push({
+ master_pub: details.masterPublicKey,
+ url: e.baseUrl,
+ });
+ }
+ },
+ );
+
+ const now = AbsoluteTime.now();
+ const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
+ const contractTerms: MerchantContractTerms = {
+ exchanges: exchangeInfos,
+ amount: req.amount,
+ max_fee: Amounts.stringify(amount),
+ wire_method: p.targetType,
+ timestamp: nowRounded,
+ merchant_base_url: "",
+ summary: "",
+ nonce: "",
+ wire_transfer_deadline: nowRounded,
+ order_id: "",
+ h_wire: "",
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
+ ),
+ merchant: {
+ name: "(wallet)",
+ },
+ merchant_pub: "",
+ refund_deadline: TalerProtocolTimestamp.zero(),
+ };
+
+ const { h: contractTermsHash } = await wex.cryptoApi.hashString({
+ str: canonicalJson(contractTerms),
+ });
+
+ const contractData = extractContractData(
+ contractTerms,
+ contractTermsHash,
+ "",
+ );
+
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ });
+
+ let selCoins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (payCoinSel.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ selCoins = payCoinSel.result.prospectiveCoins;
+ break;
+ case "success":
+ selCoins = payCoinSel.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(payCoinSel);
+ }
+
+ const totalDepositCost = await getTotalPaymentCost(wex, currency, selCoins);
+
+ const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
+ wex,
+ p.targetType,
+ selCoins,
+ );
+
+ const fees = await getTotalFeesForDepositAmount(
+ wex,
+ p.targetType,
+ amount,
+ selCoins,
+ );
+
+ return {
+ totalDepositCost: Amounts.stringify(totalDepositCost),
+ effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
+ fees,
+ };
+}
+
+export function generateDepositGroupTxId(): string {
+ const depositGroupId = encodeCrock(getRandomBytes(32));
+ return constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: depositGroupId,
+ });
+}
+
+export async function createDepositGroup(
+ wex: WalletExecutionContext,
+ req: CreateDepositGroupRequest,
+): Promise<CreateDepositGroupResponse> {
+ const p = parsePaytoUri(req.depositPaytoUri);
+ if (!p) {
+ throw Error("invalid payto URI");
+ }
+
+ const amount = Amounts.parseOrThrow(req.amount);
+ const currency = amount.currency;
+
+ const exchangeInfos: { url: string; master_pub: string }[] = [];
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || amount.currency !== details.currency) {
+ continue;
+ }
+ exchangeInfos.push({
+ master_pub: details.masterPublicKey,
+ url: e.baseUrl,
+ });
+ }
+ },
+ );
+
+ const now = AbsoluteTime.now();
+ const wireDeadline = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
+ );
+ const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
+ const noncePair = await wex.cryptoApi.createEddsaKeypair({});
+ const merchantPair = await wex.cryptoApi.createEddsaKeypair({});
+ const wireSalt = encodeCrock(getRandomBytes(16));
+ const wireHash = hashWire(req.depositPaytoUri, wireSalt);
+ const contractTerms: MerchantContractTerms = {
+ exchanges: exchangeInfos,
+ amount: req.amount,
+ max_fee: Amounts.stringify(amount),
+ wire_method: p.targetType,
+ timestamp: nowRounded,
+ merchant_base_url: "",
+ summary: "",
+ nonce: noncePair.pub,
+ wire_transfer_deadline: wireDeadline,
+ order_id: "",
+ h_wire: wireHash,
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
+ ),
+ merchant: {
+ name: "(wallet)",
+ },
+ merchant_pub: merchantPair.pub,
+ refund_deadline: TalerProtocolTimestamp.zero(),
+ };
+
+ const { h: contractTermsHash } = await wex.cryptoApi.hashString({
+ str: canonicalJson(contractTerms),
+ });
+
+ const contractData = extractContractData(
+ contractTerms,
+ contractTermsHash,
+ "",
+ );
+
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (payCoinSel.type) {
+ case "success":
+ coins = payCoinSel.coinSel.coins;
+ break;
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = payCoinSel.result.prospectiveCoins;
+ break;
+ default:
+ assertUnreachable(payCoinSel);
+ }
+
+ const totalDepositCost = await getTotalPaymentCost(wex, currency, coins);
+
+ let depositGroupId: string;
+ if (req.transactionId) {
+ const txId = parseTransactionIdentifier(req.transactionId);
+ if (!txId || txId.tag !== TransactionType.Deposit) {
+ throw Error("invalid transaction ID");
+ }
+ depositGroupId = txId.depositGroupId;
+ } else {
+ depositGroupId = encodeCrock(getRandomBytes(32));
+ }
+
+ const infoPerExchange: Record<string, DepositInfoPerExchange> = {};
+
+ for (let i = 0; i < coins.length; i++) {
+ let depPerExchange = infoPerExchange[coins[i].exchangeBaseUrl];
+ if (!depPerExchange) {
+ infoPerExchange[coins[i].exchangeBaseUrl] = depPerExchange = {
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfAmount(totalDepositCost),
+ ),
+ };
+ }
+ const contrib = coins[i].contribution;
+ depPerExchange.amountEffective = Amounts.stringify(
+ Amounts.add(depPerExchange.amountEffective, contrib).amount,
+ );
+ }
+
+ const counterpartyEffectiveDepositAmount =
+ await getCounterpartyEffectiveDepositAmount(wex, p.targetType, coins);
+
+ const depositGroup: DepositGroupRecord = {
+ contractTermsHash,
+ depositGroupId,
+ currency: Amounts.currencyOf(totalDepositCost),
+ amount: contractData.amount,
+ noncePriv: noncePair.priv,
+ noncePub: noncePair.pub,
+ timestampCreated: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(now),
+ ),
+ timestampFinished: undefined,
+ statusPerCoin: undefined,
+ payCoinSelection: undefined,
+ payCoinSelectionUid: undefined,
+ merchantPriv: merchantPair.priv,
+ merchantPub: merchantPair.pub,
+ totalPayCost: Amounts.stringify(totalDepositCost),
+ counterpartyEffectiveDepositAmount: Amounts.stringify(
+ counterpartyEffectiveDepositAmount,
+ ),
+ wireTransferDeadline: timestampProtocolToDb(
+ contractTerms.wire_transfer_deadline,
+ ),
+ wire: {
+ payto_uri: req.depositPaytoUri,
+ salt: wireSalt,
+ },
+ operationStatus: DepositOperationStatus.PendingDeposit,
+ infoPerExchange,
+ };
+
+ if (payCoinSel.type === "success") {
+ depositGroup.payCoinSelection = {
+ coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution),
+ coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
+ };
+ depositGroup.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ depositGroup.statusPerCoin = payCoinSel.coinSel.coins.map(
+ () => DepositElementStatus.DepositPending,
+ );
+ }
+
+ const ctx = new DepositTransactionContext(wex, depositGroupId);
+ const transactionId = ctx.transactionId;
+
+ const newTxState = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "depositGroups",
+ "coins",
+ "recoupGroups",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coinAvailability",
+ "contractTerms",
+ ],
+ },
+ async (tx) => {
+ if (depositGroup.payCoinSelection) {
+ await spendCoins(wex, tx, {
+ allocationId: transactionId,
+ coinPubs: depositGroup.payCoinSelection.coinPubs,
+ contributions: depositGroup.payCoinSelection.coinContributions.map(
+ (x) => Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayDeposit,
+ });
+ }
+ await tx.depositGroups.put(depositGroup);
+ await tx.contractTerms.put({
+ contractTermsRaw: contractTerms,
+ h: contractTermsHash,
+ });
+ return computeDepositTransactionStatus(depositGroup);
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ });
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ depositGroupId,
+ transactionId,
+ };
+}
+
+/**
+ * Get the amount that will be deposited on the users bank
+ * account after depositing, not considering aggregation.
+ */
+export async function getCounterpartyEffectiveDepositAmount(
+ wex: WalletExecutionContext,
+ wireType: string,
+ pcs: SelectedProspectiveCoin[],
+): Promise<AmountJson> {
+ const amt: AmountJson[] = [];
+ const fees: AmountJson[] = [];
+ const exchangeSet: Set<string> = new Set();
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations", "exchangeDetails", "exchanges"] },
+ async (tx) => {
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ );
+ if (!denom) {
+ throw Error("can't find denomination to calculate deposit amount");
+ }
+ amt.push(Amounts.parseOrThrow(pcs[i].contribution));
+ fees.push(Amounts.parseOrThrow(denom.feeDeposit));
+ exchangeSet.add(pcs[i].exchangeBaseUrl);
+ }
+
+ for (const exchangeUrl of exchangeSet.values()) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeUrl,
+ );
+ if (!exchangeDetails) {
+ continue;
+ }
+
+ // FIXME/NOTE: the line below _likely_ throws exception
+ // about "find method not found on undefined" when the wireType
+ // is not supported by the Exchange.
+ const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+ if (fee) {
+ fees.push(Amounts.parseOrThrow(fee));
+ }
+ }
+ },
+ );
+ return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
+}
+
+/**
+ * Get the fee amount that will be charged when trying to deposit the
+ * specified amount using the selected coins and the wire method.
+ */
+async function getTotalFeesForDepositAmount(
+ wex: WalletExecutionContext,
+ wireType: string,
+ total: AmountJson,
+ pcs: SelectedProspectiveCoin[],
+): Promise<DepositGroupFees> {
+ const wireFee: AmountJson[] = [];
+ const coinFee: AmountJson[] = [];
+ const refreshFee: AmountJson[] = [];
+ const exchangeSet: Set<string> = new Set();
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations", "exchanges", "exchangeDetails"] },
+ async (tx) => {
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ );
+ if (!denom) {
+ throw Error("can't find denomination to calculate deposit amount");
+ }
+ coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
+ exchangeSet.add(pcs[i].exchangeBaseUrl);
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ denom,
+ amountLeft,
+ );
+ refreshFee.push(refreshCost);
+ }
+
+ for (const exchangeUrl of exchangeSet.values()) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeUrl,
+ );
+ if (!exchangeDetails) {
+ continue;
+ }
+ const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
+ (x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ },
+ )?.wireFee;
+ if (fee) {
+ wireFee.push(Amounts.parseOrThrow(fee));
+ }
+ }
+ },
+ );
+
+ return {
+ coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
+ wire: Amounts.stringify(Amounts.sumOrZero(total.currency, wireFee).amount),
+ refresh: Amounts.stringify(
+ Amounts.sumOrZero(total.currency, refreshFee).amount,
+ ),
+ };
+}
diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts
new file mode 100644
index 000000000..5cb9400be
--- /dev/null
+++ b/packages/taler-wallet-core/src/dev-experiments.ts
@@ -0,0 +1,153 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of dev experiments, i.e. scenarios
+ * triggered by taler://dev-experiment URIs.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+
+import {
+ DenomLossEventType,
+ Logger,
+ RefreshReason,
+ TalerPreciseTimestamp,
+ encodeCrock,
+ getRandomBytes,
+ parseDevExperimentUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "@gnu-taler/taler-util/http";
+import { PendingTaskType, constructTaskIdentifier } from "./common.js";
+import {
+ DenomLossEventRecord,
+ DenomLossStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+} from "./db.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("dev-experiments.ts");
+
+/**
+ * Apply a dev experiment to the wallet database / state.
+ */
+export async function applyDevExperiment(
+ wex: WalletExecutionContext,
+ uri: string,
+): Promise<void> {
+ logger.info(`applying dev experiment ${uri}`);
+ const parsedUri = parseDevExperimentUri(uri);
+ if (!parsedUri) {
+ logger.info("unable to parse dev experiment URI");
+ return;
+ }
+ if (!wex.ws.config.testing.devModeActive) {
+ throw Error("can't handle devmode URI unless devmode is active");
+ }
+
+ switch (parsedUri.devExperimentId) {
+ case "start-block-refresh": {
+ wex.ws.devExperimentState.blockRefreshes = true;
+ return;
+ }
+ case "stop-block-refresh": {
+ wex.ws.devExperimentState.blockRefreshes = false;
+ return;
+ }
+ case "insert-pending-refresh": {
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refreshGroups"] },
+ async (tx) => {
+ const newRg: RefreshGroupRecord = {
+ currency: "TESTKUDOS",
+ expectedOutputPerCoin: [],
+ inputPerCoin: [],
+ oldCoinPubs: [],
+ operationStatus: RefreshOperationStatus.Pending,
+ reason: RefreshReason.Manual,
+ refreshGroupId,
+ statusPerCoin: [],
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ timestampFinished: undefined,
+ originatingTransactionId: undefined,
+ infoPerExchange: {},
+ };
+ await tx.refreshGroups.put(newRg);
+ },
+ );
+ wex.taskScheduler.startShepherdTask(
+ constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId,
+ }),
+ );
+ return;
+ }
+ case "insert-denom-loss": {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["denomLossEvents"] },
+ async (tx) => {
+ const eventId = encodeCrock(getRandomBytes(32));
+ const newRg: DenomLossEventRecord = {
+ amount: "TESTKUDOS:42",
+ currency: "TESTKUDOS",
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ denomLossEventId: eventId,
+ denomPubHashes: [
+ encodeCrock(getRandomBytes(64)),
+ encodeCrock(getRandomBytes(64)),
+ ],
+ eventType: DenomLossEventType.DenomExpired,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ await tx.denomLossEvents.put(newRg);
+ },
+ );
+ return;
+ }
+ }
+
+ throw Error(`dev-experiment id not understood ${parsedUri.devExperimentId}`);
+}
+
+export class DevExperimentHttpLib implements HttpRequestLibrary {
+ _isDevExperimentLib = true;
+ underlyingLib: HttpRequestLibrary;
+
+ constructor(lib: HttpRequestLibrary) {
+ this.underlyingLib = lib;
+ }
+
+ fetch(
+ url: string,
+ opt?: HttpRequestOptions | undefined,
+ ): Promise<HttpResponse> {
+ logger.trace(`devexperiment httplib ${url}`);
+ return this.underlyingLib.fetch(url, opt);
+ }
+}
diff --git a/packages/taler-wallet-core/src/errors.ts b/packages/taler-wallet-core/src/errors.ts
deleted file mode 100644
index d788405ff..000000000
--- a/packages/taler-wallet-core/src/errors.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2020 Taler Systems SA
-
- 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/>
- */
-
-/**
- * Classes and helpers for error handling specific to wallet operations.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import { TalerErrorCode, TalerErrorDetails } from "@gnu-taler/taler-util";
-
-/**
- * This exception is there to let the caller know that an error happened,
- * but the error has already been reported by writing it to the database.
- */
-export class OperationFailedAndReportedError extends Error {
- static fromCode(
- ec: TalerErrorCode,
- message: string,
- details: Record<string, unknown>,
- ): OperationFailedAndReportedError {
- return new OperationFailedAndReportedError(
- makeErrorDetails(ec, message, details),
- );
- }
-
- constructor(public operationError: TalerErrorDetails) {
- super(operationError.message);
-
- // Set the prototype explicitly.
- Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype);
- }
-}
-
-/**
- * This exception is thrown when an error occurred and the caller is
- * responsible for recording the failure in the database.
- */
-export class OperationFailedError extends Error {
- static fromCode(
- ec: TalerErrorCode,
- message: string,
- details: Record<string, unknown>,
- ): OperationFailedError {
- return new OperationFailedError(makeErrorDetails(ec, message, details));
- }
-
- constructor(public operationError: TalerErrorDetails) {
- super(operationError.message);
-
- // Set the prototype explicitly.
- Object.setPrototypeOf(this, OperationFailedError.prototype);
- }
-}
-
-export function makeErrorDetails(
- ec: TalerErrorCode,
- message: string,
- details: Record<string, unknown>,
-): TalerErrorDetails {
- return {
- code: ec,
- hint: `Error: ${TalerErrorCode[ec]}`,
- details: details,
- message,
- };
-}
-
-/**
- * Run an operation and call the onOpError callback
- * when there was an exception or operation error that must be reported.
- * The cause will be re-thrown to the caller.
- */
-export async function guardOperationException<T>(
- op: () => Promise<T>,
- onOpError: (e: TalerErrorDetails) => Promise<void>,
-): Promise<T> {
- try {
- return await op();
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- throw e;
- }
- if (e instanceof OperationFailedError) {
- await onOpError(e.operationError);
- throw new OperationFailedAndReportedError(e.operationError);
- }
- if (e instanceof Error) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception (message: ${e.message})`,
- {
- stack: e.stack,
- },
- );
- await onOpError(opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
- // Something was thrown that is not even an exception!
- // Try to stringify it.
- let excString: string;
- try {
- excString = e.toString();
- } catch (e) {
- // Something went horribly wrong.
- excString = "can't stringify exception";
- }
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception (not an exception, ${excString})`,
- {},
- );
- await onOpError(opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
-}
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
new file mode 100644
index 000000000..d8063d561
--- /dev/null
+++ b/packages/taler-wallet-core/src/exchanges.ts
@@ -0,0 +1,2581 @@
+/*
+ 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/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of exchange entry management in wallet-core.
+ * The details of exchange entry management are specified in DD48.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ Amount,
+ Amounts,
+ AsyncFlag,
+ CancellationToken,
+ CoinRefreshRequest,
+ CoinStatus,
+ DeleteExchangeRequest,
+ DenomKeyType,
+ DenomLossEventType,
+ DenomOperationMap,
+ DenominationInfo,
+ DenominationPubKey,
+ Duration,
+ EddsaPublicKeyString,
+ ExchangeAuditor,
+ ExchangeDetailedResponse,
+ ExchangeGlobalFees,
+ ExchangeListItem,
+ ExchangeSignKeyJson,
+ ExchangeTosStatus,
+ ExchangeWireAccount,
+ ExchangesListResponse,
+ FeeDescription,
+ GetExchangeEntryByUrlRequest,
+ GetExchangeResourcesResponse,
+ GetExchangeTosResult,
+ GlobalFees,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ OperationErrorInfo,
+ Recoup,
+ RefreshReason,
+ ScopeInfo,
+ ScopeType,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletNotification,
+ WireFee,
+ WireFeeMap,
+ WireFeesJson,
+ WireInfo,
+ assertUnreachable,
+ checkDbInvariant,
+ codecForExchangeKeysJson,
+ durationMul,
+ encodeCrock,
+ getRandomBytes,
+ hashDenomPub,
+ j2s,
+ makeErrorDetail,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ getExpiry,
+ readSuccessResponseJsonOrThrow,
+ readSuccessResponseTextOrThrow,
+} from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ computeDbBackoff,
+ constructTaskIdentifier,
+ getAutoRefreshExecuteThreshold,
+ getExchangeEntryStatusFromRecord,
+ getExchangeState,
+ getExchangeTosStatusFromRecord,
+ getExchangeUpdateStatusFromRecord,
+} from "./common.js";
+import {
+ DenomLossEventRecord,
+ DenomLossStatus,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ ExchangeDetailsRecord,
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ ExchangeEntryRecord,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletStoresV1,
+ timestampAbsoluteFromDb,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ createTimeline,
+ isWithdrawableDenom,
+ selectBestForOverlappingDenominations,
+ selectMinimumFee,
+} from "./denominations.js";
+import { DbReadOnlyTransaction } from "./query.js";
+import { createRecoupGroup } from "./recoup.js";
+import { createRefreshGroup } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
+import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("exchanges.ts");
+
+function getExchangeRequestTimeout(): Duration {
+ return Duration.fromSpec({
+ seconds: 15,
+ });
+}
+
+interface ExchangeTosDownloadResult {
+ tosText: string;
+ tosEtag: string;
+ tosContentType: string;
+ tosContentLanguage: string | undefined;
+ tosAvailableLanguages: string[];
+}
+
+async function downloadExchangeWithTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ acceptFormat: string,
+ acceptLanguage: string | undefined,
+): Promise<ExchangeTosDownloadResult> {
+ logger.trace(`downloading exchange tos (type ${acceptFormat})`);
+ const reqUrl = new URL("terms", exchangeBaseUrl);
+ const headers: {
+ Accept: string;
+ "Accept-Language"?: string;
+ } = {
+ Accept: acceptFormat,
+ };
+
+ if (acceptLanguage) {
+ headers["Accept-Language"] = acceptLanguage;
+ }
+
+ const resp = await http.fetch(reqUrl.href, {
+ headers,
+ timeout,
+ cancellationToken: wex.cancellationToken,
+ });
+ const tosText = await readSuccessResponseTextOrThrow(resp);
+ const tosEtag = resp.headers.get("etag") || "unknown";
+ const tosContentLanguage = resp.headers.get("content-language") || undefined;
+ const tosContentType = resp.headers.get("content-type") || "text/plain";
+ const availLangStr = resp.headers.get("avail-languages") || "";
+ // Work around exchange bug that reports the same language multiple times.
+ const availLangSet = new Set<string>(
+ availLangStr.split(",").map((x) => x.trim()),
+ );
+ const tosAvailableLanguages = [...availLangSet];
+
+ return {
+ tosText,
+ tosEtag,
+ tosContentType,
+ tosContentLanguage,
+ tosAvailableLanguages,
+ };
+}
+
+/**
+ * Get exchange details from the database.
+ */
+async function getExchangeRecordsInternal(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeDetailsRecord | undefined> {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`no exchange found for ${exchangeBaseUrl}`);
+ return;
+ }
+ const dp = r.detailsPointer;
+ if (!dp) {
+ logger.warn(`no exchange details pointer for ${exchangeBaseUrl}`);
+ return;
+ }
+ const { currency, masterPublicKey } = dp;
+ const details = await tx.exchangeDetails.indexes.byPointer.get([
+ r.baseUrl,
+ currency,
+ masterPublicKey,
+ ]);
+ if (!details) {
+ logger.warn(
+ `no exchange details with pointer ${j2s(dp)} for ${exchangeBaseUrl}`,
+ );
+ }
+ return details;
+}
+
+export async function getExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
+ ]
+ >,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<ScopeInfo> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return {
+ type: ScopeType.Exchange,
+ currency: currency,
+ url: exchangeBaseUrl,
+ };
+ }
+ return internalGetExchangeScopeInfo(tx, det);
+}
+
+async function internalGetExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ exchangeDetails: ExchangeDetailsRecord,
+): Promise<ScopeInfo> {
+ const globalExchangeRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ exchangeDetails.exchangeBaseUrl,
+ exchangeDetails.masterPublicKey,
+ ]);
+ if (globalExchangeRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Global,
+ };
+ } else {
+ for (const aud of exchangeDetails.auditors) {
+ const globalAuditorRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ aud.auditor_url,
+ aud.auditor_pub,
+ ]);
+ if (globalAuditorRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Auditor,
+ url: aud.auditor_url,
+ };
+ }
+ }
+ }
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Exchange,
+ url: exchangeDetails.exchangeBaseUrl,
+ };
+}
+
+async function makeExchangeListItem(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ r: ExchangeEntryRecord,
+ exchangeDetails: ExchangeDetailsRecord | undefined,
+ lastError: TalerErrorDetail | undefined,
+): Promise<ExchangeListItem> {
+ const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
+ ? {
+ error: lastError,
+ }
+ : undefined;
+
+ let scopeInfo: ScopeInfo | undefined = undefined;
+
+ if (exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+
+ return {
+ exchangeBaseUrl: r.baseUrl,
+ masterPub: exchangeDetails?.masterPublicKey,
+ noFees: r.noFees ?? false,
+ peerPaymentsDisabled: r.peerPaymentsDisabled ?? false,
+ currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN",
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+ tosStatus: getExchangeTosStatusFromRecord(r),
+ ageRestrictionOptions: exchangeDetails?.ageMask
+ ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
+ : [],
+ paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
+ lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate),
+ lastUpdateErrorInfo,
+ scopeInfo: scopeInfo ?? {
+ type: ScopeType.Exchange,
+ currency: "UNKNOWN",
+ url: r.baseUrl,
+ },
+ };
+}
+
+export interface ExchangeWireDetails {
+ currency: string;
+ masterPublicKey: EddsaPublicKeyString;
+ wireInfo: WireInfo;
+ exchangeBaseUrl: string;
+ auditors: ExchangeAuditor[];
+ globalFees: ExchangeGlobalFees[];
+}
+
+export async function getExchangeWireDetailsInTx(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeWireDetails | undefined> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return undefined;
+ }
+ return {
+ currency: det.currency,
+ masterPublicKey: det.masterPublicKey,
+ wireInfo: det.wireInfo,
+ exchangeBaseUrl: det.exchangeBaseUrl,
+ auditors: det.auditors,
+ globalFees: det.globalFees,
+ };
+}
+
+export async function lookupExchangeByUri(
+ wex: WalletExecutionContext,
+ req: GetExchangeEntryByUrlRequest,
+): Promise<ExchangeListItem> {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
+ if (!exchangeRec) {
+ throw Error("exchange not found");
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ exchangeRec.baseUrl,
+ );
+ const opRetryRecord = await tx.operationRetries.get(
+ TaskIdentifiers.forExchangeUpdate(exchangeRec),
+ );
+ return await makeExchangeListItem(
+ tx,
+ exchangeRec,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ );
+ },
+ );
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function acceptExchangeTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await wex.db.runReadWriteTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch && exch.tosCurrentEtag) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = exch.tosCurrentEtag;
+ exch.tosAcceptedTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ wex.ws.exchangeCache.clear();
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ wex.ws.notify(notif);
+ }
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function forgetExchangeTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await wex.db.runReadWriteTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = undefined;
+ exch.tosAcceptedTimestamp = undefined;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ wex.ws.exchangeCache.clear();
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ wex.ws.notify(notif);
+ }
+}
+
+/**
+ * Validate wire fees and wire accounts.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateWireInfo(
+ wex: WalletExecutionContext,
+ versionCurrent: number,
+ wireInfo: ExchangeKeysDownloadResult,
+ masterPublicKey: string,
+): Promise<WireInfo> {
+ for (const a of wireInfo.accounts) {
+ logger.trace("validating exchange acct");
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.ws.cryptoApi.isValidWireAccount({
+ masterPub: masterPublicKey,
+ paytoUri: a.payto_uri,
+ sig: a.master_sig,
+ versionCurrent,
+ conversionUrl: a.conversion_url,
+ creditRestrictions: a.credit_restrictions,
+ debitRestrictions: a.debit_restrictions,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange acct signature invalid");
+ }
+ }
+ logger.trace("account validation done");
+ const feesForType: WireFeeMap = {};
+ for (const wireMethod of Object.keys(wireInfo.wireFees)) {
+ const feeList: WireFee[] = [];
+ for (const x of wireInfo.wireFees[wireMethod]) {
+ const startStamp = x.start_date;
+ const endStamp = x.end_date;
+ const fee: WireFee = {
+ closingFee: Amounts.stringify(x.closing_fee),
+ endStamp,
+ sig: x.sig,
+ startStamp,
+ wireFee: Amounts.stringify(x.wire_fee),
+ };
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.ws.cryptoApi.isValidWireFee({
+ masterPub: masterPublicKey,
+ type: wireMethod,
+ wf: fee,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange wire fee signature invalid");
+ }
+ feeList.push(fee);
+ }
+ feesForType[wireMethod] = feeList;
+ }
+
+ return {
+ accounts: wireInfo.accounts,
+ feesForType,
+ };
+}
+
+/**
+ * Validate global fees.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateGlobalFees(
+ wex: WalletExecutionContext,
+ fees: GlobalFees[],
+ masterPub: string,
+): Promise<ExchangeGlobalFees[]> {
+ const egf: ExchangeGlobalFees[] = [];
+ for (const gf of fees) {
+ logger.trace("validating exchange global fees");
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.cryptoApi.isValidGlobalFees({
+ masterPub,
+ gf,
+ });
+ isValid = v;
+ }
+
+ if (!isValid) {
+ throw Error("exchange global fees signature invalid: " + gf.master_sig);
+ }
+ egf.push({
+ accountFee: Amounts.stringify(gf.account_fee),
+ historyFee: Amounts.stringify(gf.history_fee),
+ purseFee: Amounts.stringify(gf.purse_fee),
+ startDate: gf.start_date,
+ endDate: gf.end_date,
+ signature: gf.master_sig,
+ historyTimeout: gf.history_expiration,
+ purseLimit: gf.purse_account_limit,
+ purseTimeout: gf.purse_timeout,
+ });
+ }
+
+ return egf;
+}
+
+/**
+ * Add an exchange entry to the wallet database in the
+ * entry state "preset".
+ *
+ * Returns the notification to the caller that should be emitted
+ * if the DB transaction succeeds.
+ */
+export async function addPresetExchangeEntry(
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+ currencyHint?: string,
+): Promise<{ notification?: WalletNotification }> {
+ let exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Preset,
+ updateStatus: ExchangeEntryDbUpdateStatus.Initial,
+ baseUrl: exchangeBaseUrl,
+ presetCurrencyHint: currencyHint,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ lastKeysEtag: undefined,
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ return {
+ notification: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: exchangeBaseUrl,
+ // Exchange did not exist yet
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ },
+ };
+ }
+ return {};
+}
+
+async function provideExchangeRecordInTx(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
+ baseUrl: string,
+): Promise<{
+ exchange: ExchangeEntryRecord;
+ exchangeDetails: ExchangeDetailsRecord | undefined;
+ notification?: WalletNotification;
+}> {
+ let notification: WalletNotification | undefined = undefined;
+ let exchange = await tx.exchanges.get(baseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
+ updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
+ baseUrl: baseUrl,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ // The first update should always be done in a way that ignores the cache,
+ // so that removing and re-adding an exchange works properly, even
+ // if /keys is cached in the browser.
+ cachebreakNextUpdate: true,
+ lastKeysEtag: undefined,
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ exchange = r;
+ notification = {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: r.baseUrl,
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
+ return { exchange, exchangeDetails, notification };
+}
+
+export interface ExchangeKeysDownloadResult {
+ baseUrl: string;
+ masterPublicKey: string;
+ currency: string;
+ auditors: ExchangeAuditor[];
+ currentDenominations: DenominationRecord[];
+ protocolVersion: string;
+ signingKeys: ExchangeSignKeyJson[];
+ reserveClosingDelay: TalerProtocolDuration;
+ expiry: TalerProtocolTimestamp;
+ recoup: Recoup[];
+ listIssueDate: TalerProtocolTimestamp;
+ globalFees: GlobalFees[];
+ accounts: ExchangeWireAccount[];
+ wireFees: { [methodName: string]: WireFeesJson[] };
+}
+
+/**
+ * Download and validate an exchange's /keys data.
+ */
+async function downloadExchangeKeysInfo(
+ baseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ cancellationToken: CancellationToken,
+ noCache: boolean,
+): Promise<ExchangeKeysDownloadResult> {
+ const keysUrl = new URL("keys", baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (noCache) {
+ headers["cache-control"] = "no-cache";
+ }
+ const resp = await http.fetch(keysUrl.href, {
+ timeout,
+ cancellationToken,
+ headers,
+ });
+
+ logger.info("got response to /keys request");
+
+ // We must make sure to parse out the protocol version
+ // before we validate the body.
+ // Otherwise the parser might complain with a hard to understand
+ // message about some other field, when it is just a version
+ // incompatibility.
+
+ const keysJson = await resp.json();
+
+ const protocolVersion = keysJson.version;
+ if (typeof protocolVersion !== "string") {
+ throw Error("bad exchange, does not even specify protocol version");
+ }
+
+ const versionRes = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ protocolVersion,
+ );
+ if (!versionRes) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: resp.requestUrl,
+ httpStatusCode: resp.status,
+ requestMethod: resp.requestMethod,
+ },
+ "exchange protocol version malformed",
+ );
+ }
+ if (!versionRes.compatible) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ exchangeProtocolVersion: protocolVersion,
+ walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ },
+ "exchange protocol version not compatible with wallet",
+ );
+ }
+
+ const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeKeysJson(),
+ );
+
+ if (exchangeKeysJsonUnchecked.denominations.length === 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+ {
+ exchangeBaseUrl: baseUrl,
+ },
+ "exchange doesn't offer any denominations",
+ );
+ }
+
+ const currency = exchangeKeysJsonUnchecked.currency;
+
+ const currentDenominations: DenominationRecord[] = [];
+
+ for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
+ switch (denomGroup.cipher) {
+ case "RSA":
+ case "RSA+age_restricted": {
+ let ageMask = 0;
+ if (denomGroup.cipher === "RSA+age_restricted") {
+ ageMask = denomGroup.age_mask;
+ }
+ for (const denomIn of denomGroup.denoms) {
+ const denomPub: DenominationPubKey = {
+ age_mask: ageMask,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: denomIn.rsa_pub,
+ };
+ const denomPubHash = encodeCrock(hashDenomPub(denomPub));
+ const value = Amounts.parseOrThrow(denomGroup.value);
+ const rec: DenominationRecord = {
+ denomPub,
+ denomPubHash,
+ exchangeBaseUrl: baseUrl,
+ exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
+ isOffered: true,
+ isRevoked: false,
+ isLost: denomIn.lost ?? false,
+ value: Amounts.stringify(value),
+ currency: value.currency,
+ stampExpireDeposit: timestampProtocolToDb(
+ denomIn.stamp_expire_deposit,
+ ),
+ stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
+ stampExpireWithdraw: timestampProtocolToDb(
+ denomIn.stamp_expire_withdraw,
+ ),
+ stampStart: timestampProtocolToDb(denomIn.stamp_start),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ masterSig: denomIn.master_sig,
+ fees: {
+ feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
+ feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
+ feeRefund: Amounts.stringify(denomGroup.fee_refund),
+ feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
+ },
+ };
+ currentDenominations.push(rec);
+ }
+ break;
+ }
+ case "CS+age_restricted":
+ case "CS":
+ logger.warn("Clause-Schnorr denominations not supported");
+ continue;
+ default:
+ logger.warn(
+ `denomination type ${(denomGroup as any).cipher} not supported`,
+ );
+ continue;
+ }
+ }
+
+ return {
+ masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
+ currency,
+ baseUrl: exchangeKeysJsonUnchecked.base_url,
+ auditors: exchangeKeysJsonUnchecked.auditors,
+ currentDenominations,
+ protocolVersion: exchangeKeysJsonUnchecked.version,
+ signingKeys: exchangeKeysJsonUnchecked.signkeys,
+ reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
+ expiry: AbsoluteTime.toProtocolTimestamp(
+ getExpiry(resp, {
+ minDuration: Duration.fromSpec({ hours: 1 }),
+ }),
+ ),
+ recoup: exchangeKeysJsonUnchecked.recoup ?? [],
+ listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
+ globalFees: exchangeKeysJsonUnchecked.global_fees,
+ accounts: exchangeKeysJsonUnchecked.accounts,
+ wireFees: exchangeKeysJsonUnchecked.wire_fees,
+ };
+}
+
+async function downloadTosFromAcceptedFormat(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ timeout: Duration,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<ExchangeTosDownloadResult> {
+ let tosFound: ExchangeTosDownloadResult | undefined;
+ // Remove this when exchange supports multiple content-type in accept header
+ if (acceptedFormat)
+ for (const format of acceptedFormat) {
+ const resp = await downloadExchangeWithTermsOfService(
+ wex,
+ baseUrl,
+ wex.http,
+ timeout,
+ format,
+ acceptLanguage,
+ );
+ if (resp.tosContentType === format) {
+ tosFound = resp;
+ break;
+ }
+ }
+ if (tosFound !== undefined) {
+ return tosFound;
+ }
+ // If none of the specified format was found try text/plain
+ return await downloadExchangeWithTermsOfService(
+ wex,
+ baseUrl,
+ wex.http,
+ timeout,
+ "text/plain",
+ acceptLanguage,
+ );
+}
+
+/**
+ * Transition an exchange into an updating state.
+ *
+ * If the update is forced, the exchange is put into an updating state
+ * even if the old information should still be up to date.
+ *
+ * If the exchange entry doesn't exist,
+ * a new ephemeral entry is created.
+ */
+async function startUpdateExchangeEntry(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ options: { forceUpdate?: boolean } = {},
+): Promise<void> {
+ logger.info(
+ `starting update of exchange entry ${exchangeBaseUrl}, forced=${
+ options.forceUpdate ?? false
+ }`,
+ );
+
+ const { notification } = await wex.db.runReadWriteTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ wex.ws.exchangeCache.clear();
+ return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl);
+ },
+ );
+
+ logger.trace("created exchange record");
+
+ if (notification) {
+ wex.ws.notify(notification);
+ }
+
+ const { oldExchangeState, newExchangeState, taskId } =
+ await wex.db.runReadWriteTx(
+ { storeNames: ["exchanges", "operationRetries"] },
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ throw Error("exchange not found");
+ }
+ const oldExchangeState = getExchangeState(r);
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready: {
+ const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
+ timestampPreciseFromDb(r.nextUpdateStamp),
+ );
+ // Only update if entry is outdated or update is forced.
+ if (
+ options.forceUpdate ||
+ AbsoluteTime.isExpired(nextUpdateTimestamp)
+ ) {
+ r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
+ r.cachebreakNextUpdate = options.forceUpdate;
+ }
+ break;
+ }
+ case ExchangeEntryDbUpdateStatus.Initial:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(r);
+ const newExchangeState = getExchangeState(r);
+ // Reset retries for updating the exchange entry.
+ const taskId = TaskIdentifiers.forExchangeUpdate(r);
+ await tx.operationRetries.delete(taskId);
+ return { oldExchangeState, newExchangeState, taskId };
+ },
+ );
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ });
+ await wex.taskScheduler.resetTaskRetries(taskId);
+}
+
+/**
+ * Basic information about an exchange in a ready state.
+ */
+export interface ReadyExchangeSummary {
+ exchangeBaseUrl: string;
+ currency: string;
+ masterPub: string;
+ tosStatus: ExchangeTosStatus;
+ tosAcceptedEtag: string | undefined;
+ tosCurrentEtag: string | undefined;
+ wireInfo: WireInfo;
+ protocolVersionRange: string;
+ tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
+ scopeInfo: ScopeInfo;
+}
+
+async function internalWaitReadyExchange(
+ wex: WalletExecutionContext,
+ canonUrl: string,
+ exchangeNotifFlag: AsyncFlag,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ const operationId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: canonUrl,
+ });
+ while (true) {
+ if (wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+ logger.info(`waiting for ready exchange ${canonUrl}`);
+ const { exchange, exchangeDetails, retryInfo, scopeInfo } =
+ await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchange = await tx.exchanges.get(canonUrl);
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ canonUrl,
+ );
+ const retryInfo = await tx.operationRetries.get(operationId);
+ let scopeInfo: ScopeInfo | undefined = undefined;
+ if (exchange && exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+ return { exchange, exchangeDetails, retryInfo, scopeInfo };
+ },
+ );
+
+ if (!exchange) {
+ throw Error("exchange entry does not exist anymore");
+ }
+
+ let ready = false;
+
+ switch (exchange.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Ready:
+ ready = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ // If the update is forced,
+ // we wait until we're in a full "ready" state,
+ // as we're not happy with the stale information.
+ if (!options.forceUpdate) {
+ ready = true;
+ }
+ break;
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ default: {
+ if (retryInfo) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ }
+ }
+ }
+
+ if (!ready) {
+ logger.info("waiting for exchange update notification");
+ await exchangeNotifFlag.wait();
+ logger.info("done waiting for exchange update notification");
+ exchangeNotifFlag.reset();
+ continue;
+ }
+
+ if (!exchangeDetails) {
+ throw Error("invariant failed");
+ }
+
+ if (!scopeInfo) {
+ throw Error("invariant failed");
+ }
+
+ const res: ReadyExchangeSummary = {
+ currency: exchangeDetails.currency,
+ exchangeBaseUrl: canonUrl,
+ masterPub: exchangeDetails.masterPublicKey,
+ tosStatus: getExchangeTosStatusFromRecord(exchange),
+ tosAcceptedEtag: exchange.tosAcceptedEtag,
+ wireInfo: exchangeDetails.wireInfo,
+ protocolVersionRange: exchangeDetails.protocolVersionRange,
+ tosCurrentEtag: exchange.tosCurrentEtag,
+ tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
+ exchange.tosAcceptedTimestamp,
+ ),
+ scopeInfo,
+ };
+
+ if (options.expectedMasterPub) {
+ if (res.masterPub !== options.expectedMasterPub) {
+ throw Error(
+ "public key of the exchange does not match expected public key",
+ );
+ }
+ }
+ return res;
+ }
+}
+
+/**
+ * Ensure that a fresh exchange entry exists for the given
+ * exchange base URL.
+ *
+ * The cancellation token can be used to abort waiting for the
+ * updated exchange entry.
+ *
+ * If an exchange entry for the database doesn't exist in the
+ * DB, it will be added ephemerally.
+ *
+ * If the expectedMasterPub is given and does not match the actual
+ * master pub, an exception will be thrown. However, the exchange
+ * will still have been added as an ephemeral exchange entry.
+ */
+export async function fetchFreshExchange(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ options: {
+ forceUpdate?: boolean;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ if (!options.forceUpdate) {
+ const cachedResp = wex.ws.exchangeCache.get(baseUrl);
+ if (cachedResp) {
+ return cachedResp;
+ }
+ } else {
+ wex.ws.exchangeCache.clear();
+ }
+
+ await wex.taskScheduler.ensureRunning();
+
+ await startUpdateExchangeEntry(wex, baseUrl, {
+ forceUpdate: options.forceUpdate,
+ });
+
+ const resp = await waitReadyExchange(wex, baseUrl, options);
+ wex.ws.exchangeCache.put(baseUrl, resp);
+ return resp;
+}
+
+async function waitReadyExchange(
+ wex: WalletExecutionContext,
+ canonUrl: string,
+ options: {
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ logger.trace(`waiting for exchange ${canonUrl} to become ready`);
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const exchangeNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.ExchangeStateTransition &&
+ notif.exchangeBaseUrl === canonUrl
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ exchangeNotifFlag.raise();
+ }
+ });
+
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ exchangeNotifFlag.raise();
+ });
+
+ try {
+ const res = await internalWaitReadyExchange(
+ wex,
+ canonUrl,
+ exchangeNotifFlag,
+ options,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+function checkPeerPaymentsDisabled(
+ keysInfo: ExchangeKeysDownloadResult,
+): boolean {
+ const now = AbsoluteTime.now();
+ for (let gf of keysInfo.globalFees) {
+ const isActive = AbsoluteTime.isBetween(
+ now,
+ AbsoluteTime.fromProtocolTimestamp(gf.start_date),
+ AbsoluteTime.fromProtocolTimestamp(gf.end_date),
+ );
+ if (!isActive) {
+ continue;
+ }
+ return false;
+ }
+ // No global fees, we can't do p2p payments!
+ return true;
+}
+
+function checkNoFees(keysInfo: ExchangeKeysDownloadResult): boolean {
+ for (const gf of keysInfo.globalFees) {
+ if (!Amounts.isZero(gf.account_fee)) {
+ return false;
+ }
+ if (!Amounts.isZero(gf.history_fee)) {
+ return false;
+ }
+ if (!Amounts.isZero(gf.purse_fee)) {
+ return false;
+ }
+ }
+ for (const denom of keysInfo.currentDenominations) {
+ if (!Amounts.isZero(denom.fees.feeWithdraw)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeDeposit)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeRefund)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeRefresh)) {
+ return false;
+ }
+ }
+ for (const wft of Object.values(keysInfo.wireFees)) {
+ for (const wf of wft) {
+ if (!Amounts.isZero(wf.wire_fee)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+/**
+ * Update an exchange entry in the wallet's database
+ * 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 updateExchangeFromUrlHandler(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<TaskRunResult> {
+ logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
+
+ const oldExchangeRec = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges"] },
+ async (tx) => {
+ return tx.exchanges.get(exchangeBaseUrl);
+ },
+ );
+
+ if (!oldExchangeRec) {
+ logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
+ return TaskRunResult.finished();
+ }
+
+ let updateRequestedExplicitly = false;
+
+ switch (oldExchangeRec.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ logger.info(`not updating exchange in status "suspended"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.Initial:
+ logger.info(`not updating exchange in status "initial"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ updateRequestedExplicitly = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ // Only retry when scheduled to respect backoff
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready:
+ break;
+ default:
+ assertUnreachable(oldExchangeRec.updateStatus);
+ }
+
+ let refreshCheckNecessary = true;
+
+ if (!updateRequestedExplicitly) {
+ // If the update wasn't requested explicitly,
+ // check if we really need to update.
+
+ let nextUpdateStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextUpdateStamp,
+ );
+
+ let nextRefreshCheckStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextRefreshCheckStamp,
+ );
+
+ let updateNecessary = true;
+
+ if (
+ !AbsoluteTime.isNever(nextUpdateStamp) &&
+ !AbsoluteTime.isExpired(nextUpdateStamp)
+ ) {
+ logger.info(
+ `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextUpdateStamp,
+ )}`,
+ );
+ updateNecessary = false;
+ }
+
+ if (
+ !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
+ !AbsoluteTime.isExpired(nextRefreshCheckStamp)
+ ) {
+ logger.info(
+ `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextRefreshCheckStamp,
+ )}`,
+ );
+ refreshCheckNecessary = false;
+ }
+
+ if (!(updateNecessary || refreshCheckNecessary)) {
+ logger.trace("update not necessary, running again later");
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
+ );
+ }
+ }
+
+ // When doing the auto-refresh check, we always update
+ // the key info before that.
+
+ logger.trace("updating exchange /keys info");
+
+ const timeout = getExchangeRequestTimeout();
+
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ wex.http,
+ timeout,
+ wex.cancellationToken,
+ oldExchangeRec.cachebreakNextUpdate ?? false,
+ );
+
+ logger.trace("validating exchange wire info");
+
+ const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
+ if (!version) {
+ // Should have been validated earlier.
+ throw Error("unexpected invalid version");
+ }
+
+ const wireInfo = await validateWireInfo(
+ wex,
+ version.current,
+ keysInfo,
+ keysInfo.masterPublicKey,
+ );
+
+ const globalFees = await validateGlobalFees(
+ wex,
+ keysInfo.globalFees,
+ keysInfo.masterPublicKey,
+ );
+
+ if (keysInfo.baseUrl != exchangeBaseUrl) {
+ logger.warn("exchange base URL mismatch");
+ const errorDetail: TalerErrorDetail = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
+ {
+ urlWallet: exchangeBaseUrl,
+ urlExchange: keysInfo.baseUrl,
+ },
+ );
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail,
+ };
+ }
+
+ logger.trace("finished validating exchange /wire info");
+
+ // We download the text/plain version here,
+ // because that one needs to exist, and we
+ // will get the current etag from the response.
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ wex,
+ exchangeBaseUrl,
+ timeout,
+ ["text/plain"],
+ );
+
+ logger.trace("updating exchange info in database");
+
+ let ageMask = 0;
+ for (const x of keysInfo.currentDenominations) {
+ if (
+ isWithdrawableDenom(x, wex.ws.config.testing.denomselAllowLate) &&
+ x.denomPub.age_mask != 0
+ ) {
+ ageMask = x.denomPub.age_mask;
+ break;
+ }
+ }
+ let noFees = checkNoFees(keysInfo);
+ let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo);
+
+ const updated = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "exchangeSignKeys",
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "recoupGroups",
+ "coinAvailability",
+ "denomLossEvents",
+ ],
+ },
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
+ return;
+ }
+
+ wex.ws.refreshCostCache.clear();
+ wex.ws.exchangeCache.clear();
+ wex.ws.denomInfoCache.clear();
+
+ const oldExchangeState = getExchangeState(r);
+ const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ let detailsPointerChanged = false;
+ if (!existingDetails) {
+ detailsPointerChanged = true;
+ }
+ let detailsIncompatible = false;
+ if (existingDetails) {
+ if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
+ detailsIncompatible = true;
+ detailsPointerChanged = true;
+ }
+ if (existingDetails.currency !== keysInfo.currency) {
+ detailsIncompatible = true;
+ detailsPointerChanged = true;
+ }
+ // FIXME: We need to do some consistency checks!
+ }
+ if (detailsIncompatible) {
+ logger.warn(
+ `exchange ${r.baseUrl} has incompatible data in /keys, not updating`,
+ );
+ // We don't support this gracefully right now.
+ // See https://bugs.taler.net/n/8576
+ r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
+ r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1;
+ r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter);
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ );
+ r.cachebreakNextUpdate = true;
+ await tx.exchanges.put(r);
+ return {
+ oldExchangeState,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ r.updateRetryCounter = 0;
+ const newDetails: ExchangeDetailsRecord = {
+ auditors: keysInfo.auditors,
+ currency: keysInfo.currency,
+ masterPublicKey: keysInfo.masterPublicKey,
+ protocolVersionRange: keysInfo.protocolVersion,
+ reserveClosingDelay: keysInfo.reserveClosingDelay,
+ globalFees,
+ exchangeBaseUrl: r.baseUrl,
+ wireInfo,
+ ageMask,
+ };
+ r.noFees = noFees;
+ r.peerPaymentsDisabled = peerPaymentsDisabled;
+ r.tosCurrentEtag = tosDownload.tosEtag;
+ if (existingDetails?.rowId) {
+ newDetails.rowId = existingDetails.rowId;
+ }
+ r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ r.nextUpdateStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
+ ),
+ );
+ // New denominations might be available.
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ if (detailsPointerChanged) {
+ r.detailsPointer = {
+ currency: newDetails.currency,
+ masterPublicKey: newDetails.masterPublicKey,
+ updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ }
+
+ r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
+ r.cachebreakNextUpdate = false;
+ await tx.exchanges.put(r);
+ const drRowId = await tx.exchangeDetails.put(newDetails);
+ checkDbInvariant(typeof drRowId.key === "number");
+
+ for (const sk of keysInfo.signingKeys) {
+ // FIXME: validate signing keys before inserting them
+ await tx.exchangeSignKeys.put({
+ exchangeDetailsRowId: drRowId.key,
+ masterSig: sk.master_sig,
+ signkeyPub: sk.key,
+ stampEnd: timestampProtocolToDb(sk.stamp_end),
+ stampExpire: timestampProtocolToDb(sk.stamp_expire),
+ stampStart: timestampProtocolToDb(sk.stamp_start),
+ });
+ }
+
+ // In the future: Filter out old denominations by index
+ const allOldDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ const oldDenomByDph = new Map<string, DenominationRecord>();
+ for (const denom of allOldDenoms) {
+ oldDenomByDph.set(denom.denomPubHash, denom);
+ }
+
+ logger.trace("updating denominations in database");
+ const currentDenomSet = new Set<string>(
+ keysInfo.currentDenominations.map((x) => x.denomPubHash),
+ );
+
+ for (const currentDenom of keysInfo.currentDenominations) {
+ const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash);
+ if (oldDenom) {
+ // FIXME: Do consistency check, report to auditor if necessary.
+ // See https://bugs.taler.net/n/8594
+
+ // Mark lost denominations as lost.
+ if (currentDenom.isLost && !oldDenom.isLost) {
+ logger.warn(
+ `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`,
+ );
+ oldDenom.isLost = true;
+ await tx.denominations.put(currentDenom);
+ }
+ } else {
+ await tx.denominations.put(currentDenom);
+ }
+ }
+
+ // Update list issue date for all denominations,
+ // and mark non-offered denominations as such.
+ for (const x of allOldDenoms) {
+ if (!currentDenomSet.has(x.denomPubHash)) {
+ // FIXME: Here, an auditor report should be created, unless
+ // the denomination is really legally expired.
+ if (x.isOffered) {
+ x.isOffered = false;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=false`,
+ );
+ }
+ } else {
+ if (!x.isOffered) {
+ x.isOffered = true;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=true`,
+ );
+ }
+ }
+ await tx.denominations.put(x);
+ }
+
+ logger.trace("done updating denominations in database");
+
+ const denomLossResult = await handleDenomLoss(
+ wex,
+ tx,
+ newDetails.currency,
+ exchangeBaseUrl,
+ );
+
+ await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup);
+
+ const newExchangeState = getExchangeState(r);
+
+ return {
+ exchange: r,
+ exchangeDetails: newDetails,
+ oldExchangeState,
+ newExchangeState,
+ denomLossResult,
+ };
+ },
+ );
+
+ if (!updated) {
+ throw Error("something went wrong with updating the exchange");
+ }
+
+ if (updated.denomLossResult) {
+ for (const notif of updated.denomLossResult.notifications) {
+ wex.ws.notify(notif);
+ }
+ }
+
+ logger.trace("done updating exchange info in database");
+
+ logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
+
+ let minCheckThreshold = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 1 }),
+ );
+
+ if (refreshCheckNecessary) {
+ // Do auto-refresh.
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "exchanges",
+ ],
+ },
+ async (tx) => {
+ const exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange || !exchange.detailsPointer) {
+ return;
+ }
+ const coins = await tx.coins.indexes.byBaseUrl
+ .iter(exchangeBaseUrl)
+ .toArray();
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const coin of coins) {
+ if (coin.status !== CoinStatus.Fresh) {
+ continue;
+ }
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("denomination not in database");
+ continue;
+ }
+ const executeThreshold =
+ getAutoRefreshExecuteThresholdForDenom(denom);
+ if (AbsoluteTime.isExpired(executeThreshold)) {
+ refreshCoins.push({
+ coinPub: coin.coinPub,
+ amount: denom.value,
+ });
+ } else {
+ const checkThreshold = getAutoRefreshCheckThreshold(denom);
+ minCheckThreshold = AbsoluteTime.min(
+ minCheckThreshold,
+ checkThreshold,
+ );
+ }
+ }
+ if (refreshCoins.length > 0) {
+ const res = await createRefreshGroup(
+ wex,
+ tx,
+ exchange.detailsPointer?.currency,
+ refreshCoins,
+ RefreshReason.Scheduled,
+ undefined,
+ );
+ logger.trace(
+ `created refresh group for auto-refresh (${res.refreshGroupId})`,
+ );
+ }
+ logger.trace(
+ `next refresh check at ${AbsoluteTime.toIsoString(
+ minCheckThreshold,
+ )}`,
+ );
+ exchange.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
+ );
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(exchange);
+ },
+ );
+ }
+
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: updated.newExchangeState,
+ oldExchangeState: updated.oldExchangeState,
+ });
+
+ // Next invocation will cause the task to be run again
+ // at the necessary time.
+ return TaskRunResult.progress();
+}
+
+interface DenomLossResult {
+ notifications: WalletNotification[];
+}
+
+async function handleDenomLoss(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coinAvailability", "denominations", "denomLossEvents", "coins"]
+ >,
+ currency: string,
+ exchangeBaseUrl: string,
+): Promise<DenomLossResult> {
+ const coinAvailabilityRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ const denomsVanished: string[] = [];
+ const denomsUnoffered: string[] = [];
+ const denomsExpired: string[] = [];
+ let amountVanished = Amount.zeroOfCurrency(currency);
+ let amountExpired = Amount.zeroOfCurrency(currency);
+ let amountUnoffered = Amount.zeroOfCurrency(currency);
+
+ const result: DenomLossResult = {
+ notifications: [],
+ };
+
+ for (const coinAv of coinAvailabilityRecs) {
+ if (coinAv.freshCoinCount <= 0) {
+ continue;
+ }
+ const n = coinAv.freshCoinCount;
+ const denom = await tx.denominations.get([
+ coinAv.exchangeBaseUrl,
+ coinAv.denomPubHash,
+ ]);
+ const timestampExpireDeposit = !denom
+ ? undefined
+ : timestampAbsoluteFromDb(denom.stampExpireDeposit);
+ if (!denom) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsVanished.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountVanished = amountVanished.add(total);
+ } else if (!denom.isOffered) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsUnoffered.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountUnoffered = amountUnoffered.add(total);
+ } else if (
+ timestampExpireDeposit &&
+ AbsoluteTime.isExpired(timestampExpireDeposit)
+ ) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsExpired.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountExpired = amountExpired.add(total);
+ } else {
+ // Denomination is still fine!
+ continue;
+ }
+
+ logger.warn(`denomination ${coinAv.denomPubHash} is a loss`);
+
+ const coins = await tx.coins.indexes.byDenomPubHash.getAll(
+ coinAv.denomPubHash,
+ );
+ for (const coin of coins) {
+ switch (coin.status) {
+ case CoinStatus.Fresh:
+ case CoinStatus.FreshSuspended: {
+ coin.status = CoinStatus.DenomLoss;
+ await tx.coins.put(coin);
+ break;
+ }
+ }
+ }
+ }
+
+ if (denomsVanished.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountVanished.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsVanished,
+ eventType: DenomLossEventType.DenomVanished,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ if (denomsUnoffered.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountUnoffered.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsUnoffered,
+ eventType: DenomLossEventType.DenomUnoffered,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ if (denomsExpired.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountExpired.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsUnoffered,
+ eventType: DenomLossEventType.DenomExpired,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ return result;
+}
+
+export function computeDenomLossTransactionStatus(
+ rec: DenomLossEventRecord,
+): TransactionState {
+ switch (rec.status) {
+ case DenomLossStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case DenomLossStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ }
+}
+
+export class DenomLossTransactionContext implements TransactionContext {
+ get taskId(): TaskIdStr | undefined {
+ return undefined;
+ }
+ transactionId: TransactionIdStr;
+
+ abortTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ async deleteTransaction(): Promise<void> {
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ { storeNames: ["denomLossEvents"] },
+ async (tx) => {
+ const rec = await tx.denomLossEvents.get(this.denomLossEventId);
+ if (rec) {
+ const oldTxState = computeDenomLossTransactionStatus(rec);
+ await tx.denomLossEvents.delete(this.denomLossEventId);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.Deleted,
+ },
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ }
+
+ constructor(
+ private wex: WalletExecutionContext,
+ public denomLossEventId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ }
+}
+
+async function handleRecoup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "recoupGroups", "refreshGroups"]
+ >,
+ exchangeBaseUrl: string,
+ recoup: Recoup[],
+): Promise<void> {
+ // Handle recoup
+ const recoupDenomList = recoup;
+ const newlyRevokedCoinPubs: string[] = [];
+ logger.trace("recoup list from exchange", recoupDenomList);
+ for (const recoupInfo of recoupDenomList) {
+ const oldDenom = await tx.denominations.get([
+ exchangeBaseUrl,
+ recoupInfo.h_denom_pub,
+ ]);
+ if (!oldDenom) {
+ // We never even knew about the revoked denomination, all good.
+ continue;
+ }
+ if (oldDenom.isRevoked) {
+ // We already marked the denomination as revoked,
+ // this implies we revoked all coins
+ logger.trace("denom already revoked");
+ continue;
+ }
+ logger.info("revoking denom", recoupInfo.h_denom_pub);
+ oldDenom.isRevoked = true;
+ await tx.denominations.put(oldDenom);
+ const affectedCoins = await tx.coins.indexes.byDenomPubHash.getAll(
+ recoupInfo.h_denom_pub,
+ );
+ for (const ac of affectedCoins) {
+ newlyRevokedCoinPubs.push(ac.coinPub);
+ }
+ }
+ if (newlyRevokedCoinPubs.length != 0) {
+ logger.info("recouping coins", newlyRevokedCoinPubs);
+ await createRecoupGroup(wex, tx, exchangeBaseUrl, newlyRevokedCoinPubs);
+ }
+}
+
+function getAutoRefreshExecuteThresholdForDenom(
+ d: DenominationRecord,
+): AbsoluteTime {
+ return getAutoRefreshExecuteThreshold({
+ stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
+ stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
+ });
+}
+
+/**
+ * Timestamp after which the wallet would do the next check for an auto-refresh.
+ */
+function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireWithdraw),
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireDeposit),
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.75);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
+ * Find a payto:// URI of the exchange that is of one
+ * of the given target types.
+ *
+ * Throws if no matching account was found.
+ */
+export async function getExchangePaytoUri(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ supportedTargetTypes: string[],
+): Promise<string> {
+ // We do the update here, since the exchange might not even exist
+ // yet in our database.
+ const details = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ return getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ },
+ );
+ const accounts = details?.wireInfo.accounts ?? [];
+ for (const account of accounts) {
+ const res = parsePaytoUri(account.payto_uri);
+ if (!res) {
+ continue;
+ }
+ if (supportedTargetTypes.includes(res.targetType)) {
+ return account.payto_uri;
+ }
+ }
+ throw Error(
+ `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
+ supportedTargetTypes,
+ )}`,
+ );
+}
+
+/**
+ * Get the exchange ToS in the requested format.
+ * Try to download in the accepted format not cached.
+ */
+export async function getExchangeTos(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<GetExchangeTosResult> {
+ const exch = await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ wex,
+ exchangeBaseUrl,
+ getExchangeRequestTimeout(),
+ acceptedFormat,
+ acceptLanguage,
+ );
+
+ await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, async (tx) => {
+ const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
+ if (updateExchangeEntry) {
+ updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(updateExchangeEntry);
+ }
+ });
+
+ return {
+ acceptedEtag: exch.tosAcceptedEtag,
+ currentEtag: tosDownload.tosEtag,
+ content: tosDownload.tosText,
+ contentType: tosDownload.tosContentType,
+ contentLanguage: tosDownload.tosContentLanguage,
+ tosStatus: exch.tosStatus,
+ tosAvailableLanguages: tosDownload.tosAvailableLanguages,
+ };
+}
+
+/**
+ * Parsed information about an exchange,
+ * obtained by requesting /keys.
+ */
+export interface ExchangeInfo {
+ keys: ExchangeKeysDownloadResult;
+}
+
+/**
+ * Helper function to download the exchange /keys info.
+ *
+ * Only used for testing / dbless wallet.
+ */
+export async function downloadExchangeInfo(
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+): Promise<ExchangeInfo> {
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ http,
+ Duration.getForever(),
+ CancellationToken.CONTINUE,
+ false,
+ );
+ return {
+ keys: keysInfo,
+ };
+}
+
+/**
+ * List all exchange entries known to the wallet.
+ */
+export async function listExchanges(
+ wex: WalletExecutionContext,
+): Promise<ExchangesListResponse> {
+ const exchanges: ExchangeListItem[] = [];
+ await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "operationRetries",
+ "exchangeDetails",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
+ async (tx) => {
+ const exchangeRecords = await tx.exchanges.iter().toArray();
+ for (const r of exchangeRecords) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: r.baseUrl,
+ });
+ const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ const opRetryRecord = await tx.operationRetries.get(taskId);
+ exchanges.push(
+ await makeExchangeListItem(
+ tx,
+ r,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ ),
+ );
+ }
+ },
+ );
+ return { exchanges };
+}
+
+/**
+ * Transition an exchange to the "used" entry state if necessary.
+ *
+ * Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
+ *
+ * The caller should emit the returned notification iff the current transaction
+ * succeeded.
+ */
+export async function markExchangeUsed(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+): Promise<{ notif: WalletNotification | undefined }> {
+ logger.info(`marking exchange ${exchangeBaseUrl} as used`);
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exch) {
+ return {
+ notif: undefined,
+ };
+ }
+ const oldExchangeState = getExchangeState(exch);
+ switch (exch.entryStatus) {
+ case ExchangeEntryDbRecordStatus.Ephemeral:
+ case ExchangeEntryDbRecordStatus.Preset: {
+ exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ return {
+ notif: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification,
+ };
+ }
+ default:
+ return {
+ notif: undefined,
+ };
+ }
+}
+
+/**
+ * Get detailed information about the exchange including a timeline
+ * for the fees charged by the exchange.
+ */
+export async function getExchangeDetailedInfo(
+ wex: WalletExecutionContext,
+ exchangeBaseurl: string,
+): Promise<ExchangeDetailedResponse> {
+ const exchange = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails", "denominations"] },
+ async (tx) => {
+ const ex = await tx.exchanges.get(exchangeBaseurl);
+ const dp = ex?.detailsPointer;
+ if (!dp) {
+ return;
+ }
+ const { currency } = dp;
+ const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
+ if (!exchangeDetails) {
+ return;
+ }
+ const denominationRecords =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
+
+ if (!denominationRecords) {
+ return;
+ }
+
+ const denominations: DenominationInfo[] = denominationRecords.map((x) =>
+ DenominationRecord.toDenomInfo(x),
+ );
+
+ return {
+ info: {
+ exchangeBaseUrl: ex.baseUrl,
+ currency,
+ paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+ auditors: exchangeDetails.auditors,
+ wireInfo: exchangeDetails.wireInfo,
+ globalFees: exchangeDetails.globalFees,
+ },
+ denominations,
+ };
+ },
+ );
+
+ if (!exchange) {
+ throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
+ }
+
+ const denoms = exchange.denominations.map((d) => ({
+ ...d,
+ group: Amounts.stringifyValue(d.value),
+ }));
+ const denomFees: DenomOperationMap<FeeDescription[]> = {
+ deposit: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refresh: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefresh",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refund: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefund",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ withdraw: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeWithdraw",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ };
+
+ const transferFees = Object.entries(
+ exchange.info.wireInfo.feesForType,
+ ).reduce(
+ (prev, [wireType, infoForType]) => {
+ const feesByGroup = [
+ ...infoForType.map((w) => ({
+ ...w,
+ fee: Amounts.stringify(w.closingFee),
+ group: "closing",
+ })),
+ ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
+ ];
+ prev[wireType] = createTimeline(
+ feesByGroup,
+ "sig",
+ "startStamp",
+ "endStamp",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+ return prev;
+ },
+ {} as Record<string, FeeDescription[]>,
+ );
+
+ const globalFeesByGroup = [
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.accountFee,
+ group: "account",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.historyFee,
+ group: "history",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.purseFee,
+ group: "purse",
+ })),
+ ];
+
+ const globalFees = createTimeline(
+ globalFeesByGroup,
+ "signature",
+ "startDate",
+ "endDate",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+
+ return {
+ exchange: {
+ ...exchange.info,
+ denomFees,
+ transferFees,
+ globalFees,
+ },
+ };
+}
+
+async function internalGetExchangeResources(
+ wex: WalletExecutionContext,
+ tx: DbReadOnlyTransaction<
+ typeof WalletStoresV1,
+ ["exchanges", "coins", "withdrawalGroups"]
+ >,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ let numWithdrawals = 0;
+ let numCoins = 0;
+ numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
+ numWithdrawals =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
+ const total = numWithdrawals + numCoins;
+ return {
+ hasResources: total != 0,
+ };
+}
+
+/**
+ * Purge information in the database associated with the exchange.
+ *
+ * Deletes information specific to the exchange and withdrawals,
+ * but keeps some transactions (payments, p2p, refreshes) around.
+ */
+async function purgeExchange(
+ tx: WalletDbReadWriteTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ]
+ >,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
+ for (const r of detRecs) {
+ if (r.rowId == null) {
+ // Should never happen, as rowId is the primary key.
+ continue;
+ }
+ await tx.exchangeDetails.delete(r.rowId);
+ const signkeyRecs =
+ await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId);
+ for (const rec of signkeyRecs) {
+ await tx.exchangeSignKeys.delete([r.rowId, rec.signkeyPub]);
+ }
+ }
+ // FIXME: Also remove records related to transactions?
+ await tx.exchanges.delete(exchangeBaseUrl);
+
+ {
+ const coinAvailabilityRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const rec of coinAvailabilityRecs) {
+ await tx.coinAvailability.delete([
+ exchangeBaseUrl,
+ rec.denomPubHash,
+ rec.maxAge,
+ ]);
+ }
+ }
+
+ {
+ const coinRecs = await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl);
+ for (const rec of coinRecs) {
+ await tx.coins.delete(rec.coinPub);
+ }
+ }
+
+ {
+ const denomRecs =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ for (const rec of denomRecs) {
+ await tx.denominations.delete(rec.denomPubHash);
+ }
+ }
+
+ {
+ const withdrawalGroupRecs =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const wg of withdrawalGroupRecs) {
+ await tx.withdrawalGroups.delete(wg.withdrawalGroupId);
+ const planchets = await tx.planchets.indexes.byGroup.getAll(
+ wg.withdrawalGroupId,
+ );
+ for (const p of planchets) {
+ await tx.planchets.delete(p.coinPub);
+ }
+ }
+ }
+}
+
+export async function deleteExchange(
+ wex: WalletExecutionContext,
+ req: DeleteExchangeRequest,
+): Promise<void> {
+ let inUse: boolean = false;
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ],
+ },
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRec) {
+ // Nothing to delete!
+ logger.info("no exchange found to delete");
+ return;
+ }
+ const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ if (res.hasResources && !req.purge) {
+ inUse = true;
+ return;
+ }
+ await purgeExchange(tx, exchangeBaseUrl);
+ wex.ws.exchangeCache.clear();
+ },
+ );
+
+ if (inUse) {
+ throw TalerError.fromUncheckedDetail({
+ code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
+ hint: "Exchange in use.",
+ });
+ }
+}
+
+export async function getExchangeResources(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ // Withdrawals include internal withdrawals from peer transactions
+ const res = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "withdrawalGroups", "coins"] },
+ async (tx) => {
+ const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRecord) {
+ return undefined;
+ }
+ return internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ },
+ );
+ if (!res) {
+ throw Error("exchange not found");
+ }
+ return res;
+}
diff --git a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
deleted file mode 100644
index 5a90994b1..000000000
--- a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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/>
-
- SPDX-License-Identifier: AGPL3.0-or-later
-*/
-
-/**
- * Imports.
- */
-import {
- Headers,
- HttpRequestLibrary,
- HttpRequestOptions,
- HttpResponse,
-} from "../util/http.js";
-import { RequestThrottler } from "../util/RequestThrottler.js";
-import Axios, { AxiosResponse } from "axios";
-import { OperationFailedError, makeErrorDetails } from "../errors.js";
-import { Logger, bytesToString } from "@gnu-taler/taler-util";
-import { TalerErrorCode, URL } from "@gnu-taler/taler-util";
-
-const logger = new Logger("NodeHttpLib.ts");
-
-/**
- * Implementation of the HTTP request library interface for node.
- */
-export class NodeHttpLib implements HttpRequestLibrary {
- private throttle = new RequestThrottler();
- private throttlingEnabled = true;
-
- /**
- * Set whether requests should be throttled.
- */
- setThrottling(enabled: boolean): void {
- this.throttlingEnabled = enabled;
- }
-
- async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
- const method = opt?.method ?? "GET";
- let body = opt?.body;
-
- logger.trace(`Requesting ${method} ${url}`);
-
- const parsedUrl = new URL(url);
- if (this.throttlingEnabled && this.throttle.applyThrottle(url)) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
- `request to origin ${parsedUrl.origin} was throttled`,
- {
- requestMethod: method,
- requestUrl: url,
- throttleStats: this.throttle.getThrottleStats(url),
- },
- );
- }
- let timeout: number | undefined;
- if (typeof opt?.timeout?.d_ms === "number") {
- timeout = opt.timeout.d_ms;
- }
- let resp: AxiosResponse;
- try {
- resp = await Axios({
- method,
- url: url,
- responseType: "arraybuffer",
- headers: opt?.headers,
- validateStatus: () => true,
- transformResponse: (x) => x,
- data: body,
- timeout,
- maxRedirects: 0,
- });
- } catch (e: any) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_NETWORK_ERROR,
- `${e.message}`,
- {
- requestUrl: url,
- requestMethod: method,
- },
- );
- }
-
- const makeText = async (): Promise<string> => {
- const respText = new Uint8Array(resp.data);
- return bytesToString(respText);
- };
-
- const makeJson = async (): Promise<any> => {
- let responseJson;
- const respText = await makeText();
- try {
- responseJson = JSON.parse(respText);
- } catch (e) {
- logger.trace(`invalid json: '${resp.data}'`);
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "invalid JSON",
- {
- httpStatusCode: resp.status,
- requestUrl: url,
- requestMethod: method,
- },
- ),
- );
- }
- if (responseJson === null || typeof responseJson !== "object") {
- logger.trace(`invalid json (not an object): '${respText}'`);
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "invalid JSON",
- {
- httpStatusCode: resp.status,
- requestUrl: url,
- requestMethod: method,
- },
- ),
- );
- }
- return responseJson;
- };
- const makeBytes = async () => {
- if (typeof resp.data.byteLength !== "number") {
- throw Error("expected array buffer");
- }
- const buf = resp.data;
- return buf;
- };
- const headers = new Headers();
- for (const hn of Object.keys(resp.headers)) {
- headers.set(hn, resp.headers[hn]);
- }
- return {
- requestUrl: url,
- requestMethod: method,
- headers,
- status: resp.status,
- text: makeText,
- json: makeJson,
- bytes: makeBytes,
- };
- }
- async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
- return this.fetch(url, {
- method: "GET",
- ...opt,
- });
- }
-
- async postJson(
- url: string,
- body: any,
- opt?: HttpRequestOptions,
- ): Promise<HttpResponse> {
- return this.fetch(url, {
- method: "POST",
- body,
- ...opt,
- });
- }
-}
diff --git a/packages/taler-wallet-core/src/headless/helpers.ts b/packages/taler-wallet-core/src/headless/helpers.ts
deleted file mode 100644
index f2285e149..000000000
--- a/packages/taler-wallet-core/src/headless/helpers.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Helpers to create headless wallets.
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- MemoryBackend,
- BridgeIDBFactory,
- shimIndexedDB,
-} from "@gnu-taler/idb-bridge";
-import { openTalerDatabase } from "../db-utils.js";
-import { HttpRequestLibrary } from "../util/http.js";
-import { NodeThreadCryptoWorkerFactory } from "../crypto/workers/nodeThreadWorker.js";
-import { NodeHttpLib } from "./NodeHttpLib.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { SynchronousCryptoWorkerFactory } from "../crypto/workers/synchronousWorker.js";
-import type { IDBFactory } from "@gnu-taler/idb-bridge";
-import { WalletNotification } from "@gnu-taler/taler-util";
-import { Wallet } from "../wallet.js";
-import * as fs from "fs";
-
-const logger = new Logger("headless/helpers.ts");
-
-export interface DefaultNodeWalletArgs {
- /**
- * Location of the wallet database.
- *
- * If not specified, the wallet starts out with an empty database and
- * the wallet database is stored only in memory.
- */
- persistentStoragePath?: string;
-
- /**
- * Handler for asynchronous notifications from the wallet.
- */
- notifyHandler?: (n: WalletNotification) => void;
-
- /**
- * If specified, use this as HTTP request library instead
- * of the default one.
- */
- httpLib?: HttpRequestLibrary;
-}
-
-/**
- * Generate a random alphanumeric ID. Does *not* use cryptographically
- * secure randomness.
- */
-function makeId(length: number): string {
- let result = "";
- const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
- for (let i = 0; i < length; i++) {
- result += characters.charAt(Math.floor(Math.random() * characters.length));
- }
- return result;
-}
-
-/**
- * Get a wallet instance with default settings for node.
- */
-export async function getDefaultNodeWallet(
- args: DefaultNodeWalletArgs = {},
-): Promise<Wallet> {
- BridgeIDBFactory.enableTracing = false;
- const myBackend = new MemoryBackend();
- myBackend.enableTracing = false;
-
- const storagePath = args.persistentStoragePath;
- if (storagePath) {
- try {
- const dbContentStr: string = fs.readFileSync(storagePath, {
- encoding: "utf-8",
- });
- const dbContent = JSON.parse(dbContentStr);
- myBackend.importDump(dbContent);
- } catch (e: any) {
- const code: string = e.code;
- if (code === "ENOENT") {
- logger.trace("wallet file doesn't exist yet");
- } else {
- logger.error("could not open wallet database file");
- throw e;
- }
- }
-
- myBackend.afterCommitCallback = async () => {
- logger.trace("committing database");
- // Allow caller to stop persisting the wallet.
- if (args.persistentStoragePath === undefined) {
- return;
- }
- const tmpPath = `${args.persistentStoragePath}-${makeId(5)}.tmp`;
- const dbContent = myBackend.exportDump();
- fs.writeFileSync(tmpPath, JSON.stringify(dbContent, undefined, 2), {
- encoding: "utf-8",
- });
- // Atomically move the temporary file onto the DB path.
- fs.renameSync(tmpPath, args.persistentStoragePath);
- };
- }
-
- BridgeIDBFactory.enableTracing = false;
-
- const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
- const myIdbFactory: IDBFactory = (myBridgeIdbFactory as any) as IDBFactory;
-
- let myHttpLib;
- if (args.httpLib) {
- myHttpLib = args.httpLib;
- } else {
- myHttpLib = new NodeHttpLib();
- }
-
- const myVersionChange = (): Promise<void> => {
- logger.error("version change requested, should not happen");
- throw Error(
- "BUG: wallet DB version change event can't happen with memory IDB",
- );
- };
-
- shimIndexedDB(myBridgeIdbFactory);
-
- const myDb = await openTalerDatabase(myIdbFactory, myVersionChange);
-
- let workerFactory;
- try {
- // Try if we have worker threads available, fails in older node versions.
- const _r = "require";
- const worker_threads = module[_r]("worker_threads");
- // require("worker_threads");
- workerFactory = new NodeThreadCryptoWorkerFactory();
- } catch (e) {
- logger.warn(
- "worker threads not available, falling back to synchronous workers",
- );
- workerFactory = new SynchronousCryptoWorkerFactory();
- }
-
- const w = await Wallet.create(myDb, myHttpLib, workerFactory);
-
- if (args.notifyHandler) {
- w.addNotificationListener(args.notifyHandler);
- }
- return w;
-}
diff --git a/packages/taler-wallet-core/src/host-common.ts b/packages/taler-wallet-core/src/host-common.ts
new file mode 100644
index 000000000..7651e5a12
--- /dev/null
+++ b/packages/taler-wallet-core/src/host-common.ts
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ 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 { WalletNotification } from "@gnu-taler/taler-util";
+import { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
+
+/**
+ * Helpers to initiate a wallet in a host environment.
+ */
+
+/**
+ */
+export interface DefaultNodeWalletArgs {
+ /**
+ * Location of the wallet database.
+ *
+ * If not specified, the wallet starts out with an empty database and
+ * the wallet database is stored only in memory.
+ */
+ persistentStoragePath?: string;
+
+ /**
+ * Handler for asynchronous notifications from the wallet.
+ */
+ notifyHandler?: (n: WalletNotification) => void;
+
+ /**
+ * If specified, use this as HTTP request library instead
+ * of the default one.
+ */
+ httpLib?: HttpRequestLibrary;
+
+ cryptoWorkerType?: "sync" | "node-worker-thread";
+}
+
+/**
+ * Generate a random alphanumeric ID. Does *not* use cryptographically
+ * secure randomness.
+ */
+export function makeTempfileId(length: number): string {
+ let result = "";
+ const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
+ for (let i = 0; i < length; i++) {
+ result += characters.charAt(Math.floor(Math.random() * characters.length));
+ }
+ return result;
+}
diff --git a/packages/taler-wallet-core/src/util/debugFlags.ts b/packages/taler-wallet-core/src/host-impl.missing.ts
index cea249d27..464a5af15 100644
--- a/packages/taler-wallet-core/src/util/debugFlags.ts
+++ b/packages/taler-wallet-core/src/host-impl.missing.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2019 Taler Systems S.A.
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
@@ -15,18 +15,27 @@
*/
/**
- * Debug flags for wallet-core.
- *
- * @author Florian Dold
+ * Helpers to create headless wallets.
+ * @author Florian Dold <dold@taler.net>
*/
-export interface WalletCoreDebugFlags {
- /**
- * Allow withdrawal of denominations even though they are about to expire.
- */
- denomselAllowLate: boolean;
-}
+/**
+ * Imports.
+ */
+import type { AccessStats, IDBFactory } from "@gnu-taler/idb-bridge";
+import { DefaultNodeWalletArgs } from "./host-common.js";
+import { Wallet } from "./index.js";
-export const walletCoreDebugFlags: WalletCoreDebugFlags = {
- denomselAllowLate: false,
-};
+/**
+ * Get a wallet instance with default settings for node.
+ *
+ * Extended version that allows getting DB stats.
+ */
+export async function createNativeWalletHost2(
+ args: DefaultNodeWalletArgs = {},
+): Promise<{
+ wallet: Wallet;
+ getDbStats: () => AccessStats;
+}> {
+ throw Error("not implemented");
+}
diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts
new file mode 100644
index 000000000..ec026b296
--- /dev/null
+++ b/packages/taler-wallet-core/src/host-impl.node.ts
@@ -0,0 +1,212 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Helpers to create headless wallets.
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import type { IDBFactory } from "@gnu-taler/idb-bridge";
+// eslint-disable-next-line no-duplicate-imports
+import {
+ AccessStats,
+ BridgeIDBFactory,
+ MemoryBackend,
+ createSqliteBackend,
+ shimIndexedDB,
+} from "@gnu-taler/idb-bridge";
+import { createNodeSqlite3Impl } from "@gnu-taler/idb-bridge/node-sqlite3-bindings";
+import {
+ Logger,
+ SetTimeoutTimerAPI,
+ WalletRunConfig,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import * as fs from "fs";
+import { NodeThreadCryptoWorkerFactory } from "./crypto/workers/nodeThreadWorker.js";
+import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
+import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js";
+import { Wallet } from "./wallet.js";
+
+const logger = new Logger("host-impl.node.ts");
+
+interface MakeDbResult {
+ idbFactory: BridgeIDBFactory;
+ getStats: () => AccessStats;
+}
+
+async function makeFileDb(
+ args: DefaultNodeWalletArgs = {},
+): Promise<MakeDbResult> {
+ const myBackend = new MemoryBackend();
+ myBackend.enableTracing = false;
+ const storagePath = args.persistentStoragePath;
+ if (storagePath) {
+ try {
+ const dbContentStr: string = fs.readFileSync(storagePath, {
+ encoding: "utf-8",
+ });
+ const dbContent = JSON.parse(dbContentStr);
+ myBackend.importDump(dbContent);
+ } catch (e: any) {
+ const code: string = e.code;
+ if (code === "ENOENT") {
+ logger.trace("wallet file doesn't exist yet");
+ } else {
+ logger.error("could not open wallet database file");
+ throw Error(
+ "could not open wallet database file",
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
+ );
+ }
+ }
+
+ myBackend.afterCommitCallback = async () => {
+ logger.trace("committing database");
+ // Allow caller to stop persisting the wallet.
+ if (args.persistentStoragePath === undefined) {
+ return;
+ }
+ const tmpPath = `${args.persistentStoragePath}-${makeTempfileId(5)}.tmp`;
+ logger.trace("exported DB dump");
+ const dbContent = myBackend.exportDump();
+ fs.writeFileSync(tmpPath, JSON.stringify(dbContent, undefined, 2), {
+ encoding: "utf-8",
+ });
+ // Atomically move the temporary file onto the DB path.
+ fs.renameSync(tmpPath, args.persistentStoragePath);
+ logger.trace("committing database done");
+ };
+ }
+
+ BridgeIDBFactory.enableTracing = false;
+
+ const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
+ return {
+ idbFactory: myBridgeIdbFactory,
+ getStats: () => myBackend.accessStats,
+ };
+}
+
+async function makeSqliteDb(
+ args: DefaultNodeWalletArgs,
+): Promise<MakeDbResult> {
+ BridgeIDBFactory.enableTracing = false;
+ const imp = await createNodeSqlite3Impl();
+ const dbFilename = args.persistentStoragePath ?? ":memory:";
+ logger.info(`using database ${dbFilename}`);
+ const myBackend = await createSqliteBackend(imp, {
+ filename: dbFilename,
+ });
+ myBackend.enableTracing = false;
+ if (process.env.TALER_WALLET_DBSTATS) {
+ myBackend.trackStats = true;
+ }
+ const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
+ return {
+ getStats() {
+ return myBackend.accessStats;
+ },
+ idbFactory: myBridgeIdbFactory,
+ };
+}
+
+/**
+ * Get a wallet instance with default settings for node.
+ *
+ * Extended version that allows getting DB stats.
+ */
+export async function createNativeWalletHost2(
+ args: DefaultNodeWalletArgs = {},
+): Promise<{
+ wallet: Wallet;
+ getDbStats: () => AccessStats;
+}> {
+ const myHttpFactory = (config: WalletRunConfig) => {
+ let myHttpLib;
+ if (args.httpLib) {
+ myHttpLib = args.httpLib;
+ } else {
+ myHttpLib = createPlatformHttpLib({
+ enableThrottling: true,
+ requireTls: !config.features.allowHttp,
+ });
+ }
+ return myHttpLib;
+ };
+
+ let dbResp: MakeDbResult;
+
+ if (
+ args.persistentStoragePath &&
+ args.persistentStoragePath.endsWith(".json")
+ ) {
+ logger.info("using JSON file DB backend (slow, only use for testing)");
+ dbResp = await makeFileDb(args);
+ } else {
+ logger.info(`using sqlite3 DB backend`);
+ dbResp = await makeSqliteDb(args);
+ }
+
+ const myIdbFactory: IDBFactory = dbResp.idbFactory as any as IDBFactory;
+
+ shimIndexedDB(dbResp.idbFactory);
+
+ let workerFactory;
+ const cryptoWorkerType = args.cryptoWorkerType ?? "node-worker-thread";
+ if (cryptoWorkerType === "sync") {
+ logger.info("using synchronous crypto worker");
+ workerFactory = new SynchronousCryptoWorkerFactoryPlain();
+ } else if (cryptoWorkerType === "node-worker-thread") {
+ try {
+ // Try if we have worker threads available, fails in older node versions.
+ const _r = "require";
+ // eslint-disable-next-line no-unused-vars
+ const worker_threads = module[_r]("worker_threads");
+ // require("worker_threads");
+ workerFactory = new NodeThreadCryptoWorkerFactory();
+ logger.info("using node thread crypto worker");
+ } catch (e) {
+ logger.warn(
+ "worker threads not available, falling back to synchronous workers",
+ );
+ workerFactory = new SynchronousCryptoWorkerFactoryPlain();
+ }
+ } else {
+ throw Error(`unsupported crypto worker type '${cryptoWorkerType}'`);
+ }
+
+ const timer = new SetTimeoutTimerAPI();
+
+ const w = await Wallet.create(
+ myIdbFactory,
+ myHttpFactory,
+ timer,
+ workerFactory,
+ );
+
+ if (args.notifyHandler) {
+ w.addNotificationListener(args.notifyHandler);
+ }
+ return {
+ wallet: w,
+ getDbStats: dbResp.getStats,
+ };
+}
diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts
new file mode 100644
index 000000000..9c985d0c1
--- /dev/null
+++ b/packages/taler-wallet-core/src/host-impl.qtart.ts
@@ -0,0 +1,219 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Helpers to create headless wallets.
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import type {
+ IDBFactory,
+ ResultRow,
+ Sqlite3Interface,
+ Sqlite3Statement,
+} from "@gnu-taler/idb-bridge";
+// eslint-disable-next-line no-duplicate-imports
+import {
+ AccessStats,
+ BridgeIDBFactory,
+ MemoryBackend,
+ createSqliteBackend,
+ shimIndexedDB,
+} from "@gnu-taler/idb-bridge";
+import {
+ Logger,
+ SetTimeoutTimerAPI,
+ WalletRunConfig,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import { qjsOs, qjsStd } from "@gnu-taler/taler-util/qtart";
+import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
+import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js";
+import { Wallet } from "./wallet.js";
+
+const logger = new Logger("host-impl.qtart.ts");
+
+interface MakeDbResult {
+ idbFactory: BridgeIDBFactory;
+ getStats: () => AccessStats;
+}
+
+let numStmt = 0;
+
+export async function createQtartSqlite3Impl(): Promise<Sqlite3Interface> {
+ const tart: any = (globalThis as any)._tart;
+ if (!tart) {
+ throw Error("globalThis._qtart not defined");
+ }
+ return {
+ open(filename: string) {
+ const internalDbHandle = tart.sqlite3Open(filename);
+ return {
+ internalDbHandle,
+ close() {
+ tart.sqlite3Close(internalDbHandle);
+ },
+ prepare(stmtStr): Sqlite3Statement {
+ const stmtHandle = tart.sqlite3Prepare(internalDbHandle, stmtStr);
+ return {
+ internalStatement: stmtHandle,
+ getAll(params): ResultRow[] {
+ numStmt++;
+ return tart.sqlite3StmtGetAll(stmtHandle, params);
+ },
+ getFirst(params): ResultRow | undefined {
+ numStmt++;
+ return tart.sqlite3StmtGetFirst(stmtHandle, params);
+ },
+ run(params) {
+ numStmt++;
+ return tart.sqlite3StmtRun(stmtHandle, params);
+ },
+ };
+ },
+ exec(sqlStr): void {
+ numStmt++;
+ tart.sqlite3Exec(internalDbHandle, sqlStr);
+ },
+ };
+ },
+ };
+}
+
+async function makeSqliteDb(
+ args: DefaultNodeWalletArgs,
+): Promise<MakeDbResult> {
+ BridgeIDBFactory.enableTracing = false;
+ const imp = await createQtartSqlite3Impl();
+ const myBackend = await createSqliteBackend(imp, {
+ filename: args.persistentStoragePath ?? ":memory:",
+ });
+ myBackend.trackStats = true;
+ myBackend.enableTracing = false;
+ const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
+ return {
+ getStats() {
+ return {
+ ...myBackend.accessStats,
+ primitiveStatements: numStmt,
+ };
+ },
+ idbFactory: myBridgeIdbFactory,
+ };
+}
+
+async function makeFileDb(
+ args: DefaultNodeWalletArgs = {},
+): Promise<MakeDbResult> {
+ BridgeIDBFactory.enableTracing = false;
+ const myBackend = new MemoryBackend();
+ myBackend.enableTracing = false;
+
+ const storagePath = args.persistentStoragePath;
+ if (storagePath) {
+ const dbContentStr = qjsStd.loadFile(storagePath);
+ if (dbContentStr != null) {
+ const dbContent = JSON.parse(dbContentStr);
+ myBackend.importDump(dbContent);
+ }
+
+ myBackend.afterCommitCallback = async () => {
+ logger.trace("committing database");
+ // Allow caller to stop persisting the wallet.
+ if (args.persistentStoragePath === undefined) {
+ return;
+ }
+ const tmpPath = `${args.persistentStoragePath}-${makeTempfileId(5)}.tmp`;
+ const dbContent = myBackend.exportDump();
+ logger.trace("exported DB dump");
+ qjsStd.writeFile(tmpPath, JSON.stringify(dbContent, undefined, 2));
+ // Atomically move the temporary file onto the DB path.
+ const res = qjsOs.rename(tmpPath, args.persistentStoragePath);
+ if (res != 0) {
+ throw Error("db commit failed at rename");
+ }
+ logger.trace("committing database done");
+ };
+ }
+
+ const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
+ return {
+ idbFactory: myBridgeIdbFactory,
+ getStats: () => myBackend.accessStats,
+ };
+}
+
+export async function createNativeWalletHost2(
+ args: DefaultNodeWalletArgs = {},
+): Promise<{
+ wallet: Wallet;
+ getDbStats: () => AccessStats;
+}> {
+ BridgeIDBFactory.enableTracing = false;
+
+ let dbResp: MakeDbResult;
+
+ if (
+ args.persistentStoragePath &&
+ args.persistentStoragePath.endsWith(".json")
+ ) {
+ logger.info("using JSON file DB backend (slow, only use for testing)");
+ dbResp = await makeFileDb(args);
+ } else {
+ logger.info("using sqlite3 DB backend");
+ dbResp = await makeSqliteDb(args);
+ }
+
+ const myIdbFactory: IDBFactory = dbResp.idbFactory as any as IDBFactory;
+
+ shimIndexedDB(dbResp.idbFactory);
+
+ const myHttpFactory = (config: WalletRunConfig) => {
+ let myHttpLib;
+ if (args.httpLib) {
+ myHttpLib = args.httpLib;
+ } else {
+ myHttpLib = createPlatformHttpLib({
+ enableThrottling: true,
+ requireTls: !config.features.allowHttp,
+ });
+ }
+ return myHttpLib;
+ };
+
+ let workerFactory;
+ workerFactory = new SynchronousCryptoWorkerFactoryPlain();
+
+ const timer = new SetTimeoutTimerAPI();
+
+ const w = await Wallet.create(
+ myIdbFactory,
+ myHttpFactory,
+ timer,
+ workerFactory,
+ );
+
+ if (args.notifyHandler) {
+ w.addNotificationListener(args.notifyHandler);
+ }
+ return {
+ wallet: w,
+ getDbStats: dbResp.getStats,
+ };
+}
diff --git a/packages/taler-wallet-core/src/host.ts b/packages/taler-wallet-core/src/host.ts
new file mode 100644
index 000000000..feccf42a6
--- /dev/null
+++ b/packages/taler-wallet-core/src/host.ts
@@ -0,0 +1,46 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ 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 { DefaultNodeWalletArgs } from "./host-common.js";
+import { Wallet } from "./index.js";
+import * as hostImpl from "#host-impl";
+import { AccessStats } from "@gnu-taler/idb-bridge";
+
+/**
+ * Helpers to initiate a wallet in a host environment.
+ */
+
+/**
+ * Get a wallet instance.
+ */
+export async function createNativeWalletHost2(
+ args: DefaultNodeWalletArgs = {},
+): Promise<{
+ wallet: Wallet;
+ getDbStats: () => AccessStats;
+}> {
+ return hostImpl.createNativeWalletHost2(args);
+}
+
+/**
+ * Get a wallet instance.
+ */
+export async function createNativeWalletHost(
+ args: DefaultNodeWalletArgs = {},
+): Promise<Wallet> {
+ const res = await hostImpl.createNativeWalletHost2(args);
+ return res.wallet;
+}
diff --git a/packages/taler-wallet-core/src/index.browser.ts b/packages/taler-wallet-core/src/index.browser.ts
index 88ea52479..9409673a0 100644
--- a/packages/taler-wallet-core/src/index.browser.ts
+++ b/packages/taler-wallet-core/src/index.browser.ts
@@ -15,3 +15,4 @@
*/
export * from "./index.js";
+export { SynchronousCryptoWorkerPlain } from "./crypto/workers/synchronousWorkerPlain.js";
diff --git a/packages/taler-wallet-core/src/index.node.ts b/packages/taler-wallet-core/src/index.node.ts
index 0860ccc26..13392d39c 100644
--- a/packages/taler-wallet-core/src/index.node.ts
+++ b/packages/taler-wallet-core/src/index.node.ts
@@ -16,10 +16,8 @@
export * from "./index.js";
-// Utils for using the wallet under node
-export { NodeHttpLib } from "./headless/NodeHttpLib.js";
-export {
- getDefaultNodeWallet,
- DefaultNodeWalletArgs,
-} from "./headless/helpers.js";
export * from "./crypto/workers/nodeThreadWorker.js";
+export { SynchronousCryptoWorkerPlain } from "./crypto/workers/synchronousWorkerPlain.js";
+
+export type { AccessStats } from "@gnu-taler/idb-bridge";
+export * from "./crypto/workers/synchronousWorkerFactoryPlain.js";
diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts
index 0b360a248..fe2d3af15 100644
--- a/packages/taler-wallet-core/src/index.ts
+++ b/packages/taler-wallet-core/src/index.ts
@@ -18,31 +18,29 @@
* Module entry point for the wallet when used as a node module.
*/
-// Errors
-export * from "./errors.js";
-
-// Util functionality
-export * from "./util/promiseUtils.js";
-export * from "./util/query.js";
-export * from "./util/http.js";
-
+export * from "./crypto/cryptoImplementation.js";
+export * from "./crypto/cryptoTypes.js";
+export {
+ CryptoDispatcher,
+ CryptoWorkerFactory,
+} from "./crypto/workers/crypto-dispatcher.js";
+export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js";
+export { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
+export * from "./host-common.js";
+export * from "./host.js";
export * from "./versions.js";
-
-export * from "./db.js";
-export * from "./db-utils.js";
-
-// Crypto and crypto workers
-// export * from "./crypto/workers/nodeThreadWorker.js";
-export { CryptoImplementation } from "./crypto/workers/cryptoImplementation.js";
-export type { CryptoWorker } from "./crypto/workers/cryptoWorker.js";
-export { CryptoWorkerFactory, CryptoApi } from "./crypto/workers/cryptoApi.js";
-
-export * from "./pending-types.js";
-
-export * from "./util/debugFlags.js";
-export { InternalWalletState } from "./common.js";
export * from "./wallet-api-types.js";
export * from "./wallet.js";
-export * from "./operations/backup/index.js";
-export { makeEventId } from "./operations/transactions.js";
+export { parseTransactionIdentifier } from "./transactions.js";
+
+export { createPairTimeline } from "./denominations.js";
+
+// FIXME: Should these really be exported?!
+export {
+ WalletStoresV1,
+ deleteTalerDatabase,
+ exportDb,
+ importDb,
+} from "./db.js";
+export { DbAccess } from "./query.js";
diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
new file mode 100644
index 000000000..03e702568
--- /dev/null
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
@@ -0,0 +1,767 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ 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 {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ Duration,
+ TransactionAmountMode,
+} from "@gnu-taler/taler-util";
+import test, { ExecutionContext } from "ava";
+import {
+ CoinInfo,
+ convertDepositAmountForAvailableCoins,
+ convertWithdrawalAmountFromAvailableCoins,
+ getMaxDepositAmountForAvailableCoins,
+} from "./instructedAmountConversion.js";
+
+function makeCurrencyHelper(currency: string) {
+ return (sx: TemplateStringsArray, ...vx: any[]) => {
+ const s = String.raw({ raw: sx }, ...vx);
+ return Amounts.parseOrThrow(`${currency}:${s}`);
+ };
+}
+
+const kudos = makeCurrencyHelper("kudos");
+
+function defaultFeeConfig(value: AmountJson, totalAvailable: number): CoinInfo {
+ return {
+ id: Amounts.stringify(value),
+ denomDeposit: kudos`0.01`,
+ denomRefresh: kudos`0.01`,
+ denomWithdraw: kudos`0.01`,
+ exchangeBaseUrl: "1",
+ duration: Duration.getForever(),
+ exchangePurse: undefined,
+ exchangeWire: undefined,
+ maxAge: AgeRestriction.AGE_UNRESTRICTED,
+ totalAvailable,
+ value,
+ };
+}
+type Coin = [AmountJson, number];
+
+/**
+ * Making a deposit with effective amount
+ *
+ */
+
+test("deposit effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.99");
+});
+
+test("deposit effective 10", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "9.98");
+});
+
+test("deposit effective 24", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "23.94");
+});
+
+test("deposit effective 40", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "35");
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+});
+
+test("deposit with wire fee effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.89");
+});
+
+/**
+ * Making a deposit with raw amount, using the result from effective
+ *
+ */
+
+test("deposit raw 1.99 (effective 2)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`1.99`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.99");
+});
+
+test("deposit raw 9.98 (effective 10)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`9.98`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "9.98");
+});
+
+test("deposit raw 23.94 (effective 24)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`23.94`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "23.94");
+});
+
+test("deposit raw 34.9 (effective 40)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`34.9`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "35");
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+});
+
+test("deposit with wire fee raw 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.89");
+});
+
+/**
+ * Calculating the max amount possible to deposit
+ *
+ */
+
+test("deposit max 35", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`0.00`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+ t.is(Amounts.stringifyValue(result.effective), "35");
+});
+
+test("deposit max 35 with wirefee", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "33.9");
+ t.is(Amounts.stringifyValue(result.effective), "35");
+});
+
+test("deposit max repeated denom", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 1],
+ [kudos`2`, 1],
+ [kudos`5`, 1],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`0.00`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "8.97");
+ t.is(Amounts.stringifyValue(result.effective), "9");
+});
+
+/**
+ * Making a withdrawal with effective amount
+ *
+ */
+
+test("withdraw effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "2.01");
+});
+
+test("withdraw effective 10", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "10.02");
+});
+
+test("withdraw effective 24", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "24.06");
+});
+
+test("withdraw effective 40", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "40");
+ t.is(Amounts.stringifyValue(result.raw), "40.08");
+});
+
+/**
+ * Making a deposit with raw amount, using the result from effective
+ *
+ */
+
+test("withdraw raw 2.01 (effective 2)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2.01`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "2.01");
+});
+
+test("withdraw raw 10.02 (effective 10)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10.02`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "10.02");
+});
+
+test("withdraw raw 24.06 (effective 24)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24.06`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "24.06");
+});
+
+test("withdraw raw 40.08 (effective 40)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40.08`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "40");
+ t.is(Amounts.stringifyValue(result.raw), "40.08");
+});
+
+test("withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`25`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.94");
+});
+
+test("withdraw effective 24.8 (raw 25)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24.8`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.94");
+});
+
+/**
+ * Making a deposit with refresh
+ *
+ */
+
+test("deposit with refresh: effective 3", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`3`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.1");
+ t.is(Amounts.stringifyValue(result.raw), "2.98");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 2 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 9 x 0.01
+ //-----------------
+ //op 0.12
+
+ //coins sent 2 x 2.0
+ //coins recv 9 x 0.1
+ //-------------------
+ //effective 3.10
+ //raw 2.98
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 9]]);
+});
+
+test("deposit with refresh: raw 2.98 (effective 3)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2.98`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.2");
+ t.is(Amounts.stringifyValue(result.raw), "3.09");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 1 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 8 x 0.01
+ //-----------------
+ //op 0.10
+
+ //coins sent 1 x 2.0
+ //coins recv 8 x 0.1
+ //-------------------
+ //effective 3.20
+ //raw 3.09
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 8]]);
+});
+
+test("deposit with refresh: effective 3.2 (raw 2.98)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`3.2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.3");
+ t.is(Amounts.stringifyValue(result.raw), "3.2");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 2 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 7 x 0.01
+ //-----------------
+ //op 0.10
+
+ //coins sent 2 x 2.0
+ //coins recv 7 x 0.1
+ //-------------------
+ //effective 3.30
+ //raw 3.20
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 7]]);
+});
+
+function expectDefined<T>(
+ t: ExecutionContext,
+ v: T | undefined,
+): asserts v is T {
+ t.assert(v !== undefined);
+}
+
+function asCoinList(v: { info: CoinInfo; size: number }[]): any {
+ return v.map((c) => {
+ return [c.info.value, c.size];
+ });
+}
+
+/**
+ * regression tests
+ */
+
+test("demo: withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ [kudos`10`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`25`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.92");
+ // coins received
+ // 8 x 0.1
+ // 2 x 0.2
+ // 2 x 10.0
+ // total effective 24.8
+ // fee 12 x 0.01 = 0.12
+ // total raw 24.92
+ // left in reserve 25 - 24.92 == 0.08
+
+ //current wallet impl: hides the left in reserve fee
+ //shows fee = 0.2
+});
+
+test("demo: deposit max after withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 8],
+ [kudos`1`, 0],
+ [kudos`2`, 2],
+ [kudos`5`, 0],
+ [kudos`10`, 2],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.01`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.67");
+
+ // 8 x 0.1
+ // 2 x 0.2
+ // 2 x 10.0
+ // total effective 24.8
+ // deposit fee 12 x 0.01 = 0.12
+ // wire fee 0.01
+ // total raw: 24.8 - 0.13 = 24.67
+
+ // current wallet impl fee 0.14
+});
+
+test("demo: withdraw raw 13", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ [kudos`10`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`13`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "12.8");
+ t.is(Amounts.stringifyValue(result.raw), "12.9");
+ // coins received
+ // 8 x 0.1
+ // 1 x 0.2
+ // 1 x 10.0
+ // total effective 12.8
+ // fee 10 x 0.01 = 0.10
+ // total raw 12.9
+ // left in reserve 13 - 12.9 == 0.1
+
+ //current wallet impl: hides the left in reserve fee
+ //shows fee = 0.2
+});
+
+test("demo: deposit max after withdraw raw 13", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 8],
+ [kudos`1`, 0],
+ [kudos`2`, 1],
+ [kudos`5`, 0],
+ [kudos`10`, 1],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.01`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.effective), "12.8");
+ t.is(Amounts.stringifyValue(result.raw), "12.69");
+
+ // 8 x 0.1
+ // 1 x 0.2
+ // 1 x 10.0
+ // total effective 12.8
+ // deposit fee 10 x 0.01 = 0.10
+ // wire fee 0.01
+ // total raw: 12.8 - 0.11 = 12.69
+
+ // current wallet impl fee 0.14
+});
diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts
new file mode 100644
index 000000000..1f7d95959
--- /dev/null
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts
@@ -0,0 +1,872 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ 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 { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ AmountResponse,
+ Amounts,
+ ConvertAmountRequest,
+ Duration,
+ GetAmountRequest,
+ GetPlanForOperationRequest,
+ TransactionAmountMode,
+ TransactionType,
+ checkDbInvariant,
+ parsePaytoUri,
+ strcmp,
+} from "@gnu-taler/taler-util";
+import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
+import { getExchangeWireDetailsInTx } from "./exchanges.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+export interface CoinInfo {
+ id: string;
+ value: AmountJson;
+ denomDeposit: AmountJson;
+ denomWithdraw: AmountJson;
+ denomRefresh: AmountJson;
+ totalAvailable: number | undefined;
+ exchangeWire: AmountJson | undefined;
+ exchangePurse: AmountJson | undefined;
+ duration: Duration;
+ exchangeBaseUrl: string;
+ maxAge: number;
+}
+
+/**
+ * If the operation going to be plan subtracts
+ * or adds amount in the wallet db
+ */
+export enum OperationType {
+ Credit = "credit",
+ Debit = "debit",
+}
+
+// FIXME: Name conflict ...
+interface ExchangeInfo {
+ wireFee: AmountJson | undefined;
+ purseFee: AmountJson | undefined;
+ creditDeadline: AbsoluteTime;
+ debitDeadline: AbsoluteTime;
+}
+
+function getOperationType(txType: TransactionType): OperationType {
+ const operationType =
+ txType === TransactionType.Withdrawal
+ ? OperationType.Credit
+ : txType === TransactionType.Deposit
+ ? OperationType.Debit
+ : undefined;
+ if (!operationType) {
+ throw Error(`operation type ${txType} not yet supported`);
+ }
+ return operationType;
+}
+
+interface SelectedCoins {
+ totalValue: AmountJson;
+ coins: { info: CoinInfo; size: number }[];
+ refresh?: RefreshChoice;
+}
+
+function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter {
+ switch (req.type) {
+ case TransactionType.Withdrawal: {
+ return {
+ exchanges:
+ req.exchangeUrl === undefined ? undefined : [req.exchangeUrl],
+ };
+ }
+ case TransactionType.Deposit: {
+ const payto = parsePaytoUri(req.account);
+ if (!payto) {
+ throw Error(`wrong payto ${req.account}`);
+ }
+ return {
+ wireMethod: payto.targetType,
+ };
+ }
+ }
+}
+
+interface RefreshChoice {
+ /**
+ * Amount that need to be covered
+ */
+ gap: AmountJson;
+ totalFee: AmountJson;
+ selected: CoinInfo;
+ totalChangeValue: AmountJson;
+ refreshEffective: AmountJson;
+ coins: { info: CoinInfo; size: number }[];
+
+ // totalValue: AmountJson;
+ // totalDepositFee: AmountJson;
+ // totalRefreshFee: AmountJson;
+ // totalChangeContribution: AmountJson;
+ // totalChangeWithdrawalFee: AmountJson;
+}
+
+interface CoinsFilter {
+ shouldCalculatePurseFee?: boolean;
+ exchanges?: string[];
+ wireMethod?: string;
+ ageRestricted?: number;
+}
+
+interface AvailableCoins {
+ list: CoinInfo[];
+ exchanges: Record<string, ExchangeInfo>;
+}
+
+/**
+ * Get all the denoms that can be used for a operation that is limited
+ * by the following restrictions.
+ * This function is costly (by the database access) but with high chances
+ * of being cached
+ */
+async function getAvailableDenoms(
+ wex: WalletExecutionContext,
+ op: TransactionType,
+ currency: string,
+ filters: CoinsFilter = {},
+): Promise<AvailableCoins> {
+ const operationType = getOperationType(TransactionType.Deposit);
+
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const list: CoinInfo[] = [];
+ const exchanges: Record<string, ExchangeInfo> = {};
+
+ const databaseExchanges = await tx.exchanges.iter().toArray();
+ const filteredExchanges =
+ filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
+
+ for (const exchangeBaseUrl of filteredExchanges) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeBaseUrl,
+ );
+ // 1.- exchange has same currency
+ if (exchangeDetails?.currency !== currency) {
+ continue;
+ }
+
+ let deadline = AbsoluteTime.never();
+ // 2.- exchange supports wire method
+ let wireFee: AmountJson | undefined;
+ if (filters.wireMethod) {
+ const wireMethodWithDates =
+ exchangeDetails.wireInfo.feesForType[filters.wireMethod];
+
+ if (!wireMethodWithDates) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
+ );
+ }
+ const wireMethodFee = wireMethodWithDates.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ });
+
+ if (!wireMethodFee) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
+ );
+ }
+ wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee);
+ deadline = AbsoluteTime.min(
+ deadline,
+ AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp),
+ );
+ }
+ // exchanges[exchangeBaseUrl].wireFee = wireMethodFee;
+
+ // 3.- exchange supports wire method
+ let purseFee: AmountJson | undefined;
+ if (filters.shouldCalculatePurseFee) {
+ const purseFeeFound = exchangeDetails.globalFees.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startDate),
+ AbsoluteTime.fromProtocolTimestamp(x.endDate),
+ );
+ });
+ if (!purseFeeFound) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
+ );
+ }
+ purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee);
+ deadline = AbsoluteTime.min(
+ deadline,
+ AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate),
+ );
+ }
+
+ let creditDeadline = AbsoluteTime.never();
+ let debitDeadline = AbsoluteTime.never();
+ //4.- filter coins restricted by age
+ if (operationType === OperationType.Credit) {
+ // FIXME: Use denom groups instead of querying all denominations!
+ const ds =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const denom of ds) {
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireWithdraw),
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ AgeRestriction.AGE_UNRESTRICTED,
+ Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom
+ ),
+ );
+ }
+ } else {
+ const ageLower = filters.ageRestricted ?? 0;
+ const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+
+ const myExchangeCoins =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [
+ exchangeDetails.exchangeBaseUrl,
+ ageUpper,
+ Number.MAX_SAFE_INTEGER,
+ ],
+ ),
+ );
+ //5.- save denoms with how many coins are available
+ // FIXME: Check that the individual denomination is audited!
+ // FIXME: Should we exclude denominations that are
+ // not spendable anymore?
+ for (const coinAvail of myExchangeCoins) {
+ const denom = await tx.denominations.get([
+ coinAvail.exchangeBaseUrl,
+ coinAvail.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ if (denom.isRevoked || !denom.isOffered) {
+ continue;
+ }
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireWithdraw),
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ coinAvail.maxAge,
+ coinAvail.freshCoinCount,
+ ),
+ );
+ }
+ }
+
+ exchanges[exchangeBaseUrl] = {
+ purseFee,
+ wireFee,
+ debitDeadline,
+ creditDeadline,
+ };
+ }
+
+ return { list, exchanges };
+ },
+ );
+}
+
+function buildCoinInfoFromDenom(
+ denom: DenominationRecord,
+ purseFee: AmountJson | undefined,
+ wireFee: AmountJson | undefined,
+ maxAge: number,
+ total: number,
+): CoinInfo {
+ return {
+ id: denom.denomPubHash,
+ denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
+ denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh),
+ exchangePurse: purseFee,
+ exchangeWire: wireFee,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ duration: AbsoluteTime.difference(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ ),
+ ),
+ totalAvailable: total,
+ value: Amounts.parseOrThrow(denom.value),
+ maxAge,
+ };
+}
+
+export async function convertDepositAmount(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ const amount = Amounts.parseOrThrow(req.amount);
+ // const filter = getCoinsFilter(req);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ TransactionType.Deposit,
+ amount.currency,
+ {},
+ );
+ const result = convertDepositAmountForAvailableCoins(
+ denoms,
+ amount,
+ req.type,
+ );
+
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+const LOG_REFRESH = false;
+const LOG_DEPOSIT = false;
+export function convertDepositAmountForAvailableCoins(
+ denoms: AvailableCoins,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+): AmountAndRefresh {
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+ const depositDenoms = rankDenominationForDeposit(denoms.list, mode);
+
+ //FIXME: we are not taking into account
+ // * exchanges with multiple accounts
+ // * wallet with multiple exchanges
+ const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
+ const adjustedAmount = Amounts.add(amount, wireFee).amount;
+
+ const selected = selectGreedyCoins(depositDenoms, adjustedAmount);
+
+ const gap = Amounts.sub(amount, selected.totalValue).amount;
+
+ const result = getTotalEffectiveAndRawForDeposit(
+ selected.coins,
+ amount.currency,
+ );
+ result.raw = Amounts.sub(result.raw, wireFee).amount;
+
+ if (Amounts.isZero(gap)) {
+ // exact amount founds
+ return result;
+ }
+
+ if (LOG_DEPOSIT) {
+ const logInfo = selected.coins.map((c) => {
+ return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
+ });
+ console.log(
+ "deposit used:",
+ logInfo.join(", "),
+ "gap:",
+ Amounts.stringifyValue(gap),
+ );
+ }
+
+ const refreshDenoms = rankDenominationForRefresh(denoms.list);
+ /**
+ * FIXME: looking for refresh AFTER selecting greedy is not optimal
+ */
+ const refreshCoin = searchBestRefreshCoin(
+ depositDenoms,
+ refreshDenoms,
+ gap,
+ mode,
+ );
+
+ if (refreshCoin) {
+ const fee = Amounts.sub(result.effective, result.raw).amount;
+ const effective = Amounts.add(
+ result.effective,
+ refreshCoin.refreshEffective,
+ ).amount;
+ const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount;
+ //found with change
+ return {
+ effective,
+ raw,
+ refresh: refreshCoin,
+ };
+ }
+
+ // there is a gap, but no refresh coin was found
+ return result;
+}
+
+export async function getMaxDepositAmount(
+ wex: WalletExecutionContext,
+ req: GetAmountRequest,
+): Promise<AmountResponse> {
+ // const filter = getCoinsFilter(req);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ TransactionType.Deposit,
+ req.currency,
+ {},
+ );
+
+ const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency);
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+export function getMaxDepositAmountForAvailableCoins(
+ denoms: AvailableCoins,
+ currency: string,
+) {
+ const zero = Amounts.zeroOfCurrency(currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+
+ const result = getTotalEffectiveAndRawForDeposit(
+ denoms.list.map((info) => {
+ return { info, size: info.totalAvailable ?? 0 };
+ }),
+ currency,
+ );
+
+ const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
+ result.raw = Amounts.sub(result.raw, wireFee).amount;
+
+ return result;
+}
+
+export async function convertPeerPushAmount(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ throw Error("to be implemented after 1.0");
+}
+
+export async function getMaxPeerPushAmount(
+ wex: WalletExecutionContext,
+ req: GetAmountRequest,
+): Promise<AmountResponse> {
+ throw Error("to be implemented after 1.0");
+}
+
+export async function convertWithdrawalAmount(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ const amount = Amounts.parseOrThrow(req.amount);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ TransactionType.Withdrawal,
+ amount.currency,
+ {},
+ );
+
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ denoms,
+ amount,
+ req.type,
+ );
+
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+export function convertWithdrawalAmountFromAvailableCoins(
+ denoms: AvailableCoins,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+) {
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+ const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode);
+
+ const selected = selectGreedyCoins(withdrawDenoms, amount);
+
+ return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency);
+}
+
+/** *****************************************************
+ * HELPERS
+ * *****************************************************
+ */
+
+/**
+ *
+ * @param depositDenoms
+ * @param refreshDenoms
+ * @param amount
+ * @param mode
+ * @returns
+ */
+function searchBestRefreshCoin(
+ depositDenoms: SelectableElement[],
+ refreshDenoms: Record<string, SelectableElement[]>,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+): RefreshChoice | undefined {
+ let choice: RefreshChoice | undefined = undefined;
+ let refreshIdx = 0;
+ refreshIteration: while (refreshIdx < depositDenoms.length) {
+ const d = depositDenoms[refreshIdx];
+
+ const denomContribution =
+ mode === TransactionAmountMode.Effective
+ ? d.value
+ : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount;
+
+ const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount;
+ if (Amounts.isZero(changeAfterDeposit)) {
+ //this coin is not big enough to use for refresh
+ //since the list is sorted, we can break here
+ break refreshIteration;
+ }
+
+ const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl];
+ const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit);
+
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ const withdrawChangeFee = change.coins.reduce((cur, prev) => {
+ return Amounts.add(
+ cur,
+ Amounts.mult(prev.info.denomWithdraw, prev.size).amount,
+ ).amount;
+ }, zero);
+
+ const withdrawChangeValue = change.coins.reduce((cur, prev) => {
+ return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount)
+ .amount;
+ }, zero);
+
+ const totalFee = Amounts.add(
+ d.info.denomDeposit,
+ d.info.denomRefresh,
+ withdrawChangeFee,
+ ).amount;
+
+ if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
+ //found cheaper change
+ choice = {
+ gap: amount,
+ totalFee: totalFee,
+ totalChangeValue: change.totalValue, //change after refresh
+ refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered
+ selected: d.info,
+ coins: change.coins,
+ };
+ }
+ refreshIdx++;
+ }
+ if (choice) {
+ if (LOG_REFRESH) {
+ const logInfo = choice.coins.map((c) => {
+ return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
+ });
+ console.log(
+ "refresh used:",
+ Amounts.stringifyValue(choice.selected.value),
+ "change:",
+ logInfo.join(", "),
+ "fee:",
+ Amounts.stringifyValue(choice.totalFee),
+ "refreshEffective:",
+ Amounts.stringifyValue(choice.refreshEffective),
+ "totalChangeValue:",
+ Amounts.stringifyValue(choice.totalChangeValue),
+ );
+ }
+ }
+ return choice;
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to withdraw first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForWithdrawals(
+ denoms: CoinInfo[],
+ mode: TransactionAmountMode,
+): SelectableElement[] {
+ const copyList = [...denoms];
+ /**
+ * Rank coins
+ */
+ copyList.sort((d1, d2) => {
+ // the best coin to use is
+ // 1.- the one that contrib more and pay less fee
+ // 2.- it takes more time before expires
+
+ //different exchanges may have different wireFee
+ //ranking should take the relative contribution in the exchange
+ //which is (value - denomFee / fixedFee)
+ const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient;
+ const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient;
+ const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
+ return (
+ contribCmp ||
+ Duration.cmp(d1.duration, d2.duration) ||
+ strcmp(d1.id, d2.id)
+ );
+ });
+
+ return copyList.map((info) => {
+ switch (mode) {
+ case TransactionAmountMode.Effective: {
+ //if the user instructed "effective" then we need to selected
+ //greedy total coin value
+ return {
+ info,
+ value: info.value,
+ total: Number.MAX_SAFE_INTEGER,
+ };
+ }
+ case TransactionAmountMode.Raw: {
+ //if the user instructed "raw" then we need to selected
+ //greedy total coin raw amount (without fee)
+ return {
+ info,
+ value: Amounts.add(info.value, info.denomWithdraw).amount,
+ total: Number.MAX_SAFE_INTEGER,
+ };
+ }
+ }
+ });
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to deposit first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForDeposit(
+ denoms: CoinInfo[],
+ mode: TransactionAmountMode,
+): SelectableElement[] {
+ const copyList = [...denoms];
+ /**
+ * Rank coins
+ */
+ copyList.sort((d1, d2) => {
+ // the best coin to use is
+ // 1.- the one that contrib more and pay less fee
+ // 2.- it takes more time before expires
+
+ //different exchanges may have different wireFee
+ //ranking should take the relative contribution in the exchange
+ //which is (value - denomFee / fixedFee)
+ const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient;
+ const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient;
+ const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
+ return (
+ contribCmp ||
+ Duration.cmp(d1.duration, d2.duration) ||
+ strcmp(d1.id, d2.id)
+ );
+ });
+
+ return copyList.map((info) => {
+ switch (mode) {
+ case TransactionAmountMode.Effective: {
+ //if the user instructed "effective" then we need to selected
+ //greedy total coin value
+ return {
+ info,
+ value: info.value,
+ total: info.totalAvailable ?? 0,
+ };
+ }
+ case TransactionAmountMode.Raw: {
+ //if the user instructed "raw" then we need to selected
+ //greedy total coin raw amount (without fee)
+ return {
+ info,
+ value: Amounts.sub(info.value, info.denomDeposit).amount,
+ total: info.totalAvailable ?? 0,
+ };
+ }
+ }
+ });
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to withdraw first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForRefresh(
+ denoms: CoinInfo[],
+): Record<string, SelectableElement[]> {
+ const groupByExchange: Record<string, CoinInfo[]> = {};
+ for (const d of denoms) {
+ if (!groupByExchange[d.exchangeBaseUrl]) {
+ groupByExchange[d.exchangeBaseUrl] = [];
+ }
+ groupByExchange[d.exchangeBaseUrl].push(d);
+ }
+
+ const result: Record<string, SelectableElement[]> = {};
+ for (const d of denoms) {
+ result[d.exchangeBaseUrl] = rankDenominationForWithdrawals(
+ groupByExchange[d.exchangeBaseUrl],
+ TransactionAmountMode.Raw,
+ );
+ }
+ return result;
+}
+
+interface SelectableElement {
+ total: number;
+ value: AmountJson;
+ info: CoinInfo;
+}
+
+function selectGreedyCoins(
+ coins: SelectableElement[],
+ limit: AmountJson,
+): SelectedCoins {
+ const result: SelectedCoins = {
+ totalValue: Amounts.zeroOfCurrency(limit.currency),
+ coins: [],
+ };
+ if (!coins.length) return result;
+
+ let denomIdx = 0;
+ iterateDenoms: while (denomIdx < coins.length) {
+ const denom = coins[denomIdx];
+ // let total = denom.total;
+ const left = Amounts.sub(limit, result.totalValue).amount;
+
+ if (Amounts.isZero(denom.value)) {
+ // 0 contribution denoms should be the last
+ break iterateDenoms;
+ }
+
+ //use Amounts.divmod instead of iterate
+ const div = Amounts.divmod(left, denom.value);
+ const size = Math.min(div.quotient, denom.total);
+ if (size > 0) {
+ const mul = Amounts.mult(denom.value, size).amount;
+ const progress = Amounts.add(result.totalValue, mul).amount;
+
+ result.totalValue = progress;
+ result.coins.push({ info: denom.info, size });
+ denom.total = denom.total - size;
+ }
+
+ //go next denom
+ denomIdx++;
+ }
+
+ return result;
+}
+
+type AmountWithFee = { raw: AmountJson; effective: AmountJson };
+type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice };
+
+export function getTotalEffectiveAndRawForDeposit(
+ list: { info: CoinInfo; size: number }[],
+ currency: string,
+): AmountWithFee {
+ const init = {
+ raw: Amounts.zeroOfCurrency(currency),
+ effective: Amounts.zeroOfCurrency(currency),
+ };
+ return list.reduce((prev, cur) => {
+ const ef = Amounts.mult(cur.info.value, cur.size).amount;
+ const rw = Amounts.mult(
+ Amounts.sub(cur.info.value, cur.info.denomDeposit).amount,
+ cur.size,
+ ).amount;
+
+ prev.effective = Amounts.add(prev.effective, ef).amount;
+ prev.raw = Amounts.add(prev.raw, rw).amount;
+ return prev;
+ }, init);
+}
+
+function getTotalEffectiveAndRawForWithdrawal(
+ list: { info: CoinInfo; size: number }[],
+ currency: string,
+): AmountWithFee {
+ const init = {
+ raw: Amounts.zeroOfCurrency(currency),
+ effective: Amounts.zeroOfCurrency(currency),
+ };
+ return list.reduce((prev, cur) => {
+ const ef = Amounts.mult(cur.info.value, cur.size).amount;
+ const rw = Amounts.mult(
+ Amounts.add(cur.info.value, cur.info.denomWithdraw).amount,
+ cur.size,
+ ).amount;
+
+ prev.effective = Amounts.add(prev.effective, ef).amount;
+ prev.raw = Amounts.add(prev.raw, rw).amount;
+ return prev;
+ }, init);
+}
diff --git a/packages/taler-wallet-core/src/observable-wrappers.ts b/packages/taler-wallet-core/src/observable-wrappers.ts
new file mode 100644
index 000000000..717de41ca
--- /dev/null
+++ b/packages/taler-wallet-core/src/observable-wrappers.ts
@@ -0,0 +1,295 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems SA
+
+ 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/>
+ */
+
+/**
+ * @fileoverview Wrappers/proxies to make various interfaces observable.
+ */
+
+/**
+ * Imports.
+ */
+import { IDBDatabase } from "@gnu-taler/idb-bridge";
+import {
+ ObservabilityContext,
+ ObservabilityEventType,
+} from "@gnu-taler/taler-util";
+import { TaskIdStr } from "./common.js";
+import { TalerCryptoInterface } from "./index.js";
+import {
+ DbAccess,
+ DbReadOnlyTransaction,
+ DbReadWriteTransaction,
+ StoreNames,
+} from "./query.js";
+import { TaskScheduler } from "./shepherd.js";
+
+/**
+ * Task scheduler with extra observability events.
+ */
+export class ObservableTaskScheduler implements TaskScheduler {
+ constructor(
+ private impl: TaskScheduler,
+ private oc: ObservabilityContext,
+ ) {}
+
+ private taskDepCache = new Set<string>();
+
+ private declareDep(taskId: TaskIdStr): void {
+ if (this.taskDepCache.size > 500) {
+ this.taskDepCache.clear();
+ }
+ if (!this.taskDepCache.has(taskId)) {
+ this.taskDepCache.add(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.DeclareTaskDependency,
+ taskId,
+ });
+ }
+ }
+
+ shutdown(): Promise<void> {
+ return this.impl.shutdown();
+ }
+
+ getActiveTasks(): TaskIdStr[] {
+ return this.impl.getActiveTasks();
+ }
+
+ isIdle(): boolean {
+ return this.impl.isIdle();
+ }
+
+ ensureRunning(): Promise<void> {
+ return this.impl.ensureRunning();
+ }
+
+ startShepherdTask(taskId: TaskIdStr): void {
+ this.declareDep(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.TaskStart,
+ taskId,
+ });
+ return this.impl.startShepherdTask(taskId);
+ }
+
+ stopShepherdTask(taskId: TaskIdStr): void {
+ this.declareDep(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.TaskStop,
+ taskId,
+ });
+ return this.impl.stopShepherdTask(taskId);
+ }
+
+ resetTaskRetries(taskId: TaskIdStr): Promise<void> {
+ this.declareDep(taskId);
+ if (this.taskDepCache.size > 500) {
+ this.taskDepCache.clear();
+ }
+ this.oc.observe({
+ type: ObservabilityEventType.TaskReset,
+ taskId,
+ });
+ return this.impl.resetTaskRetries(taskId);
+ }
+
+ async reload(): Promise<void> {
+ return this.impl.reload();
+ }
+}
+
+const locRegex = /\s*at\s*([a-zA-Z0-9_.!]*)\s*/;
+
+export function getCallerInfo(up: number = 2): string {
+ const stack = new Error().stack ?? "";
+ const identifies: string[] = [];
+ for (const line of stack.split("\n")) {
+ let l = line.match(locRegex);
+ if (l) {
+ identifies.push(l[1]);
+ }
+ }
+ return identifies.slice(up, up + 2).join("/");
+}
+
+export class ObservableDbAccess<StoreMap> implements DbAccess<StoreMap> {
+ constructor(
+ private impl: DbAccess<StoreMap>,
+ private oc: ObservabilityContext,
+ ) {}
+ idbHandle(): IDBDatabase {
+ return this.impl.idbHandle();
+ }
+
+ async runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, StoreNames<StoreMap>[]>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runAllStoresReadWriteTx(options, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, StoreNames<StoreMap>[]>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: options.label ?? "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runAllStoresReadOnlyTx(options, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: options.label ?? "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: options.label ?? "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runReadWriteTx<T, StoreNameArray extends StoreNames<StoreMap>[]>(
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runReadWriteTx(opts, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runReadOnlyTx<T, StoreNameArray extends StoreNames<StoreMap>[]>(
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ try {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ const ret = await this.impl.runReadOnlyTx(opts, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: opts.label ?? "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+}
+
+export function observeTalerCrypto(
+ impl: TalerCryptoInterface,
+ oc: ObservabilityContext,
+): TalerCryptoInterface {
+ return Object.fromEntries(
+ Object.keys(impl).map((name) => {
+ return [
+ name,
+ async (req: any) => {
+ oc.observe({
+ type: ObservabilityEventType.CryptoStart,
+ operation: name,
+ });
+ try {
+ const res = await (impl as any)[name](req);
+ oc.observe({
+ type: ObservabilityEventType.CryptoFinishSuccess,
+ operation: name,
+ });
+ return res;
+ } catch (e) {
+ oc.observe({
+ type: ObservabilityEventType.CryptoFinishError,
+ operation: name,
+ });
+ throw e;
+ }
+ },
+ ];
+ }),
+ ) as any;
+}
diff --git a/packages/taler-wallet-core/src/operations/README.md b/packages/taler-wallet-core/src/operations/README.md
deleted file mode 100644
index 32e2fbfc8..000000000
--- a/packages/taler-wallet-core/src/operations/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Wallet Operations
-
-This folder contains the implementations for all wallet operations that operate on the wallet state.
-
-To avoid cyclic dependencies, these files must **not** reference each other. Instead, other operations should only be accessed via injected dependencies.
-
-Avoiding cyclic dependencies is important for module bundlers. \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts
deleted file mode 100644
index a66bc2e84..000000000
--- a/packages/taler-wallet-core/src/operations/backup/export.ts
+++ /dev/null
@@ -1,512 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- 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/>
- */
-
-/**
- * Implementation of wallet backups (export/import/upload) and sync
- * server management.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- Amounts,
- BackupBackupProvider,
- BackupBackupProviderTerms,
- BackupCoin,
- BackupCoinSource,
- BackupCoinSourceType,
- BackupDenomination,
- BackupExchange,
- BackupExchangeDetails,
- BackupExchangeWireFee,
- BackupProposal,
- BackupProposalStatus,
- BackupPurchase,
- BackupRecoupGroup,
- BackupRefreshGroup,
- BackupRefreshOldCoin,
- BackupRefreshSession,
- BackupRefundItem,
- BackupRefundState,
- BackupReserve,
- BackupTip,
- BackupWithdrawalGroup,
- canonicalizeBaseUrl,
- canonicalJson,
- getTimestampNow,
- Logger,
- timestampToIsoString,
- WalletBackupContentV1,
- hash,
- encodeCrock,
- getRandomBytes,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../../common.js";
-import {
- AbortStatus,
- CoinSourceType,
- CoinStatus,
- ProposalStatus,
- RefreshCoinStatus,
- RefundState,
- WALLET_BACKUP_STATE_KEY,
-} from "../../db.js";
-import { getWalletBackupState, provideBackupState } from "./state.js";
-
-const logger = new Logger("backup/export.ts");
-
-export async function exportBackup(
- ws: InternalWalletState,
-): Promise<WalletBackupContentV1> {
- await provideBackupState(ws);
- return ws.db
- .mktx((x) => ({
- config: x.config,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- coins: x.coins,
- denominations: x.denominations,
- purchases: x.purchases,
- proposals: x.proposals,
- refreshGroups: x.refreshGroups,
- backupProviders: x.backupProviders,
- tips: x.tips,
- recoupGroups: x.recoupGroups,
- reserves: x.reserves,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadWrite(async (tx) => {
- const bs = await getWalletBackupState(ws, tx);
-
- const backupExchangeDetails: BackupExchangeDetails[] = [];
- const backupExchanges: BackupExchange[] = [];
- const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {};
- const backupDenominationsByExchange: {
- [url: string]: BackupDenomination[];
- } = {};
- const backupReservesByExchange: { [url: string]: BackupReserve[] } = {};
- const backupPurchases: BackupPurchase[] = [];
- const backupProposals: BackupProposal[] = [];
- const backupRefreshGroups: BackupRefreshGroup[] = [];
- const backupBackupProviders: BackupBackupProvider[] = [];
- const backupTips: BackupTip[] = [];
- const backupRecoupGroups: BackupRecoupGroup[] = [];
- const withdrawalGroupsByReserve: {
- [reservePub: string]: BackupWithdrawalGroup[];
- } = {};
-
- await tx.withdrawalGroups.iter().forEachAsync(async (wg) => {
- const withdrawalGroups = (withdrawalGroupsByReserve[
- wg.reservePub
- ] ??= []);
- withdrawalGroups.push({
- raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount),
- selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({
- count: x.count,
- denom_pub_hash: x.denomPubHash,
- })),
- timestamp_created: wg.timestampStart,
- timestamp_finish: wg.timestampFinish,
- withdrawal_group_id: wg.withdrawalGroupId,
- secret_seed: wg.secretSeed,
- selected_denoms_id: wg.denomSelUid,
- });
- });
-
- await tx.reserves.iter().forEach((reserve) => {
- const backupReserve: BackupReserve = {
- initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map(
- (x) => ({
- count: x.count,
- denom_pub_hash: x.denomPubHash,
- }),
- ),
- initial_withdrawal_group_id: reserve.initialWithdrawalGroupId,
- instructed_amount: Amounts.stringify(reserve.instructedAmount),
- reserve_priv: reserve.reservePriv,
- timestamp_created: reserve.timestampCreated,
- withdrawal_groups:
- withdrawalGroupsByReserve[reserve.reservePub] ?? [],
- // FIXME!
- timestamp_last_activity: reserve.timestampCreated,
- };
- const backupReserves = (backupReservesByExchange[
- reserve.exchangeBaseUrl
- ] ??= []);
- backupReserves.push(backupReserve);
- });
-
- await tx.tips.iter().forEach((tip) => {
- backupTips.push({
- exchange_base_url: tip.exchangeBaseUrl,
- merchant_base_url: tip.merchantBaseUrl,
- merchant_tip_id: tip.merchantTipId,
- wallet_tip_id: tip.walletTipId,
- secret_seed: tip.secretSeed,
- selected_denoms: tip.denomsSel.selectedDenoms.map((x) => ({
- count: x.count,
- denom_pub_hash: x.denomPubHash,
- })),
- timestamp_finished: tip.pickedUpTimestamp,
- timestamp_accepted: tip.acceptedTimestamp,
- timestamp_created: tip.createdTimestamp,
- timestamp_expiration: tip.tipExpiration,
- tip_amount_raw: Amounts.stringify(tip.tipAmountRaw),
- selected_denoms_uid: tip.denomSelUid,
- });
- });
-
- await tx.recoupGroups.iter().forEach((recoupGroup) => {
- backupRecoupGroups.push({
- recoup_group_id: recoupGroup.recoupGroupId,
- timestamp_created: recoupGroup.timestampStarted,
- timestamp_finish: recoupGroup.timestampFinished,
- coins: recoupGroup.coinPubs.map((x, i) => ({
- coin_pub: x,
- recoup_finished: recoupGroup.recoupFinishedPerCoin[i],
- old_amount: Amounts.stringify(recoupGroup.oldAmountPerCoin[i]),
- })),
- });
- });
-
- await tx.backupProviders.iter().forEach((bp) => {
- let terms: BackupBackupProviderTerms | undefined;
- if (bp.terms) {
- terms = {
- annual_fee: Amounts.stringify(bp.terms.annualFee),
- storage_limit_in_megabytes: bp.terms.storageLimitInMegabytes,
- supported_protocol_version: bp.terms.supportedProtocolVersion,
- };
- }
- backupBackupProviders.push({
- terms,
- base_url: canonicalizeBaseUrl(bp.baseUrl),
- pay_proposal_ids: bp.paymentProposalIds,
- uids: bp.uids,
- });
- });
-
- await tx.coins.iter().forEach((coin) => {
- let bcs: BackupCoinSource;
- switch (coin.coinSource.type) {
- case CoinSourceType.Refresh:
- bcs = {
- type: BackupCoinSourceType.Refresh,
- old_coin_pub: coin.coinSource.oldCoinPub,
- };
- break;
- case CoinSourceType.Tip:
- bcs = {
- type: BackupCoinSourceType.Tip,
- coin_index: coin.coinSource.coinIndex,
- wallet_tip_id: coin.coinSource.walletTipId,
- };
- break;
- case CoinSourceType.Withdraw:
- bcs = {
- type: BackupCoinSourceType.Withdraw,
- coin_index: coin.coinSource.coinIndex,
- reserve_pub: coin.coinSource.reservePub,
- withdrawal_group_id: coin.coinSource.withdrawalGroupId,
- };
- break;
- }
-
- const coins = (backupCoinsByDenom[coin.denomPubHash] ??= []);
- coins.push({
- blinding_key: coin.blindingKey,
- coin_priv: coin.coinPriv,
- coin_source: bcs,
- current_amount: Amounts.stringify(coin.currentAmount),
- fresh: coin.status === CoinStatus.Fresh,
- denom_sig: coin.denomSig,
- });
- });
-
- await tx.denominations.iter().forEach((denom) => {
- const backupDenoms = (backupDenominationsByExchange[
- denom.exchangeBaseUrl
- ] ??= []);
- backupDenoms.push({
- coins: backupCoinsByDenom[denom.denomPubHash] ?? [],
- denom_pub: denom.denomPub,
- fee_deposit: Amounts.stringify(denom.feeDeposit),
- fee_refresh: Amounts.stringify(denom.feeRefresh),
- fee_refund: Amounts.stringify(denom.feeRefund),
- fee_withdraw: Amounts.stringify(denom.feeWithdraw),
- is_offered: denom.isOffered,
- is_revoked: denom.isRevoked,
- master_sig: denom.masterSig,
- stamp_expire_deposit: denom.stampExpireDeposit,
- stamp_expire_legal: denom.stampExpireLegal,
- stamp_expire_withdraw: denom.stampExpireWithdraw,
- stamp_start: denom.stampStart,
- value: Amounts.stringify(denom.value),
- list_issue_date: denom.listIssueDate,
- });
- });
-
- await tx.exchanges.iter().forEachAsync(async (ex) => {
- const dp = ex.detailsPointer;
- if (!dp) {
- return;
- }
- backupExchanges.push({
- base_url: ex.baseUrl,
- currency: dp.currency,
- master_public_key: dp.masterPublicKey,
- update_clock: dp.updateClock,
- });
- });
-
- await tx.exchangeDetails.iter().forEachAsync(async (ex) => {
- // Only back up permanently added exchanges.
-
- const wi = ex.wireInfo;
- const wireFees: BackupExchangeWireFee[] = [];
-
- Object.keys(wi.feesForType).forEach((x) => {
- for (const f of wi.feesForType[x]) {
- wireFees.push({
- wire_type: x,
- closing_fee: Amounts.stringify(f.closingFee),
- end_stamp: f.endStamp,
- sig: f.sig,
- start_stamp: f.startStamp,
- wire_fee: Amounts.stringify(f.wireFee),
- });
- }
- });
-
- backupExchangeDetails.push({
- base_url: ex.exchangeBaseUrl,
- reserve_closing_delay: ex.reserveClosingDelay,
- accounts: ex.wireInfo.accounts.map((x) => ({
- payto_uri: x.payto_uri,
- master_sig: x.master_sig,
- })),
- auditors: ex.auditors.map((x) => ({
- auditor_pub: x.auditor_pub,
- auditor_url: x.auditor_url,
- denomination_keys: x.denomination_keys,
- })),
- master_public_key: ex.masterPublicKey,
- currency: ex.currency,
- protocol_version: ex.protocolVersion,
- wire_fees: wireFees,
- signing_keys: ex.signingKeys.map((x) => ({
- key: x.key,
- master_sig: x.master_sig,
- stamp_end: x.stamp_end,
- stamp_expire: x.stamp_expire,
- stamp_start: x.stamp_start,
- })),
- tos_accepted_etag: ex.termsOfServiceAcceptedEtag,
- tos_accepted_timestamp: ex.termsOfServiceAcceptedTimestamp,
- denominations:
- backupDenominationsByExchange[ex.exchangeBaseUrl] ?? [],
- reserves: backupReservesByExchange[ex.exchangeBaseUrl] ?? [],
- });
- });
-
- const purchaseProposalIdSet = new Set<string>();
-
- await tx.purchases.iter().forEach((purch) => {
- const refunds: BackupRefundItem[] = [];
- purchaseProposalIdSet.add(purch.proposalId);
- for (const refundKey of Object.keys(purch.refunds)) {
- const ri = purch.refunds[refundKey];
- const common = {
- coin_pub: ri.coinPub,
- execution_time: ri.executionTime,
- obtained_time: ri.obtainedTime,
- refund_amount: Amounts.stringify(ri.refundAmount),
- rtransaction_id: ri.rtransactionId,
- total_refresh_cost_bound: Amounts.stringify(
- ri.totalRefreshCostBound,
- ),
- };
- switch (ri.type) {
- case RefundState.Applied:
- refunds.push({ type: BackupRefundState.Applied, ...common });
- break;
- case RefundState.Failed:
- refunds.push({ type: BackupRefundState.Failed, ...common });
- break;
- case RefundState.Pending:
- refunds.push({ type: BackupRefundState.Pending, ...common });
- break;
- }
- }
-
- backupPurchases.push({
- contract_terms_raw: purch.download.contractTermsRaw,
- auto_refund_deadline: purch.autoRefundDeadline,
- merchant_pay_sig: purch.merchantPaySig,
- pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
- coin_pub: x,
- contribution: Amounts.stringify(
- purch.payCoinSelection.coinContributions[i],
- ),
- })),
- proposal_id: purch.proposalId,
- refunds,
- timestamp_accept: purch.timestampAccept,
- timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
- abort_status:
- purch.abortStatus === AbortStatus.None
- ? undefined
- : purch.abortStatus,
- nonce_priv: purch.noncePriv,
- merchant_sig: purch.download.contractData.merchantSig,
- total_pay_cost: Amounts.stringify(purch.totalPayCost),
- pay_coins_uid: purch.payCoinSelectionUid,
- });
- });
-
- await tx.proposals.iter().forEach((prop) => {
- if (purchaseProposalIdSet.has(prop.proposalId)) {
- return;
- }
- let propStatus: BackupProposalStatus;
- switch (prop.proposalStatus) {
- case ProposalStatus.ACCEPTED:
- return;
- case ProposalStatus.DOWNLOADING:
- case ProposalStatus.PROPOSED:
- propStatus = BackupProposalStatus.Proposed;
- break;
- case ProposalStatus.PERMANENTLY_FAILED:
- propStatus = BackupProposalStatus.PermanentlyFailed;
- break;
- case ProposalStatus.REFUSED:
- propStatus = BackupProposalStatus.Refused;
- break;
- case ProposalStatus.REPURCHASE:
- propStatus = BackupProposalStatus.Repurchase;
- break;
- }
- backupProposals.push({
- claim_token: prop.claimToken,
- nonce_priv: prop.noncePriv,
- proposal_id: prop.noncePriv,
- proposal_status: propStatus,
- repurchase_proposal_id: prop.repurchaseProposalId,
- timestamp: prop.timestamp,
- contract_terms_raw: prop.download?.contractTermsRaw,
- download_session_id: prop.downloadSessionId,
- merchant_base_url: prop.merchantBaseUrl,
- order_id: prop.orderId,
- merchant_sig: prop.download?.contractData.merchantSig,
- });
- });
-
- await tx.refreshGroups.iter().forEach((rg) => {
- const oldCoins: BackupRefreshOldCoin[] = [];
-
- for (let i = 0; i < rg.oldCoinPubs.length; i++) {
- let refreshSession: BackupRefreshSession | undefined;
- const s = rg.refreshSessionPerCoin[i];
- if (s) {
- refreshSession = {
- new_denoms: s.newDenoms.map((x) => ({
- count: x.count,
- denom_pub_hash: x.denomPubHash,
- })),
- session_secret_seed: s.sessionSecretSeed,
- noreveal_index: s.norevealIndex,
- };
- }
- oldCoins.push({
- coin_pub: rg.oldCoinPubs[i],
- estimated_output_amount: Amounts.stringify(
- rg.estimatedOutputPerCoin[i],
- ),
- finished: rg.statusPerCoin[i] === RefreshCoinStatus.Finished,
- input_amount: Amounts.stringify(rg.inputPerCoin[i]),
- refresh_session: refreshSession,
- });
- }
-
- backupRefreshGroups.push({
- reason: rg.reason as any,
- refresh_group_id: rg.refreshGroupId,
- timestamp_created: rg.timestampCreated,
- timestamp_finish: rg.timestampFinished,
- old_coins: oldCoins,
- });
- });
-
- const ts = getTimestampNow();
-
- if (!bs.lastBackupTimestamp) {
- bs.lastBackupTimestamp = ts;
- }
-
- const backupBlob: WalletBackupContentV1 = {
- schema_id: "gnu-taler-wallet-backup-content",
- schema_version: 1,
- exchanges: backupExchanges,
- exchange_details: backupExchangeDetails,
- wallet_root_pub: bs.walletRootPub,
- backup_providers: backupBackupProviders,
- current_device_id: bs.deviceId,
- proposals: backupProposals,
- purchases: backupPurchases,
- recoup_groups: backupRecoupGroups,
- refresh_groups: backupRefreshGroups,
- tips: backupTips,
- timestamp: bs.lastBackupTimestamp,
- trusted_auditors: {},
- trusted_exchanges: {},
- intern_table: {},
- error_reports: [],
- tombstones: [],
- };
-
- // If the backup changed, we change our nonce and timestamp.
-
- let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
- if (h !== bs.lastBackupPlainHash) {
- logger.trace(
- `plain backup hash changed (from ${bs.lastBackupPlainHash}to ${h})`,
- );
- bs.lastBackupTimestamp = ts;
- backupBlob.timestamp = ts;
- bs.lastBackupPlainHash = encodeCrock(
- hash(stringToBytes(canonicalJson(backupBlob))),
- );
- bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
- logger.trace(
- `setting timestamp to ${timestampToIsoString(ts)} and nonce to ${
- bs.lastBackupNonce
- }`,
- );
- await tx.config.put({
- key: WALLET_BACKUP_STATE_KEY,
- value: bs,
- });
- } else {
- logger.trace("backup hash did not change");
- }
-
- return backupBlob;
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts
deleted file mode 100644
index 7623ab189..000000000
--- a/packages/taler-wallet-core/src/operations/backup/import.ts
+++ /dev/null
@@ -1,915 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- 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 {
- BackupPurchase,
- AmountJson,
- Amounts,
- BackupDenomSel,
- WalletBackupContentV1,
- getTimestampNow,
- BackupCoinSourceType,
- BackupProposalStatus,
- codecForContractTerms,
- BackupRefundState,
- RefreshReason,
- BackupRefreshReason,
-} from "@gnu-taler/taler-util";
-import {
- WalletContractData,
- DenomSelectionState,
- DenominationVerificationStatus,
- CoinSource,
- CoinSourceType,
- CoinStatus,
- ReserveBankInfo,
- ReserveRecordStatus,
- ProposalDownload,
- ProposalStatus,
- WalletRefundItem,
- RefundState,
- AbortStatus,
- RefreshSessionRecord,
- WireInfo,
- WalletStoresV1,
- RefreshCoinStatus,
-} from "../../db.js";
-import { PayCoinSelection } from "../../util/coinSelection.js";
-import { j2s } from "@gnu-taler/taler-util";
-import {
- checkDbInvariant,
- checkLogicInvariant,
-} from "../../util/invariants.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { initRetryInfo } from "../../util/retries.js";
-import { InternalWalletState } from "../../common.js";
-import { provideBackupState } from "./state.js";
-import { makeEventId, TombstoneTag } from "../transactions.js";
-import { getExchangeDetails } from "../exchanges.js";
-import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
-
-const logger = new Logger("operations/backup/import.ts");
-
-function checkBackupInvariant(b: boolean, m?: string): asserts b {
- if (!b) {
- if (m) {
- throw Error(`BUG: backup invariant failed (${m})`);
- } else {
- throw Error("BUG: backup invariant failed");
- }
- }
-}
-
-/**
- * Re-compute information about the coin selection for a payment.
- */
-async function recoverPayCoinSelection(
- tx: GetReadWriteAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- contractData: WalletContractData,
- backupPurchase: BackupPurchase,
-): Promise<PayCoinSelection> {
- const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
- const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- );
-
- const coveredExchanges: Set<string> = new Set();
-
- let totalWireFee: AmountJson = Amounts.getZero(contractData.amount.currency);
- let totalDepositFees: AmountJson = Amounts.getZero(
- contractData.amount.currency,
- );
-
- for (const coinPub of coinPubs) {
- const coinRecord = await tx.coins.get(coinPub);
- checkBackupInvariant(!!coinRecord);
- const denom = await tx.denominations.get([
- coinRecord.exchangeBaseUrl,
- coinRecord.denomPubHash,
- ]);
- checkBackupInvariant(!!denom);
- totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount;
-
- if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) {
- const exchangeDetails = await getExchangeDetails(
- tx,
- coinRecord.exchangeBaseUrl,
- );
- checkBackupInvariant(!!exchangeDetails);
- let wireFee: AmountJson | undefined;
- const feesForType = exchangeDetails.wireInfo.feesForType;
- checkBackupInvariant(!!feesForType);
- for (const fee of feesForType[contractData.wireMethod] || []) {
- if (
- fee.startStamp <= contractData.timestamp &&
- fee.endStamp >= contractData.timestamp
- ) {
- wireFee = fee.wireFee;
- break;
- }
- }
- if (wireFee) {
- totalWireFee = Amounts.add(totalWireFee, wireFee).amount;
- }
- coveredExchanges.add(coinRecord.exchangeBaseUrl);
- }
- }
-
- let customerWireFee: AmountJson;
-
- const amortizedWireFee = Amounts.divide(
- totalWireFee,
- contractData.wireFeeAmortization,
- );
- if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
- customerWireFee = amortizedWireFee;
- } else {
- customerWireFee = Amounts.getZero(contractData.amount.currency);
- }
-
- const customerDepositFees = Amounts.sub(
- totalDepositFees,
- contractData.maxDepositFee,
- ).amount;
-
- return {
- coinPubs,
- coinContributions,
- paymentAmount: contractData.amount,
- customerWireFees: customerWireFee,
- customerDepositFees,
- };
-}
-
-async function getDenomSelStateFromBackup(
- tx: GetReadOnlyAccess<{ denominations: typeof WalletStoresV1.denominations }>,
- exchangeBaseUrl: string,
- sel: BackupDenomSel,
-): Promise<DenomSelectionState> {
- const d0 = await tx.denominations.get([
- exchangeBaseUrl,
- sel[0].denom_pub_hash,
- ]);
- checkBackupInvariant(!!d0);
- const selectedDenoms: {
- denomPubHash: string;
- count: number;
- }[] = [];
- let totalCoinValue = Amounts.getZero(d0.value.currency);
- let totalWithdrawCost = Amounts.getZero(d0.value.currency);
- for (const s of sel) {
- const d = await tx.denominations.get([exchangeBaseUrl, s.denom_pub_hash]);
- checkBackupInvariant(!!d);
- totalCoinValue = Amounts.add(totalCoinValue, d.value).amount;
- totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw)
- .amount;
- }
- return {
- selectedDenoms,
- totalCoinValue,
- totalWithdrawCost,
- };
-}
-
-export interface CompletedCoin {
- coinPub: string;
- coinEvHash: string;
-}
-
-/**
- * Precomputed cryptographic material for a backup import.
- *
- * We separate this data from the backup blob as we want the backup
- * blob to be small, and we can't compute it during the database transaction,
- * as the async crypto worker communication would auto-close the database transaction.
- */
-export interface BackupCryptoPrecomputedData {
- denomPubToHash: Record<string, string>;
- coinPrivToCompletedCoin: Record<string, CompletedCoin>;
- proposalNoncePrivToPub: { [priv: string]: string };
- proposalIdToContractTermsHash: { [proposalId: string]: string };
- reservePrivToPub: Record<string, string>;
-}
-
-export async function importBackup(
- ws: InternalWalletState,
- backupBlobArg: any,
- cryptoComp: BackupCryptoPrecomputedData,
-): Promise<void> {
- await provideBackupState(ws);
-
- logger.info(`importing backup ${j2s(backupBlobArg)}`);
-
- return ws.db
- .mktx((x) => ({
- config: x.config,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- coins: x.coins,
- denominations: x.denominations,
- purchases: x.purchases,
- proposals: x.proposals,
- refreshGroups: x.refreshGroups,
- backupProviders: x.backupProviders,
- tips: x.tips,
- recoupGroups: x.recoupGroups,
- reserves: x.reserves,
- withdrawalGroups: x.withdrawalGroups,
- tombstones: x.tombstones,
- depositGroups: x.depositGroups,
- }))
- .runReadWrite(async (tx) => {
- // FIXME: validate schema!
- const backupBlob = backupBlobArg as WalletBackupContentV1;
-
- // FIXME: validate version
-
- for (const tombstone of backupBlob.tombstones) {
- await tx.tombstones.put({
- id: tombstone,
- });
- }
-
- const tombstoneSet = new Set(
- (await tx.tombstones.iter().toArray()).map((x) => x.id),
- );
-
- // FIXME: Validate that the "details pointer" is correct
-
- for (const backupExchange of backupBlob.exchanges) {
- const existingExchange = await tx.exchanges.get(
- backupExchange.base_url,
- );
- if (existingExchange) {
- continue;
- }
- await tx.exchanges.put({
- baseUrl: backupExchange.base_url,
- detailsPointer: {
- currency: backupExchange.currency,
- masterPublicKey: backupExchange.master_public_key,
- updateClock: backupExchange.update_clock,
- },
- permanent: true,
- retryInfo: initRetryInfo(),
- lastUpdate: undefined,
- nextUpdate: getTimestampNow(),
- nextRefreshCheck: getTimestampNow(),
- });
- }
-
- for (const backupExchangeDetails of backupBlob.exchange_details) {
- const existingExchangeDetails = await tx.exchangeDetails.get([
- backupExchangeDetails.base_url,
- backupExchangeDetails.currency,
- backupExchangeDetails.master_public_key,
- ]);
-
- if (!existingExchangeDetails) {
- const wireInfo: WireInfo = {
- accounts: backupExchangeDetails.accounts.map((x) => ({
- master_sig: x.master_sig,
- payto_uri: x.payto_uri,
- })),
- feesForType: {},
- };
- for (const fee of backupExchangeDetails.wire_fees) {
- const w = (wireInfo.feesForType[fee.wire_type] ??= []);
- w.push({
- closingFee: Amounts.parseOrThrow(fee.closing_fee),
- endStamp: fee.end_stamp,
- sig: fee.sig,
- startStamp: fee.start_stamp,
- wireFee: Amounts.parseOrThrow(fee.wire_fee),
- });
- }
- await tx.exchangeDetails.put({
- exchangeBaseUrl: backupExchangeDetails.base_url,
- termsOfServiceAcceptedEtag: backupExchangeDetails.tos_accepted_etag,
- termsOfServiceText: undefined,
- termsOfServiceLastEtag: undefined,
- termsOfServiceContentType: undefined,
- termsOfServiceAcceptedTimestamp:
- backupExchangeDetails.tos_accepted_timestamp,
- wireInfo,
- currency: backupExchangeDetails.currency,
- auditors: backupExchangeDetails.auditors.map((x) => ({
- auditor_pub: x.auditor_pub,
- auditor_url: x.auditor_url,
- denomination_keys: x.denomination_keys,
- })),
- masterPublicKey: backupExchangeDetails.master_public_key,
- protocolVersion: backupExchangeDetails.protocol_version,
- reserveClosingDelay: backupExchangeDetails.reserve_closing_delay,
- signingKeys: backupExchangeDetails.signing_keys.map((x) => ({
- key: x.key,
- master_sig: x.master_sig,
- stamp_end: x.stamp_end,
- stamp_expire: x.stamp_expire,
- stamp_start: x.stamp_start,
- })),
- });
- }
-
- for (const backupDenomination of backupExchangeDetails.denominations) {
- const denomPubHash =
- cryptoComp.denomPubToHash[backupDenomination.denom_pub];
- checkLogicInvariant(!!denomPubHash);
- const existingDenom = await tx.denominations.get([
- backupExchangeDetails.base_url,
- denomPubHash,
- ]);
- if (!existingDenom) {
- logger.info(
- `importing backup denomination: ${j2s(backupDenomination)}`,
- );
-
- await tx.denominations.put({
- denomPub: backupDenomination.denom_pub,
- denomPubHash: denomPubHash,
- exchangeBaseUrl: backupExchangeDetails.base_url,
- exchangeMasterPub: backupExchangeDetails.master_public_key,
- feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit),
- feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh),
- feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund),
- feeWithdraw: Amounts.parseOrThrow(
- backupDenomination.fee_withdraw,
- ),
- isOffered: backupDenomination.is_offered,
- isRevoked: backupDenomination.is_revoked,
- masterSig: backupDenomination.master_sig,
- stampExpireDeposit: backupDenomination.stamp_expire_deposit,
- stampExpireLegal: backupDenomination.stamp_expire_legal,
- stampExpireWithdraw: backupDenomination.stamp_expire_withdraw,
- stampStart: backupDenomination.stamp_start,
- verificationStatus: DenominationVerificationStatus.VerifiedGood,
- value: Amounts.parseOrThrow(backupDenomination.value),
- listIssueDate: backupDenomination.list_issue_date,
- });
- }
- for (const backupCoin of backupDenomination.coins) {
- const compCoin =
- cryptoComp.coinPrivToCompletedCoin[backupCoin.coin_priv];
- checkLogicInvariant(!!compCoin);
- const existingCoin = await tx.coins.get(compCoin.coinPub);
- if (!existingCoin) {
- let coinSource: CoinSource;
- switch (backupCoin.coin_source.type) {
- case BackupCoinSourceType.Refresh:
- coinSource = {
- type: CoinSourceType.Refresh,
- oldCoinPub: backupCoin.coin_source.old_coin_pub,
- };
- break;
- case BackupCoinSourceType.Tip:
- coinSource = {
- type: CoinSourceType.Tip,
- coinIndex: backupCoin.coin_source.coin_index,
- walletTipId: backupCoin.coin_source.wallet_tip_id,
- };
- break;
- case BackupCoinSourceType.Withdraw:
- coinSource = {
- type: CoinSourceType.Withdraw,
- coinIndex: backupCoin.coin_source.coin_index,
- reservePub: backupCoin.coin_source.reserve_pub,
- withdrawalGroupId:
- backupCoin.coin_source.withdrawal_group_id,
- };
- break;
- }
- await tx.coins.put({
- blindingKey: backupCoin.blinding_key,
- coinEvHash: compCoin.coinEvHash,
- coinPriv: backupCoin.coin_priv,
- currentAmount: Amounts.parseOrThrow(backupCoin.current_amount),
- denomSig: backupCoin.denom_sig,
- coinPub: compCoin.coinPub,
- suspended: false,
- exchangeBaseUrl: backupExchangeDetails.base_url,
- denomPub: backupDenomination.denom_pub,
- denomPubHash,
- status: backupCoin.fresh
- ? CoinStatus.Fresh
- : CoinStatus.Dormant,
- coinSource,
- });
- }
- }
- }
-
- for (const backupReserve of backupExchangeDetails.reserves) {
- const reservePub =
- cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
- const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
- if (tombstoneSet.has(ts)) {
- continue;
- }
- checkLogicInvariant(!!reservePub);
- const existingReserve = await tx.reserves.get(reservePub);
- const instructedAmount = Amounts.parseOrThrow(
- backupReserve.instructed_amount,
- );
- if (!existingReserve) {
- let bankInfo: ReserveBankInfo | undefined;
- if (backupReserve.bank_info) {
- bankInfo = {
- exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
- statusUrl: backupReserve.bank_info.status_url,
- confirmUrl: backupReserve.bank_info.confirm_url,
- };
- }
- await tx.reserves.put({
- currency: instructedAmount.currency,
- instructedAmount,
- exchangeBaseUrl: backupExchangeDetails.base_url,
- reservePub,
- reservePriv: backupReserve.reserve_priv,
- requestedQuery: false,
- bankInfo,
- timestampCreated: backupReserve.timestamp_created,
- timestampBankConfirmed:
- backupReserve.bank_info?.timestamp_bank_confirmed,
- timestampReserveInfoPosted:
- backupReserve.bank_info?.timestamp_reserve_info_posted,
- senderWire: backupReserve.sender_wire,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- lastSuccessfulStatusQuery: { t_ms: "never" },
- initialWithdrawalGroupId:
- backupReserve.initial_withdrawal_group_id,
- initialWithdrawalStarted:
- backupReserve.withdrawal_groups.length > 0,
- // FIXME!
- reserveStatus: ReserveRecordStatus.QUERYING_STATUS,
- initialDenomSel: await getDenomSelStateFromBackup(
- tx,
- backupExchangeDetails.base_url,
- backupReserve.initial_selected_denoms,
- ),
- });
- }
- for (const backupWg of backupReserve.withdrawal_groups) {
- const ts = makeEventId(
- TombstoneTag.DeleteWithdrawalGroup,
- backupWg.withdrawal_group_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingWg = await tx.withdrawalGroups.get(
- backupWg.withdrawal_group_id,
- );
- if (!existingWg) {
- await tx.withdrawalGroups.put({
- denomsSel: await getDenomSelStateFromBackup(
- tx,
- backupExchangeDetails.base_url,
- backupWg.selected_denoms,
- ),
- exchangeBaseUrl: backupExchangeDetails.base_url,
- lastError: undefined,
- rawWithdrawalAmount: Amounts.parseOrThrow(
- backupWg.raw_withdrawal_amount,
- ),
- reservePub,
- retryInfo: initRetryInfo(),
- secretSeed: backupWg.secret_seed,
- timestampStart: backupWg.timestamp_created,
- timestampFinish: backupWg.timestamp_finish,
- withdrawalGroupId: backupWg.withdrawal_group_id,
- denomSelUid: backupWg.selected_denoms_id,
- });
- }
- }
- }
- }
-
- for (const backupProposal of backupBlob.proposals) {
- const ts = makeEventId(
- TombstoneTag.DeletePayment,
- backupProposal.proposal_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingProposal = await tx.proposals.get(
- backupProposal.proposal_id,
- );
- if (!existingProposal) {
- let download: ProposalDownload | undefined;
- let proposalStatus: ProposalStatus;
- switch (backupProposal.proposal_status) {
- case BackupProposalStatus.Proposed:
- if (backupProposal.contract_terms_raw) {
- proposalStatus = ProposalStatus.PROPOSED;
- } else {
- proposalStatus = ProposalStatus.DOWNLOADING;
- }
- break;
- case BackupProposalStatus.Refused:
- proposalStatus = ProposalStatus.REFUSED;
- break;
- case BackupProposalStatus.Repurchase:
- proposalStatus = ProposalStatus.REPURCHASE;
- break;
- case BackupProposalStatus.PermanentlyFailed:
- proposalStatus = ProposalStatus.PERMANENTLY_FAILED;
- break;
- }
- if (backupProposal.contract_terms_raw) {
- checkDbInvariant(!!backupProposal.merchant_sig);
- const parsedContractTerms = codecForContractTerms().decode(
- backupProposal.contract_terms_raw,
- );
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- const contractTermsHash =
- cryptoComp.proposalIdToContractTermsHash[
- backupProposal.proposal_id
- ];
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(
- parsedContractTerms.max_wire_fee,
- );
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- download = {
- contractData: {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig: backupProposal.merchant_sig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization:
- parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.auditor_pub,
- })),
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.parseOrThrow(
- parsedContractTerms.max_fee,
- ),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- },
- contractTermsRaw: backupProposal.contract_terms_raw,
- };
- }
- await tx.proposals.put({
- claimToken: backupProposal.claim_token,
- lastError: undefined,
- merchantBaseUrl: backupProposal.merchant_base_url,
- timestamp: backupProposal.timestamp,
- orderId: backupProposal.order_id,
- noncePriv: backupProposal.nonce_priv,
- noncePub:
- cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
- proposalId: backupProposal.proposal_id,
- repurchaseProposalId: backupProposal.repurchase_proposal_id,
- retryInfo: initRetryInfo(),
- download,
- proposalStatus,
- });
- }
- }
-
- for (const backupPurchase of backupBlob.purchases) {
- const ts = makeEventId(
- TombstoneTag.DeletePayment,
- backupPurchase.proposal_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingPurchase = await tx.purchases.get(
- backupPurchase.proposal_id,
- );
- if (!existingPurchase) {
- const refunds: { [refundKey: string]: WalletRefundItem } = {};
- for (const backupRefund of backupPurchase.refunds) {
- const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`;
- const coin = await tx.coins.get(backupRefund.coin_pub);
- checkBackupInvariant(!!coin);
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- checkBackupInvariant(!!denom);
- const common = {
- coinPub: backupRefund.coin_pub,
- executionTime: backupRefund.execution_time,
- obtainedTime: backupRefund.obtained_time,
- refundAmount: Amounts.parseOrThrow(backupRefund.refund_amount),
- refundFee: denom.feeRefund,
- rtransactionId: backupRefund.rtransaction_id,
- totalRefreshCostBound: Amounts.parseOrThrow(
- backupRefund.total_refresh_cost_bound,
- ),
- };
- switch (backupRefund.type) {
- case BackupRefundState.Applied:
- refunds[key] = {
- type: RefundState.Applied,
- ...common,
- };
- break;
- case BackupRefundState.Failed:
- refunds[key] = {
- type: RefundState.Failed,
- ...common,
- };
- break;
- case BackupRefundState.Pending:
- refunds[key] = {
- type: RefundState.Pending,
- ...common,
- };
- break;
- }
- }
- let abortStatus: AbortStatus;
- switch (backupPurchase.abort_status) {
- case "abort-finished":
- abortStatus = AbortStatus.AbortFinished;
- break;
- case "abort-refund":
- abortStatus = AbortStatus.AbortRefund;
- break;
- case undefined:
- abortStatus = AbortStatus.None;
- break;
- default:
- logger.warn(
- `got backup purchase abort_status ${j2s(
- backupPurchase.abort_status,
- )}`,
- );
- throw Error("not reachable");
- }
- const parsedContractTerms = codecForContractTerms().decode(
- backupPurchase.contract_terms_raw,
- );
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- const contractTermsHash =
- cryptoComp.proposalIdToContractTermsHash[
- backupPurchase.proposal_id
- ];
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- const download: ProposalDownload = {
- contractData: {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig: backupPurchase.merchant_sig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization:
- parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.auditor_pub,
- })),
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- },
- contractTermsRaw: backupPurchase.contract_terms_raw,
- };
- await tx.purchases.put({
- proposalId: backupPurchase.proposal_id,
- noncePriv: backupPurchase.nonce_priv,
- noncePub:
- cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
- lastPayError: undefined,
- autoRefundDeadline: { t_ms: "never" },
- refundStatusRetryInfo: initRetryInfo(),
- lastRefundStatusError: undefined,
- timestampAccept: backupPurchase.timestamp_accept,
- timestampFirstSuccessfulPay:
- backupPurchase.timestamp_first_successful_pay,
- timestampLastRefundStatus: undefined,
- merchantPaySig: backupPurchase.merchant_pay_sig,
- lastSessionId: undefined,
- abortStatus,
- // FIXME!
- payRetryInfo: initRetryInfo(),
- download,
- paymentSubmitPending: !backupPurchase.timestamp_first_successful_pay,
- refundQueryRequested: false,
- payCoinSelection: await recoverPayCoinSelection(
- tx,
- download.contractData,
- backupPurchase,
- ),
- coinDepositPermissions: undefined,
- totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost),
- refunds,
- payCoinSelectionUid: backupPurchase.pay_coins_uid,
- });
- }
- }
-
- for (const backupRefreshGroup of backupBlob.refresh_groups) {
- const ts = makeEventId(
- TombstoneTag.DeleteRefreshGroup,
- backupRefreshGroup.refresh_group_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingRg = await tx.refreshGroups.get(
- backupRefreshGroup.refresh_group_id,
- );
- if (!existingRg) {
- let reason: RefreshReason;
- switch (backupRefreshGroup.reason) {
- case BackupRefreshReason.AbortPay:
- reason = RefreshReason.AbortPay;
- break;
- case BackupRefreshReason.BackupRestored:
- reason = RefreshReason.BackupRestored;
- break;
- case BackupRefreshReason.Manual:
- reason = RefreshReason.Manual;
- break;
- case BackupRefreshReason.Pay:
- reason = RefreshReason.Pay;
- break;
- case BackupRefreshReason.Recoup:
- reason = RefreshReason.Recoup;
- break;
- case BackupRefreshReason.Refund:
- reason = RefreshReason.Refund;
- break;
- case BackupRefreshReason.Scheduled:
- reason = RefreshReason.Scheduled;
- break;
- }
- const refreshSessionPerCoin: (
- | RefreshSessionRecord
- | undefined
- )[] = [];
- for (const oldCoin of backupRefreshGroup.old_coins) {
- const c = await tx.coins.get(oldCoin.coin_pub);
- checkBackupInvariant(!!c);
- if (oldCoin.refresh_session) {
- const denomSel = await getDenomSelStateFromBackup(
- tx,
- c.exchangeBaseUrl,
- oldCoin.refresh_session.new_denoms,
- );
- refreshSessionPerCoin.push({
- sessionSecretSeed: oldCoin.refresh_session.session_secret_seed,
- norevealIndex: oldCoin.refresh_session.noreveal_index,
- newDenoms: oldCoin.refresh_session.new_denoms.map((x) => ({
- count: x.count,
- denomPubHash: x.denom_pub_hash,
- })),
- amountRefreshOutput: denomSel.totalCoinValue,
- });
- } else {
- refreshSessionPerCoin.push(undefined);
- }
- }
- await tx.refreshGroups.put({
- timestampFinished: backupRefreshGroup.timestamp_finish,
- timestampCreated: backupRefreshGroup.timestamp_created,
- refreshGroupId: backupRefreshGroup.refresh_group_id,
- reason,
- lastError: undefined,
- lastErrorPerCoin: {},
- oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),
- statusPerCoin: backupRefreshGroup.old_coins.map((x) =>
- x.finished
- ? RefreshCoinStatus.Finished
- : RefreshCoinStatus.Pending,
- ),
- inputPerCoin: backupRefreshGroup.old_coins.map((x) =>
- Amounts.parseOrThrow(x.input_amount),
- ),
- estimatedOutputPerCoin: backupRefreshGroup.old_coins.map((x) =>
- Amounts.parseOrThrow(x.estimated_output_amount),
- ),
- refreshSessionPerCoin,
- retryInfo: initRetryInfo(),
- });
- }
- }
-
- for (const backupTip of backupBlob.tips) {
- const ts = makeEventId(TombstoneTag.DeleteTip, backupTip.wallet_tip_id);
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingTip = await tx.tips.get(backupTip.wallet_tip_id);
- if (!existingTip) {
- const denomsSel = await getDenomSelStateFromBackup(
- tx,
- backupTip.exchange_base_url,
- backupTip.selected_denoms,
- );
- await tx.tips.put({
- acceptedTimestamp: backupTip.timestamp_accepted,
- createdTimestamp: backupTip.timestamp_created,
- denomsSel,
- exchangeBaseUrl: backupTip.exchange_base_url,
- lastError: undefined,
- merchantBaseUrl: backupTip.exchange_base_url,
- merchantTipId: backupTip.merchant_tip_id,
- pickedUpTimestamp: backupTip.timestamp_finished,
- retryInfo: initRetryInfo(),
- secretSeed: backupTip.secret_seed,
- tipAmountEffective: denomsSel.totalCoinValue,
- tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw),
- tipExpiration: backupTip.timestamp_expiration,
- walletTipId: backupTip.wallet_tip_id,
- denomSelUid: backupTip.selected_denoms_uid,
- });
- }
- }
-
- // We now process tombstones.
- // The import code above should already prevent
- // importing things that are tombstoned,
- // but we do tombstone processing last just to be sure.
-
- for (const tombstone of tombstoneSet) {
- const [type, ...rest] = tombstone.split(":");
- if (type === TombstoneTag.DeleteDepositGroup) {
- await tx.depositGroups.delete(rest[0]);
- } else if (type === TombstoneTag.DeletePayment) {
- await tx.purchases.delete(rest[0]);
- await tx.proposals.delete(rest[0]);
- } else if (type === TombstoneTag.DeleteRefreshGroup) {
- await tx.refreshGroups.delete(rest[0]);
- } else if (type === TombstoneTag.DeleteRefund) {
- // Nothing required, will just prevent display
- // in the transactions list
- } else if (type === TombstoneTag.DeleteReserve) {
- // FIXME: Once we also have account (=kyc) reserves,
- // we need to check if the reserve is an account before deleting here
- await tx.reserves.delete(rest[0]);
- } else if (type === TombstoneTag.DeleteTip) {
- await tx.tips.delete(rest[0]);
- } else if (type === TombstoneTag.DeleteWithdrawalGroup) {
- await tx.withdrawalGroups.delete(rest[0]);
- } else {
- logger.warn(`unable to process tombstone of type '${type}'`);
- }
- }
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts
deleted file mode 100644
index 913ffcb2e..000000000
--- a/packages/taler-wallet-core/src/operations/backup/index.ts
+++ /dev/null
@@ -1,1005 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- 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/>
- */
-
-/**
- * Implementation of wallet backups (export/import/upload) and sync
- * server management.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- AmountString,
- BackupRecovery,
- buildCodecForObject,
- canonicalizeBaseUrl,
- canonicalJson,
- Codec,
- codecForAmountString,
- codecForBoolean,
- codecForList,
- codecForNumber,
- codecForString,
- codecOptional,
- ConfirmPayResultType,
- durationFromSpec,
- getTimestampNow,
- j2s,
- Logger,
- notEmpty,
- NotificationType,
- PreparePayResultType,
- RecoveryLoadRequest,
- RecoveryMergeStrategy,
- TalerErrorDetails,
- Timestamp,
- timestampAddDuration,
- URL,
- WalletBackupContentV1,
-} from "@gnu-taler/taler-util";
-import { gunzipSync, gzipSync } from "fflate";
-import { InternalWalletState } from "../../common.js";
-import { kdf } from "@gnu-taler/taler-util";
-import {
- secretbox,
- secretbox_open,
-} from "@gnu-taler/taler-util";
-import {
- bytesToString,
- decodeCrock,
- eddsaGetPublic,
- EddsaKeyPair,
- encodeCrock,
- getRandomBytes,
- hash,
- rsaBlind,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import { CryptoApi } from "../../crypto/workers/cryptoApi.js";
-import {
- BackupProviderRecord,
- BackupProviderState,
- BackupProviderStateTag,
- BackupProviderTerms,
- ConfigRecord,
- WalletBackupConfState,
- WalletStoresV1,
- WALLET_BACKUP_STATE_KEY,
-} from "../../db.js";
-import { guardOperationException } from "../../errors.js";
-import {
- HttpResponseStatus,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "../../util/http.js";
-import {
- checkDbInvariant,
- checkLogicInvariant,
-} from "../../util/invariants.js";
-import { GetReadWriteAccess } from "../../util/query.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../../util/retries.js";
-import {
- checkPaymentByProposalId,
- confirmPay,
- preparePayForUri,
-} from "../pay.js";
-import { exportBackup } from "./export.js";
-import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
-import { getWalletBackupState, provideBackupState } from "./state.js";
-
-const logger = new Logger("operations/backup.ts");
-
-function concatArrays(xs: Uint8Array[]): Uint8Array {
- let len = 0;
- for (const x of xs) {
- len += x.byteLength;
- }
- const out = new Uint8Array(len);
- let offset = 0;
- for (const x of xs) {
- out.set(x, offset);
- offset += x.length;
- }
- return out;
-}
-
-const magic = "TLRWBK01";
-
-/**
- * Encrypt the backup.
- *
- * Blob format:
- * Magic "TLRWBK01" (8 bytes)
- * Nonce (24 bytes)
- * Compressed JSON blob (rest)
- */
-export async function encryptBackup(
- config: WalletBackupConfState,
- blob: WalletBackupContentV1,
-): Promise<Uint8Array> {
- const chunks: Uint8Array[] = [];
- chunks.push(stringToBytes(magic));
- const nonceStr = config.lastBackupNonce;
- checkLogicInvariant(!!nonceStr);
- const nonce = decodeCrock(nonceStr).slice(0, 24);
- chunks.push(nonce);
- const backupJsonContent = canonicalJson(blob);
- logger.trace("backup JSON size", backupJsonContent.length);
- const compressedContent = gzipSync(stringToBytes(backupJsonContent), {
- mtime: 0,
- });
- const secret = deriveBlobSecret(config);
- const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
- chunks.push(encrypted);
- return concatArrays(chunks);
-}
-
-/**
- * Compute cryptographic values for a backup blob.
- *
- * FIXME: Take data that we already know from the DB.
- * FIXME: Move computations into crypto worker.
- */
-async function computeBackupCryptoData(
- cryptoApi: CryptoApi,
- backupContent: WalletBackupContentV1,
-): Promise<BackupCryptoPrecomputedData> {
- const cryptoData: BackupCryptoPrecomputedData = {
- coinPrivToCompletedCoin: {},
- denomPubToHash: {},
- proposalIdToContractTermsHash: {},
- proposalNoncePrivToPub: {},
- reservePrivToPub: {},
- };
- for (const backupExchangeDetails of backupContent.exchange_details) {
- for (const backupDenom of backupExchangeDetails.denominations) {
- for (const backupCoin of backupDenom.coins) {
- const coinPub = encodeCrock(
- eddsaGetPublic(decodeCrock(backupCoin.coin_priv)),
- );
- const blindedCoin = rsaBlind(
- hash(decodeCrock(backupCoin.coin_priv)),
- decodeCrock(backupCoin.blinding_key),
- decodeCrock(backupDenom.denom_pub),
- );
- cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = {
- coinEvHash: encodeCrock(hash(blindedCoin)),
- coinPub,
- };
- }
- cryptoData.denomPubToHash[backupDenom.denom_pub] = encodeCrock(
- hash(decodeCrock(backupDenom.denom_pub)),
- );
- }
- for (const backupReserve of backupExchangeDetails.reserves) {
- cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock(
- eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)),
- );
- }
- }
- for (const prop of backupContent.proposals) {
- const contractTermsHash = await cryptoApi.hashString(
- canonicalJson(prop.contract_terms_raw),
- );
- const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv)));
- cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub;
- cryptoData.proposalIdToContractTermsHash[
- prop.proposal_id
- ] = contractTermsHash;
- }
- for (const purch of backupContent.purchases) {
- const contractTermsHash = await cryptoApi.hashString(
- canonicalJson(purch.contract_terms_raw),
- );
- const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(purch.nonce_priv)));
- cryptoData.proposalNoncePrivToPub[purch.nonce_priv] = noncePub;
- cryptoData.proposalIdToContractTermsHash[
- purch.proposal_id
- ] = contractTermsHash;
- }
- return cryptoData;
-}
-
-function deriveAccountKeyPair(
- bc: WalletBackupConfState,
- providerUrl: string,
-): EddsaKeyPair {
- const privateKey = kdf(
- 32,
- decodeCrock(bc.walletRootPriv),
- stringToBytes("taler-sync-account-key-salt"),
- stringToBytes(providerUrl),
- );
- return {
- eddsaPriv: privateKey,
- eddsaPub: eddsaGetPublic(privateKey),
- };
-}
-
-function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
- return kdf(
- 32,
- decodeCrock(bc.walletRootPriv),
- stringToBytes("taler-sync-blob-secret-salt"),
- stringToBytes("taler-sync-blob-secret-info"),
- );
-}
-
-interface BackupForProviderArgs {
- backupProviderBaseUrl: string;
-
- /**
- * Should we attempt one more upload after trying
- * to pay?
- */
- retryAfterPayment: boolean;
-}
-
-function getNextBackupTimestamp(): Timestamp {
- // FIXME: Randomize!
- return timestampAddDuration(
- getTimestampNow(),
- durationFromSpec({ minutes: 5 }),
- );
-}
-
-async function runBackupCycleForProvider(
- ws: InternalWalletState,
- args: BackupForProviderArgs,
-): Promise<void> {
- const provider = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return tx.backupProviders.get(args.backupProviderBaseUrl);
- });
-
- if (!provider) {
- logger.warn("provider disappeared");
- return;
- }
-
- const backupJson = await exportBackup(ws);
- const backupConfig = await provideBackupState(ws);
- const encBackup = await encryptBackup(backupConfig, backupJson);
- const currentBackupHash = hash(encBackup);
-
- const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
-
- const newHash = encodeCrock(currentBackupHash);
- const oldHash = provider.lastBackupHash;
-
- logger.trace(`trying to upload backup to ${provider.baseUrl}`);
- logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
-
- const syncSig = await ws.cryptoApi.makeSyncSignature({
- newHash: encodeCrock(currentBackupHash),
- oldHash: provider.lastBackupHash,
- accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
- });
-
- logger.trace(`sync signature is ${syncSig}`);
-
- const accountBackupUrl = new URL(
- `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
- provider.baseUrl,
- );
-
- const resp = await ws.http.fetch(accountBackupUrl.href, {
- method: "POST",
- body: encBackup,
- headers: {
- "content-type": "application/octet-stream",
- "sync-signature": syncSig,
- "if-none-match": newHash,
- ...(provider.lastBackupHash
- ? {
- "if-match": provider.lastBackupHash,
- }
- : {}),
- },
- });
-
- logger.trace(`sync response status: ${resp.status}`);
-
- if (resp.status === HttpResponseStatus.NotModified) {
- await ws.db
- .mktx((x) => ({ backupProvider: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const prov = await tx.backupProvider.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupCycleTimestamp = getTimestampNow();
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getNextBackupTimestamp(),
- };
- await tx.backupProvider.put(prov);
- });
- return;
- }
-
- if (resp.status === HttpResponseStatus.PaymentRequired) {
- logger.trace("payment required for backup");
- logger.trace(`headers: ${j2s(resp.headers)}`);
- const talerUri = resp.headers.get("taler");
- if (!talerUri) {
- throw Error("no taler URI available to pay provider");
- }
- const res = await preparePayForUri(ws, talerUri);
- let proposalId = res.proposalId;
- let doPay: boolean = false;
- switch (res.status) {
- case PreparePayResultType.InsufficientBalance:
- // FIXME: record in provider state!
- logger.warn("insufficient balance to pay for backup provider");
- proposalId = res.proposalId;
- break;
- case PreparePayResultType.PaymentPossible:
- doPay = true;
- break;
- case PreparePayResultType.AlreadyConfirmed:
- break;
- }
-
- // FIXME: check if the provider is overcharging us!
-
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const provRec = await tx.backupProviders.get(provider.baseUrl);
- checkDbInvariant(!!provRec);
- const ids = new Set(provRec.paymentProposalIds);
- ids.add(proposalId);
- provRec.paymentProposalIds = Array.from(ids).sort();
- provRec.currentPaymentProposalId = proposalId;
- // FIXME: allocate error code for this!
- await tx.backupProviders.put(provRec);
- await incrementBackupRetryInTx(
- tx,
- args.backupProviderBaseUrl,
- undefined,
- );
- });
-
- if (doPay) {
- const confirmRes = await confirmPay(ws, proposalId);
- switch (confirmRes.type) {
- case ConfirmPayResultType.Pending:
- logger.warn("payment not yet finished yet");
- break;
- }
- }
-
- if (args.retryAfterPayment) {
- await runBackupCycleForProvider(ws, {
- ...args,
- retryAfterPayment: false,
- });
- }
- return;
- }
-
- if (resp.status === HttpResponseStatus.NoContent) {
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupHash = encodeCrock(currentBackupHash);
- prov.lastBackupCycleTimestamp = getTimestampNow();
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getNextBackupTimestamp(),
- };
- await tx.backupProviders.put(prov);
- });
- return;
- }
-
- if (resp.status === HttpResponseStatus.Conflict) {
- logger.info("conflicting backup found");
- const backupEnc = new Uint8Array(await resp.bytes());
- const backupConfig = await provideBackupState(ws);
- const blob = await decryptBackup(backupConfig, backupEnc);
- const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
- await importBackup(ws, blob, cryptoData);
- await ws.db
- .mktx((x) => ({ backupProvider: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const prov = await tx.backupProvider.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- prov.lastBackupHash = encodeCrock(hash(backupEnc));
- // FIXME: Allocate error code for this situation?
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- retryInfo: initRetryInfo(),
- };
- await tx.backupProvider.put(prov);
- });
- logger.info("processed existing backup");
- // Now upload our own, merged backup.
- await runBackupCycleForProvider(ws, {
- ...args,
- retryAfterPayment: false,
- });
- return;
- }
-
- // Some other response that we did not expect!
-
- logger.error("parsing error response");
-
- const err = await readTalerErrorResponse(resp);
- logger.error(`got error response from backup provider: ${j2s(err)}`);
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- incrementBackupRetryInTx(tx, args.backupProviderBaseUrl, err);
- });
-}
-
-async function incrementBackupRetryInTx(
- tx: GetReadWriteAccess<{
- backupProviders: typeof WalletStoresV1.backupProviders;
- }>,
- backupProviderBaseUrl: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- const pr = await tx.backupProviders.get(backupProviderBaseUrl);
- if (!pr) {
- return;
- }
- if (pr.state.tag === BackupProviderStateTag.Retrying) {
- pr.state.retryInfo.retryCounter++;
- pr.state.lastError = err;
- updateRetryInfoTimeout(pr.state.retryInfo);
- } else if (pr.state.tag === BackupProviderStateTag.Ready) {
- pr.state = {
- tag: BackupProviderStateTag.Retrying,
- retryInfo: initRetryInfo(),
- lastError: err,
- };
- }
- await tx.backupProviders.put(pr);
-}
-
-async function incrementBackupRetry(
- ws: InternalWalletState,
- backupProviderBaseUrl: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) =>
- incrementBackupRetryInTx(tx, backupProviderBaseUrl, err),
- );
-}
-
-export async function processBackupForProvider(
- ws: InternalWalletState,
- backupProviderBaseUrl: string,
-): Promise<void> {
- const provider = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return await tx.backupProviders.get(backupProviderBaseUrl);
- });
- if (!provider) {
- throw Error("unknown backup provider");
- }
-
- const onOpErr = (err: TalerErrorDetails): Promise<void> =>
- incrementBackupRetry(ws, backupProviderBaseUrl, err);
-
- const run = async () => {
- await runBackupCycleForProvider(ws, {
- backupProviderBaseUrl: provider.baseUrl,
- retryAfterPayment: true,
- });
- };
-
- await guardOperationException(run, onOpErr);
-}
-
-export interface RemoveBackupProviderRequest {
- provider: string;
-}
-
-export const codecForRemoveBackupProvider = (): Codec<RemoveBackupProviderRequest> =>
- buildCodecForObject<RemoveBackupProviderRequest>()
- .property("provider", codecForString())
- .build("RemoveBackupProviderRequest");
-
-export async function removeBackupProvider(
- ws: InternalWalletState,
- req: RemoveBackupProviderRequest,
-): Promise<void> {
- await ws.db
- .mktx(({ backupProviders }) => ({ backupProviders }))
- .runReadWrite(async (tx) => {
- await tx.backupProviders.delete(req.provider);
- });
-}
-
-export interface RunBackupCycleRequest {
- /**
- * List of providers to backup or empty for all known providers.
- */
- providers?: Array<string>;
-}
-
-export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
- buildCodecForObject<RunBackupCycleRequest>()
- .property("providers", codecOptional(codecForList(codecForString())))
- .build("RunBackupCycleRequest");
-
-/**
- * Do one backup cycle that consists of:
- * 1. Exporting a backup and try to upload it.
- * Stop if this step succeeds.
- * 2. Download, verify and import backups from connected sync accounts.
- * 3. Upload the updated backup blob.
- */
-export async function runBackupCycle(
- ws: InternalWalletState,
- req: RunBackupCycleRequest,
-): Promise<void> {
- const providers = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- if (req.providers) {
- const rs = await Promise.all(
- req.providers.map((id) => tx.backupProviders.get(id)),
- );
- return rs.filter(notEmpty);
- }
- return await tx.backupProviders.iter().toArray();
- });
-
- for (const provider of providers) {
- await runBackupCycleForProvider(ws, {
- backupProviderBaseUrl: provider.baseUrl,
- retryAfterPayment: true,
- });
- }
-}
-
-interface SyncTermsOfServiceResponse {
- // maximum backup size supported
- storage_limit_in_megabytes: number;
-
- // Fee for an account, per year.
- annual_fee: AmountString;
-
- // protocol version supported by the server,
- // for now always "0.0".
- version: string;
-}
-
-const codecForSyncTermsOfServiceResponse = (): Codec<SyncTermsOfServiceResponse> =>
- buildCodecForObject<SyncTermsOfServiceResponse>()
- .property("storage_limit_in_megabytes", codecForNumber())
- .property("annual_fee", codecForAmountString())
- .property("version", codecForString())
- .build("SyncTermsOfServiceResponse");
-
-export interface AddBackupProviderRequest {
- backupProviderBaseUrl: string;
-
- name: string;
- /**
- * Activate the provider. Should only be done after
- * the user has reviewed the provider.
- */
- activate?: boolean;
-}
-
-export const codecForAddBackupProviderRequest = (): Codec<AddBackupProviderRequest> =>
- buildCodecForObject<AddBackupProviderRequest>()
- .property("backupProviderBaseUrl", codecForString())
- .property("name", codecForString())
- .property("activate", codecOptional(codecForBoolean()))
- .build("AddBackupProviderRequest");
-
-export async function addBackupProvider(
- ws: InternalWalletState,
- req: AddBackupProviderRequest,
-): Promise<void> {
- logger.info(`adding backup provider ${j2s(req)}`);
- await provideBackupState(ws);
- const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const oldProv = await tx.backupProviders.get(canonUrl);
- if (oldProv) {
- logger.info("old backup provider found");
- if (req.activate) {
- oldProv.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getTimestampNow(),
- };
- logger.info("setting existing backup provider to active");
- await tx.backupProviders.put(oldProv);
- }
- return;
- }
- });
- const termsUrl = new URL("config", canonUrl);
- const resp = await ws.http.get(termsUrl.href);
- const terms = await readSuccessResponseJsonOrThrow(
- resp,
- codecForSyncTermsOfServiceResponse(),
- );
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- let state: BackupProviderState;
- if (req.activate) {
- state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getTimestampNow(),
- };
- } else {
- state = {
- tag: BackupProviderStateTag.Provisional,
- };
- }
- await tx.backupProviders.put({
- state,
- name: req.name,
- terms: {
- annualFee: terms.annual_fee,
- storageLimitInMegabytes: terms.storage_limit_in_megabytes,
- supportedProtocolVersion: terms.version,
- },
- paymentProposalIds: [],
- baseUrl: canonUrl,
- uids: [encodeCrock(getRandomBytes(32))],
- });
- });
-}
-
-export async function restoreFromRecoverySecret(): Promise<void> {}
-
-/**
- * Information about one provider.
- *
- * We don't store the account key here,
- * as that's derived from the wallet root key.
- */
-export interface ProviderInfo {
- active: boolean;
- syncProviderBaseUrl: string;
- name: string;
- terms?: BackupProviderTerms;
- /**
- * Last communication issue with the provider.
- */
- lastError?: TalerErrorDetails;
- lastSuccessfulBackupTimestamp?: Timestamp;
- lastAttemptedBackupTimestamp?: Timestamp;
- paymentProposalIds: string[];
- backupProblem?: BackupProblem;
- paymentStatus: ProviderPaymentStatus;
-}
-
-export type BackupProblem =
- | BackupUnreadableProblem
- | BackupConflictingDeviceProblem;
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupConflictingDeviceProblem {
- type: "backup-conflicting-device";
- otherDeviceId: string;
- myDeviceId: string;
- backupTimestamp: Timestamp;
-}
-
-export type ProviderPaymentStatus =
- | ProviderPaymentTermsChanged
- | ProviderPaymentPaid
- | ProviderPaymentInsufficientBalance
- | ProviderPaymentUnpaid
- | ProviderPaymentPending;
-
-export interface BackupInfo {
- walletRootPub: string;
- deviceId: string;
- providers: ProviderInfo[];
-}
-
-export async function importBackupPlain(
- ws: InternalWalletState,
- blob: any,
-): Promise<void> {
- // FIXME: parse
- const backup: WalletBackupContentV1 = blob;
-
- const cryptoData = await computeBackupCryptoData(ws.cryptoApi, backup);
-
- await importBackup(ws, blob, cryptoData);
-}
-
-export enum ProviderPaymentType {
- Unpaid = "unpaid",
- Pending = "pending",
- InsufficientBalance = "insufficient-balance",
- Paid = "paid",
- TermsChanged = "terms-changed",
-}
-
-export interface ProviderPaymentUnpaid {
- type: ProviderPaymentType.Unpaid;
-}
-
-export interface ProviderPaymentInsufficientBalance {
- type: ProviderPaymentType.InsufficientBalance;
-}
-
-export interface ProviderPaymentPending {
- type: ProviderPaymentType.Pending;
-}
-
-export interface ProviderPaymentPaid {
- type: ProviderPaymentType.Paid;
- paidUntil: Timestamp;
-}
-
-export interface ProviderPaymentTermsChanged {
- type: ProviderPaymentType.TermsChanged;
- paidUntil: Timestamp;
- oldTerms: BackupProviderTerms;
- newTerms: BackupProviderTerms;
-}
-
-async function getProviderPaymentInfo(
- ws: InternalWalletState,
- provider: BackupProviderRecord,
-): Promise<ProviderPaymentStatus> {
- if (!provider.currentPaymentProposalId) {
- return {
- type: ProviderPaymentType.Unpaid,
- };
- }
- const status = await checkPaymentByProposalId(
- ws,
- provider.currentPaymentProposalId,
- );
- if (status.status === PreparePayResultType.InsufficientBalance) {
- return {
- type: ProviderPaymentType.InsufficientBalance,
- };
- }
- if (status.status === PreparePayResultType.PaymentPossible) {
- return {
- type: ProviderPaymentType.Pending,
- };
- }
- if (status.status === PreparePayResultType.AlreadyConfirmed) {
- if (status.paid) {
- return {
- type: ProviderPaymentType.Paid,
- paidUntil: timestampAddDuration(
- status.contractTerms.timestamp,
- durationFromSpec({ years: 1 }),
- ),
- };
- } else {
- return {
- type: ProviderPaymentType.Pending,
- };
- }
- }
- throw Error("not reached");
-}
-
-/**
- * Get information about the current state of wallet backups.
- */
-export async function getBackupInfo(
- ws: InternalWalletState,
-): Promise<BackupInfo> {
- const backupConfig = await provideBackupState(ws);
- const providerRecords = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return await tx.backupProviders.iter().toArray();
- });
- const providers: ProviderInfo[] = [];
- for (const x of providerRecords) {
- providers.push({
- active: x.state.tag !== BackupProviderStateTag.Provisional,
- syncProviderBaseUrl: x.baseUrl,
- lastSuccessfulBackupTimestamp: x.lastBackupCycleTimestamp,
- paymentProposalIds: x.paymentProposalIds,
- lastError:
- x.state.tag === BackupProviderStateTag.Retrying
- ? x.state.lastError
- : undefined,
- paymentStatus: await getProviderPaymentInfo(ws, x),
- terms: x.terms,
- name: x.name,
- });
- }
- return {
- deviceId: backupConfig.deviceId,
- walletRootPub: backupConfig.walletRootPub,
- providers,
- };
-}
-
-/**
- * Get backup recovery information, including the wallet's
- * private key.
- */
-export async function getBackupRecovery(
- ws: InternalWalletState,
-): Promise<BackupRecovery> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return await tx.backupProviders.iter().toArray();
- });
- return {
- providers: providers
- .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
- .map((x) => {
- return {
- url: x.baseUrl,
- };
- }),
- walletRootPriv: bs.walletRootPriv,
- };
-}
-
-async function backupRecoveryTheirs(
- ws: InternalWalletState,
- br: BackupRecovery,
-) {
- await ws.db
- .mktx((x) => ({ config: x.config, backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- WALLET_BACKUP_STATE_KEY,
- );
- checkDbInvariant(!!backupStateEntry);
- checkDbInvariant(backupStateEntry.key === WALLET_BACKUP_STATE_KEY);
- backupStateEntry.value.lastBackupNonce = undefined;
- backupStateEntry.value.lastBackupTimestamp = undefined;
- backupStateEntry.value.lastBackupCheckTimestamp = undefined;
- backupStateEntry.value.lastBackupPlainHash = undefined;
- backupStateEntry.value.walletRootPriv = br.walletRootPriv;
- backupStateEntry.value.walletRootPub = encodeCrock(
- eddsaGetPublic(decodeCrock(br.walletRootPriv)),
- );
- await tx.config.put(backupStateEntry);
- for (const prov of br.providers) {
- const existingProv = await tx.backupProviders.get(prov.url);
- if (!existingProv) {
- await tx.backupProviders.put({
- baseUrl: prov.url,
- name: "not-defined",
- paymentProposalIds: [],
- state: {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getTimestampNow(),
- },
- uids: [encodeCrock(getRandomBytes(32))],
- });
- }
- }
- const providers = await tx.backupProviders.iter().toArray();
- for (const prov of providers) {
- prov.lastBackupCycleTimestamp = undefined;
- prov.lastBackupHash = undefined;
- await tx.backupProviders.put(prov);
- }
- });
-}
-
-async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) {
- throw Error("not implemented");
-}
-
-export async function loadBackupRecovery(
- ws: InternalWalletState,
- br: RecoveryLoadRequest,
-): Promise<void> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return await tx.backupProviders.iter().toArray();
- });
- let strategy = br.strategy;
- if (
- br.recovery.walletRootPriv != bs.walletRootPriv &&
- providers.length > 0 &&
- !strategy
- ) {
- throw Error(
- "recovery load strategy must be specified for wallet with existing providers",
- );
- } else if (!strategy) {
- // Default to using the new key if we don't have providers yet.
- strategy = RecoveryMergeStrategy.Theirs;
- }
- if (strategy === RecoveryMergeStrategy.Theirs) {
- return backupRecoveryTheirs(ws, br.recovery);
- } else {
- return backupRecoveryOurs(ws, br.recovery);
- }
-}
-
-export async function exportBackupEncrypted(
- ws: InternalWalletState,
-): Promise<Uint8Array> {
- await provideBackupState(ws);
- const blob = await exportBackup(ws);
- const bs = await ws.db
- .mktx((x) => ({ config: x.config }))
- .runReadOnly(async (tx) => {
- return await getWalletBackupState(ws, tx);
- });
- return encryptBackup(bs, blob);
-}
-
-export async function decryptBackup(
- backupConfig: WalletBackupConfState,
- data: Uint8Array,
-): Promise<WalletBackupContentV1> {
- const rMagic = bytesToString(data.slice(0, 8));
- if (rMagic != magic) {
- throw Error("invalid backup file (magic tag mismatch)");
- }
-
- const nonce = data.slice(8, 8 + 24);
- const box = data.slice(8 + 24);
- const secret = deriveBlobSecret(backupConfig);
- const dataCompressed = secretbox_open(box, nonce, secret);
- if (!dataCompressed) {
- throw Error("decryption failed");
- }
- return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
-}
-
-export async function importBackupEncrypted(
- ws: InternalWalletState,
- data: Uint8Array,
-): Promise<void> {
- const backupConfig = await provideBackupState(ws);
- const blob = await decryptBackup(backupConfig, data);
- const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
- await importBackup(ws, blob, cryptoData);
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts b/packages/taler-wallet-core/src/operations/backup/state.ts
deleted file mode 100644
index dc89c3d99..000000000
--- a/packages/taler-wallet-core/src/operations/backup/state.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- 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 { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import {
- ConfigRecord,
- WalletBackupConfState,
- WalletStoresV1,
- WALLET_BACKUP_STATE_KEY,
-} from "../../db.js";
-import { checkDbInvariant } from "../../util/invariants.js";
-import { GetReadOnlyAccess } from "../../util/query.js";
-import { InternalWalletState } from "../../common.js";
-
-export async function provideBackupState(
- ws: InternalWalletState,
-): Promise<WalletBackupConfState> {
- const bs: ConfigRecord | undefined = await ws.db
- .mktx((x) => ({
- config: x.config,
- }))
- .runReadOnly(async (tx) => {
- return await tx.config.get(WALLET_BACKUP_STATE_KEY);
- });
- if (bs) {
- checkDbInvariant(bs.key === WALLET_BACKUP_STATE_KEY);
- return bs.value;
- }
- // We need to generate the key outside of the transaction
- // due to how IndexedDB works.
- const k = await ws.cryptoApi.createEddsaKeypair();
- const d = getRandomBytes(5);
- // FIXME: device ID should be configured when wallet is initialized
- // and be based on hostname
- const deviceId = `wallet-core-${encodeCrock(d)}`;
- return await ws.db
- .mktx((x) => ({
- config: x.config,
- }))
- .runReadWrite(async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- WALLET_BACKUP_STATE_KEY,
- );
- if (!backupStateEntry) {
- backupStateEntry = {
- key: WALLET_BACKUP_STATE_KEY,
- value: {
- deviceId,
- walletRootPub: k.pub,
- walletRootPriv: k.priv,
- lastBackupPlainHash: undefined,
- },
- };
- await tx.config.put(backupStateEntry);
- }
- checkDbInvariant(backupStateEntry.key === WALLET_BACKUP_STATE_KEY);
- return backupStateEntry.value;
- });
-}
-
-export async function getWalletBackupState(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{ config: typeof WalletStoresV1.config }>,
-): Promise<WalletBackupConfState> {
- const bs = await tx.config.get(WALLET_BACKUP_STATE_KEY);
- checkDbInvariant(!!bs, "wallet backup state should be in DB");
- checkDbInvariant(bs.key === WALLET_BACKUP_STATE_KEY);
- return bs.value;
-}
-
-export async function setWalletDeviceId(
- ws: InternalWalletState,
- deviceId: string,
-): Promise<void> {
- await provideBackupState(ws);
- await ws.db
- .mktx((x) => ({
- config: x.config,
- }))
- .runReadWrite(async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- WALLET_BACKUP_STATE_KEY,
- );
- if (
- !backupStateEntry ||
- backupStateEntry.key !== WALLET_BACKUP_STATE_KEY
- ) {
- return;
- }
- backupStateEntry.value.deviceId = deviceId;
- await tx.config.put(backupStateEntry);
- });
-}
-
-export async function getWalletDeviceId(
- ws: InternalWalletState,
-): Promise<string> {
- const bs = await provideBackupState(ws);
- return bs.deviceId;
-}
diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts
deleted file mode 100644
index 298893920..000000000
--- a/packages/taler-wallet-core/src/operations/balance.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-/*
- 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 {
- AmountJson,
- BalancesResponse,
- Amounts,
- Logger,
-} from "@gnu-taler/taler-util";
-import { CoinStatus, WalletStoresV1 } from "../db.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-import { InternalWalletState } from "../common.js";
-
-const logger = new Logger("operations/balance.ts");
-
-interface WalletBalance {
- available: AmountJson;
- pendingIncoming: AmountJson;
- pendingOutgoing: AmountJson;
-}
-
-/**
- * Get balance information.
- */
-export async function getBalancesInsideTransaction(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- reserves: typeof WalletStoresV1.reserves;
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
- }>,
-): Promise<BalancesResponse> {
- const balanceStore: Record<string, WalletBalance> = {};
-
- /**
- * Add amount to a balance field, both for
- * the slicing by exchange and currency.
- */
- const initBalance = (currency: string): WalletBalance => {
- const b = balanceStore[currency];
- if (!b) {
- balanceStore[currency] = {
- available: Amounts.getZero(currency),
- pendingIncoming: Amounts.getZero(currency),
- pendingOutgoing: Amounts.getZero(currency),
- };
- }
- return balanceStore[currency];
- };
-
- // Initialize balance to zero, even if we didn't start withdrawing yet.
- await tx.reserves.iter().forEach((r) => {
- const b = initBalance(r.currency);
- if (!r.initialWithdrawalStarted) {
- b.pendingIncoming = Amounts.add(
- b.pendingIncoming,
- r.initialDenomSel.totalCoinValue,
- ).amount;
- }
- });
-
- await tx.coins.iter().forEach((c) => {
- // Only count fresh coins, as dormant coins will
- // already be in a refresh session.
- if (c.status === CoinStatus.Fresh) {
- const b = initBalance(c.currentAmount.currency);
- b.available = Amounts.add(b.available, c.currentAmount).amount;
- }
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- // Don't count finished refreshes, since the refresh already resulted
- // in coins being added to the wallet.
- if (r.timestampFinished) {
- return;
- }
- for (let i = 0; i < r.oldCoinPubs.length; i++) {
- const session = r.refreshSessionPerCoin[i];
- if (session) {
- const b = initBalance(session.amountRefreshOutput.currency);
- // We are always assuming the refresh will succeed, thus we
- // report the output as available balance.
- b.available = Amounts.add(
- b.available,
- session.amountRefreshOutput,
- ).amount;
- } else {
- const b = initBalance(r.inputPerCoin[i].currency);
- b.available = Amounts.add(
- b.available,
- r.estimatedOutputPerCoin[i],
- ).amount;
- }
- }
- });
-
- await tx.withdrawalGroups.iter().forEach((wds) => {
- if (wds.timestampFinish) {
- return;
- }
- const b = initBalance(wds.denomsSel.totalWithdrawCost.currency);
- b.pendingIncoming = Amounts.add(
- b.pendingIncoming,
- wds.denomsSel.totalCoinValue,
- ).amount;
- });
-
- const balancesResponse: BalancesResponse = {
- balances: [],
- };
-
- Object.keys(balanceStore)
- .sort()
- .forEach((c) => {
- const v = balanceStore[c];
- balancesResponse.balances.push({
- available: Amounts.stringify(v.available),
- pendingIncoming: Amounts.stringify(v.pendingIncoming),
- pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
- hasPendingTransactions: false,
- requiresUserInput: false,
- });
- });
-
- return balancesResponse;
-}
-
-/**
- * Get detailed balance information, sliced by exchange and by currency.
- */
-export async function getBalances(
- ws: InternalWalletState,
-): Promise<BalancesResponse> {
- logger.trace("starting to compute balance");
-
- const wbal = await ws.db
- .mktx((x) => ({
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- reserves: x.reserves,
- purchases: x.purchases,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadOnly(async (tx) => {
- return getBalancesInsideTransaction(ws, tx);
- });
-
- logger.trace("finished computing wallet balance");
-
- return wbal;
-}
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts
deleted file mode 100644
index 740242050..000000000
--- a/packages/taler-wallet-core/src/operations/deposits.ts
+++ /dev/null
@@ -1,470 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- 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 {
- Amounts,
- buildCodecForObject,
- canonicalJson,
- Codec,
- codecForString,
- codecForTimestamp,
- codecOptional,
- ContractTerms,
- CreateDepositGroupRequest,
- CreateDepositGroupResponse,
- durationFromSpec,
- getTimestampNow,
- Logger,
- NotificationType,
- parsePaytoUri,
- TalerErrorDetails,
- Timestamp,
- timestampAddDuration,
- timestampTruncateToSecond,
- TrackDepositGroupRequest,
- TrackDepositGroupResponse,
- URL,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../common.js";
-import { kdf } from "@gnu-taler/taler-util";
-import {
- encodeCrock,
- getRandomBytes,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import { DepositGroupRecord } from "../db.js";
-import { guardOperationException } from "../errors.js";
-import { selectPayCoins } from "../util/coinSelection.js";
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import { getExchangeDetails } from "./exchanges.js";
-import {
- applyCoinSpend,
- extractContractData,
- generateDepositPermissions,
- getCandidatePayCoins,
- getEffectiveDepositAmount,
- getTotalPaymentCost,
-} from "./pay.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("deposits.ts");
-
-interface DepositSuccess {
- // Optional base URL of the exchange for looking up wire transfers
- // associated with this transaction. If not given,
- // the base URL is the same as the one used for this request.
- // Can be used if the base URL for /transactions/ differs from that
- // for /coins/, i.e. for load balancing. Clients SHOULD
- // respect the transaction_base_url if provided. Any HTTP server
- // belonging to an exchange MUST generate a 307 or 308 redirection
- // to the correct base URL should a client uses the wrong base
- // URL, or if the base URL has changed since the deposit.
- transaction_base_url?: string;
-
- // timestamp when the deposit was received by the exchange.
- exchange_timestamp: Timestamp;
-
- // the EdDSA signature of TALER_DepositConfirmationPS using a current
- // signing key of the exchange affirming the successful
- // deposit and that the exchange will transfer the funds after the refund
- // deadline, or as soon as possible if the refund deadline is zero.
- exchange_sig: string;
-
- // public EdDSA key of the exchange that was used to
- // generate the signature.
- // Should match one of the exchange's signing keys from /keys. It is given
- // explicitly as the client might otherwise be confused by clock skew as to
- // which signing key was used.
- exchange_pub: string;
-}
-
-const codecForDepositSuccess = (): Codec<DepositSuccess> =>
- buildCodecForObject<DepositSuccess>()
- .property("exchange_pub", codecForString())
- .property("exchange_sig", codecForString())
- .property("exchange_timestamp", codecForTimestamp)
- .property("transaction_base_url", codecOptional(codecForString()))
- .build("DepositSuccess");
-
-function hashWire(paytoUri: string, salt: string): string {
- const r = kdf(
- 64,
- stringToBytes(paytoUri + "\0"),
- stringToBytes(salt + "\0"),
- stringToBytes("merchant-wire-signature"),
- );
- return encodeCrock(r);
-}
-
-async function resetDepositGroupRetry(
- ws: InternalWalletState,
- depositGroupId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.depositGroups.get(depositGroupId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.depositGroups.put(x);
- }
- });
-}
-
-async function incrementDepositRetry(
- ws: InternalWalletState,
- depositGroupId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ depositGroups: x.depositGroups }))
- .runReadWrite(async (tx) => {
- const r = await tx.depositGroups.get(depositGroupId);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.depositGroups.put(r);
- });
- if (err) {
- ws.notify({ type: NotificationType.DepositOperationError, error: err });
- }
-}
-
-export async function processDepositGroup(
- ws: InternalWalletState,
- depositGroupId: string,
- forceNow = false,
-): Promise<void> {
- await ws.memoProcessDeposit.memo(depositGroupId, async () => {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementDepositRetry(ws, depositGroupId, e);
- return await guardOperationException(
- async () => await processDepositGroupImpl(ws, depositGroupId, forceNow),
- onOpErr,
- );
- });
-}
-
-async function processDepositGroupImpl(
- ws: InternalWalletState,
- depositGroupId: string,
- forceNow: boolean = false,
-): Promise<void> {
- if (forceNow) {
- await resetDepositGroupRetry(ws, depositGroupId);
- }
- const depositGroup = await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- }))
- .runReadOnly(async (tx) => {
- return tx.depositGroups.get(depositGroupId);
- });
- if (!depositGroup) {
- logger.warn(`deposit group ${depositGroupId} not found`);
- return;
- }
- if (depositGroup.timestampFinished) {
- logger.trace(`deposit group ${depositGroupId} already finished`);
- return;
- }
-
- const contractData = extractContractData(
- depositGroup.contractTermsRaw,
- depositGroup.contractTermsHash,
- "",
- );
-
- const depositPermissions = await generateDepositPermissions(
- ws,
- depositGroup.payCoinSelection,
- contractData,
- );
-
- for (let i = 0; i < depositPermissions.length; i++) {
- if (depositGroup.depositedPerCoin[i]) {
- continue;
- }
- const perm = depositPermissions[i];
- const url = new URL(`coins/${perm.coin_pub}/deposit`, perm.exchange_url);
- const httpResp = await ws.http.postJson(url.href, {
- contribution: Amounts.stringify(perm.contribution),
- wire: depositGroup.wire,
- h_wire: depositGroup.contractTermsRaw.h_wire,
- h_contract_terms: depositGroup.contractTermsHash,
- ub_sig: perm.ub_sig,
- timestamp: depositGroup.contractTermsRaw.timestamp,
- wire_transfer_deadline:
- depositGroup.contractTermsRaw.wire_transfer_deadline,
- refund_deadline: depositGroup.contractTermsRaw.refund_deadline,
- coin_sig: perm.coin_sig,
- denom_pub_hash: perm.h_denom,
- merchant_pub: depositGroup.merchantPub,
- });
- await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess());
- await ws.db
- .mktx((x) => ({ depositGroups: x.depositGroups }))
- .runReadWrite(async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- dg.depositedPerCoin[i] = true;
- await tx.depositGroups.put(dg);
- });
- }
-
- await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- }))
- .runReadWrite(async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- let allDeposited = true;
- for (const d of depositGroup.depositedPerCoin) {
- if (!d) {
- allDeposited = false;
- }
- }
- if (allDeposited) {
- dg.timestampFinished = getTimestampNow();
- delete dg.lastError;
- delete dg.retryInfo;
- await tx.depositGroups.put(dg);
- }
- });
-}
-
-export async function trackDepositGroup(
- ws: InternalWalletState,
- req: TrackDepositGroupRequest,
-): Promise<TrackDepositGroupResponse> {
- const responses: {
- status: number;
- body: any;
- }[] = [];
- const depositGroup = await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- }))
- .runReadOnly(async (tx) => {
- return tx.depositGroups.get(req.depositGroupId);
- });
- if (!depositGroup) {
- throw Error("deposit group not found");
- }
- const contractData = extractContractData(
- depositGroup.contractTermsRaw,
- depositGroup.contractTermsHash,
- "",
- );
-
- const depositPermissions = await generateDepositPermissions(
- ws,
- depositGroup.payCoinSelection,
- contractData,
- );
-
- const wireHash = depositGroup.contractTermsRaw.h_wire;
-
- for (const dp of depositPermissions) {
- const url = new URL(
- `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${dp.coin_pub}`,
- dp.exchange_url,
- );
- const sig = await ws.cryptoApi.signTrackTransaction({
- coinPub: dp.coin_pub,
- contractTermsHash: depositGroup.contractTermsHash,
- merchantPriv: depositGroup.merchantPriv,
- merchantPub: depositGroup.merchantPub,
- wireHash,
- });
- url.searchParams.set("merchant_sig", sig);
- const httpResp = await ws.http.get(url.href);
- const body = await httpResp.json();
- responses.push({
- body,
- status: httpResp.status,
- });
- }
- return {
- responses,
- };
-}
-
-export async function createDepositGroup(
- ws: InternalWalletState,
- req: CreateDepositGroupRequest,
-): Promise<CreateDepositGroupResponse> {
- const p = parsePaytoUri(req.depositPaytoUri);
- if (!p) {
- throw Error("invalid payto URI");
- }
-
- const amount = Amounts.parseOrThrow(req.amount);
-
- const exchangeInfos: { url: string; master_pub: string }[] = [];
-
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeDetails(tx, e.baseUrl);
- if (!details) {
- continue;
- }
- exchangeInfos.push({
- master_pub: details.masterPublicKey,
- url: e.baseUrl,
- });
- }
- });
-
- const timestamp = getTimestampNow();
- const timestampRound = timestampTruncateToSecond(timestamp);
- const noncePair = await ws.cryptoApi.createEddsaKeypair();
- const merchantPair = await ws.cryptoApi.createEddsaKeypair();
- const wireSalt = encodeCrock(getRandomBytes(64));
- const wireHash = hashWire(req.depositPaytoUri, wireSalt);
- const contractTerms: ContractTerms = {
- auditors: [],
- exchanges: exchangeInfos,
- amount: req.amount,
- max_fee: Amounts.stringify(amount),
- max_wire_fee: Amounts.stringify(amount),
- wire_method: p.targetType,
- timestamp: timestampRound,
- merchant_base_url: "",
- summary: "",
- nonce: noncePair.pub,
- wire_transfer_deadline: timestampRound,
- order_id: "",
- h_wire: wireHash,
- pay_deadline: timestampAddDuration(
- timestampRound,
- durationFromSpec({ hours: 1 }),
- ),
- merchant: {
- name: "",
- },
- merchant_pub: merchantPair.pub,
- refund_deadline: { t_ms: 0 },
- };
-
- const contractTermsHash = await ws.cryptoApi.hashString(
- canonicalJson(contractTerms),
- );
-
- const contractData = extractContractData(
- contractTerms,
- contractTermsHash,
- "",
- );
-
- const candidates = await getCandidatePayCoins(ws, {
- allowedAuditors: contractData.allowedAuditors,
- allowedExchanges: contractData.allowedExchanges,
- amount: contractData.amount,
- maxDepositFee: contractData.maxDepositFee,
- maxWireFee: contractData.maxWireFee,
- timestamp: contractData.timestamp,
- wireFeeAmortization: contractData.wireFeeAmortization,
- wireMethod: contractData.wireMethod,
- });
-
- const payCoinSel = selectPayCoins({
- candidates,
- contractTermsAmount: contractData.amount,
- depositFeeLimit: contractData.maxDepositFee,
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: contractData.maxWireFee,
- prevPayCoins: [],
- });
-
- if (!payCoinSel) {
- throw Error("insufficient funds");
- }
-
- const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel);
-
- const depositGroupId = encodeCrock(getRandomBytes(32));
-
- const effectiveDepositAmount = await getEffectiveDepositAmount(
- ws,
- p.targetType,
- payCoinSel,
- );
-
- const depositGroup: DepositGroupRecord = {
- contractTermsHash,
- contractTermsRaw: contractTerms,
- depositGroupId,
- noncePriv: noncePair.priv,
- noncePub: noncePair.pub,
- timestampCreated: timestamp,
- timestampFinished: undefined,
- payCoinSelection: payCoinSel,
- payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
- depositedPerCoin: payCoinSel.coinPubs.map(() => false),
- merchantPriv: merchantPair.priv,
- merchantPub: merchantPair.pub,
- totalPayCost: totalDepositCost,
- effectiveDepositAmount,
- wire: {
- payto_uri: req.depositPaytoUri,
- salt: wireSalt,
- },
- retryInfo: initRetryInfo(),
- lastError: undefined,
- };
-
- await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- denominations: x.denominations,
- }))
- .runReadWrite(async (tx) => {
- await applyCoinSpend(
- ws,
- tx,
- payCoinSel,
- `deposit-group:${depositGroup.depositGroupId}`,
- );
- await tx.depositGroups.put(depositGroup);
- });
-
- return { depositGroupId };
-}
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
deleted file mode 100644
index 629957efb..000000000
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ /dev/null
@@ -1,732 +0,0 @@
-/*
- 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 {
- Amounts,
- Auditor,
- canonicalizeBaseUrl,
- codecForExchangeKeysJson,
- codecForExchangeWireJson,
- compare,
- Denomination,
- Duration,
- durationFromSpec,
- ExchangeSignKeyJson,
- ExchangeWireJson,
- getTimestampNow,
- isTimestampExpired,
- Logger,
- NotificationType,
- parsePaytoUri,
- Recoup,
- TalerErrorCode,
- URL,
- TalerErrorDetails,
- Timestamp,
-} from "@gnu-taler/taler-util";
-import { decodeCrock, encodeCrock, hash } from "@gnu-taler/taler-util";
-import { CryptoApi } from "../crypto/workers/cryptoApi.js";
-import {
- DenominationRecord,
- DenominationVerificationStatus,
- ExchangeDetailsRecord,
- ExchangeRecord,
- WalletStoresV1,
- WireFee,
- WireInfo,
-} from "../db.js";
-import {
- getExpiryTimestamp,
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
- readSuccessResponseTextOrThrow,
-} from "../util/http.js";
-import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import {
- guardOperationException,
- makeErrorDetails,
- OperationFailedError,
-} from "../errors.js";
-import { InternalWalletState, TrustInfo } from "../common.js";
-import {
- WALLET_CACHE_BREAKER_CLIENT_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "../versions.js";
-
-const logger = new Logger("exchanges.ts");
-
-function denominationRecordFromKeys(
- exchangeBaseUrl: string,
- exchangeMasterPub: string,
- listIssueDate: Timestamp,
- denomIn: Denomination,
-): DenominationRecord {
- const denomPubHash = encodeCrock(hash(decodeCrock(denomIn.denom_pub)));
- const d: DenominationRecord = {
- denomPub: denomIn.denom_pub,
- denomPubHash,
- exchangeBaseUrl,
- exchangeMasterPub,
- 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,
- isRevoked: false,
- masterSig: denomIn.master_sig,
- stampExpireDeposit: denomIn.stamp_expire_deposit,
- stampExpireLegal: denomIn.stamp_expire_legal,
- stampExpireWithdraw: denomIn.stamp_expire_withdraw,
- stampStart: denomIn.stamp_start,
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: Amounts.parseOrThrow(denomIn.value),
- listIssueDate,
- };
- return d;
-}
-
-async function handleExchangeUpdateError(
- ws: InternalWalletState,
- baseUrl: string,
- err: TalerErrorDetails,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ exchanges: x.exchanges }))
- .runReadOnly(async (tx) => {
- const exchange = await tx.exchanges.get(baseUrl);
- if (!exchange) {
- return;
- }
- exchange.retryInfo.retryCounter++;
- updateRetryInfoTimeout(exchange.retryInfo);
- exchange.lastError = err;
- });
- if (err) {
- ws.notify({ type: NotificationType.ExchangeOperationError, error: err });
- }
-}
-
-function getExchangeRequestTimeout(e: ExchangeRecord): Duration {
- return { d_ms: 5000 };
-}
-
-export interface ExchangeTosDownloadResult {
- tosText: string;
- tosEtag: string;
- tosContentType: string;
-}
-
-export async function downloadExchangeWithTermsOfService(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
- contentType: string,
-): Promise<ExchangeTosDownloadResult> {
- const reqUrl = new URL("terms", exchangeBaseUrl);
- reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
- const headers = {
- Accept: contentType,
- };
-
- const resp = await http.get(reqUrl.href, {
- headers,
- timeout,
- });
- const tosText = await readSuccessResponseTextOrThrow(resp);
- const tosEtag = resp.headers.get("etag") || "unknown";
- const tosContentType = resp.headers.get("content-type") || "text/plain";
-
- return { tosText, tosEtag, tosContentType };
-}
-
-/**
- * Get exchange details from the database.
- */
-export async function getExchangeDetails(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- exchangeBaseUrl: string,
-): Promise<ExchangeDetailsRecord | undefined> {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- return;
- }
- const dp = r.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- return await tx.exchangeDetails.get([r.baseUrl, currency, masterPublicKey]);
-}
-
-getExchangeDetails.makeContext = (db: DbAccess<typeof WalletStoresV1>) =>
- db.mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }));
-
-export async function acceptExchangeTermsOfService(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- etag: string | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadWrite(async (tx) => {
- const d = await getExchangeDetails(tx, exchangeBaseUrl);
- if (d) {
- d.termsOfServiceAcceptedEtag = etag;
- await tx.exchangeDetails.put(d);
- }
- });
-}
-
-async function validateWireInfo(
- wireInfo: ExchangeWireJson,
- masterPublicKey: string,
- cryptoApi: CryptoApi,
-): Promise<WireInfo> {
- for (const a of wireInfo.accounts) {
- logger.trace("validating exchange acct");
- const isValid = await cryptoApi.isValidWireAccount(
- a.payto_uri,
- a.master_sig,
- masterPublicKey,
- );
- if (!isValid) {
- throw Error("exchange acct signature invalid");
- }
- }
- 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 = x.start_date;
- const endStamp = x.end_date;
- const fee: WireFee = {
- closingFee: Amounts.parseOrThrow(x.closing_fee),
- endStamp,
- sig: x.sig,
- startStamp,
- wireFee: Amounts.parseOrThrow(x.wire_fee),
- };
- const isValid = await cryptoApi.isValidWireFee(
- wireMethod,
- fee,
- masterPublicKey,
- );
- if (!isValid) {
- throw Error("exchange wire fee signature invalid");
- }
- feeList.push(fee);
- }
- feesForType[wireMethod] = feeList;
- }
-
- return {
- accounts: wireInfo.accounts,
- feesForType,
- };
-}
-
-/**
- * Fetch wire information for an exchange.
- *
- * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized.
- */
-async function downloadExchangeWithWireInfo(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
-): Promise<ExchangeWireJson> {
- const reqUrl = new URL("wire", exchangeBaseUrl);
- reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
-
- const resp = await http.get(reqUrl.href, {
- timeout,
- });
- const wireInfo = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeWireJson(),
- );
-
- return wireInfo;
-}
-
-export async function updateExchangeFromUrl(
- ws: InternalWalletState,
- baseUrl: string,
- acceptedFormat?: string[],
- forceNow = false,
-): Promise<{
- exchange: ExchangeRecord;
- exchangeDetails: ExchangeDetailsRecord;
-}> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- handleExchangeUpdateError(ws, baseUrl, e);
- return await guardOperationException(
- () => updateExchangeFromUrlImpl(ws, baseUrl, acceptedFormat, forceNow),
- onOpErr,
- );
-}
-
-async function provideExchangeRecord(
- ws: InternalWalletState,
- baseUrl: string,
- now: Timestamp,
-): Promise<ExchangeRecord> {
- return await ws.db
- .mktx((x) => ({ exchanges: x.exchanges }))
- .runReadWrite(async (tx) => {
- let r = await tx.exchanges.get(baseUrl);
- if (!r) {
- r = {
- permanent: true,
- baseUrl: baseUrl,
- retryInfo: initRetryInfo(),
- detailsPointer: undefined,
- lastUpdate: undefined,
- nextUpdate: now,
- nextRefreshCheck: now,
- };
- await tx.exchanges.put(r);
- }
- return r;
- });
-}
-
-interface ExchangeKeysDownloadResult {
- masterPublicKey: string;
- currency: string;
- auditors: Auditor[];
- currentDenominations: DenominationRecord[];
- protocolVersion: string;
- signingKeys: ExchangeSignKeyJson[];
- reserveClosingDelay: Duration;
- expiry: Timestamp;
- recoup: Recoup[];
- listIssueDate: Timestamp;
-}
-
-/**
- * Download and validate an exchange's /keys data.
- */
-async function downloadKeysInfo(
- baseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
-): Promise<ExchangeKeysDownloadResult> {
- const keysUrl = new URL("keys", baseUrl);
- keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
-
- const resp = await http.get(keysUrl.href, {
- timeout,
- });
- const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeKeysJson(),
- );
-
- logger.info("received /keys response");
-
- if (exchangeKeysJson.denoms.length === 0) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
- "exchange doesn't offer any denominations",
- {
- exchangeBaseUrl: baseUrl,
- },
- );
- throw new OperationFailedError(opErr);
- }
-
- const protocolVersion = exchangeKeysJson.version;
-
- const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion);
- if (versionRes?.compatible != true) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- "exchange protocol version not compatible with wallet",
- {
- exchangeProtocolVersion: protocolVersion,
- walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- },
- );
- throw new OperationFailedError(opErr);
- }
-
- const currency = Amounts.parseOrThrow(
- exchangeKeysJson.denoms[0].value,
- ).currency.toUpperCase();
-
- return {
- masterPublicKey: exchangeKeysJson.master_public_key,
- currency,
- auditors: exchangeKeysJson.auditors,
- currentDenominations: exchangeKeysJson.denoms.map((d) =>
- denominationRecordFromKeys(
- baseUrl,
- exchangeKeysJson.master_public_key,
- exchangeKeysJson.list_issue_date,
- d,
- ),
- ),
- protocolVersion: exchangeKeysJson.version,
- signingKeys: exchangeKeysJson.signkeys,
- reserveClosingDelay: exchangeKeysJson.reserve_closing_delay,
- expiry: getExpiryTimestamp(resp, {
- minDuration: durationFromSpec({ hours: 1 }),
- }),
- recoup: exchangeKeysJson.recoup ?? [],
- listIssueDate: exchangeKeysJson.list_issue_date,
- };
-}
-
-/**
- * Update or add exchange DB entry by fetching the /keys and /wire information.
- * Optionally link the reserve entry to the new or existing
- * exchange entry in then DB.
- */
-async function updateExchangeFromUrlImpl(
- ws: InternalWalletState,
- baseUrl: string,
- acceptedFormat?: string[],
- forceNow = false,
-): Promise<{
- exchange: ExchangeRecord;
- exchangeDetails: ExchangeDetailsRecord;
-}> {
- logger.trace(`updating exchange info for ${baseUrl}`);
- const now = getTimestampNow();
- baseUrl = canonicalizeBaseUrl(baseUrl);
-
- const r = await provideExchangeRecord(ws, baseUrl, now);
-
- if (!forceNow && r && !isTimestampExpired(r.nextUpdate)) {
- const res = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const exchange = await tx.exchanges.get(baseUrl);
- if (!exchange) {
- return;
- }
- const exchangeDetails = await getExchangeDetails(tx, baseUrl);
- if (!exchangeDetails) {
- return;
- }
- return { exchange, exchangeDetails };
- });
- if (res) {
- logger.info("using existing exchange info");
- return res;
- }
- }
-
- logger.info("updating exchange /keys info");
-
- const timeout = getExchangeRequestTimeout(r);
-
- const keysInfo = await downloadKeysInfo(baseUrl, ws.http, timeout);
-
- logger.info("updating exchange /wire info");
- const wireInfoDownload = await downloadExchangeWithWireInfo(
- baseUrl,
- ws.http,
- timeout,
- );
-
- logger.info("validating exchange /wire info");
-
- const wireInfo = await validateWireInfo(
- wireInfoDownload,
- keysInfo.masterPublicKey,
- ws.cryptoApi,
- );
-
- logger.info("finished validating exchange /wire info");
-
- let tosFound: ExchangeTosDownloadResult | undefined;
- //Remove this when exchange supports multiple content-type in accept header
- if (acceptedFormat) for (const format of acceptedFormat) {
- const resp = await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- format
- );
- if (resp.tosContentType === format) {
- tosFound = resp
- break
- }
- }
- // If none of the specified format was found try text/plain
- const tosDownload = tosFound !== undefined ? tosFound :
- await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- "text/plain"
- );
-
- let recoupGroupId: string | undefined = undefined;
-
- logger.trace("updating exchange info in database");
-
- const updated = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- denominations: x.denominations,
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- recoupGroups: x.recoupGroups,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.exchanges.get(baseUrl);
- if (!r) {
- logger.warn(`exchange ${baseUrl} no longer present`);
- return;
- }
- let details = await getExchangeDetails(tx, r.baseUrl);
- if (details) {
- // FIXME: We need to do some consistency checks!
- }
- // FIXME: validate signing keys and merge with old set
- details = {
- auditors: keysInfo.auditors,
- currency: keysInfo.currency,
- masterPublicKey: keysInfo.masterPublicKey,
- protocolVersion: keysInfo.protocolVersion,
- signingKeys: keysInfo.signingKeys,
- reserveClosingDelay: keysInfo.reserveClosingDelay,
- exchangeBaseUrl: r.baseUrl,
- wireInfo,
- termsOfServiceText: tosDownload.tosText,
- termsOfServiceAcceptedEtag: undefined,
- termsOfServiceContentType: tosDownload.tosContentType,
- termsOfServiceLastEtag: tosDownload.tosEtag,
- termsOfServiceAcceptedTimestamp: getTimestampNow(),
- };
- // FIXME: only update if pointer got updated
- r.lastError = undefined;
- r.retryInfo = initRetryInfo();
- r.lastUpdate = getTimestampNow();
- r.nextUpdate = keysInfo.expiry;
- // New denominations might be available.
- r.nextRefreshCheck = getTimestampNow();
- r.detailsPointer = {
- currency: details.currency,
- masterPublicKey: details.masterPublicKey,
- // FIXME: only change if pointer really changed
- updateClock: getTimestampNow(),
- };
- await tx.exchanges.put(r);
- await tx.exchangeDetails.put(details);
-
- logger.trace("updating denominations in database");
- const currentDenomSet = new Set<string>(
- keysInfo.currentDenominations.map((x) => x.denomPubHash),
- );
- for (const currentDenom of keysInfo.currentDenominations) {
- const oldDenom = await tx.denominations.get([
- baseUrl,
- currentDenom.denomPubHash,
- ]);
- if (oldDenom) {
- // FIXME: Do consistency check, report to auditor if necessary.
- } else {
- await tx.denominations.put(currentDenom);
- }
- }
-
- // Update list issue date for all denominations,
- // and mark non-offered denominations as such.
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(r.baseUrl)
- .forEachAsync(async (x) => {
- if (!currentDenomSet.has(x.denomPubHash)) {
- // FIXME: Here, an auditor report should be created, unless
- // the denomination is really legally expired.
- if (x.isOffered) {
- x.isOffered = false;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=false`,
- );
- }
- } else {
- x.listIssueDate = keysInfo.listIssueDate;
- if (!x.isOffered) {
- x.isOffered = true;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=true`,
- );
- }
- }
- await tx.denominations.put(x);
- });
-
- logger.trace("done updating denominations in database");
-
- // Handle recoup
- const recoupDenomList = keysInfo.recoup;
- const newlyRevokedCoinPubs: string[] = [];
- logger.trace("recoup list from exchange", recoupDenomList);
- for (const recoupInfo of recoupDenomList) {
- const oldDenom = await tx.denominations.get([
- r.baseUrl,
- recoupInfo.h_denom_pub,
- ]);
- if (!oldDenom) {
- // We never even knew about the revoked denomination, all good.
- continue;
- }
- if (oldDenom.isRevoked) {
- // We already marked the denomination as revoked,
- // this implies we revoked all coins
- logger.trace("denom already revoked");
- continue;
- }
- logger.trace("revoking denom", recoupInfo.h_denom_pub);
- oldDenom.isRevoked = true;
- await tx.denominations.put(oldDenom);
- const affectedCoins = await tx.coins.indexes.byDenomPubHash
- .iter(recoupInfo.h_denom_pub)
- .toArray();
- for (const ac of affectedCoins) {
- newlyRevokedCoinPubs.push(ac.coinPub);
- }
- }
- if (newlyRevokedCoinPubs.length != 0) {
- logger.trace("recouping coins", newlyRevokedCoinPubs);
- recoupGroupId = await ws.recoupOps.createRecoupGroup(
- ws,
- tx,
- newlyRevokedCoinPubs,
- );
- }
- return {
- exchange: r,
- exchangeDetails: details,
- };
- });
-
- if (recoupGroupId) {
- // Asynchronously start recoup. This doesn't need to finish
- // for the exchange update to be considered finished.
- ws.recoupOps.processRecoupGroup(ws, recoupGroupId).catch((e) => {
- logger.error("error while recouping coins:", e);
- });
- }
-
- if (!updated) {
- throw Error("something went wrong with updating the exchange");
- }
-
- logger.trace("done updating exchange info in database");
-
- return {
- exchange: updated.exchange,
- exchangeDetails: updated.exchangeDetails,
- };
-}
-
-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 details = await getExchangeDetails
- .makeContext(ws.db)
- .runReadOnly(async (tx) => {
- return getExchangeDetails(tx, exchangeBaseUrl);
- });
- const accounts = details?.wireInfo.accounts ?? [];
- for (const account of accounts) {
- const res = parsePaytoUri(account.payto_uri);
- if (!res) {
- continue;
- }
- if (supportedTargetTypes.includes(res.targetType)) {
- return account.payto_uri;
- }
- }
- throw Error("no matching exchange account found");
-}
-
-/**
- * Check if and how an exchange is trusted and/or audited.
- */
-export async function getExchangeTrust(
- ws: InternalWalletState,
- exchangeInfo: ExchangeRecord,
-): Promise<TrustInfo> {
- let isTrusted = false;
- let isAudited = false;
-
- return await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- exchangesTrustStore: x.exchangeTrust,
- auditorTrust: x.auditorTrust,
- }))
- .runReadOnly(async (tx) => {
- const exchangeDetails = await getExchangeDetails(
- tx,
- exchangeInfo.baseUrl,
- );
-
- if (!exchangeDetails) {
- throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
- }
- const exchangeTrustRecord = await tx.exchangesTrustStore.indexes.byExchangeMasterPub.get(
- exchangeDetails.masterPublicKey,
- );
- if (
- exchangeTrustRecord &&
- exchangeTrustRecord.uids.length > 0 &&
- exchangeTrustRecord.currency === exchangeDetails.currency
- ) {
- isTrusted = true;
- }
-
- for (const auditor of exchangeDetails.auditors) {
- const auditorTrustRecord = await tx.auditorTrust.indexes.byAuditorPub.get(
- auditor.auditor_pub,
- );
- if (auditorTrustRecord && auditorTrustRecord.uids.length > 0) {
- isAudited = true;
- break;
- }
- }
-
- return { isTrusted, isAudited };
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts
deleted file mode 100644
index 8fad55994..000000000
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ /dev/null
@@ -1,1768 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Implementation of the payment operation, including downloading and
- * claiming of proposals.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
- AmountJson,
- Amounts,
- timestampIsBetween,
- getTimestampNow,
- isTimestampExpired,
- Timestamp,
- RefreshReason,
- CoinDepositPermission,
- NotificationType,
- TalerErrorDetails,
- Duration,
- durationMax,
- durationMin,
- durationMul,
- ContractTerms,
- codecForProposal,
- TalerErrorCode,
- codecForContractTerms,
- timestampAddDuration,
- ConfirmPayResult,
- ConfirmPayResultType,
- codecForMerchantPayResponse,
- PreparePayResult,
- PreparePayResultType,
- parsePayUri,
- Logger,
- URL,
- getDurationRemaining,
-} from "@gnu-taler/taler-util";
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import {
- PayCoinSelection,
- CoinCandidateSelection,
- AvailableCoinInfo,
- selectPayCoins,
- PreviousPayCoins,
-} from "../util/coinSelection.js";
-import { j2s } from "@gnu-taler/taler-util";
-import {
- initRetryInfo,
- updateRetryInfoTimeout,
- getRetryDuration,
-} from "../util/retries.js";
-import { getTotalRefreshCost, createRefreshGroup } from "./refresh.js";
-import { InternalWalletState, EXCHANGE_COINS_LOCK } from "../common.js";
-import { ContractTermsUtil } from "../util/contractTerms.js";
-import { getExchangeDetails } from "./exchanges.js";
-import { GetReadWriteAccess } from "../util/query.js";
-import {
- AbortStatus,
- AllowedAuditorInfo,
- AllowedExchangeInfo,
- BackupProviderStateTag,
- CoinRecord,
- CoinStatus,
- DenominationRecord,
- ProposalRecord,
- ProposalStatus,
- PurchaseRecord,
- WalletContractData,
- WalletStoresV1,
-} from "../db.js";
-import {
- getHttpResponseErrorDetails,
- HttpResponseStatus,
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
- readUnexpectedResponseDetails,
- throwUnexpectedRequestError,
-} from "../util/http.js";
-import {
- guardOperationException,
- makeErrorDetails,
- OperationFailedAndReportedError,
- OperationFailedError,
-} from "../errors.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("pay.ts");
-
-/**
- * Compute the total cost of a payment to the customer.
- *
- * This includes the amount taken by the merchant, fees (wire/deposit) contributed
- * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
- * of coins that are too small to spend.
- */
-export async function getTotalPaymentCost(
- ws: InternalWalletState,
- pcs: PayCoinSelection,
-): Promise<AmountJson> {
- return ws.db
- .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- const costs = [];
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
- );
- }
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter()
- .toArray();
- const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
- .amount;
- const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
- costs.push(pcs.coinContributions[i]);
- costs.push(refreshCost);
- }
- const zero = Amounts.getZero(pcs.paymentAmount.currency);
- return Amounts.sum([zero, ...costs]).amount;
- });
-}
-
-/**
- * Get the amount that will be deposited on the merchant's bank
- * account, not considering aggregation.
- */
-export async function getEffectiveDepositAmount(
- ws: InternalWalletState,
- wireType: string,
- pcs: PayCoinSelection,
-): Promise<AmountJson> {
- const amt: AmountJson[] = [];
- const fees: AmountJson[] = [];
- const exchangeSet: Set<string> = new Set();
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate deposit amountt, coin not found");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error("can't find denomination to calculate deposit amount");
- }
- amt.push(pcs.coinContributions[i]);
- fees.push(denom.feeDeposit);
- exchangeSet.add(coin.exchangeBaseUrl);
- }
- for (const exchangeUrl of exchangeSet.values()) {
- const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
- if (!exchangeDetails) {
- continue;
- }
- const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
- return timestampIsBetween(
- getTimestampNow(),
- x.startStamp,
- x.endStamp,
- );
- })?.wireFee;
- if (fee) {
- fees.push(fee);
- }
- }
- });
- return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
-}
-
-function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean {
- if (coin.suspended) {
- return false;
- }
- if (denom.isRevoked) {
- return false;
- }
- if (!denom.isOffered) {
- return false;
- }
- if (coin.status !== CoinStatus.Fresh) {
- return false;
- }
- if (isTimestampExpired(denom.stampExpireDeposit)) {
- return false;
- }
- return true;
-}
-
-export interface CoinSelectionRequest {
- amount: AmountJson;
-
- allowedAuditors: AllowedAuditorInfo[];
- allowedExchanges: AllowedExchangeInfo[];
-
- /**
- * Timestamp of the contract.
- */
- timestamp: Timestamp;
-
- wireMethod: string;
-
- wireFeeAmortization: number;
-
- maxWireFee: AmountJson;
-
- maxDepositFee: AmountJson;
-}
-
-/**
- * Get candidate coins. From these candidate coins,
- * the actual contributions will be computed later.
- *
- * The resulting candidate coin list is sorted deterministically.
- *
- * TODO: Exclude more coins:
- * - when we already have a coin with more remaining amount than
- * the payment amount, coins with even higher amounts can be skipped.
- */
-export async function getCandidatePayCoins(
- ws: InternalWalletState,
- req: CoinSelectionRequest,
-): Promise<CoinCandidateSelection> {
- const candidateCoins: AvailableCoinInfo[] = [];
- const wireFeesPerExchange: Record<string, AmountJson> = {};
-
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- denominations: x.denominations,
- coins: x.coins,
- }))
- .runReadOnly(async (tx) => {
- const exchanges = await tx.exchanges.iter().toArray();
- for (const exchange of exchanges) {
- let isOkay = false;
- const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
- if (!exchangeDetails) {
- continue;
- }
- const exchangeFees = exchangeDetails.wireInfo;
- if (!exchangeFees) {
- continue;
- }
-
- // is the exchange explicitly allowed?
- for (const allowedExchange of req.allowedExchanges) {
- if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
- isOkay = true;
- break;
- }
- }
-
- // is the exchange allowed because of one of its auditors?
- if (!isOkay) {
- for (const allowedAuditor of req.allowedAuditors) {
- for (const auditor of exchangeDetails.auditors) {
- if (auditor.auditor_pub === allowedAuditor.auditorPub) {
- isOkay = true;
- break;
- }
- }
- if (isOkay) {
- break;
- }
- }
- }
-
- if (!isOkay) {
- continue;
- }
-
- const coins = await tx.coins.indexes.byBaseUrl
- .iter(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 tx.denominations.get([
- exchange.baseUrl,
- coins[0].denomPubHash,
- ]);
- if (!firstDenom) {
- throw Error("db inconsistent");
- }
- const currency = firstDenom.value.currency;
- for (const coin of coins) {
- const denom = await tx.denominations.get([
- exchange.baseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error("db inconsistent");
- }
- if (denom.value.currency !== currency) {
- logger.warn(
- `same pubkey for different currencies at exchange ${exchange.baseUrl}`,
- );
- continue;
- }
- if (!isSpendableCoin(coin, denom)) {
- continue;
- }
- candidateCoins.push({
- availableAmount: coin.currentAmount,
- coinPub: coin.coinPub,
- denomPub: coin.denomPub,
- feeDeposit: denom.feeDeposit,
- exchangeBaseUrl: denom.exchangeBaseUrl,
- });
- }
-
- let wireFee: AmountJson | undefined;
- for (const fee of exchangeFees.feesForType[req.wireMethod] || []) {
- if (
- fee.startStamp <= req.timestamp &&
- fee.endStamp >= req.timestamp
- ) {
- wireFee = fee.wireFee;
- break;
- }
- }
- if (wireFee) {
- wireFeesPerExchange[exchange.baseUrl] = wireFee;
- }
- }
- });
-
- return {
- candidateCoins,
- wireFeesPerExchange,
- };
-}
-
-/**
- * Apply a coin selection to the database. Marks coins as spent
- * and creates a refresh session for the remaining amount.
- *
- * FIXME: This does not deal well with conflicting spends!
- * When two payments are made in parallel, the same coin can be selected
- * for two payments.
- * However, this is a situation that can also happen via sync.
- */
-export async function applyCoinSpend(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- coinSelection: PayCoinSelection,
- allocationId: string,
-) {
- for (let i = 0; i < coinSelection.coinPubs.length; i++) {
- const coin = await tx.coins.get(coinSelection.coinPubs[i]);
- if (!coin) {
- throw Error("coin allocated for payment doesn't exist anymore");
- }
- const contrib = coinSelection.coinContributions[i];
- if (coin.status !== CoinStatus.Fresh) {
- const alloc = coin.allocation;
- if (!alloc) {
- continue;
- }
- if (alloc.id !== allocationId) {
- // FIXME: assign error code
- throw Error("conflicting coin allocation (id)");
- }
- if (0 !== Amounts.cmp(alloc.amount, contrib)) {
- // FIXME: assign error code
- throw Error("conflicting coin allocation (contrib)");
- }
- continue;
- }
- coin.status = CoinStatus.Dormant;
- coin.allocation = {
- id: allocationId,
- amount: Amounts.stringify(contrib),
- };
- const remaining = Amounts.sub(coin.currentAmount, contrib);
- if (remaining.saturated) {
- throw Error("not enough remaining balance on coin for payment");
- }
- coin.currentAmount = remaining.amount;
- await tx.coins.put(coin);
- }
- const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
- coinPub: x,
- }));
- await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
-}
-
-/**
- * Record all information that is necessary to
- * pay for a proposal in the wallet's database.
- */
-async function recordConfirmPay(
- ws: InternalWalletState,
- proposal: ProposalRecord,
- coinSelection: PayCoinSelection,
- coinDepositPermissions: CoinDepositPermission[],
- sessionIdOverride: string | undefined,
-): Promise<PurchaseRecord> {
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
- let sessionId;
- if (sessionIdOverride) {
- sessionId = sessionIdOverride;
- } else {
- sessionId = proposal.downloadSessionId;
- }
- logger.trace(
- `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
- );
- const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
- const t: PurchaseRecord = {
- abortStatus: AbortStatus.None,
- download: d,
- lastSessionId: sessionId,
- payCoinSelection: coinSelection,
- payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
- totalPayCost: payCostInfo,
- coinDepositPermissions,
- timestampAccept: getTimestampNow(),
- timestampLastRefundStatus: undefined,
- proposalId: proposal.proposalId,
- lastPayError: undefined,
- lastRefundStatusError: undefined,
- payRetryInfo: initRetryInfo(),
- refundStatusRetryInfo: initRetryInfo(),
- refundQueryRequested: false,
- timestampFirstSuccessfulPay: undefined,
- autoRefundDeadline: undefined,
- paymentSubmitPending: true,
- refunds: {},
- merchantPaySig: undefined,
- noncePriv: proposal.noncePriv,
- noncePub: proposal.noncePub,
- };
-
- await ws.db
- .mktx((x) => ({
- proposals: x.proposals,
- purchases: x.purchases,
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- denominations: x.denominations,
- }))
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposal.proposalId);
- if (p) {
- p.proposalStatus = ProposalStatus.ACCEPTED;
- delete p.lastError;
- p.retryInfo = initRetryInfo();
- await tx.proposals.put(p);
- }
- await tx.purchases.put(t);
- await applyCoinSpend(ws, tx, coinSelection, `proposal:${t.proposalId}`);
- });
-
- ws.notify({
- type: NotificationType.ProposalAccepted,
- proposalId: proposal.proposalId,
- });
- return t;
-}
-
-async function incrementProposalRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const pr = await tx.proposals.get(proposalId);
- if (!pr) {
- return;
- }
- if (!pr.retryInfo) {
- return;
- }
- pr.retryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.retryInfo);
- pr.lastError = err;
- await tx.proposals.put(pr);
- });
- if (err) {
- ws.notify({ type: NotificationType.ProposalOperationError, error: err });
- }
-}
-
-async function incrementPurchasePayRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- logger.warn("incrementing purchase pay retry with error", err);
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const pr = await tx.purchases.get(proposalId);
- if (!pr) {
- return;
- }
- if (!pr.payRetryInfo) {
- pr.payRetryInfo = initRetryInfo();
- }
- pr.payRetryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.payRetryInfo);
- logger.trace(
- `retrying pay in ${
- getDurationRemaining(pr.payRetryInfo.nextRetry).d_ms
- } ms`,
- );
- pr.lastPayError = err;
- await tx.purchases.put(pr);
- });
- if (err) {
- ws.notify({ type: NotificationType.PayOperationError, error: err });
- }
-}
-
-export async function processDownloadProposal(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (err: TalerErrorDetails): Promise<void> =>
- incrementProposalRetry(ws, proposalId, err);
- await guardOperationException(
- () => processDownloadProposalImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetDownloadProposalRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposalId);
- if (p) {
- delete p.retryInfo;
- await tx.proposals.put(p);
- }
- });
-}
-
-async function failProposalPermanently(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposalId);
- if (!p) {
- return;
- }
- delete p.retryInfo;
- p.lastError = err;
- p.proposalStatus = ProposalStatus.PERMANENTLY_FAILED;
- await tx.proposals.put(p);
- });
-}
-
-function getProposalRequestTimeout(proposal: ProposalRecord): Duration {
- return durationMax(
- { d_ms: 60000 },
- durationMin({ d_ms: 5000 }, getRetryDuration(proposal.retryInfo)),
- );
-}
-
-function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
- return durationMul(
- { d_ms: 15000 },
- 1 + purchase.payCoinSelection.coinPubs.length / 5,
- );
-}
-
-export function extractContractData(
- parsedContractTerms: ContractTerms,
- contractTermsHash: string,
- merchantSig: string,
-): WalletContractData {
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- return {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.auditor_pub,
- })),
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- };
-}
-
-async function processDownloadProposalImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetDownloadProposalRetry(ws, proposalId);
- }
- const proposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
- });
- if (!proposal) {
- return;
- }
- if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
- return;
- }
-
- const orderClaimUrl = new URL(
- `orders/${proposal.orderId}/claim`,
- proposal.merchantBaseUrl,
- ).href;
- logger.trace("downloading contract from '" + orderClaimUrl + "'");
-
- const requestBody: {
- nonce: string;
- token?: string;
- } = {
- nonce: proposal.noncePub,
- };
- if (proposal.claimToken) {
- requestBody.token = proposal.claimToken;
- }
-
- const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, {
- timeout: getProposalRequestTimeout(proposal),
- });
- const r = await readSuccessResponseJsonOrErrorCode(
- httpResponse,
- codecForProposal(),
- );
- if (r.isError) {
- switch (r.talerErrorResponse.code) {
- case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
- "order already claimed (likely by other wallet)",
- {
- orderId: proposal.orderId,
- claimUrl: orderClaimUrl,
- },
- );
- default:
- throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
- }
- }
- const proposalResp = r.response;
-
- // The proposalResp contains the contract terms as raw JSON,
- // as the coded to parse them doesn't necessarily round-trip.
- // We need this raw JSON to compute the contract terms hash.
-
- // FIXME: Do better error handling, check if the
- // contract terms have all their forgettable information still
- // present. The wallet should never accept contract terms
- // with missing information from the merchant.
-
- const isWellFormed = ContractTermsUtil.validateForgettable(
- proposalResp.contract_terms,
- );
-
- if (!isWellFormed) {
- logger.trace(
- `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
- );
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
- "validation for well-formedness failed",
- {},
- );
- await failProposalPermanently(ws, proposalId, err);
- throw new OperationFailedAndReportedError(err);
- }
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(
- proposalResp.contract_terms,
- );
-
- logger.info(`Contract terms hash: ${contractTermsHash}`);
-
- let parsedContractTerms: ContractTerms;
-
- try {
- parsedContractTerms = codecForContractTerms().decode(
- proposalResp.contract_terms,
- );
- } catch (e) {
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
- "schema validation failed",
- {},
- );
- await failProposalPermanently(ws, proposalId, err);
- throw new OperationFailedAndReportedError(err);
- }
-
- const sigValid = await ws.cryptoApi.isValidContractTermsSignature(
- contractTermsHash,
- proposalResp.sig,
- parsedContractTerms.merchant_pub,
- );
-
- if (!sigValid) {
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
- "merchant's signature on contract terms is invalid",
- {
- merchantPub: parsedContractTerms.merchant_pub,
- orderId: parsedContractTerms.order_id,
- },
- );
- await failProposalPermanently(ws, proposalId, err);
- throw new OperationFailedAndReportedError(err);
- }
-
- const fulfillmentUrl = parsedContractTerms.fulfillment_url;
-
- const baseUrlForDownload = proposal.merchantBaseUrl;
- const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
-
- if (baseUrlForDownload !== baseUrlFromContractTerms) {
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
- "merchant base URL mismatch",
- {
- baseUrlForDownload,
- baseUrlFromContractTerms,
- },
- );
- await failProposalPermanently(ws, proposalId, err);
- throw new OperationFailedAndReportedError(err);
- }
-
- const contractData = extractContractData(
- parsedContractTerms,
- contractTermsHash,
- proposalResp.sig,
- );
-
- await ws.db
- .mktx((x) => ({ proposals: x.proposals, purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposalId);
- if (!p) {
- return;
- }
- if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
- return;
- }
- p.download = {
- contractData,
- contractTermsRaw: proposalResp.contract_terms,
- };
- if (
- fulfillmentUrl &&
- (fulfillmentUrl.startsWith("http://") ||
- fulfillmentUrl.startsWith("https://"))
- ) {
- const differentPurchase = await tx.purchases.indexes.byFulfillmentUrl.get(
- fulfillmentUrl,
- );
- if (differentPurchase) {
- logger.warn("repurchase detected");
- p.proposalStatus = ProposalStatus.REPURCHASE;
- p.repurchaseProposalId = differentPurchase.proposalId;
- await tx.proposals.put(p);
- return;
- }
- }
- p.proposalStatus = ProposalStatus.PROPOSED;
- await tx.proposals.put(p);
- });
-
- ws.notify({
- type: NotificationType.ProposalDownloaded,
- proposalId: proposal.proposalId,
- });
-}
-
-/**
- * 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 startDownloadProposal(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- orderId: string,
- sessionId: string | undefined,
- claimToken: string | undefined,
- noncePriv: string | undefined,
-): Promise<string> {
-
- const oldProposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.indexes.byUrlAndOrderId.get([
- merchantBaseUrl,
- orderId,
- ]);
- });
-
- /**
- * If we have already claimed this proposal with the same sessionId
- * nonce and claim token, reuse it.
- */
- if (oldProposal &&
- oldProposal.downloadSessionId === sessionId &&
- (!noncePriv || oldProposal.noncePriv === noncePriv) &&
- oldProposal.claimToken === claimToken) {
- await processDownloadProposal(ws, oldProposal.proposalId);
- return oldProposal.proposalId;
- }
-
- const { priv, pub } = await (noncePriv ? ws.cryptoApi.eddsaGetPublic(noncePriv) : ws.cryptoApi.createEddsaKeypair());
- const proposalId = encodeCrock(getRandomBytes(32));
-
- const proposalRecord: ProposalRecord = {
- download: undefined,
- noncePriv: priv,
- noncePub: pub,
- claimToken,
- timestamp: getTimestampNow(),
- merchantBaseUrl,
- orderId,
- proposalId: proposalId,
- proposalStatus: ProposalStatus.DOWNLOADING,
- repurchaseProposalId: undefined,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- downloadSessionId: sessionId,
- };
-
- await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const existingRecord = await tx.proposals.indexes.byUrlAndOrderId.get([
- merchantBaseUrl,
- orderId,
- ]);
- if (existingRecord) {
- // Created concurrently
- return;
- }
- await tx.proposals.put(proposalRecord);
- });
-
- await processDownloadProposal(ws, proposalId);
- return proposalId;
-}
-
-async function storeFirstPaySuccess(
- ws: InternalWalletState,
- proposalId: string,
- sessionId: string | undefined,
- paySig: string,
-): Promise<void> {
- const now = getTimestampNow();
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
-
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (!isFirst) {
- logger.warn("payment success already stored");
- return;
- }
- purchase.timestampFirstSuccessfulPay = now;
- purchase.paymentSubmitPending = false;
- purchase.lastPayError = undefined;
- purchase.lastSessionId = sessionId;
- purchase.payRetryInfo = initRetryInfo();
- purchase.merchantPaySig = paySig;
- if (isFirst) {
- const ar = purchase.download.contractData.autoRefund;
- if (ar) {
- logger.info("auto_refund present");
- purchase.refundQueryRequested = true;
- purchase.refundStatusRetryInfo = initRetryInfo();
- purchase.lastRefundStatusError = undefined;
- purchase.autoRefundDeadline = timestampAddDuration(now, ar);
- }
- }
- await tx.purchases.put(purchase);
- });
-}
-
-async function storePayReplaySuccess(
- ws: InternalWalletState,
- proposalId: string,
- sessionId: string | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
-
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (isFirst) {
- throw Error("invalid payment state");
- }
- purchase.paymentSubmitPending = false;
- purchase.lastPayError = undefined;
- purchase.payRetryInfo = initRetryInfo();
- purchase.lastSessionId = sessionId;
- await tx.purchases.put(purchase);
- });
-}
-
-/**
- * Handle a 409 Conflict response from the merchant.
- *
- * We do this by going through the coin history provided by the exchange and
- * (1) verifying the signatures from the exchange
- * (2) adjusting the remaining coin value and refreshing it
- * (3) re-do coin selection with the bad coin removed
- */
-async function handleInsufficientFunds(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails,
-): Promise<void> {
- logger.trace("handling insufficient funds, trying to re-select coins");
-
- const proposal = await ws.db
- .mktx((x) => ({ purchaes: x.purchases }))
- .runReadOnly(async (tx) => {
- return tx.purchaes.get(proposalId);
- });
- if (!proposal) {
- return;
- }
-
- const brokenCoinPub = (err as any).coin_pub;
-
- const exchangeReply = (err as any).exchange_reply;
- if (
- exchangeReply.code !== TalerErrorCode.EXCHANGE_DEPOSIT_INSUFFICIENT_FUNDS
- ) {
- // FIXME: set as failed
- throw Error("can't handle error code");
- }
-
- logger.trace(`got error details: ${j2s(err)}`);
-
- const { contractData } = proposal.download;
-
- const candidates = await getCandidatePayCoins(ws, {
- allowedAuditors: contractData.allowedAuditors,
- allowedExchanges: contractData.allowedExchanges,
- amount: contractData.amount,
- maxDepositFee: contractData.maxDepositFee,
- maxWireFee: contractData.maxWireFee,
- timestamp: contractData.timestamp,
- wireFeeAmortization: contractData.wireFeeAmortization,
- wireMethod: contractData.wireMethod,
- });
-
- const prevPayCoins: PreviousPayCoins = [];
-
- await ws.db
- .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- for (let i = 0; i < proposal.payCoinSelection.coinPubs.length; i++) {
- const coinPub = proposal.payCoinSelection.coinPubs[i];
- if (coinPub === brokenCoinPub) {
- continue;
- }
- const contrib = proposal.payCoinSelection.coinContributions[i];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- continue;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- continue;
- }
- prevPayCoins.push({
- coinPub,
- contribution: contrib,
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: denom.feeDeposit,
- });
- }
- });
-
- const res = selectPayCoins({
- candidates,
- contractTermsAmount: contractData.amount,
- depositFeeLimit: contractData.maxDepositFee,
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: contractData.maxWireFee,
- prevPayCoins,
- });
-
- if (!res) {
- logger.trace("insufficient funds for coin re-selection");
- return;
- }
-
- logger.trace("re-selected coins");
-
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- coins: x.coins,
- denominations: x.denominations,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- p.payCoinSelection = res;
- p.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
- p.coinDepositPermissions = undefined;
- await tx.purchases.put(p);
- await applyCoinSpend(ws, tx, res, `proposal:${p.proposalId}`);
- });
-}
-
-async function unblockBackup(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const bp = await tx.backupProviders.indexes.byPaymentProposalId
- .iter(proposalId)
- .forEachAsync(async (bp) => {
- if (bp.state.tag === BackupProviderStateTag.Retrying) {
- bp.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getTimestampNow(),
- };
- }
- });
- });
-}
-
-/**
- * Submit a payment to the merchant.
- *
- * If the wallet has previously paid, it just transmits the merchant's
- * own signature certifying that the wallet has previously paid.
- */
-async function submitPay(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<ConfirmPayResult> {
- const purchase = await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- throw Error("Purchase not found: " + proposalId);
- }
- if (purchase.abortStatus !== AbortStatus.None) {
- throw Error("not submitting payment for aborted purchase");
- }
- const sessionId = purchase.lastSessionId;
-
- logger.trace("paying with session ID", sessionId);
-
- if (!purchase.merchantPaySig) {
- const payUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/pay`,
- purchase.download.contractData.merchantBaseUrl,
- ).href;
-
- let depositPermissions: CoinDepositPermission[];
-
- if (purchase.coinDepositPermissions) {
- depositPermissions = purchase.coinDepositPermissions;
- } else {
- // FIXME: also cache!
- depositPermissions = await generateDepositPermissions(
- ws,
- purchase.payCoinSelection,
- purchase.download.contractData,
- );
- }
-
- const reqBody = {
- coins: depositPermissions,
- session_id: purchase.lastSessionId,
- };
-
- logger.trace(
- "making pay request ... ",
- JSON.stringify(reqBody, undefined, 2),
- );
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.postJson(payUrl, reqBody, {
- timeout: getPayRequestTimeout(purchase),
- }),
- );
-
- logger.trace(`got resp ${JSON.stringify(resp)}`);
-
- // Hide transient errors.
- if (
- (purchase.payRetryInfo?.retryCounter ?? 0) <= 5 &&
- resp.status >= 500 &&
- resp.status <= 599
- ) {
- logger.trace("treating /pay error as transient");
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "/pay failed",
- getHttpResponseErrorDetails(resp),
- );
- incrementPurchasePayRetry(ws, proposalId, undefined);
- return {
- type: ConfirmPayResultType.Pending,
- lastError: err,
- };
- }
-
- if (resp.status === HttpResponseStatus.BadRequest) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- logger.warn("unexpected 400 response for /pay");
- logger.warn(j2s(errDetails));
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const purch = await tx.purchases.get(proposalId);
- if (!purch) {
- return;
- }
- purch.payFrozen = true;
- purch.lastPayError = errDetails;
- delete purch.payRetryInfo;
- await tx.purchases.put(purch);
- });
- // FIXME: Maybe introduce a new return type for this instead of throwing?
- throw new OperationFailedAndReportedError(errDetails);
- }
-
- if (resp.status === HttpResponseStatus.Conflict) {
- const err = await readTalerErrorResponse(resp);
- if (
- err.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
- ) {
- // Do this in the background, as it might take some time
- handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
- await incrementProposalRetry(ws, proposalId, {
- code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- message: "unexpected exception",
- hint: "unexpected exception",
- details: {
- exception: e.toString(),
- },
- });
- });
-
- return {
- type: ConfirmPayResultType.Pending,
- // FIXME: should we return something better here?
- lastError: err,
- };
- }
- }
-
- const merchantResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantPayResponse(),
- );
-
- logger.trace("got success from pay URL", merchantResp);
-
- const merchantPub = purchase.download.contractData.merchantPub;
- const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
- merchantResp.sig,
- purchase.download.contractData.contractTermsHash,
- merchantPub,
- );
-
- if (!valid) {
- logger.error("merchant payment signature invalid");
- // FIXME: properly display error
- throw Error("merchant payment signature invalid");
- }
-
- await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp.sig);
- await unblockBackup(ws, proposalId);
- } else {
- const payAgainUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/paid`,
- purchase.download.contractData.merchantBaseUrl,
- ).href;
- const reqBody = {
- sig: purchase.merchantPaySig,
- h_contract: purchase.download.contractData.contractTermsHash,
- session_id: sessionId ?? "",
- };
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.postJson(payAgainUrl, reqBody),
- );
- // Hide transient errors.
- if (
- (purchase.payRetryInfo?.retryCounter ?? 0) <= 5 &&
- resp.status >= 500 &&
- resp.status <= 599
- ) {
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "/paid failed",
- getHttpResponseErrorDetails(resp),
- );
- incrementPurchasePayRetry(ws, proposalId, undefined);
- return {
- type: ConfirmPayResultType.Pending,
- lastError: err,
- };
- }
- if (resp.status !== 204) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "/paid failed",
- getHttpResponseErrorDetails(resp),
- );
- }
- await storePayReplaySuccess(ws, proposalId, sessionId);
- await unblockBackup(ws, proposalId);
- }
-
- ws.notify({
- type: NotificationType.PayOperationSuccess,
- proposalId: purchase.proposalId,
- });
-
- return {
- type: ConfirmPayResultType.Done,
- contractTerms: purchase.download.contractTermsRaw,
- };
-}
-
-export async function checkPaymentByProposalId(
- ws: InternalWalletState,
- proposalId: string,
- sessionId?: string,
-): Promise<PreparePayResult> {
- let proposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
- });
- if (!proposal) {
- throw Error(`could not get proposal ${proposalId}`);
- }
- if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
- const existingProposalId = proposal.repurchaseProposalId;
- if (!existingProposalId) {
- throw Error("invalid proposal state");
- }
- logger.trace("using existing purchase for same product");
- proposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.get(existingProposalId);
- });
- if (!proposal) {
- throw Error("existing proposal is in wrong state");
- }
- }
- const d = proposal.download;
- if (!d) {
- logger.error("bad proposal", proposal);
- throw Error("proposal is in invalid state");
- }
- const contractData = d.contractData;
- const merchantSig = d.contractData.merchantSig;
- if (!merchantSig) {
- throw Error("BUG: proposal is in invalid state");
- }
-
- proposalId = proposal.proposalId;
-
- // First check if we already paid for it.
- const purchase = await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!purchase) {
- // If not already paid, check if we could pay for it.
- const candidates = await getCandidatePayCoins(ws, {
- allowedAuditors: contractData.allowedAuditors,
- allowedExchanges: contractData.allowedExchanges,
- amount: contractData.amount,
- maxDepositFee: contractData.maxDepositFee,
- maxWireFee: contractData.maxWireFee,
- timestamp: contractData.timestamp,
- wireFeeAmortization: contractData.wireFeeAmortization,
- wireMethod: contractData.wireMethod,
- });
- const res = selectPayCoins({
- candidates,
- contractTermsAmount: contractData.amount,
- depositFeeLimit: contractData.maxDepositFee,
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: contractData.maxWireFee,
- prevPayCoins: [],
- });
-
- if (!res) {
- logger.info("not confirming payment, insufficient coins");
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- noncePriv: proposal.noncePriv,
- amountRaw: Amounts.stringify(d.contractData.amount),
- };
- }
-
- const totalCost = await getTotalPaymentCost(ws, res);
- logger.trace("costInfo", totalCost);
- logger.trace("coinsForPayment", res);
-
- return {
- status: PreparePayResultType.PaymentPossible,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- noncePriv: proposal.noncePriv,
- amountEffective: Amounts.stringify(totalCost),
- amountRaw: Amounts.stringify(res.paymentAmount),
- contractTermsHash: d.contractData.contractTermsHash,
- };
- }
-
- if (purchase.lastSessionId !== sessionId) {
- logger.trace(
- "automatically re-submitting payment with different session ID",
- );
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- p.lastSessionId = sessionId;
- await tx.purchases.put(p);
- });
- const r = await guardOperationException(
- () => submitPay(ws, proposalId),
- (e: TalerErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e),
- );
- if (r.type !== ConfirmPayResultType.Done) {
- throw Error("submitting pay failed");
- }
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- paid: true,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
- proposalId,
- };
- } else if (!purchase.timestampFirstSuccessfulPay) {
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- paid: false,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
- proposalId,
- };
- } else {
- const paid = !purchase.paymentSubmitPending;
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- paid,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
- ...(paid ? { nextUrl: purchase.download.contractData.orderId } : {}),
- proposalId,
- };
- }
-}
-
-/**
- * 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 preparePayForUri(
- ws: InternalWalletState,
- talerPayUri: string,
-): Promise<PreparePayResult> {
- const uriResult = parsePayUri(talerPayUri);
-
- if (!uriResult) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
- `invalid taler://pay URI (${talerPayUri})`,
- {
- talerPayUri,
- },
- );
- }
-
- let proposalId = await startDownloadProposal(
- ws,
- uriResult.merchantBaseUrl,
- uriResult.orderId,
- uriResult.sessionId,
- uriResult.claimToken,
- uriResult.noncePriv,
- );
-
- return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
-}
-
-/**
- * Generate deposit permissions for a purchase.
- *
- * Accesses the database and the crypto worker.
- */
-export async function generateDepositPermissions(
- ws: InternalWalletState,
- payCoinSel: PayCoinSelection,
- contractData: WalletContractData,
-): Promise<CoinDepositPermission[]> {
- const depositPermissions: CoinDepositPermission[] = [];
- const coinWithDenom: Array<{
- coin: CoinRecord;
- denom: DenominationRecord;
- }> = [];
- await ws.db
- .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
- const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
- if (!coin) {
- throw Error("can't pay, allocated coin not found anymore");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't pay, denomination of allocated coin not found anymore",
- );
- }
- coinWithDenom.push({ coin, denom });
- }
- });
-
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
- const { coin, denom } = coinWithDenom[i];
- const dp = await ws.cryptoApi.signDepositPermission({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contractTermsHash: contractData.contractTermsHash,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: denom.feeDeposit,
- merchantPub: contractData.merchantPub,
- refundDeadline: contractData.refundDeadline,
- spendAmount: payCoinSel.coinContributions[i],
- timestamp: contractData.timestamp,
- wireInfoHash: contractData.wireInfoHash,
- });
- depositPermissions.push(dp);
- }
- return depositPermissions;
-}
-
-/**
- * Add a contract to the wallet and sign coins, and send them.
- */
-export async function confirmPay(
- ws: InternalWalletState,
- proposalId: string,
- sessionIdOverride?: string,
-): Promise<ConfirmPayResult> {
- logger.trace(
- `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
- );
- const proposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
- });
-
- if (!proposal) {
- throw Error(`proposal with id ${proposalId} not found`);
- }
-
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
-
- const existingPurchase = await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (
- purchase &&
- sessionIdOverride !== undefined &&
- sessionIdOverride != purchase.lastSessionId
- ) {
- logger.trace(`changing session ID to ${sessionIdOverride}`);
- purchase.lastSessionId = sessionIdOverride;
- purchase.paymentSubmitPending = true;
- await tx.purchases.put(purchase);
- }
- return purchase;
- });
-
- if (existingPurchase) {
- logger.trace("confirmPay: submitting payment for existing purchase");
- return await guardOperationException(
- () => submitPay(ws, proposalId),
- (e: TalerErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e),
- );
- }
-
- logger.trace("confirmPay: purchase record does not exist yet");
-
- const contractData = d.contractData;
-
- const candidates = await getCandidatePayCoins(ws, {
- allowedAuditors: contractData.allowedAuditors,
- allowedExchanges: contractData.allowedExchanges,
- amount: contractData.amount,
- maxDepositFee: contractData.maxDepositFee,
- maxWireFee: contractData.maxWireFee,
- timestamp: contractData.timestamp,
- wireFeeAmortization: contractData.wireFeeAmortization,
- wireMethod: contractData.wireMethod,
- });
-
- const res = selectPayCoins({
- candidates,
- contractTermsAmount: contractData.amount,
- depositFeeLimit: contractData.maxDepositFee,
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: contractData.maxWireFee,
- prevPayCoins: [],
- });
-
- logger.trace("coin selection result", res);
-
- if (!res) {
- // Should not happen, since checkPay should be called first
- // FIXME: Actually, this should be handled gracefully,
- // and the status should be stored in the DB.
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
- }
-
- const depositPermissions = await generateDepositPermissions(
- ws,
- res,
- d.contractData,
- );
- await recordConfirmPay(
- ws,
- proposal,
- res,
- depositPermissions,
- sessionIdOverride,
- );
-
- return await guardOperationException(
- () => submitPay(ws, proposalId),
- (e: TalerErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e),
- );
-}
-
-export async function processPurchasePay(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e);
- await guardOperationException(
- () => processPurchasePayImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetPurchasePayRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (p) {
- p.payRetryInfo = initRetryInfo();
- await tx.purchases.put(p);
- }
- });
-}
-
-async function processPurchasePayImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetPurchasePayRetry(ws, proposalId);
- }
- const purchase = await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return;
- }
- if (!purchase.paymentSubmitPending) {
- return;
- }
- logger.trace(`processing purchase pay ${proposalId}`);
- await submitPay(ws, proposalId);
-}
-
-export async function refuseProposal(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const success = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const proposal = await tx.proposals.get(proposalId);
- if (!proposal) {
- logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
- return false;
- }
- if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
- return false;
- }
- proposal.proposalStatus = ProposalStatus.REFUSED;
- await tx.proposals.put(proposal);
- return true;
- });
- if (success) {
- ws.notify({
- type: NotificationType.ProposalRefused,
- });
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts
deleted file mode 100644
index a87b1c8b1..000000000
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ /dev/null
@@ -1,354 +0,0 @@
-/*
- 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/>
- */
-
-/**
- * Derive pending tasks from the wallet database.
- */
-
-/**
- * Imports.
- */
-import {
- ProposalStatus,
- ReserveRecordStatus,
- AbortStatus,
- WalletStoresV1,
- BackupProviderStateTag,
- RefreshCoinStatus,
-} from "../db.js";
-import {
- PendingOperationsResponse,
- PendingTaskType,
- ReserveType,
-} from "../pending-types.js";
-import {
- getTimestampNow,
- isTimestampExpired,
- Timestamp,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../common.js";
-import { getBalancesInsideTransaction } from "./balance.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-
-async function gatherExchangePending(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.exchanges.iter().forEachAsync(async (e) => {
- resp.pendingOperations.push({
- type: PendingTaskType.ExchangeUpdate,
- givesLifeness: false,
- timestampDue: e.nextUpdate,
- exchangeBaseUrl: e.baseUrl,
- lastError: e.lastError,
- });
-
- resp.pendingOperations.push({
- type: PendingTaskType.ExchangeCheckRefresh,
- timestampDue: e.nextRefreshCheck,
- givesLifeness: false,
- exchangeBaseUrl: e.baseUrl,
- });
- });
-}
-
-async function gatherReservePending(
- tx: GetReadOnlyAccess<{ reserves: typeof WalletStoresV1.reserves }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.reserves.iter().forEach((reserve) => {
- const reserveType = reserve.bankInfo
- ? ReserveType.TalerBankWithdraw
- : ReserveType.Manual;
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.DORMANT:
- // nothing to report as pending
- break;
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.QUERYING_STATUS:
- case ReserveRecordStatus.REGISTERING_BANK:
- resp.pendingOperations.push({
- type: PendingTaskType.Reserve,
- givesLifeness: true,
- timestampDue: reserve.retryInfo.nextRetry,
- stage: reserve.reserveStatus,
- timestampCreated: reserve.timestampCreated,
- reserveType,
- reservePub: reserve.reservePub,
- retryInfo: reserve.retryInfo,
- });
- break;
- default:
- // FIXME: report problem!
- break;
- }
- });
-}
-
-async function gatherRefreshPending(
- tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.refreshGroups.iter().forEach((r) => {
- if (r.timestampFinished) {
- return;
- }
- if (r.frozen) {
- return;
- }
- resp.pendingOperations.push({
- type: PendingTaskType.Refresh,
- givesLifeness: true,
- timestampDue: r.retryInfo.nextRetry,
- refreshGroupId: r.refreshGroupId,
- finishedPerCoin: r.statusPerCoin.map(
- (x) => x === RefreshCoinStatus.Finished,
- ),
- retryInfo: r.retryInfo,
- });
- });
-}
-
-async function gatherWithdrawalPending(
- tx: GetReadOnlyAccess<{
- withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
- planchets: typeof WalletStoresV1.planchets;
- }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.withdrawalGroups.iter().forEachAsync(async (wsr) => {
- if (wsr.timestampFinish) {
- return;
- }
- let numCoinsWithdrawn = 0;
- let numCoinsTotal = 0;
- await tx.planchets.indexes.byGroup
- .iter(wsr.withdrawalGroupId)
- .forEach((x) => {
- numCoinsTotal++;
- if (x.withdrawalDone) {
- numCoinsWithdrawn++;
- }
- });
- resp.pendingOperations.push({
- type: PendingTaskType.Withdraw,
- givesLifeness: true,
- timestampDue: wsr.retryInfo.nextRetry,
- withdrawalGroupId: wsr.withdrawalGroupId,
- lastError: wsr.lastError,
- retryInfo: wsr.retryInfo,
- });
- });
-}
-
-async function gatherProposalPending(
- tx: GetReadOnlyAccess<{ proposals: typeof WalletStoresV1.proposals }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.proposals.iter().forEach((proposal) => {
- if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
- // Nothing to do, user needs to choose.
- } else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) {
- const timestampDue = proposal.retryInfo?.nextRetry ?? getTimestampNow();
- resp.pendingOperations.push({
- type: PendingTaskType.ProposalDownload,
- givesLifeness: true,
- timestampDue,
- merchantBaseUrl: proposal.merchantBaseUrl,
- orderId: proposal.orderId,
- proposalId: proposal.proposalId,
- proposalTimestamp: proposal.timestamp,
- lastError: proposal.lastError,
- retryInfo: proposal.retryInfo,
- });
- }
- });
-}
-
-async function gatherDepositPending(
- tx: GetReadOnlyAccess<{ depositGroups: typeof WalletStoresV1.depositGroups }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.depositGroups.iter().forEach((dg) => {
- if (dg.timestampFinished) {
- return;
- }
- const timestampDue = dg.retryInfo?.nextRetry ?? getTimestampNow();
- resp.pendingOperations.push({
- type: PendingTaskType.Deposit,
- givesLifeness: true,
- timestampDue,
- depositGroupId: dg.depositGroupId,
- lastError: dg.lastError,
- retryInfo: dg.retryInfo,
- });
- });
-}
-
-async function gatherTipPending(
- tx: GetReadOnlyAccess<{ tips: typeof WalletStoresV1.tips }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.tips.iter().forEach((tip) => {
- if (tip.pickedUpTimestamp) {
- return;
- }
- if (tip.acceptedTimestamp) {
- resp.pendingOperations.push({
- type: PendingTaskType.TipPickup,
- givesLifeness: true,
- timestampDue: tip.retryInfo.nextRetry,
- merchantBaseUrl: tip.merchantBaseUrl,
- tipId: tip.walletTipId,
- merchantTipId: tip.merchantTipId,
- });
- }
- });
-}
-
-async function gatherPurchasePending(
- tx: GetReadOnlyAccess<{ purchases: typeof WalletStoresV1.purchases }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.purchases.iter().forEach((pr) => {
- if (
- pr.paymentSubmitPending &&
- pr.abortStatus === AbortStatus.None &&
- !pr.payFrozen
- ) {
- const timestampDue = pr.payRetryInfo?.nextRetry ?? getTimestampNow();
- resp.pendingOperations.push({
- type: PendingTaskType.Pay,
- givesLifeness: true,
- timestampDue,
- isReplay: false,
- proposalId: pr.proposalId,
- retryInfo: pr.payRetryInfo,
- lastError: pr.lastPayError,
- });
- }
- if (pr.refundQueryRequested) {
- resp.pendingOperations.push({
- type: PendingTaskType.RefundQuery,
- givesLifeness: true,
- timestampDue: pr.refundStatusRetryInfo.nextRetry,
- proposalId: pr.proposalId,
- retryInfo: pr.refundStatusRetryInfo,
- lastError: pr.lastRefundStatusError,
- });
- }
- });
-}
-
-async function gatherRecoupPending(
- tx: GetReadOnlyAccess<{ recoupGroups: typeof WalletStoresV1.recoupGroups }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.recoupGroups.iter().forEach((rg) => {
- if (rg.timestampFinished) {
- return;
- }
- resp.pendingOperations.push({
- type: PendingTaskType.Recoup,
- givesLifeness: true,
- timestampDue: rg.retryInfo.nextRetry,
- recoupGroupId: rg.recoupGroupId,
- retryInfo: rg.retryInfo,
- lastError: rg.lastError,
- });
- });
-}
-
-async function gatherBackupPending(
- tx: GetReadOnlyAccess<{
- backupProviders: typeof WalletStoresV1.backupProviders;
- }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.backupProviders.iter().forEach((bp) => {
- if (bp.state.tag === BackupProviderStateTag.Ready) {
- resp.pendingOperations.push({
- type: PendingTaskType.Backup,
- givesLifeness: false,
- timestampDue: bp.state.nextBackupTimestamp,
- backupProviderBaseUrl: bp.baseUrl,
- lastError: undefined,
- });
- } else if (bp.state.tag === BackupProviderStateTag.Retrying) {
- resp.pendingOperations.push({
- type: PendingTaskType.Backup,
- givesLifeness: false,
- timestampDue: bp.state.retryInfo.nextRetry,
- backupProviderBaseUrl: bp.baseUrl,
- retryInfo: bp.state.retryInfo,
- lastError: bp.state.lastError,
- });
- }
- });
-}
-
-export async function getPendingOperations(
- ws: InternalWalletState,
-): Promise<PendingOperationsResponse> {
- const now = getTimestampNow();
- return await ws.db
- .mktx((x) => ({
- backupProviders: x.backupProviders,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- reserves: x.reserves,
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- withdrawalGroups: x.withdrawalGroups,
- proposals: x.proposals,
- tips: x.tips,
- purchases: x.purchases,
- planchets: x.planchets,
- depositGroups: x.depositGroups,
- recoupGroups: x.recoupGroups,
- }))
- .runReadWrite(async (tx) => {
- const walletBalance = await getBalancesInsideTransaction(ws, tx);
- const resp: PendingOperationsResponse = {
- walletBalance,
- pendingOperations: [],
- };
- await gatherExchangePending(tx, now, resp);
- await gatherReservePending(tx, now, resp);
- await gatherRefreshPending(tx, now, resp);
- await gatherWithdrawalPending(tx, now, resp);
- await gatherProposalPending(tx, now, resp);
- await gatherDepositPending(tx, now, resp);
- await gatherTipPending(tx, now, resp);
- await gatherPurchasePending(tx, now, resp);
- await gatherRecoupPending(tx, now, resp);
- await gatherBackupPending(tx, now, resp);
- return resp;
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts
deleted file mode 100644
index b1f46e4ba..000000000
--- a/packages/taler-wallet-core/src/operations/recoup.ts
+++ /dev/null
@@ -1,485 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2020 Taler Systems SA
-
- 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/>
- */
-
-/**
- * Implementation of the recoup operation, which allows to recover the
- * value of coins held in a revoked denomination.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- Amounts,
- codecForRecoupConfirmation,
- getTimestampNow,
- NotificationType,
- RefreshReason,
- TalerErrorDetails,
-} from "@gnu-taler/taler-util";
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import {
- CoinRecord,
- CoinSourceType,
- CoinStatus,
- RecoupGroupRecord,
- RefreshCoinSource,
- ReserveRecordStatus,
- WithdrawCoinSource,
- WalletStoresV1,
-} from "../db.js";
-
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { Logger, URL } from "@gnu-taler/taler-util";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import { guardOperationException } from "../errors.js";
-import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
-import { getReserveRequestTimeout, processReserve } from "./reserves.js";
-import { InternalWalletState } from "../common.js";
-import { GetReadWriteAccess } from "../util/query.js";
-
-const logger = new Logger("operations/recoup.ts");
-
-async function incrementRecoupRetry(
- ws: InternalWalletState,
- recoupGroupId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.recoupGroups.get(recoupGroupId);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.recoupGroups.put(r);
- });
- if (err) {
- ws.notify({ type: NotificationType.RecoupOperationError, error: err });
- }
-}
-
-async function putGroupAsFinished(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
- recoupGroup: RecoupGroupRecord,
- coinIdx: number,
-): Promise<void> {
- logger.trace(
- `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
- );
- if (recoupGroup.timestampFinished) {
- return;
- }
- recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
- let allFinished = true;
- for (const b of recoupGroup.recoupFinishedPerCoin) {
- if (!b) {
- allFinished = false;
- }
- }
- if (allFinished) {
- logger.trace("all recoups of recoup group are finished");
- recoupGroup.timestampFinished = getTimestampNow();
- recoupGroup.retryInfo = initRetryInfo();
- recoupGroup.lastError = undefined;
- if (recoupGroup.scheduleRefreshCoins.length > 0) {
- const refreshGroupId = await createRefreshGroup(
- ws,
- tx,
- recoupGroup.scheduleRefreshCoins.map((x) => ({ coinPub: x })),
- RefreshReason.Recoup,
- );
- processRefreshGroup(ws, refreshGroupId.refreshGroupId).then((e) => {
- console.error("error while refreshing after recoup", e);
- });
- }
- }
- await tx.recoupGroups.put(recoupGroup);
-}
-
-async function recoupTipCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
-): Promise<void> {
- // We can't really recoup a coin we got via tipping.
- // Thus we just put the coin to sleep.
- // FIXME: somehow report this to the user
- await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- denominations: WalletStoresV1.denominations,
- refreshGroups: WalletStoresV1.refreshGroups,
- coins: WalletStoresV1.coins,
- }))
- .runReadWrite(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
-}
-
-async function recoupWithdrawCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: WithdrawCoinSource,
-): Promise<void> {
- const reservePub = cs.reservePub;
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reservePub);
- });
- if (!reserve) {
- // FIXME: We should at least emit some pending operation / warning for this?
- return;
- }
-
- ws.notify({
- type: NotificationType.RecoupStarted,
- });
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
- const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
- const resp = await ws.http.postJson(reqUrl.href, recoupRequest, {
- timeout: getReserveRequestTimeout(reserve),
- });
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- if (recoupConfirmation.reserve_pub !== reservePub) {
- throw Error(`Coin's reserve doesn't match reserve on recoup`);
- }
-
- // FIXME: verify that our expectations about the amount match
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- reserves: x.reserves,
- recoupGroups: x.recoupGroups,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const updatedCoin = await tx.coins.get(coin.coinPub);
- if (!updatedCoin) {
- return;
- }
- const updatedReserve = await tx.reserves.get(reserve.reservePub);
- if (!updatedReserve) {
- return;
- }
- updatedCoin.status = CoinStatus.Dormant;
- const currency = updatedCoin.currentAmount.currency;
- updatedCoin.currentAmount = Amounts.getZero(currency);
- if (updatedReserve.reserveStatus === ReserveRecordStatus.DORMANT) {
- updatedReserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- updatedReserve.retryInfo = initRetryInfo();
- } else {
- updatedReserve.requestedQuery = true;
- updatedReserve.retryInfo = initRetryInfo();
- }
- await tx.coins.put(updatedCoin);
- await tx.reserves.put(updatedReserve);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
-
- ws.notify({
- type: NotificationType.RecoupFinished,
- });
-}
-
-async function recoupRefreshCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: RefreshCoinSource,
-): Promise<void> {
- ws.notify({
- type: NotificationType.RecoupStarted,
- });
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
- const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
- logger.trace(`making recoup request for ${coin.coinPub}`);
-
- const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
- throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
- }
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- reserves: x.reserves,
- recoupGroups: x.recoupGroups,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const oldCoin = await tx.coins.get(cs.oldCoinPub);
- const revokedCoin = await tx.coins.get(coin.coinPub);
- if (!revokedCoin) {
- logger.warn("revoked coin for recoup not found");
- return;
- }
- if (!oldCoin) {
- logger.warn("refresh old coin for recoup not found");
- return;
- }
- revokedCoin.status = CoinStatus.Dormant;
- oldCoin.currentAmount = Amounts.add(
- oldCoin.currentAmount,
- recoupGroup.oldAmountPerCoin[coinIdx],
- ).amount;
- logger.trace(
- "recoup: setting old coin amount to",
- Amounts.stringify(oldCoin.currentAmount),
- );
- recoupGroup.scheduleRefreshCoins.push(oldCoin.coinPub);
- await tx.coins.put(revokedCoin);
- await tx.coins.put(oldCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
-}
-
-async function resetRecoupGroupRetry(
- ws: InternalWalletState,
- recoupGroupId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.recoupGroups.get(recoupGroupId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.recoupGroups.put(x);
- }
- });
-}
-
-export async function processRecoupGroup(
- ws: InternalWalletState,
- recoupGroupId: string,
- forceNow = false,
-): Promise<void> {
- await ws.memoProcessRecoup.memo(recoupGroupId, async () => {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementRecoupRetry(ws, recoupGroupId, e);
- return await guardOperationException(
- async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow),
- onOpErr,
- );
- });
-}
-
-async function processRecoupGroupImpl(
- ws: InternalWalletState,
- recoupGroupId: string,
- forceNow = false,
-): Promise<void> {
- if (forceNow) {
- await resetRecoupGroupRetry(ws, recoupGroupId);
- }
- const recoupGroup = await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- }))
- .runReadOnly(async (tx) => {
- return tx.recoupGroups.get(recoupGroupId);
- });
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.timestampFinished) {
- logger.trace("recoup group finished");
- return;
- }
- const ps = recoupGroup.coinPubs.map((x, i) =>
- processRecoup(ws, recoupGroupId, i),
- );
- await Promise.all(ps);
-
- const reserveSet = new Set<string>();
- for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
- const coinPub = recoupGroup.coinPubs[i];
- const coin = await ws.db
- .mktx((x) => ({
- coins: x.coins,
- }))
- .runReadOnly(async (tx) => {
- return tx.coins.get(coinPub);
- });
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request recoup`);
- }
- if (coin.coinSource.type === CoinSourceType.Withdraw) {
- reserveSet.add(coin.coinSource.reservePub);
- }
- }
-
- for (const r of reserveSet.values()) {
- processReserve(ws, r).catch((e) => {
- logger.error(`processing reserve ${r} after recoup failed`);
- });
- }
-}
-
-export async function createRecoupGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
- coinPubs: string[],
-): Promise<string> {
- const recoupGroupId = encodeCrock(getRandomBytes(32));
-
- const recoupGroup: RecoupGroupRecord = {
- recoupGroupId,
- coinPubs: coinPubs,
- lastError: undefined,
- timestampFinished: undefined,
- timestampStarted: getTimestampNow(),
- retryInfo: initRetryInfo(),
- recoupFinishedPerCoin: coinPubs.map(() => false),
- // Will be populated later
- oldAmountPerCoin: [],
- scheduleRefreshCoins: [],
- };
-
- for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
- const coinPub = coinPubs[coinIdx];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- continue;
- }
- if (Amounts.isZero(coin.currentAmount)) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- continue;
- }
- recoupGroup.oldAmountPerCoin[coinIdx] = coin.currentAmount;
- coin.currentAmount = Amounts.getZero(coin.currentAmount.currency);
- await tx.coins.put(coin);
- }
-
- await tx.recoupGroups.put(recoupGroup);
-
- return recoupGroupId;
-}
-
-async function processRecoup(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
-): Promise<void> {
- const coin = await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- coins: x.coins,
- }))
- .runReadOnly(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.timestampFinished) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
-
- const coinPub = recoupGroup.coinPubs[coinIdx];
-
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request payback`);
- }
- return coin;
- });
-
- if (!coin) {
- return;
- }
-
- const cs = coin.coinSource;
-
- switch (cs.type) {
- case CoinSourceType.Tip:
- return recoupTipCoin(ws, recoupGroupId, coinIdx, coin);
- case CoinSourceType.Refresh:
- return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
- case CoinSourceType.Withdraw:
- return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
- default:
- throw Error("unknown coin source type");
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts
deleted file mode 100644
index 144514e1c..000000000
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ /dev/null
@@ -1,987 +0,0 @@
-/*
- 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 { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import {
- CoinRecord,
- CoinSourceType,
- CoinStatus,
- DenominationRecord,
- RefreshCoinStatus,
- RefreshGroupRecord,
- WalletStoresV1,
-} from "../db.js";
-import {
- codecForExchangeMeltResponse,
- codecForExchangeRevealResponse,
- CoinPublicKey,
- fnutil,
- NotificationType,
- RefreshGroupId,
- RefreshPlanchetInfo,
- RefreshReason,
- stringifyTimestamp,
- TalerErrorDetails,
- timestampToIsoString,
-} from "@gnu-taler/taler-util";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { amountToPretty } from "@gnu-taler/taler-util";
-import {
- HttpResponseStatus,
- readSuccessResponseJsonOrThrow,
- readUnexpectedResponseDetails,
-} from "../util/http.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import {
- Duration,
- durationFromSpec,
- durationMul,
- getTimestampNow,
- isTimestampExpired,
- Timestamp,
- timestampAddDuration,
- timestampDifference,
- timestampMin,
- URL,
-} from "@gnu-taler/taler-util";
-import { guardOperationException } from "../errors.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
-import { EXCHANGE_COINS_LOCK, InternalWalletState } from "../common.js";
-import {
- isWithdrawableDenom,
- selectWithdrawalDenominations,
-} from "./withdraw.js";
-import { RefreshNewDenomInfo } from "../crypto/cryptoTypes.js";
-import { GetReadWriteAccess } from "../util/query.js";
-
-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 = selectWithdrawalDenominations(withdrawAmount, denoms);
- const resultingAmount = Amounts.add(
- Amounts.getZero(withdrawAmount.currency),
- ...withdrawDenoms.selectedDenoms.map(
- (d) => Amounts.mult(d.denom.value, d.count).amount,
- ),
- ).amount;
- const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
- logger.trace(
- `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
- totalCost,
- )}`,
- );
- return totalCost;
-}
-
-function updateGroupStatus(rg: RefreshGroupRecord): void {
- let allDone = fnutil.all(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Frozen,
- );
- let anyFrozen = fnutil.any(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Frozen,
- );
- if (allDone) {
- if (anyFrozen) {
- rg.frozen = true;
- rg.retryInfo = initRetryInfo();
- } else {
- rg.timestampFinished = getTimestampNow();
- rg.retryInfo = initRetryInfo();
- }
- }
-}
-
-/**
- * Create a refresh session for one particular coin inside a refresh group.
- */
-async function refreshCreateSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
- );
-
- const d = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- if (
- refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished
- ) {
- return;
- }
- const existingRefreshSession =
- refreshGroup.refreshSessionPerCoin[coinIndex];
- if (existingRefreshSession) {
- return;
- }
- const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
- const coin = await tx.coins.get(oldCoinPub);
- if (!coin) {
- throw Error("Can't refresh, coin not found");
- }
- return { refreshGroup, coin };
- });
-
- if (!d) {
- return;
- }
-
- const { refreshGroup, coin } = d;
-
- const { exchange } = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
- if (!exchange) {
- throw Error("db inconsistent: exchange of coin not found");
- }
-
- // FIXME: use helper functions from withdraw.ts
- // to update and filter withdrawable denoms.
-
- const { availableAmount, availableDenoms } = await ws.db
- .mktx((x) => ({
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- const oldDenom = await tx.denominations.get([
- exchange.baseUrl,
- coin.denomPubHash,
- ]);
-
- if (!oldDenom) {
- throw Error("db inconsistent: denomination for coin not found");
- }
-
- // FIXME: use an index here, based on the withdrawal expiration time.
- const availableDenoms: DenominationRecord[] = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(exchange.baseUrl)
- .toArray();
-
- const availableAmount = Amounts.sub(
- refreshGroup.inputPerCoin[coinIndex],
- oldDenom.feeRefresh,
- ).amount;
- return { availableAmount, availableDenoms };
- });
-
- const newCoinDenoms = selectWithdrawalDenominations(
- availableAmount,
- availableDenoms,
- );
-
- if (logger.shouldLogTrace()) {
- logger.trace(`printing selected denominations for refresh`);
- logger.trace(`current time: ${stringifyTimestamp(getTimestampNow())}`);
- for (const denom of newCoinDenoms.selectedDenoms) {
- logger.trace(`denom ${denom.denom}, count ${denom.count}`);
- logger.trace(
- `withdrawal expiration ${stringifyTimestamp(
- denom.denom.stampExpireWithdraw,
- )}`,
- );
- }
- }
-
- if (newCoinDenoms.selectedDenoms.length === 0) {
- logger.trace(
- `not refreshing, available amount ${amountToPretty(
- availableAmount,
- )} too small`,
- );
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- updateGroupStatus(rg);
-
- await tx.refreshGroups.put(rg);
- });
- ws.notify({ type: NotificationType.RefreshUnwarranted });
- return;
- }
-
- const sessionSecretSeed = encodeCrock(getRandomBytes(64));
-
- // Store refresh session for this coin in the database.
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.refreshSessionPerCoin[coinIndex]) {
- return;
- }
- rg.refreshSessionPerCoin[coinIndex] = {
- norevealIndex: undefined,
- sessionSecretSeed: sessionSecretSeed,
- newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
- count: x.count,
- denomPubHash: x.denom.denomPubHash,
- })),
- amountRefreshOutput: newCoinDenoms.totalCoinValue,
- };
- await tx.refreshGroups.put(rg);
- });
- logger.info(
- `created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
- );
- ws.notify({ type: NotificationType.RefreshStarted });
-}
-
-function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
- return { d_ms: 5000 };
-}
-
-async function refreshMelt(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const d = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- denominations: x.denominations,
- }))
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- return;
- }
- if (refreshSession.norevealIndex !== undefined) {
- return;
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- ]);
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- ]);
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- feeWithdraw: newDenom.feeWithdraw,
- value: newDenom.value,
- });
- }
- return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
- });
-
- if (!d) {
- return;
- }
-
- const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: oldDenom.feeRefresh,
- newCoinDenoms,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const reqUrl = new URL(
- `coins/${oldCoin.coinPub}/melt`,
- oldCoin.exchangeBaseUrl,
- );
- const meltReq = {
- coin_pub: oldCoin.coinPub,
- confirm_sig: derived.confirmSig,
- denom_pub_hash: oldCoin.denomPubHash,
- denom_sig: oldCoin.denomSig,
- rc: derived.hash,
- value_with_fee: Amounts.stringify(derived.meltValueWithFee),
- };
- logger.trace(`melt request for coin:`, meltReq);
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.postJson(reqUrl.href, meltReq, {
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- if (resp.status === HttpResponseStatus.NotFound) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
- return;
- }
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Frozen;
- rg.lastErrorPerCoin[coinIndex] = errDetails;
- updateGroupStatus(rg);
- await tx.refreshGroups.put(rg);
- });
- return;
- }
-
- const meltResponse = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeMeltResponse(),
- );
-
- const norevealIndex = meltResponse.noreveal_index;
-
- refreshSession.norevealIndex = norevealIndex;
-
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- const rs = rg.refreshSessionPerCoin[coinIndex];
- if (!rs) {
- return;
- }
- if (rs.norevealIndex !== undefined) {
- return;
- }
- rs.norevealIndex = norevealIndex;
- await tx.refreshGroups.put(rg);
- });
-
- ws.notify({
- type: NotificationType.RefreshMelted,
- });
-}
-
-async function refreshReveal(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const d = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- return;
- }
- const norevealIndex = refreshSession.norevealIndex;
- if (norevealIndex === undefined) {
- throw Error("can't reveal without melting first");
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- ]);
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- ]);
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- feeWithdraw: newDenom.feeWithdraw,
- value: newDenom.value,
- });
- }
- return {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- };
- });
-
- if (!d) {
- return;
- }
-
- const {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- } = d;
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: oldDenom.feeRefresh,
- newCoinDenoms,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const privs = Array.from(derived.transferPrivs);
- privs.splice(norevealIndex, 1);
-
- const planchets = derived.planchetsForGammas[norevealIndex];
- if (!planchets) {
- throw Error("refresh index error");
- }
-
- const evs = planchets.map((x: RefreshPlanchetInfo) => x.coinEv);
- const newDenomsFlat: string[] = [];
- const linkSigs: string[] = [];
-
- for (let i = 0; i < refreshSession.newDenoms.length; i++) {
- const dsel = refreshSession.newDenoms[i];
- for (let j = 0; j < dsel.count; j++) {
- const newCoinIndex = linkSigs.length;
- const linkSig = await ws.cryptoApi.signCoinLink(
- oldCoin.coinPriv,
- dsel.denomPubHash,
- oldCoin.coinPub,
- derived.transferPubs[norevealIndex],
- planchets[newCoinIndex].coinEv,
- );
- linkSigs.push(linkSig);
- newDenomsFlat.push(dsel.denomPubHash);
- }
- }
-
- const req = {
- coin_evs: evs,
- new_denoms_h: newDenomsFlat,
- rc: derived.hash,
- transfer_privs: privs,
- transfer_pub: derived.transferPubs[norevealIndex],
- link_sigs: linkSigs,
- };
-
- const reqUrl = new URL(
- `refreshes/${derived.hash}/reveal`,
- oldCoin.exchangeBaseUrl,
- );
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.postJson(reqUrl.href, req, {
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- const reveal = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeRevealResponse(),
- );
-
- const coins: CoinRecord[] = [];
-
- for (let i = 0; i < refreshSession.newDenoms.length; i++) {
- for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
- const newCoinIndex = coins.length;
- // FIXME: Look up in earlier transaction!
- const denom = await ws.db
- .mktx((x) => ({
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- return tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- refreshSession.newDenoms[i].denomPubHash,
- ]);
- });
- if (!denom) {
- console.error("denom not found");
- continue;
- }
- const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
- const denomSig = await ws.cryptoApi.rsaUnblind(
- reveal.ev_sigs[newCoinIndex].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: oldCoin.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Refresh,
- oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
- },
- suspended: false,
- coinEvHash: pc.coinEv,
- };
-
- coins.push(coin);
- }
- }
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- logger.warn("no refresh session found");
- return;
- }
- const rs = rg.refreshSessionPerCoin[coinIndex];
- if (!rs) {
- return;
- }
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- updateGroupStatus(rg);
- for (const coin of coins) {
- await tx.coins.put(coin);
- }
- await tx.refreshGroups.put(rg);
- });
- logger.trace("refresh finished (end of reveal)");
- ws.notify({
- type: NotificationType.RefreshRevealed,
- });
-}
-
-async function incrementRefreshRetry(
- ws: InternalWalletState,
- refreshGroupId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.refreshGroups.get(refreshGroupId);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.refreshGroups.put(r);
- });
- if (err) {
- ws.notify({ type: NotificationType.RefreshOperationError, error: err });
- }
-}
-
-/**
- * Actually process a refresh group that has been created.
- */
-export async function processRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
- forceNow = false,
-): Promise<void> {
- await ws.memoProcessRefresh.memo(refreshGroupId, async () => {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementRefreshRetry(ws, refreshGroupId, e);
- return await guardOperationException(
- async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow),
- onOpErr,
- );
- });
-}
-
-async function resetRefreshGroupRetry(
- ws: InternalWalletState,
- refreshGroupId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.refreshGroups.get(refreshGroupId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.refreshGroups.put(x);
- }
- });
-}
-
-async function processRefreshGroupImpl(
- ws: InternalWalletState,
- refreshGroupId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetRefreshGroupRetry(ws, refreshGroupId);
- }
- const refreshGroup = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadOnly(async (tx) => {
- return tx.refreshGroups.get(refreshGroupId);
- });
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.timestampFinished) {
- return;
- }
- // Process refresh sessions of the group in parallel.
- const ps = refreshGroup.oldCoinPubs.map((x, i) =>
- processRefreshSession(ws, refreshGroupId, i),
- );
- await Promise.all(ps);
- logger.trace("refresh finished");
-}
-
-async function processRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
- );
- let refreshGroup = await ws.db
- .mktx((x) => ({ refreshGroups: x.refreshGroups }))
- .runReadOnly(async (tx) => {
- return tx.refreshGroups.get(refreshGroupId);
- });
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
- return;
- }
- if (!refreshGroup.refreshSessionPerCoin[coinIndex]) {
- await refreshCreateSession(ws, refreshGroupId, coinIndex);
- refreshGroup = await ws.db
- .mktx((x) => ({ refreshGroups: x.refreshGroups }))
- .runReadOnly(async (tx) => {
- return tx.refreshGroups.get(refreshGroupId);
- });
- if (!refreshGroup) {
- return;
- }
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- if (refreshGroup.statusPerCoin[coinIndex] !== RefreshCoinStatus.Finished) {
- throw Error(
- "BUG: refresh session was not created and coin not marked as finished",
- );
- }
- return;
- }
- if (refreshSession.norevealIndex === undefined) {
- await refreshMelt(ws, refreshGroupId, coinIndex);
- }
- await refreshReveal(ws, refreshGroupId, coinIndex);
-}
-
-/**
- * Create a refresh group for a list of coins.
- *
- * Refreshes the remaining amount on the coin, effectively capturing the remaining
- * value in the refresh group.
- *
- * The caller must ensure that
- * the remaining amount was updated correctly before the coin was deposited or
- * credited.
- *
- * The caller must also ensure that the coins that should be refreshed exist
- * in the current database transaction.
- */
-export async function createRefreshGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- }>,
- oldCoinPubs: CoinPublicKey[],
- reason: RefreshReason,
-): Promise<RefreshGroupId> {
- const refreshGroupId = encodeCrock(getRandomBytes(32));
-
- const inputPerCoin: AmountJson[] = [];
- const estimatedOutputPerCoin: AmountJson[] = [];
-
- const denomsPerExchange: Record<string, DenominationRecord[]> = {};
-
- const getDenoms = async (
- exchangeBaseUrl: string,
- ): Promise<DenominationRecord[]> => {
- if (denomsPerExchange[exchangeBaseUrl]) {
- return denomsPerExchange[exchangeBaseUrl];
- }
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(exchangeBaseUrl)
- .filter((x) => {
- return isWithdrawableDenom(x);
- });
- denomsPerExchange[exchangeBaseUrl] = allDenoms;
- return allDenoms;
- };
-
- for (const ocp of oldCoinPubs) {
- const coin = await tx.coins.get(ocp.coinPub);
- checkDbInvariant(!!coin, "coin must be in database");
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- checkDbInvariant(
- !!denom,
- "denomination for existing coin must be in database",
- );
- const refreshAmount = coin.currentAmount;
- inputPerCoin.push(refreshAmount);
- coin.currentAmount = Amounts.getZero(refreshAmount.currency);
- coin.status = CoinStatus.Dormant;
- await tx.coins.put(coin);
- const denoms = await getDenoms(coin.exchangeBaseUrl);
- const cost = getTotalRefreshCost(denoms, denom, refreshAmount);
- const output = Amounts.sub(refreshAmount, cost).amount;
- estimatedOutputPerCoin.push(output);
- }
-
- const refreshGroup: RefreshGroupRecord = {
- timestampFinished: undefined,
- statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
- lastError: undefined,
- lastErrorPerCoin: {},
- oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
- reason,
- refreshGroupId,
- refreshSessionPerCoin: oldCoinPubs.map(() => undefined),
- retryInfo: initRetryInfo(),
- inputPerCoin,
- estimatedOutputPerCoin,
- timestampCreated: getTimestampNow(),
- };
-
- if (oldCoinPubs.length == 0) {
- logger.warn("created refresh group with zero coins");
- refreshGroup.timestampFinished = getTimestampNow();
- }
-
- await tx.refreshGroups.put(refreshGroup);
-
- logger.trace(`created refresh group ${refreshGroupId}`);
-
- processRefreshGroup(ws, refreshGroupId).catch((e) => {
- logger.warn(`processing refresh group ${refreshGroupId} failed`);
- });
-
- return {
- refreshGroupId,
- };
-}
-
-/**
- * Timestamp after which the wallet would do the next check for an auto-refresh.
- */
-function getAutoRefreshCheckThreshold(d: DenominationRecord): Timestamp {
- const delta = timestampDifference(
- d.stampExpireWithdraw,
- d.stampExpireDeposit,
- );
- const deltaDiv = durationMul(delta, 0.75);
- return timestampAddDuration(d.stampExpireWithdraw, deltaDiv);
-}
-
-/**
- * Timestamp after which the wallet would do an auto-refresh.
- */
-function getAutoRefreshExecuteThreshold(d: DenominationRecord): Timestamp {
- const delta = timestampDifference(
- d.stampExpireWithdraw,
- d.stampExpireDeposit,
- );
- const deltaDiv = durationMul(delta, 0.5);
- return timestampAddDuration(d.stampExpireWithdraw, deltaDiv);
-}
-
-export async function autoRefresh(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- logger.info(`doing auto-refresh check for '${exchangeBaseUrl}'`);
- await updateExchangeFromUrl(ws, exchangeBaseUrl, undefined, true);
- let minCheckThreshold = timestampAddDuration(
- getTimestampNow(),
- durationFromSpec({ days: 1 }),
- );
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- refreshGroups: x.refreshGroups,
- exchanges: x.exchanges,
- }))
- .runReadWrite(async (tx) => {
- const exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange) {
- return;
- }
- const coins = await tx.coins.indexes.byBaseUrl
- .iter(exchangeBaseUrl)
- .toArray();
- const refreshCoins: CoinPublicKey[] = [];
- for (const coin of coins) {
- if (coin.status !== CoinStatus.Fresh) {
- continue;
- }
- if (coin.suspended) {
- continue;
- }
- const denom = await tx.denominations.get([
- exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination not in database");
- continue;
- }
- const executeThreshold = getAutoRefreshExecuteThreshold(denom);
- if (isTimestampExpired(executeThreshold)) {
- refreshCoins.push(coin);
- } else {
- const checkThreshold = getAutoRefreshCheckThreshold(denom);
- minCheckThreshold = timestampMin(minCheckThreshold, checkThreshold);
- }
- }
- if (refreshCoins.length > 0) {
- const res = await createRefreshGroup(
- ws,
- tx,
- refreshCoins,
- RefreshReason.Scheduled,
- );
- logger.info(
- `created refresh group for auto-refresh (${res.refreshGroupId})`,
- );
- }
- logger.info(
- `current wallet time: ${timestampToIsoString(getTimestampNow())}`,
- );
- logger.info(
- `next refresh check at ${timestampToIsoString(minCheckThreshold)}`,
- );
- exchange.nextRefreshCheck = minCheckThreshold;
- await tx.exchanges.put(exchange);
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts
deleted file mode 100644
index a5846f259..000000000
--- a/packages/taler-wallet-core/src/operations/refund.ts
+++ /dev/null
@@ -1,777 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2019 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Implementation of the refund operation.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
- AbortingCoin,
- AbortRequest,
- AmountJson,
- Amounts,
- ApplyRefundResponse,
- codecForAbortResponse,
- codecForMerchantOrderRefundPickupResponse,
- CoinPublicKey,
- getTimestampNow,
- Logger,
- MerchantCoinRefundFailureStatus,
- MerchantCoinRefundStatus,
- MerchantCoinRefundSuccessStatus,
- NotificationType,
- parseRefundUri,
- RefreshReason,
- TalerErrorCode,
- TalerErrorDetails,
- URL,
- timestampAddDuration,
- codecForMerchantOrderStatusPaid,
- isTimestampExpired,
-} from "@gnu-taler/taler-util";
-import {
- AbortStatus,
- CoinStatus,
- PurchaseRecord,
- RefundReason,
- RefundState,
- WalletStoresV1,
-} from "../db.js";
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadWriteAccess } from "../util/query.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import { guardOperationException } from "../errors.js";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
-import { InternalWalletState } from "../common.js";
-
-const logger = new Logger("refund.ts");
-
-/**
- * Retry querying and applying refunds for an order later.
- */
-async function incrementPurchaseQueryRefundRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadWrite(async (tx) => {
- const pr = await tx.purchases.get(proposalId);
- if (!pr) {
- return;
- }
- if (!pr.refundStatusRetryInfo) {
- return;
- }
- pr.refundStatusRetryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.refundStatusRetryInfo);
- pr.lastRefundStatusError = err;
- await tx.purchases.put(pr);
- });
- if (err) {
- ws.notify({
- type: NotificationType.RefundStatusOperationError,
- error: err,
- });
- }
-}
-
-function getRefundKey(d: MerchantCoinRefundStatus): string {
- return `${d.coin_pub}-${d.rtransaction_id}`;
-}
-
-async function applySuccessfulRefund(
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, { coinPub: string }>,
- r: MerchantCoinRefundSuccessStatus,
-): Promise<void> {
- // FIXME: check signature before storing it as valid!
-
- const refundKey = getRefundKey(r);
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error("inconsistent database");
- }
- refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
- const refundAmount = Amounts.parseOrThrow(r.refund_amount);
- const refundFee = denom.feeRefund;
- coin.status = CoinStatus.Dormant;
- coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
- coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
- logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
- await tx.coins.put(coin);
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Applied,
- obtainedTime: getTimestampNow(),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.feeRefund,
- totalRefreshCostBound,
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-}
-
-async function storePendingRefund(
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- }>,
- p: PurchaseRecord,
- r: MerchantCoinRefundFailureStatus,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Pending,
- obtainedTime: getTimestampNow(),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.feeRefund,
- totalRefreshCostBound,
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-}
-
-async function storeFailedRefund(
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, { coinPub: string }>,
- r: MerchantCoinRefundFailureStatus,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Failed,
- obtainedTime: getTimestampNow(),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.feeRefund,
- totalRefreshCostBound,
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-
- if (p.abortStatus === AbortStatus.AbortRefund) {
- // Refund failed because the merchant didn't even try to deposit
- // the coin yet, so we try to refresh.
- if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination for coin missing");
- return;
- }
- let contrib: AmountJson | undefined;
- for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) {
- if (p.payCoinSelection.coinPubs[i] === r.coin_pub) {
- contrib = p.payCoinSelection.coinContributions[i];
- }
- }
- if (contrib) {
- coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
- coin.currentAmount = Amounts.sub(
- coin.currentAmount,
- denom.feeRefund,
- ).amount;
- }
- refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
- await tx.coins.put(coin);
- }
- }
-}
-
-async function acceptRefunds(
- ws: InternalWalletState,
- proposalId: string,
- refunds: MerchantCoinRefundStatus[],
- reason: RefundReason,
-): Promise<void> {
- logger.trace("handling refunds", refunds);
- const now = getTimestampNow();
-
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- coins: x.coins,
- denominations: x.denominations,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("purchase not found, not adding refunds");
- return;
- }
-
- const refreshCoinsMap: Record<string, CoinPublicKey> = {};
-
- for (const refundStatus of refunds) {
- const refundKey = getRefundKey(refundStatus);
- const existingRefundInfo = p.refunds[refundKey];
-
- const isPermanentFailure =
- refundStatus.type === "failure" &&
- refundStatus.exchange_status >= 400 &&
- refundStatus.exchange_status < 500;
-
- // Already failed.
- if (existingRefundInfo?.type === RefundState.Failed) {
- continue;
- }
-
- // Already applied.
- if (existingRefundInfo?.type === RefundState.Applied) {
- continue;
- }
-
- // Still pending.
- if (
- refundStatus.type === "failure" &&
- !isPermanentFailure &&
- existingRefundInfo?.type === RefundState.Pending
- ) {
- continue;
- }
-
- // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
-
- if (refundStatus.type === "success") {
- await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
- } else if (isPermanentFailure) {
- await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
- } else {
- await storePendingRefund(tx, p, refundStatus);
- }
- }
-
- const refreshCoinsPubs = Object.values(refreshCoinsMap);
- if (refreshCoinsPubs.length > 0) {
- await createRefreshGroup(
- ws,
- tx,
- refreshCoinsPubs,
- RefreshReason.Refund,
- );
- }
-
- // Are we done with querying yet, or do we need to do another round
- // after a retry delay?
- let queryDone = true;
-
- if (
- p.timestampFirstSuccessfulPay &&
- p.autoRefundDeadline &&
- p.autoRefundDeadline.t_ms > now.t_ms
- ) {
- queryDone = false;
- }
-
- let numPendingRefunds = 0;
- for (const ri of Object.values(p.refunds)) {
- switch (ri.type) {
- case RefundState.Pending:
- numPendingRefunds++;
- break;
- }
- }
-
- if (numPendingRefunds > 0) {
- queryDone = false;
- }
-
- if (queryDone) {
- p.timestampLastRefundStatus = now;
- p.lastRefundStatusError = undefined;
- p.refundStatusRetryInfo = initRetryInfo();
- p.refundQueryRequested = false;
- if (p.abortStatus === AbortStatus.AbortRefund) {
- p.abortStatus = AbortStatus.AbortFinished;
- }
- logger.trace("refund query done");
- } else {
- // No error, but we need to try again!
- p.timestampLastRefundStatus = now;
- p.refundStatusRetryInfo.retryCounter++;
- updateRetryInfoTimeout(p.refundStatusRetryInfo);
- p.lastRefundStatusError = undefined;
- logger.trace("refund query not done");
- }
-
- await tx.purchases.put(p);
- });
-
- ws.notify({
- type: NotificationType.RefundQueried,
- });
-}
-
-/**
- * Summary of the refund status of a purchase.
- */
-export interface RefundSummary {
- pendingAtExchange: boolean;
- amountEffectivePaid: AmountJson;
- amountRefundGranted: AmountJson;
- amountRefundGone: AmountJson;
-}
-
-/**
- * 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<ApplyRefundResponse> {
- const parseResult = parseRefundUri(talerRefundUri);
-
- logger.trace("applying refund", parseResult);
-
- if (!parseResult) {
- throw Error("invalid refund URI");
- }
-
- let purchase = await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadOnly(async (tx) => {
- return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
- parseResult.merchantBaseUrl,
- parseResult.orderId,
- ]);
- });
-
- if (!purchase) {
- throw Error(
- `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
- );
- }
-
- const proposalId = purchase.proposalId;
-
- logger.info("processing purchase for refund");
- const success = await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("no purchase found for refund URL");
- return false;
- }
- p.refundQueryRequested = true;
- p.lastRefundStatusError = undefined;
- p.refundStatusRetryInfo = initRetryInfo();
- await tx.purchases.put(p);
- return true;
- });
-
- if (success) {
- ws.notify({
- type: NotificationType.RefundStarted,
- });
- await processPurchaseQueryRefundImpl(ws, proposalId, true, false);
- }
-
- purchase = await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!purchase) {
- throw Error("purchase no longer exists");
- }
-
- const p = purchase;
-
- let amountRefundGranted = Amounts.getZero(
- purchase.download.contractData.amount.currency,
- );
- let amountRefundGone = Amounts.getZero(
- purchase.download.contractData.amount.currency,
- );
-
- let pendingAtExchange = false;
-
- Object.keys(purchase.refunds).forEach((rk) => {
- const refund = p.refunds[rk];
- if (refund.type === RefundState.Pending) {
- pendingAtExchange = true;
- }
- if (
- refund.type === RefundState.Applied ||
- refund.type === RefundState.Pending
- ) {
- amountRefundGranted = Amounts.add(
- amountRefundGranted,
- Amounts.sub(
- refund.refundAmount,
- refund.refundFee,
- refund.totalRefreshCostBound,
- ).amount,
- ).amount;
- } else {
- amountRefundGone = Amounts.add(amountRefundGone, refund.refundAmount)
- .amount;
- }
- });
-
- return {
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- proposalId: purchase.proposalId,
- amountEffectivePaid: Amounts.stringify(purchase.totalPayCost),
- amountRefundGone: Amounts.stringify(amountRefundGone),
- amountRefundGranted: Amounts.stringify(amountRefundGranted),
- pendingAtExchange,
- info: {
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- merchant: purchase.download.contractData.merchant,
- orderId: purchase.download.contractData.orderId,
- products: purchase.download.contractData.products,
- summary: purchase.download.contractData.summary,
- fulfillmentMessage: purchase.download.contractData.fulfillmentMessage,
- summary_i18n: purchase.download.contractData.summaryI18n,
- fulfillmentMessage_i18n:
- purchase.download.contractData.fulfillmentMessageI18n,
- },
- };
-}
-
-export async function processPurchaseQueryRefund(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementPurchaseQueryRefundRetry(ws, proposalId, e);
- await guardOperationException(
- () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow, true),
- onOpErr,
- );
-}
-
-async function resetPurchaseQueryRefundRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.purchases.get(proposalId);
- if (x) {
- x.refundStatusRetryInfo = initRetryInfo();
- await tx.purchases.put(x);
- }
- });
-}
-
-async function processPurchaseQueryRefundImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
- waitForAutoRefund: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetPurchaseQueryRefundRetry(ws, proposalId);
- }
- const purchase = await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return;
- }
-
- if (!purchase.refundQueryRequested) {
- return;
- }
-
- if (purchase.timestampFirstSuccessfulPay) {
- if (
- waitForAutoRefund &&
- purchase.autoRefundDeadline &&
- !isTimestampExpired(purchase.autoRefundDeadline)
- ) {
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}`,
- purchase.download.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- purchase.download.contractData.contractTermsHash,
- );
- // Long-poll for one second
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
- logger.trace("making long-polling request for auto-refund");
- const resp = await ws.http.get(requestUrl.href);
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
- if (!orderStatus.refunded) {
- incrementPurchaseQueryRefundRetry(ws, proposalId, undefined);
- return;
- }
- }
-
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/refund`,
- purchase.download.contractData.merchantBaseUrl,
- );
-
- logger.trace(`making refund request to ${requestUrl.href}`);
-
- const request = await ws.http.postJson(requestUrl.href, {
- h_contract: purchase.download.contractData.contractTermsHash,
- });
-
- logger.trace(
- "got json",
- JSON.stringify(await request.json(), undefined, 2),
- );
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderRefundPickupResponse(),
- );
-
- await acceptRefunds(
- ws,
- proposalId,
- refundResponse.refunds,
- RefundReason.NormalRefund,
- );
- } else if (purchase.abortStatus === AbortStatus.AbortRefund) {
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/abort`,
- purchase.download.contractData.merchantBaseUrl,
- );
-
- const abortingCoins: AbortingCoin[] = [];
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- }))
- .runReadOnly(async (tx) => {
- for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
- const coinPub = purchase.payCoinSelection.coinPubs[i];
- const coin = await tx.coins.get(coinPub);
- checkDbInvariant(!!coin, "expected coin to be present");
- abortingCoins.push({
- coin_pub: coinPub,
- contribution: Amounts.stringify(
- purchase.payCoinSelection.coinContributions[i],
- ),
- exchange_url: coin.exchangeBaseUrl,
- });
- }
- });
-
- const abortReq: AbortRequest = {
- h_contract: purchase.download.contractData.contractTermsHash,
- coins: abortingCoins,
- };
-
- logger.trace(`making order abort request to ${requestUrl.href}`);
-
- const request = await ws.http.postJson(requestUrl.href, abortReq);
- const abortResp = await readSuccessResponseJsonOrThrow(
- request,
- codecForAbortResponse(),
- );
-
- const refunds: MerchantCoinRefundStatus[] = [];
-
- if (abortResp.refunds.length != abortingCoins.length) {
- // FIXME: define error code!
- throw Error("invalid order abort response");
- }
-
- for (let i = 0; i < abortResp.refunds.length; i++) {
- const r = abortResp.refunds[i];
- refunds.push({
- ...r,
- coin_pub: purchase.payCoinSelection.coinPubs[i],
- refund_amount: Amounts.stringify(
- purchase.payCoinSelection.coinContributions[i],
- ),
- rtransaction_id: 0,
- execution_time: timestampAddDuration(
- purchase.download.contractData.timestamp,
- {
- d_ms: 1000,
- },
- ),
- });
- }
- await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
- }
-}
-
-export async function abortFailedPayWithRefund(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- if (purchase.timestampFirstSuccessfulPay) {
- // No point in aborting it. We don't even report an error.
- logger.warn(`tried to abort successful payment`);
- return;
- }
- if (purchase.abortStatus !== AbortStatus.None) {
- return;
- }
- purchase.refundQueryRequested = true;
- purchase.paymentSubmitPending = false;
- purchase.abortStatus = AbortStatus.AbortRefund;
- purchase.lastPayError = undefined;
- purchase.payRetryInfo = initRetryInfo();
- await tx.purchases.put(purchase);
- });
- processPurchaseQueryRefund(ws, proposalId, true).catch((e) => {
- logger.trace(`error during refund processing after abort pay: ${e}`);
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts
deleted file mode 100644
index 4b5862bef..000000000
--- a/packages/taler-wallet-core/src/operations/reserves.ts
+++ /dev/null
@@ -1,829 +0,0 @@
-/*
- 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,
- TalerErrorDetails,
- AcceptWithdrawalResponse,
- Amounts,
- codecForBankWithdrawalOperationPostResponse,
- codecForReserveStatus,
- codecForWithdrawOperationStatusResponse,
- Duration,
- durationMax,
- durationMin,
- getTimestampNow,
- NotificationType,
- ReserveTransactionType,
- TalerErrorCode,
- addPaytoQueryParams,
-} from "@gnu-taler/taler-util";
-import { randomBytes } from "@gnu-taler/taler-util";
-import {
- ReserveRecordStatus,
- ReserveBankInfo,
- ReserveRecord,
- WithdrawalGroupRecord,
- WalletStoresV1,
-} from "../db.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { canonicalizeBaseUrl } from "@gnu-taler/taler-util";
-import {
- initRetryInfo,
- getRetryDuration,
- updateRetryInfoTimeout,
-} from "../util/retries.js";
-import { guardOperationException, OperationFailedError } from "../errors.js";
-import {
- updateExchangeFromUrl,
- getExchangePaytoUri,
- getExchangeDetails,
- getExchangeTrust,
-} from "./exchanges.js";
-import { InternalWalletState } from "../common.js";
-import {
- updateWithdrawalDenoms,
- getCandidateWithdrawalDenoms,
- selectWithdrawalDenominations,
- denomSelectionInfoToState,
- processWithdrawGroup,
- getBankWithdrawalInfo,
-} from "./withdraw.js";
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import { Logger, URL } from "@gnu-taler/taler-util";
-import {
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- throwUnexpectedRequestError,
-} from "../util/http.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-
-const logger = new Logger("reserves.ts");
-
-async function resetReserveRetry(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.reserves.get(reservePub);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.reserves.put(x);
- }
- });
-}
-
-/**
- * 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.QUERYING_STATUS;
- }
-
- let bankInfo: ReserveBankInfo | undefined;
-
- if (req.bankWithdrawStatusUrl) {
- if (!req.exchangePaytoUri) {
- throw Error(
- "Exchange payto URI must be specified for a bank-integrated withdrawal",
- );
- }
- bankInfo = {
- statusUrl: req.bankWithdrawStatusUrl,
- exchangePaytoUri: req.exchangePaytoUri,
- };
- }
-
- const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32));
-
- await updateWithdrawalDenoms(ws, canonExchange);
- const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
- const denomSelInfo = selectWithdrawalDenominations(req.amount, denoms);
- const initialDenomSel = denomSelectionInfoToState(denomSelInfo);
-
- const reserveRecord: ReserveRecord = {
- instructedAmount: req.amount,
- initialWithdrawalGroupId,
- initialDenomSel,
- initialWithdrawalStarted: false,
- timestampCreated: now,
- exchangeBaseUrl: canonExchange,
- reservePriv: keypair.priv,
- reservePub: keypair.pub,
- senderWire: req.senderWire,
- timestampBankConfirmed: undefined,
- timestampReserveInfoPosted: undefined,
- bankInfo,
- reserveStatus,
- lastSuccessfulStatusQuery: undefined,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- currency: req.amount.currency,
- requestedQuery: false,
- };
-
- const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
- const exchangeDetails = exchangeInfo.exchangeDetails;
- if (!exchangeDetails) {
- logger.trace(exchangeDetails);
- throw Error("exchange not updated");
- }
- const { isAudited, isTrusted } = await getExchangeTrust(
- ws,
- exchangeInfo.exchange,
- );
-
- const resp = await ws.db
- .mktx((x) => ({
- exchangeTrust: x.exchangeTrust,
- reserves: x.reserves,
- bankWithdrawUris: x.bankWithdrawUris,
- }))
- .runReadWrite(async (tx) => {
- // Check if we have already created a reserve for that bankWithdrawStatusUrl
- if (reserveRecord.bankInfo?.statusUrl) {
- const bwi = await tx.bankWithdrawUris.get(
- reserveRecord.bankInfo.statusUrl,
- );
- if (bwi) {
- const otherReserve = await tx.reserves.get(bwi.reservePub);
- if (otherReserve) {
- logger.trace(
- "returning existing reserve for bankWithdrawStatusUri",
- );
- return {
- exchange: otherReserve.exchangeBaseUrl,
- reservePub: otherReserve.reservePub,
- };
- }
- }
- await tx.bankWithdrawUris.put({
- reservePub: reserveRecord.reservePub,
- talerWithdrawUri: reserveRecord.bankInfo.statusUrl,
- });
- }
- if (!isAudited && !isTrusted) {
- await tx.exchangeTrust.put({
- currency: reserveRecord.currency,
- exchangeBaseUrl: reserveRecord.exchangeBaseUrl,
- exchangeMasterPub: exchangeDetails.masterPublicKey,
- uids: [encodeCrock(getRandomBytes(32))],
- });
- }
- await tx.reserves.put(reserveRecord);
- const r: CreateReserveResponse = {
- exchange: canonExchange,
- reservePub: keypair.pub,
- };
- return r;
- });
-
- if (reserveRecord.reservePub === resp.reservePub) {
- // Only emit notification when a new reserve was created.
- ws.notify({
- type: NotificationType.ReserveCreated,
- reservePub: reserveRecord.reservePub,
- });
- }
-
- // Asynchronously process the reserve, but return
- // to the caller already.
- processReserve(ws, resp.reservePub, true).catch((e) => {
- logger.error("Processing reserve (after createReserve) failed:", e);
- });
-
- return resp;
-}
-
-/**
- * Re-query the status of a reserve.
- */
-export async function forceQueryReserve(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const reserve = await tx.reserves.get(reservePub);
- if (!reserve) {
- return;
- }
- // Only force status query where it makes sense
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.DORMANT:
- reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- break;
- default:
- reserve.requestedQuery = true;
- break;
- }
- reserve.retryInfo = initRetryInfo();
- await tx.reserves.put(reserve);
- });
- await processReserve(ws, reservePub, true);
-}
-
-/**
- * First fetch information required 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,
- forceNow = false,
-): Promise<void> {
- return ws.memoProcessReserve.memo(reservePub, async () => {
- const onOpError = (err: TalerErrorDetails): Promise<void> =>
- incrementReserveRetry(ws, reservePub, err);
- await guardOperationException(
- () => processReserveImpl(ws, reservePub, forceNow),
- onOpError,
- );
- });
-}
-
-async function registerReserveWithBank(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return await tx.reserves.get(reservePub);
- });
- switch (reserve?.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.REGISTERING_BANK:
- break;
- default:
- return;
- }
- const bankInfo = reserve.bankInfo;
- if (!bankInfo) {
- return;
- }
- const bankStatusUrl = bankInfo.statusUrl;
- const httpResp = await ws.http.postJson(
- bankStatusUrl,
- {
- reserve_pub: reservePub,
- selected_exchange: bankInfo.exchangePaytoUri,
- },
- {
- timeout: getReserveRequestTimeout(reserve),
- },
- );
- await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForBankWithdrawalOperationPostResponse(),
- );
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- return;
- }
- switch (r.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- r.timestampReserveInfoPosted = getTimestampNow();
- r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK;
- if (!r.bankInfo) {
- throw Error("invariant failed");
- }
- r.retryInfo = initRetryInfo();
- await tx.reserves.put(r);
- });
- ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
- return processReserveBankStatus(ws, reservePub);
-}
-
-async function processReserveBankStatus(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const onOpError = (err: TalerErrorDetails): Promise<void> =>
- incrementReserveRetry(ws, reservePub, err);
- await guardOperationException(
- () => processReserveBankStatusImpl(ws, reservePub),
- onOpError,
- );
-}
-
-export function getReserveRequestTimeout(r: ReserveRecord): Duration {
- return durationMax(
- { d_ms: 60000 },
- durationMin({ d_ms: 5000 }, getRetryDuration(r.retryInfo)),
- );
-}
-
-async function processReserveBankStatusImpl(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reservePub);
- });
- switch (reserve?.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.REGISTERING_BANK:
- break;
- default:
- return;
- }
- const bankStatusUrl = reserve.bankInfo?.statusUrl;
- if (!bankStatusUrl) {
- return;
- }
-
- const statusResp = await ws.http.get(bankStatusUrl, {
- timeout: getReserveRequestTimeout(reserve),
- });
- const status = await readSuccessResponseJsonOrThrow(
- statusResp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- if (status.aborted) {
- logger.trace("bank aborted the withdrawal");
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- return;
- }
- switch (r.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- const now = getTimestampNow();
- r.timestampBankConfirmed = now;
- r.reserveStatus = ReserveRecordStatus.BANK_ABORTED;
- r.retryInfo = initRetryInfo();
- await tx.reserves.put(r);
- });
- return;
- }
-
- 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);
- }
-
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- return;
- }
- if (status.transfer_done) {
- switch (r.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- const now = getTimestampNow();
- r.timestampBankConfirmed = now;
- r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- r.retryInfo = initRetryInfo();
- } else {
- switch (r.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- if (r.bankInfo) {
- r.bankInfo.confirmUrl = status.confirm_transfer_url;
- }
- }
- await tx.reserves.put(r);
- });
-}
-
-async function incrementReserveRetry(
- ws: InternalWalletState,
- reservePub: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.reserves.put(r);
- });
- if (err) {
- ws.notify({
- type: NotificationType.ReserveOperationError,
- error: err,
- });
- }
-}
-
-/**
- * Update the information about a reserve that is stored in the wallet
- * by querying the reserve's exchange.
- *
- * If the reserve have funds that are not allocated in a withdrawal group yet
- * and are big enough to withdraw with available denominations,
- * create a new withdrawal group for the remaining amount.
- */
-async function updateReserve(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<{ ready: boolean }> {
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reservePub);
- });
- if (!reserve) {
- throw Error("reserve not in db");
- }
-
- if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
- return { ready: true };
- }
-
- const resp = await ws.http.get(
- new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl).href,
- {
- timeout: getReserveRequestTimeout(reserve),
- },
- );
-
- const result = await readSuccessResponseJsonOrErrorCode(
- resp,
- codecForReserveStatus(),
- );
- if (result.isError) {
- if (
- resp.status === 404 &&
- result.talerErrorResponse.code ===
- TalerErrorCode.EXCHANGE_RESERVES_GET_STATUS_UNKNOWN
- ) {
- ws.notify({
- type: NotificationType.ReserveNotYetFound,
- reservePub,
- });
- await incrementReserveRetry(ws, reservePub, undefined);
- return { ready: false };
- } else {
- throwUnexpectedRequestError(resp, result.talerErrorResponse);
- }
- }
-
- const reserveInfo = result.response;
- const balance = Amounts.parseOrThrow(reserveInfo.balance);
- const currency = balance.currency;
-
- await updateWithdrawalDenoms(ws, reserve.exchangeBaseUrl);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- reserve.exchangeBaseUrl,
- );
-
- const newWithdrawalGroup = await ws.db
- .mktx((x) => ({
- coins: x.coins,
- planchets: x.planchets,
- withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const newReserve = await tx.reserves.get(reserve.reservePub);
- if (!newReserve) {
- return;
- }
- let amountReservePlus = Amounts.getZero(currency);
- let amountReserveMinus = Amounts.getZero(currency);
-
- // Subtract withdrawal groups for this reserve from the available amount.
- await tx.withdrawalGroups.indexes.byReservePub
- .iter(reservePub)
- .forEach((wg) => {
- const cost = wg.denomsSel.totalWithdrawCost;
- amountReserveMinus = Amounts.add(amountReserveMinus, cost).amount;
- });
-
- for (const entry of reserveInfo.history) {
- switch (entry.type) {
- case ReserveTransactionType.Credit:
- amountReservePlus = Amounts.add(
- amountReservePlus,
- Amounts.parseOrThrow(entry.amount),
- ).amount;
- break;
- case ReserveTransactionType.Recoup:
- amountReservePlus = Amounts.add(
- amountReservePlus,
- Amounts.parseOrThrow(entry.amount),
- ).amount;
- break;
- case ReserveTransactionType.Closing:
- amountReserveMinus = Amounts.add(
- amountReserveMinus,
- Amounts.parseOrThrow(entry.amount),
- ).amount;
- break;
- case ReserveTransactionType.Withdraw: {
- // Now we check if the withdrawal transaction
- // is part of any withdrawal known to this wallet.
- const planchet = await tx.planchets.indexes.byCoinEvHash.get(
- entry.h_coin_envelope,
- );
- if (planchet) {
- // Amount is already accounted in some withdrawal session
- break;
- }
- const coin = await tx.coins.indexes.byCoinEvHash.get(
- entry.h_coin_envelope,
- );
- if (coin) {
- // Amount is already accounted in some withdrawal session
- break;
- }
- // Amount has been claimed by some withdrawal we don't know about
- amountReserveMinus = Amounts.add(
- amountReserveMinus,
- Amounts.parseOrThrow(entry.amount),
- ).amount;
- break;
- }
- }
- }
-
- const remainingAmount = Amounts.sub(amountReservePlus, amountReserveMinus)
- .amount;
- const denomSelInfo = selectWithdrawalDenominations(
- remainingAmount,
- denoms,
- );
-
- logger.trace(
- `Remaining unclaimed amount in reseve is ${Amounts.stringify(
- remainingAmount,
- )} and can be withdrawn with ${
- denomSelInfo.selectedDenoms.length
- } coins`,
- );
-
- if (denomSelInfo.selectedDenoms.length === 0) {
- newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
- newReserve.lastError = undefined;
- newReserve.retryInfo = initRetryInfo();
- await tx.reserves.put(newReserve);
- return;
- }
-
- let withdrawalGroupId: string;
-
- if (!newReserve.initialWithdrawalStarted) {
- withdrawalGroupId = newReserve.initialWithdrawalGroupId;
- newReserve.initialWithdrawalStarted = true;
- } else {
- withdrawalGroupId = encodeCrock(randomBytes(32));
- }
-
- const withdrawalRecord: WithdrawalGroupRecord = {
- withdrawalGroupId: withdrawalGroupId,
- exchangeBaseUrl: reserve.exchangeBaseUrl,
- reservePub: reserve.reservePub,
- rawWithdrawalAmount: remainingAmount,
- timestampStart: getTimestampNow(),
- retryInfo: initRetryInfo(),
- lastError: undefined,
- denomsSel: denomSelectionInfoToState(denomSelInfo),
- secretSeed: encodeCrock(getRandomBytes(64)),
- denomSelUid: encodeCrock(getRandomBytes(32)),
- };
-
- newReserve.lastError = undefined;
- newReserve.retryInfo = initRetryInfo();
- newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
-
- await tx.reserves.put(newReserve);
- await tx.withdrawalGroups.put(withdrawalRecord);
- return withdrawalRecord;
- });
-
- if (newWithdrawalGroup) {
- logger.trace("processing new withdraw group");
- ws.notify({
- type: NotificationType.WithdrawGroupCreated,
- withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId,
- });
- await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId);
- }
-
- return { ready: true };
-}
-
-async function processReserveImpl(
- ws: InternalWalletState,
- reservePub: string,
- forceNow = false,
-): Promise<void> {
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reservePub);
- });
- if (!reserve) {
- logger.trace("not processing reserve: reserve does not exist");
- return;
- }
- if (!forceNow) {
- const now = getTimestampNow();
- if (reserve.retryInfo.nextRetry.t_ms > now.t_ms) {
- logger.trace("processReserve retry not due yet");
- return;
- }
- } else {
- await resetReserveRetry(ws, reservePub);
- }
- logger.trace(
- `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
- );
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- await processReserveBankStatus(ws, reservePub);
- return await processReserveImpl(ws, reservePub, true);
- case ReserveRecordStatus.QUERYING_STATUS:
- const res = await updateReserve(ws, reservePub);
- if (res.ready) {
- return await processReserveImpl(ws, reservePub, true);
- }
- break;
- case ReserveRecordStatus.DORMANT:
- // nothing to do
- break;
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- await processReserveBankStatus(ws, reservePub);
- break;
- case ReserveRecordStatus.BANK_ABORTED:
- break;
- default:
- console.warn("unknown reserve record status:", reserve.reserveStatus);
- assertUnreachable(reserve.reserveStatus);
- break;
- }
-}
-export async function createTalerWithdrawReserve(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- selectedExchange: string,
-): Promise<AcceptWithdrawalResponse> {
- await updateExchangeFromUrl(ws, selectedExchange);
- const withdrawInfo = await getBankWithdrawalInfo(ws, talerWithdrawUri);
- const exchangePaytoUri = await getExchangePaytoUri(
- ws,
- selectedExchange,
- withdrawInfo.wireTypes,
- );
- const reserve = await createReserve(ws, {
- amount: withdrawInfo.amount,
- bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
- exchange: selectedExchange,
- senderWire: withdrawInfo.senderWire,
- exchangePaytoUri: exchangePaytoUri,
- });
- // 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);
- const processedReserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reserve.reservePub);
- });
- if (processedReserve?.reserveStatus === ReserveRecordStatus.BANK_ABORTED) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
- "withdrawal aborted by bank",
- {},
- );
- }
- return {
- reservePub: reserve.reservePub,
- confirmTransferUrl: withdrawInfo.confirmTransferUrl,
- };
-}
-
-/**
- * Get payto URIs needed to fund a reserve.
- */
-export async function getFundingPaytoUris(
- tx: GetReadOnlyAccess<{
- reserves: typeof WalletStoresV1.reserves;
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- reservePub: string,
-): Promise<string[]> {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
- return [];
- }
- const exchangeDetails = await getExchangeDetails(tx, r.exchangeBaseUrl);
- if (!exchangeDetails) {
- logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
- return [];
- }
- const plainPaytoUris =
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
- if (!plainPaytoUris) {
- logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`);
- return [];
- }
- return plainPaytoUris.map((x) =>
- addPaytoQueryParams(x, {
- amount: Amounts.stringify(r.instructedAmount),
- message: `Taler Withdrawal ${r.reservePub}`,
- }),
- );
-}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts
deleted file mode 100644
index d2071cd53..000000000
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ /dev/null
@@ -1,435 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- 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 { Logger } from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
- checkSuccessResponseOrThrow,
-} from "../util/http.js";
-import {
- AmountString,
- codecForAny,
- CheckPaymentResponse,
- codecForCheckPaymentResponse,
- IntegrationTestArgs,
- Amounts,
- TestPayArgs,
- URL,
- PreparePayResultType,
-} from "@gnu-taler/taler-util";
-import { createTalerWithdrawReserve } from "./reserves.js";
-import { InternalWalletState } from "../common.js";
-import { confirmPay, preparePayForUri } from "./pay.js";
-import { getBalances } from "./balance.js";
-import { applyRefund } from "./refund.js";
-
-const logger = new Logger("operations/testing.ts");
-
-interface BankUser {
- username: string;
- password: string;
-}
-
-interface BankWithdrawalResponse {
- taler_withdraw_uri: string;
- withdrawal_id: string;
-}
-
-interface MerchantBackendInfo {
- baseUrl: string;
- authToken?: string;
-}
-
-/**
- * Generate a random alphanumeric ID. Does *not* use cryptographically
- * secure randomness.
- */
-function makeId(length: number): string {
- let result = "";
- const characters =
- "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- for (let i = 0; i < length; i++) {
- result += characters.charAt(Math.floor(Math.random() * characters.length));
- }
- return result;
-}
-
-/**
- * Helper function to generate the "Authorization" HTTP header.
- */
-function makeAuth(username: string, password: string): string {
- const auth = `${username}:${password}`;
- const authEncoded: string = Buffer.from(auth).toString("base64");
- return `Basic ${authEncoded}`;
-}
-
-export async function withdrawTestBalance(
- ws: InternalWalletState,
- amount = "TESTKUDOS:10",
- bankBaseUrl = "https://bank.test.taler.net/",
- exchangeBaseUrl = "https://exchange.test.taler.net/",
-): Promise<void> {
- const bankUser = await registerRandomBankUser(ws.http, bankBaseUrl);
- logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
-
- const wresp = await createBankWithdrawalUri(
- ws.http,
- bankBaseUrl,
- bankUser,
- amount,
- );
-
- await createTalerWithdrawReserve(
- ws,
- wresp.taler_withdraw_uri,
- exchangeBaseUrl,
- );
-
- await confirmBankWithdrawalUri(
- ws.http,
- bankBaseUrl,
- bankUser,
- wresp.withdrawal_id,
- );
-}
-
-function getMerchantAuthHeader(m: MerchantBackendInfo): Record<string, string> {
- if (m.authToken) {
- return {
- Authorization: `Bearer ${m.authToken}`,
- };
- }
- return {};
-}
-
-async function createBankWithdrawalUri(
- http: HttpRequestLibrary,
- bankBaseUrl: string,
- bankUser: BankUser,
- amount: AmountString,
-): Promise<BankWithdrawalResponse> {
- const reqUrl = new URL(
- `accounts/${bankUser.username}/withdrawals`,
- bankBaseUrl,
- ).href;
- const resp = await http.postJson(
- reqUrl,
- {
- amount,
- },
- {
- headers: {
- Authorization: makeAuth(bankUser.username, bankUser.password),
- },
- },
- );
- const respJson = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- return respJson;
-}
-
-async function confirmBankWithdrawalUri(
- http: HttpRequestLibrary,
- bankBaseUrl: string,
- bankUser: BankUser,
- withdrawalId: string,
-): Promise<void> {
- const reqUrl = new URL(
- `accounts/${bankUser.username}/withdrawals/${withdrawalId}/confirm`,
- bankBaseUrl,
- ).href;
- const resp = await http.postJson(
- reqUrl,
- {},
- {
- headers: {
- Authorization: makeAuth(bankUser.username, bankUser.password),
- },
- },
- );
- await readSuccessResponseJsonOrThrow(resp, codecForAny());
- return;
-}
-
-async function registerRandomBankUser(
- http: HttpRequestLibrary,
- bankBaseUrl: string,
-): Promise<BankUser> {
- const reqUrl = new URL("testing/register", bankBaseUrl).href;
- const randId = makeId(8);
- const bankUser: BankUser = {
- username: `testuser-${randId}`,
- password: `testpw-${randId}`,
- };
-
- const resp = await http.postJson(reqUrl, bankUser);
- await checkSuccessResponseOrThrow(resp);
- return bankUser;
-}
-
-async function refund(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- orderId: string,
- reason: string,
- refundAmount: string,
-): Promise<string> {
- const reqUrl = new URL(
- `private/orders/${orderId}/refund`,
- merchantBackend.baseUrl,
- );
- const refundReq = {
- order_id: orderId,
- reason,
- refund: refundAmount,
- };
- const resp = await http.postJson(reqUrl.href, refundReq, {
- headers: getMerchantAuthHeader(merchantBackend),
- });
- const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- const refundUri = r.taler_refund_uri;
- if (!refundUri) {
- throw Error("no refund URI in response");
- }
- return refundUri;
-}
-
-async function createOrder(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- amount: string,
- summary: string,
- fulfillmentUrl: string,
-): Promise<{ orderId: string }> {
- const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
- const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
- const orderReq = {
- order: {
- amount,
- summary,
- fulfillment_url: fulfillmentUrl,
- refund_deadline: { t_ms: t * 1000 },
- wire_transfer_deadline: { t_ms: t * 1000 },
- },
- };
- const resp = await http.postJson(reqUrl, orderReq, {
- headers: getMerchantAuthHeader(merchantBackend),
- });
- const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- const orderId = r.order_id;
- if (!orderId) {
- throw Error("no order id in response");
- }
- return { orderId };
-}
-
-async function checkPayment(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- orderId: string,
-): Promise<CheckPaymentResponse> {
- const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
- reqUrl.searchParams.set("order_id", orderId);
- const resp = await http.get(reqUrl.href, {
- headers: getMerchantAuthHeader(merchantBackend),
- });
- return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
-}
-
-interface BankUser {
- username: string;
- password: string;
-}
-
-interface BankWithdrawalResponse {
- taler_withdraw_uri: string;
- withdrawal_id: string;
-}
-
-async function makePayment(
- ws: InternalWalletState,
- merchant: MerchantBackendInfo,
- amount: string,
- summary: string,
-): Promise<{ orderId: string }> {
- const orderResp = await createOrder(
- ws.http,
- merchant,
- amount,
- summary,
- "taler://fulfillment-success/thx",
- );
-
- logger.trace("created order with orderId", orderResp.orderId);
-
- let paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
-
- logger.trace("payment status", paymentStatus);
-
- const talerPayUri = paymentStatus.taler_pay_uri;
- if (!talerPayUri) {
- throw Error("no taler://pay/ URI in payment response");
- }
-
- const preparePayResult = await preparePayForUri(ws, talerPayUri);
-
- logger.trace("prepare pay result", preparePayResult);
-
- if (preparePayResult.status != "payment-possible") {
- throw Error("payment not possible");
- }
-
- const confirmPayResult = await confirmPay(
- ws,
- preparePayResult.proposalId,
- undefined,
- );
-
- logger.trace("confirmPayResult", confirmPayResult);
-
- paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
-
- logger.trace("payment status after wallet payment:", paymentStatus);
-
- if (paymentStatus.order_status !== "paid") {
- throw Error("payment did not succeed");
- }
-
- return {
- orderId: orderResp.orderId,
- };
-}
-
-export async function runIntegrationTest(
- ws: InternalWalletState,
- args: IntegrationTestArgs,
-): Promise<void> {
- logger.info("running test with arguments", args);
-
- const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
- const currency = parsedSpendAmount.currency;
-
- logger.info("withdrawing test balance");
- await withdrawTestBalance(
- ws,
- args.amountToWithdraw,
- args.bankBaseUrl,
- args.exchangeBaseUrl,
- );
- await ws.runUntilDone();
- logger.info("done withdrawing test balance");
-
- const balance = await getBalances(ws);
-
- logger.trace(JSON.stringify(balance, null, 2));
-
- const myMerchant: MerchantBackendInfo = {
- baseUrl: args.merchantBaseUrl,
- authToken: args.merchantAuthToken,
- };
-
- await makePayment(ws, myMerchant, args.amountToSpend, "hello world");
-
- // Wait until the refresh is done
- await ws.runUntilDone();
-
- logger.trace("withdrawing test balance for refund");
- const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
- const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
- const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
- const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
-
- await withdrawTestBalance(
- ws,
- Amounts.stringify(withdrawAmountTwo),
- args.bankBaseUrl,
- args.exchangeBaseUrl,
- );
-
- // Wait until the withdraw is done
- await ws.runUntilDone();
-
- const { orderId: refundOrderId } = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountTwo),
- "order that will be refunded",
- );
-
- const refundUri = await refund(
- ws.http,
- myMerchant,
- refundOrderId,
- "test refund",
- Amounts.stringify(refundAmount),
- );
-
- logger.trace("refund URI", refundUri);
-
- await applyRefund(ws, refundUri);
-
- logger.trace("integration test: applied refund");
-
- // Wait until the refund is done
- await ws.runUntilDone();
-
- logger.trace("integration test: making payment after refund");
-
- await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountThree),
- "payment after refund",
- );
-
- logger.trace("integration test: make payment done");
-
- await ws.runUntilDone();
-
- logger.trace("integration test: all done!");
-}
-
-export async function testPay(ws: InternalWalletState, args: TestPayArgs) {
- logger.trace("creating order");
- const merchant = {
- authToken: args.merchantAuthToken,
- baseUrl: args.merchantBaseUrl,
- };
- const orderResp = await createOrder(
- ws.http,
- merchant,
- args.amount,
- args.summary,
- "taler://fulfillment-success/thank+you",
- );
- logger.trace("created new order with order ID", orderResp.orderId);
- const checkPayResp = await checkPayment(ws.http, merchant, orderResp.orderId);
- const talerPayUri = checkPayResp.taler_pay_uri;
- if (!talerPayUri) {
- console.error("fatal: no taler pay URI received from backend");
- process.exit(1);
- return;
- }
- logger.trace("taler pay URI:", talerPayUri);
- const result = await preparePayForUri(ws, talerPayUri);
- if (result.status !== PreparePayResultType.PaymentPossible) {
- throw Error(`unexpected prepare pay status: ${result.status}`);
- }
- await confirmPay(ws, result.proposalId, undefined);
-}
diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts
deleted file mode 100644
index a90e5270f..000000000
--- a/packages/taler-wallet-core/src/operations/tip.ts
+++ /dev/null
@@ -1,420 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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 {
- PrepareTipResult,
- parseTipUri,
- codecForTipPickupGetResponse,
- Amounts,
- getTimestampNow,
- TalerErrorDetails,
- NotificationType,
- TipPlanchetDetail,
- TalerErrorCode,
- codecForTipResponse,
- Logger,
- URL,
-} from "@gnu-taler/taler-util";
-import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js";
-import {
- DenominationRecord,
- CoinRecord,
- CoinSourceType,
- CoinStatus,
-} from "../db.js";
-import { j2s } from "@gnu-taler/taler-util";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import { guardOperationException, makeErrorDetails } from "../errors.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
-import { InternalWalletState } from "../common.js";
-import {
- getExchangeWithdrawalInfo,
- updateWithdrawalDenoms,
- getCandidateWithdrawalDenoms,
- selectWithdrawalDenominations,
- denomSelectionInfoToState,
-} from "./withdraw.js";
-import {
- getHttpResponseErrorDetails,
- readSuccessResponseJsonOrThrow,
-} from "../util/http.js";
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-
-const logger = new Logger("operations/tip.ts");
-
-export async function prepareTip(
- ws: InternalWalletState,
- talerTipUri: string,
-): Promise<PrepareTipResult> {
- const res = parseTipUri(talerTipUri);
- if (!res) {
- throw Error("invalid taler://tip URI");
- }
-
- let tipRecord = await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadOnly(async (tx) => {
- return tx.tips.indexes.byMerchantTipIdAndBaseUrl.get([
- res.merchantTipId,
- res.merchantBaseUrl,
- ]);
- });
-
- if (!tipRecord) {
- const tipStatusUrl = new URL(
- `tips/${res.merchantTipId}`,
- res.merchantBaseUrl,
- );
- logger.trace("checking tip status from", tipStatusUrl.href);
- const merchantResp = await ws.http.get(tipStatusUrl.href);
- const tipPickupStatus = await readSuccessResponseJsonOrThrow(
- merchantResp,
- codecForTipPickupGetResponse(),
- );
- logger.trace(`status ${j2s(tipPickupStatus)}`);
-
- const amount = Amounts.parseOrThrow(tipPickupStatus.tip_amount);
-
- logger.trace("new tip, creating tip record");
- await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url);
- const withdrawDetails = await getExchangeWithdrawalInfo(
- ws,
- tipPickupStatus.exchange_url,
- amount,
- );
-
- const walletTipId = encodeCrock(getRandomBytes(32));
- await updateWithdrawalDenoms(ws, tipPickupStatus.exchange_url);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- tipPickupStatus.exchange_url,
- );
- const selectedDenoms = selectWithdrawalDenominations(amount, denoms);
-
- const secretSeed = encodeCrock(getRandomBytes(64));
- const denomSelUid = encodeCrock(getRandomBytes(32));
-
- const newTipRecord = {
- walletTipId: walletTipId,
- acceptedTimestamp: undefined,
- tipAmountRaw: amount,
- tipExpiration: tipPickupStatus.expiration,
- exchangeBaseUrl: tipPickupStatus.exchange_url,
- merchantBaseUrl: res.merchantBaseUrl,
- createdTimestamp: getTimestampNow(),
- merchantTipId: res.merchantTipId,
- tipAmountEffective: Amounts.sub(
- amount,
- Amounts.add(withdrawDetails.overhead, withdrawDetails.withdrawFee)
- .amount,
- ).amount,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- denomsSel: denomSelectionInfoToState(selectedDenoms),
- pickedUpTimestamp: undefined,
- secretSeed,
- denomSelUid,
- };
- await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadWrite(async (tx) => {
- await tx.tips.put(newTipRecord);
- });
- tipRecord = newTipRecord;
- }
-
- const tipStatus: PrepareTipResult = {
- accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
- tipAmountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
- exchangeBaseUrl: tipRecord.exchangeBaseUrl,
- merchantBaseUrl: tipRecord.merchantBaseUrl,
- expirationTimestamp: tipRecord.tipExpiration,
- tipAmountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
- walletTipId: tipRecord.walletTipId,
- };
-
- return tipStatus;
-}
-
-async function incrementTipRetry(
- ws: InternalWalletState,
- walletTipId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadWrite(async (tx) => {
- const t = await tx.tips.get(walletTipId);
- if (!t) {
- return;
- }
- if (!t.retryInfo) {
- return;
- }
- t.retryInfo.retryCounter++;
- updateRetryInfoTimeout(t.retryInfo);
- t.lastError = err;
- await tx.tips.put(t);
- });
- if (err) {
- ws.notify({ type: NotificationType.TipOperationError, error: err });
- }
-}
-
-export async function processTip(
- ws: InternalWalletState,
- tipId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementTipRetry(ws, tipId, e);
- await guardOperationException(
- () => processTipImpl(ws, tipId, forceNow),
- onOpErr,
- );
-}
-
-async function resetTipRetry(
- ws: InternalWalletState,
- tipId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.tips.get(tipId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.tips.put(x);
- }
- });
-}
-
-async function processTipImpl(
- ws: InternalWalletState,
- walletTipId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetTipRetry(ws, walletTipId);
- }
- const tipRecord = await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadOnly(async (tx) => {
- return tx.tips.get(walletTipId);
- });
- if (!tipRecord) {
- return;
- }
-
- if (tipRecord.pickedUpTimestamp) {
- logger.warn("tip already picked up");
- return;
- }
-
- const denomsForWithdraw = tipRecord.denomsSel;
-
- const planchets: DerivedTipPlanchet[] = [];
- // Planchets in the form that the merchant expects
- const planchetsDetail: TipPlanchetDetail[] = [];
- const denomForPlanchet: { [index: number]: DenominationRecord } = [];
-
- for (const dh of denomsForWithdraw.selectedDenoms) {
- const denom = await ws.db
- .mktx((x) => ({
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- return tx.denominations.get([
- tipRecord.exchangeBaseUrl,
- dh.denomPubHash,
- ]);
- });
- checkDbInvariant(!!denom, "denomination should be in database");
- for (let i = 0; i < dh.count; i++) {
- const deriveReq = {
- denomPub: denom.denomPub,
- planchetIndex: planchets.length,
- secretSeed: tipRecord.secretSeed,
- };
- logger.trace(`deriving tip planchet: ${j2s(deriveReq)}`);
- const p = await ws.cryptoApi.createTipPlanchet(deriveReq);
- logger.trace(`derive result: ${j2s(p)}`);
- denomForPlanchet[planchets.length] = denom;
- planchets.push(p);
- planchetsDetail.push({
- coin_ev: p.coinEv,
- denom_pub_hash: denom.denomPubHash,
- });
- }
- }
-
- const tipStatusUrl = new URL(
- `tips/${tipRecord.merchantTipId}/pickup`,
- tipRecord.merchantBaseUrl,
- );
-
- const req = { planchets: planchetsDetail };
- logger.trace(`sending tip request: ${j2s(req)}`);
- const merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
-
- logger.trace(`got tip response, status ${merchantResp.status}`);
-
- // Hide transient errors.
- if (
- tipRecord.retryInfo.retryCounter < 5 &&
- ((merchantResp.status >= 500 && merchantResp.status <= 599) ||
- merchantResp.status === 424)
- ) {
- logger.trace(`got transient tip error`);
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "tip pickup failed (transient)",
- getHttpResponseErrorDetails(merchantResp),
- );
- await incrementTipRetry(ws, tipRecord.walletTipId, err);
- // FIXME: Maybe we want to signal to the caller that the transient error happened?
- return;
- }
-
- const response = await readSuccessResponseJsonOrThrow(
- merchantResp,
- codecForTipResponse(),
- );
-
- if (response.blind_sigs.length !== planchets.length) {
- throw Error("number of tip responses does not match requested planchets");
- }
-
- const newCoinRecords: CoinRecord[] = [];
-
- for (let i = 0; i < response.blind_sigs.length; i++) {
- const blindedSig = response.blind_sigs[i].blind_sig;
-
- const denom = denomForPlanchet[i];
- checkLogicInvariant(!!denom);
- const planchet = planchets[i];
- checkLogicInvariant(!!planchet);
-
- const denomSig = await ws.cryptoApi.rsaUnblind(
- blindedSig,
- planchet.blindingKey,
- denom.denomPub,
- );
-
- const isValid = await ws.cryptoApi.rsaVerify(
- planchet.coinPub,
- denomSig,
- denom.denomPub,
- );
-
- if (!isValid) {
- await ws.db
- .mktx((x) => ({ tips: x.tips }))
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.tips.get(walletTipId);
- if (!tipRecord) {
- return;
- }
- tipRecord.lastError = makeErrorDetails(
- TalerErrorCode.WALLET_TIPPING_COIN_SIGNATURE_INVALID,
- "invalid signature from the exchange (via merchant tip) after unblinding",
- {},
- );
- await tx.tips.put(tipRecord);
- });
- return;
- }
-
- newCoinRecords.push({
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- coinSource: {
- type: CoinSourceType.Tip,
- coinIndex: i,
- walletTipId: walletTipId,
- },
- currentAmount: denom.value,
- denomPub: denom.denomPub,
- denomPubHash: denom.denomPubHash,
- denomSig: denomSig,
- exchangeBaseUrl: tipRecord.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- suspended: false,
- coinEvHash: planchet.coinEvHash,
- });
- }
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- tips: x.tips,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadWrite(async (tx) => {
- const tr = await tx.tips.get(walletTipId);
- if (!tr) {
- return;
- }
- if (tr.pickedUpTimestamp) {
- return;
- }
- tr.pickedUpTimestamp = getTimestampNow();
- tr.lastError = undefined;
- tr.retryInfo = initRetryInfo();
- await tx.tips.put(tr);
- for (const cr of newCoinRecords) {
- await tx.coins.put(cr);
- }
- });
-}
-
-export async function acceptTip(
- ws: InternalWalletState,
- tipId: string,
-): Promise<void> {
- const found = await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.tips.get(tipId);
- if (!tipRecord) {
- logger.error("tip not found");
- return false;
- }
- tipRecord.acceptedTimestamp = getTimestampNow();
- await tx.tips.put(tipRecord);
- return true;
- });
- if (found) {
- await processTip(ws, tipId);
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts
deleted file mode 100644
index dc738b77f..000000000
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ /dev/null
@@ -1,597 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- 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 { InternalWalletState } from "../common.js";
-import {
- WalletRefundItem,
- RefundState,
- ReserveRecordStatus,
- AbortStatus,
- ReserveRecord,
-} from "../db.js";
-import { AmountJson, Amounts, timestampCmp } from "@gnu-taler/taler-util";
-import {
- TransactionsRequest,
- TransactionsResponse,
- Transaction,
- TransactionType,
- PaymentStatus,
- WithdrawalType,
- WithdrawalDetails,
- OrderShortInfo,
-} from "@gnu-taler/taler-util";
-import { getFundingPaytoUris } from "./reserves.js";
-import { getExchangeDetails } from "./exchanges.js";
-import { processWithdrawGroup } from "./withdraw.js";
-import { processPurchasePay } from "./pay.js";
-import { processDepositGroup } from "./deposits.js";
-import { processTip } from "./tip.js";
-import { processRefreshGroup } from "./refresh.js";
-
-/**
- * Create an event ID from the type and the primary key for the event.
- */
-export function makeEventId(
- type: TransactionType | TombstoneTag,
- ...args: string[]
-): string {
- return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
-}
-
-function shouldSkipCurrency(
- transactionsRequest: TransactionsRequest | undefined,
- currency: string,
-): boolean {
- if (!transactionsRequest?.currency) {
- return false;
- }
- return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase();
-}
-
-function shouldSkipSearch(
- transactionsRequest: TransactionsRequest | undefined,
- fields: string[],
-): boolean {
- if (!transactionsRequest?.search) {
- return false;
- }
- const needle = transactionsRequest.search.trim();
- for (const f of fields) {
- if (f.indexOf(needle) >= 0) {
- return false;
- }
- }
- return true;
-}
-
-/**
- * Retrieve the full event history for this wallet.
- */
-export async function getTransactions(
- ws: InternalWalletState,
- transactionsRequest?: TransactionsRequest,
-): Promise<TransactionsResponse> {
- const transactions: Transaction[] = [];
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- proposals: x.proposals,
- purchases: x.purchases,
- refreshGroups: x.refreshGroups,
- reserves: x.reserves,
- tips: x.tips,
- withdrawalGroups: x.withdrawalGroups,
- planchets: x.planchets,
- recoupGroups: x.recoupGroups,
- depositGroups: x.depositGroups,
- tombstones: x.tombstones,
- }))
- .runReadOnly(
- // Report withdrawals that are currently in progress.
- async (tx) => {
- tx.withdrawalGroups.iter().forEachAsync(async (wsr) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- wsr.rawWithdrawalAmount.currency,
- )
- ) {
- return;
- }
-
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
-
- const r = await tx.reserves.get(wsr.reservePub);
- if (!r) {
- return;
- }
- let amountRaw: AmountJson | undefined = undefined;
- if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
- amountRaw = r.instructedAmount;
- } else {
- amountRaw = wsr.denomsSel.totalWithdrawCost;
- }
- let withdrawalDetails: WithdrawalDetails;
- if (r.bankInfo) {
- withdrawalDetails = {
- type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: true,
- bankConfirmationUrl: r.bankInfo.confirmUrl,
- };
- } else {
- const exchangeDetails = await getExchangeDetails(
- tx,
- wsr.exchangeBaseUrl,
- );
- if (!exchangeDetails) {
- // FIXME: report somehow
- return;
- }
- withdrawalDetails = {
- type: WithdrawalType.ManualTransfer,
- exchangePaytoUris:
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ??
- [],
- };
- }
- transactions.push({
- type: TransactionType.Withdrawal,
- amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(amountRaw),
- withdrawalDetails,
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- pending: !wsr.timestampFinish,
- timestamp: wsr.timestampStart,
- transactionId: makeEventId(
- TransactionType.Withdrawal,
- wsr.withdrawalGroupId,
- ),
- frozen: false,
- ...(wsr.lastError ? { error: wsr.lastError } : {}),
- });
- });
-
- // Report pending withdrawals based on reserves that
- // were created, but where the actual withdrawal group has
- // not started yet.
- tx.reserves.iter().forEachAsync(async (r) => {
- if (shouldSkipCurrency(transactionsRequest, r.currency)) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- if (r.initialWithdrawalStarted) {
- return;
- }
- if (r.reserveStatus === ReserveRecordStatus.BANK_ABORTED) {
- return;
- }
- let withdrawalDetails: WithdrawalDetails;
- if (r.bankInfo) {
- withdrawalDetails = {
- type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: false,
- bankConfirmationUrl: r.bankInfo.confirmUrl,
- };
- } else {
- withdrawalDetails = {
- type: WithdrawalType.ManualTransfer,
- exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
- };
- }
- transactions.push({
- type: TransactionType.Withdrawal,
- amountRaw: Amounts.stringify(r.instructedAmount),
- amountEffective: Amounts.stringify(
- r.initialDenomSel.totalCoinValue,
- ),
- exchangeBaseUrl: r.exchangeBaseUrl,
- pending: true,
- timestamp: r.timestampCreated,
- withdrawalDetails: withdrawalDetails,
- transactionId: makeEventId(
- TransactionType.Withdrawal,
- r.initialWithdrawalGroupId,
- ),
- frozen: false,
- ...(r.lastError ? { error: r.lastError } : {}),
- });
- });
-
- tx.depositGroups.iter().forEachAsync(async (dg) => {
- const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
- if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
- return;
- }
-
- transactions.push({
- type: TransactionType.Deposit,
- amountRaw: Amounts.stringify(dg.effectiveDepositAmount),
- amountEffective: Amounts.stringify(dg.totalPayCost),
- pending: !dg.timestampFinished,
- frozen: false,
- timestamp: dg.timestampCreated,
- targetPaytoUri: dg.wire.payto_uri,
- transactionId: makeEventId(
- TransactionType.Deposit,
- dg.depositGroupId,
- ),
- depositGroupId: dg.depositGroupId,
- ...(dg.lastError ? { error: dg.lastError } : {}),
- });
- });
-
- tx.purchases.iter().forEachAsync(async (pr) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- pr.download.contractData.amount.currency,
- )
- ) {
- return;
- }
- const contractData = pr.download.contractData;
- if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
- return;
- }
- const proposal = await tx.proposals.get(pr.proposalId);
- if (!proposal) {
- return;
- }
- const info: OrderShortInfo = {
- merchant: contractData.merchant,
- orderId: contractData.orderId,
- products: contractData.products,
- summary: contractData.summary,
- summary_i18n: contractData.summaryI18n,
- contractTermsHash: contractData.contractTermsHash,
- };
- if (contractData.fulfillmentUrl !== "") {
- info.fulfillmentUrl = contractData.fulfillmentUrl;
- }
- const paymentTransactionId = makeEventId(
- TransactionType.Payment,
- pr.proposalId,
- );
- const err = pr.lastPayError ?? pr.lastRefundStatusError;
- transactions.push({
- type: TransactionType.Payment,
- amountRaw: Amounts.stringify(contractData.amount),
- amountEffective: Amounts.stringify(pr.totalPayCost),
- status: pr.timestampFirstSuccessfulPay
- ? PaymentStatus.Paid
- : PaymentStatus.Accepted,
- pending:
- !pr.timestampFirstSuccessfulPay &&
- pr.abortStatus === AbortStatus.None,
- timestamp: pr.timestampAccept,
- transactionId: paymentTransactionId,
- proposalId: pr.proposalId,
- info: info,
- frozen: pr.payFrozen ?? false,
- ...(err ? { error: err } : {}),
- });
-
- const refundGroupKeys = new Set<string>();
-
- for (const rk of Object.keys(pr.refunds)) {
- const refund = pr.refunds[rk];
- const groupKey = `${refund.executionTime.t_ms}`;
- refundGroupKeys.add(groupKey);
- }
-
- for (const groupKey of refundGroupKeys.values()) {
- const refundTombstoneId = makeEventId(
- TombstoneTag.DeleteRefund,
- pr.proposalId,
- groupKey,
- );
- const tombstone = await tx.tombstones.get(refundTombstoneId);
- if (tombstone) {
- continue;
- }
- const refundTransactionId = makeEventId(
- TransactionType.Refund,
- pr.proposalId,
- groupKey,
- );
- let r0: WalletRefundItem | undefined;
- let amountRaw = Amounts.getZero(contractData.amount.currency);
- let amountEffective = Amounts.getZero(contractData.amount.currency);
- for (const rk of Object.keys(pr.refunds)) {
- const refund = pr.refunds[rk];
- const myGroupKey = `${refund.executionTime.t_ms}`;
- if (myGroupKey !== groupKey) {
- continue;
- }
- if (!r0) {
- r0 = refund;
- }
-
- if (refund.type === RefundState.Applied) {
- amountRaw = Amounts.add(amountRaw, refund.refundAmount).amount;
- amountEffective = Amounts.add(
- amountEffective,
- Amounts.sub(
- refund.refundAmount,
- refund.refundFee,
- refund.totalRefreshCostBound,
- ).amount,
- ).amount;
- }
- }
- if (!r0) {
- throw Error("invariant violated");
- }
- transactions.push({
- type: TransactionType.Refund,
- info,
- refundedTransactionId: paymentTransactionId,
- transactionId: refundTransactionId,
- timestamp: r0.obtainedTime,
- amountEffective: Amounts.stringify(amountEffective),
- amountRaw: Amounts.stringify(amountRaw),
- pending: false,
- frozen: false,
- });
- }
- });
-
- tx.tips.iter().forEachAsync(async (tipRecord) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- tipRecord.tipAmountRaw.currency,
- )
- ) {
- return;
- }
- if (!tipRecord.acceptedTimestamp) {
- return;
- }
- transactions.push({
- type: TransactionType.Tip,
- amountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
- amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
- pending: !tipRecord.pickedUpTimestamp,
- frozen: false,
- timestamp: tipRecord.acceptedTimestamp,
- transactionId: makeEventId(
- TransactionType.Tip,
- tipRecord.walletTipId,
- ),
- merchantBaseUrl: tipRecord.merchantBaseUrl,
- error: tipRecord.lastError,
- });
- });
- },
- );
-
- const txPending = transactions.filter((x) => x.pending);
- const txNotPending = transactions.filter((x) => !x.pending);
-
- txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
- txNotPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
-
- return { transactions: [...txNotPending, ...txPending] };
-}
-
-export enum TombstoneTag {
- DeleteWithdrawalGroup = "delete-withdrawal-group",
- DeleteReserve = "delete-reserve",
- DeletePayment = "delete-payment",
- DeleteTip = "delete-tip",
- DeleteRefreshGroup = "delete-refresh-group",
- DeleteDepositGroup = "delete-deposit-group",
- DeleteRefund = "delete-refund",
-}
-
-export async function retryTransactionNow(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const [type, ...rest] = transactionId.split(":");
-}
-
-/**
- * Immediately retry the underlying operation
- * of a transaction.
- */
-export async function retryTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const [type, ...rest] = transactionId.split(":");
-
- switch (type) {
- case TransactionType.Deposit:
- const depositGroupId = rest[0];
- processDepositGroup(ws, depositGroupId, true);
- break;
- case TransactionType.Withdrawal:
- const withdrawalGroupId = rest[0];
- await processWithdrawGroup(ws, withdrawalGroupId, true);
- break;
- case TransactionType.Payment:
- const proposalId = rest[0];
- await processPurchasePay(ws, proposalId, true);
- break;
- case TransactionType.Tip:
- const walletTipId = rest[0];
- await processTip(ws, walletTipId, true);
- break;
- case TransactionType.Refresh:
- const refreshGroupId = rest[0];
- await processRefreshGroup(ws, refreshGroupId, true);
- break;
- default:
- break;
- }
-}
-
-/**
- * Permanently delete a transaction based on the transaction ID.
- */
-export async function deleteTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const [type, ...rest] = transactionId.split(":");
-
- if (type === TransactionType.Withdrawal) {
- const withdrawalGroupId = rest[0];
- await ws.db
- .mktx((x) => ({
- withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const withdrawalGroupRecord = await tx.withdrawalGroups.get(
- withdrawalGroupId,
- );
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- return;
- }
- const reserveRecord:
- | ReserveRecord
- | undefined = await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
- withdrawalGroupId,
- );
- if (reserveRecord && !reserveRecord.initialWithdrawalStarted) {
- const reservePub = reserveRecord.reservePub;
- await tx.reserves.delete(reservePub);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteReserve + ":" + reservePub,
- });
- }
- });
- } else if (type === TransactionType.Payment) {
- const proposalId = rest[0];
- await ws.db
- .mktx((x) => ({
- proposals: x.proposals,
- purchases: x.purchases,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- let found = false;
- const proposal = await tx.proposals.get(proposalId);
- if (proposal) {
- found = true;
- await tx.proposals.delete(proposalId);
- }
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- found = true;
- await tx.purchases.delete(proposalId);
- }
- if (found) {
- await tx.tombstones.put({
- id: TombstoneTag.DeletePayment + ":" + proposalId,
- });
- }
- });
- } else if (type === TransactionType.Refresh) {
- const refreshGroupId = rest[0];
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (rg) {
- await tx.refreshGroups.delete(refreshGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
- });
- }
- });
- } else if (type === TransactionType.Tip) {
- const tipId = rest[0];
- await ws.db
- .mktx((x) => ({
- tips: x.tips,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.tips.get(tipId);
- if (tipRecord) {
- await tx.tips.delete(tipId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteTip + ":" + tipId,
- });
- }
- });
- } else if (type === TransactionType.Deposit) {
- const depositGroupId = rest[0];
- await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.depositGroups.get(depositGroupId);
- if (tipRecord) {
- await tx.depositGroups.delete(depositGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
- });
- }
- });
- } else if (type === TransactionType.Refund) {
- const proposalId = rest[0];
- const executionTimeStr = rest[1];
-
- await ws.db
- .mktx((x) => ({
- proposals: x.proposals,
- purchases: x.purchases,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- // This should just influence the history view,
- // but won't delete any actual refund information.
- await tx.tombstones.put({
- id: makeEventId(
- TombstoneTag.DeleteRefund,
- proposalId,
- executionTimeStr,
- ),
- });
- }
- });
- } else {
- throw Error(`can't delete a '${type}' transaction`);
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/withdraw.test.ts b/packages/taler-wallet-core/src/operations/withdraw.test.ts
deleted file mode 100644
index b4f0d35e6..000000000
--- a/packages/taler-wallet-core/src/operations/withdraw.test.ts
+++ /dev/null
@@ -1,345 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- 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 { Amounts } from "@gnu-taler/taler-util";
-import test from "ava";
-import { DenominationRecord, DenominationVerificationStatus } from "../db.js";
-import { selectWithdrawalDenominations } from "./withdraw.js";
-
-test("withdrawal selection bug repro", (t) => {
- const amount = {
- currency: "KUDOS",
- fraction: 43000000,
- value: 23,
- };
-
- const denoms: DenominationRecord[] = [
- {
- denomPub:
- "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
- denomPubHash:
- "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 1000,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
- denomPubHash:
- "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 10,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
- denomPubHash:
- "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 5,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
- denomPubHash:
- "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 1,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
- denomPubHash:
- "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 10000000,
- value: 0,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
- denomPubHash:
- "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 2,
- },
- listIssueDate: { t_ms: 0 },
- },
- ];
-
- const res = selectWithdrawalDenominations(amount, denoms);
-
- console.error("cost", Amounts.stringify(res.totalWithdrawCost));
- console.error("withdraw amount", Amounts.stringify(amount));
-
- t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
- t.pass();
-});
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
deleted file mode 100644
index 620ad88be..000000000
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ /dev/null
@@ -1,1072 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2021 Taler Systems SA
-
- 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 {
- AmountJson,
- Amounts,
- BankWithdrawDetails,
- codecForTalerConfigResponse,
- codecForWithdrawOperationStatusResponse,
- codecForWithdrawResponse,
- compare,
- durationFromSpec,
- ExchangeListItem,
- getDurationRemaining,
- getTimestampNow,
- Logger,
- NotificationType,
- parseWithdrawUri,
- TalerErrorCode,
- TalerErrorDetails,
- Timestamp,
- timestampCmp,
- timestampSubtractDuraction,
- WithdrawResponse,
- URL,
- WithdrawUriInfoResponse,
- VersionMatchResult,
-} from "@gnu-taler/taler-util";
-import {
- CoinRecord,
- CoinSourceType,
- CoinStatus,
- DenominationRecord,
- DenominationVerificationStatus,
- DenomSelectionState,
- ExchangeDetailsRecord,
- ExchangeRecord,
- PlanchetRecord,
-} from "../db.js";
-import { walletCoreDebugFlags } from "../util/debugFlags.js";
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import {
- guardOperationException,
- makeErrorDetails,
- OperationFailedError,
-} from "../errors.js";
-import { InternalWalletState } from "../common.js";
-import {
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "../versions.js";
-
-/**
- * Logger for this file.
- */
-const logger = new Logger("withdraw.ts");
-
-/**
- * FIXME: Eliminate this in favor of DenomSelectionState.
- */
-interface DenominationSelectionInfo {
- totalCoinValue: AmountJson;
- totalWithdrawCost: AmountJson;
- selectedDenoms: {
- /**
- * How many times do we withdraw this denomination?
- */
- count: number;
- denom: DenominationRecord;
- }[];
-}
-
-/**
- * Information about what will happen when creating a reserve.
- *
- * Sent to the wallet frontend to be rendered and shown to the user.
- */
-export interface ExchangeWithdrawDetails {
- /**
- * Exchange that the reserve will be created at.
- */
- exchangeInfo: ExchangeRecord;
-
- exchangeDetails: ExchangeDetailsRecord;
-
- /**
- * Filtered wire info to send to the bank.
- */
- exchangeWireAccounts: string[];
-
- /**
- * Selected denominations for withdraw.
- */
- selectedDenoms: DenominationSelectionInfo;
-
- /**
- * Fees for withdraw.
- */
- withdrawFee: AmountJson;
-
- /**
- * Remaining balance that is too small to be withdrawn.
- */
- overhead: AmountJson;
-
- /**
- * Does the wallet know about an auditor for
- * the exchange that the reserve.
- */
- isAudited: boolean;
-
- /**
- * Did the user already accept the current terms of service for the exchange?
- */
- termsOfServiceAccepted: boolean;
-
- /**
- * The exchange is trusted directly.
- */
- isTrusted: boolean;
-
- /**
- * The earliest deposit expiration of the selected coins.
- */
- earliestDepositExpiration: Timestamp;
-
- /**
- * Number of currently offered denominations.
- */
- numOfferedDenoms: number;
-
- /**
- * Public keys of trusted auditors for the currency we're withdrawing.
- */
- trustedAuditorPubs: string[];
-
- /**
- * Result of checking the wallet's version
- * against the exchange's version.
- *
- * Older exchanges don't return version information.
- */
- versionMatch: VersionMatchResult | undefined;
-
- /**
- * Libtool-style version string for the exchange or "unknown"
- * for older exchanges.
- */
- exchangeVersion: string;
-
- /**
- * Libtool-style version string for the wallet.
- */
- walletVersion: string;
-}
-
-/**
- * Check if a denom is withdrawable based on the expiration time,
- * revocation and offered state.
- */
-export function isWithdrawableDenom(d: DenominationRecord): boolean {
- const now = getTimestampNow();
- const started = timestampCmp(now, d.stampStart) >= 0;
- let lastPossibleWithdraw: Timestamp;
- if (walletCoreDebugFlags.denomselAllowLate) {
- lastPossibleWithdraw = d.stampExpireWithdraw;
- } else {
- lastPossibleWithdraw = timestampSubtractDuraction(
- d.stampExpireWithdraw,
- durationFromSpec({ minutes: 5 }),
- );
- }
- const remaining = getDurationRemaining(lastPossibleWithdraw, now);
- const stillOkay = remaining.d_ms !== 0;
- return started && stillOkay && !d.isRevoked && d.isOffered;
-}
-
-/**
- * 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 selectWithdrawalDenominations(
- amountAvailable: AmountJson,
- denoms: DenominationRecord[],
-): DenominationSelectionInfo {
- let remaining = Amounts.copy(amountAvailable);
-
- const selectedDenoms: {
- count: number;
- denom: DenominationRecord;
- }[] = [];
-
- let totalCoinValue = Amounts.getZero(amountAvailable.currency);
- let totalWithdrawCost = Amounts.getZero(amountAvailable.currency);
-
- denoms = denoms.filter(isWithdrawableDenom);
- denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
-
- for (const d of denoms) {
- let count = 0;
- const cost = Amounts.add(d.value, d.feeWithdraw).amount;
- for (;;) {
- if (Amounts.cmp(remaining, cost) < 0) {
- break;
- }
- remaining = Amounts.sub(remaining, cost).amount;
- count++;
- }
- if (count > 0) {
- totalCoinValue = Amounts.add(
- totalCoinValue,
- Amounts.mult(d.value, count).amount,
- ).amount;
- totalWithdrawCost = Amounts.add(
- totalWithdrawCost,
- Amounts.mult(cost, count).amount,
- ).amount;
- selectedDenoms.push({
- count,
- denom: d,
- });
- }
-
- if (Amounts.isZero(remaining)) {
- break;
- }
- }
-
- if (logger.shouldLogTrace()) {
- logger.trace(
- `selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
- );
- for (const sd of selectedDenoms) {
- logger.trace(
- `denom_pub_hash=${sd.denom.denomPubHash}, count=${sd.count}`,
- );
- }
- logger.trace("(end of withdrawal denom list)");
- }
-
- return {
- selectedDenoms,
- totalCoinValue,
- totalWithdrawCost,
- };
-}
-
-/**
- * Get information about a withdrawal from
- * a taler://withdraw URI by asking the bank.
- */
-export async function getBankWithdrawalInfo(
- ws: InternalWalletState,
- talerWithdrawUri: string,
-): Promise<BankWithdrawDetails> {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse URL ${talerWithdrawUri}`);
- }
-
- const configReqUrl = new URL("config", uriResult.bankIntegrationApiBaseUrl);
-
- const configResp = await ws.http.get(configReqUrl.href);
- const config = await readSuccessResponseJsonOrThrow(
- configResp,
- codecForTalerConfigResponse(),
- );
-
- const versionRes = compare(
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- config.version,
- );
- if (versionRes?.compatible != true) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
- "bank integration protocol version not compatible with wallet",
- {
- exchangeProtocolVersion: config.version,
- walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- },
- );
- throw new OperationFailedError(opErr);
- }
-
- const reqUrl = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- const resp = await ws.http.get(reqUrl.href);
- const status = await readSuccessResponseJsonOrThrow(
- resp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- return {
- amount: Amounts.parseOrThrow(status.amount),
- confirmTransferUrl: status.confirm_transfer_url,
- extractedStatusUrl: reqUrl.href,
- selectionDone: status.selection_done,
- senderWire: status.sender_wire,
- suggestedExchange: status.suggested_exchange,
- transferDone: status.transfer_done,
- wireTypes: status.wire_types,
- };
-}
-
-/**
- * Return denominations that can potentially used for a withdrawal.
- */
-export async function getCandidateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<DenominationRecord[]> {
- return await ws.db
- .mktx((x) => ({ denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
- exchangeBaseUrl,
- );
- return allDenoms.filter(isWithdrawableDenom);
- });
-}
-
-/**
- * Generate a planchet for a coin index in a withdrawal group.
- * Does not actually withdraw the coin yet.
- *
- * Split up so that we can parallelize the crypto, but serialize
- * the exchange requests per reserve.
- */
-async function processPlanchetGenerate(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- coinIdx: number,
-): Promise<void> {
- const withdrawalGroup = await ws.db
- .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
- .runReadOnly(async (tx) => {
- return await tx.withdrawalGroups.get(withdrawalGroupId);
- });
- if (!withdrawalGroup) {
- return;
- }
- let planchet = await ws.db
- .mktx((x) => ({
- planchets: x.planchets,
- }))
- .runReadOnly(async (tx) => {
- return tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- });
- if (!planchet) {
- let ci = 0;
- let denomPubHash: string | undefined;
- for (
- let di = 0;
- di < withdrawalGroup.denomsSel.selectedDenoms.length;
- di++
- ) {
- const d = withdrawalGroup.denomsSel.selectedDenoms[di];
- if (coinIdx >= ci && coinIdx < ci + d.count) {
- denomPubHash = d.denomPubHash;
- break;
- }
- ci += d.count;
- }
- if (!denomPubHash) {
- throw Error("invariant violated");
- }
-
- const { denom, reserve } = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- const denom = await tx.denominations.get([
- withdrawalGroup.exchangeBaseUrl,
- denomPubHash!,
- ]);
- if (!denom) {
- throw Error("invariant violated");
- }
- const reserve = await tx.reserves.get(withdrawalGroup.reservePub);
- if (!reserve) {
- throw Error("invariant violated");
- }
- return { denom, reserve };
- });
- const r = await ws.cryptoApi.createPlanchet({
- denomPub: denom.denomPub,
- feeWithdraw: denom.feeWithdraw,
- reservePriv: reserve.reservePriv,
- reservePub: reserve.reservePub,
- value: denom.value,
- coinIndex: coinIdx,
- secretSeed: withdrawalGroup.secretSeed,
- });
- const newPlanchet: PlanchetRecord = {
- blindingKey: r.blindingKey,
- coinEv: r.coinEv,
- coinEvHash: r.coinEvHash,
- coinIdx,
- coinPriv: r.coinPriv,
- coinPub: r.coinPub,
- coinValue: r.coinValue,
- denomPub: r.denomPub,
- denomPubHash: r.denomPubHash,
- isFromTip: false,
- reservePub: r.reservePub,
- withdrawalDone: false,
- withdrawSig: r.withdrawSig,
- withdrawalGroupId: withdrawalGroupId,
- lastError: undefined,
- };
- await ws.db
- .mktx((x) => ({ planchets: x.planchets }))
- .runReadWrite(async (tx) => {
- const p = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (p) {
- planchet = p;
- return;
- }
- await tx.planchets.put(newPlanchet);
- planchet = newPlanchet;
- });
- }
-}
-
-/**
- * Send the withdrawal request for a generated planchet to the exchange.
- *
- * The verification of the response is done asynchronously to enable parallelism.
- */
-async function processPlanchetExchangeRequest(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- coinIdx: number,
-): Promise<WithdrawResponse | undefined> {
- const d = await ws.db
- .mktx((x) => ({
- withdrawalGroups: x.withdrawalGroups,
- planchets: x.planchets,
- exchanges: x.exchanges,
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!withdrawalGroup) {
- return;
- }
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- if (planchet.withdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
- if (!exchange) {
- logger.error("db inconsistent: exchange for planchet not found");
- return;
- }
-
- const denom = await tx.denominations.get([
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- ]);
-
- if (!denom) {
- console.error("db inconsistent: denom for planchet not found");
- return;
- }
-
- logger.trace(
- `processing planchet #${coinIdx} in withdrawal ${withdrawalGroupId}`,
- );
-
- const reqBody: any = {
- denom_pub_hash: planchet.denomPubHash,
- reserve_pub: planchet.reservePub,
- reserve_sig: planchet.withdrawSig,
- coin_ev: planchet.coinEv,
- };
- const reqUrl = new URL(
- `reserves/${planchet.reservePub}/withdraw`,
- exchange.baseUrl,
- ).href;
-
- return { reqUrl, reqBody };
- });
-
- if (!d) {
- return;
- }
- const { reqUrl, reqBody } = d;
-
- try {
- const resp = await ws.http.postJson(reqUrl, reqBody);
- const r = await readSuccessResponseJsonOrThrow(
- resp,
- codecForWithdrawResponse(),
- );
- return r;
- } catch (e) {
- logger.trace("withdrawal request failed", e);
- logger.trace(e);
- if (!(e instanceof OperationFailedError)) {
- throw e;
- }
- const errDetails = e.operationError;
- await ws.db
- .mktx((x) => ({ planchets: x.planchets }))
- .runReadWrite(async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = errDetails;
- await tx.planchets.put(planchet);
- });
- return;
- }
-}
-
-async function processPlanchetVerifyAndStoreCoin(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- coinIdx: number,
- resp: WithdrawResponse,
-): Promise<void> {
- const d = await ws.db
- .mktx((x) => ({
- withdrawalGroups: x.withdrawalGroups,
- planchets: x.planchets,
- }))
- .runReadOnly(async (tx) => {
- const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!withdrawalGroup) {
- return;
- }
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- if (planchet.withdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- return { planchet, exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl };
- });
-
- if (!d) {
- return;
- }
-
- const { planchet, exchangeBaseUrl } = d;
-
- const denomSig = await ws.cryptoApi.rsaUnblind(
- resp.ev_sig,
- planchet.blindingKey,
- planchet.denomPub,
- );
-
- const isValid = await ws.cryptoApi.rsaVerify(
- planchet.coinPub,
- denomSig,
- planchet.denomPub,
- );
-
- if (!isValid) {
- await ws.db
- .mktx((x) => ({ planchets: x.planchets }))
- .runReadWrite(async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
- "invalid signature from the exchange after unblinding",
- {},
- );
- await tx.planchets.put(planchet);
- });
- return;
- }
-
- const coin: CoinRecord = {
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- currentAmount: planchet.coinValue,
- denomPub: planchet.denomPub,
- denomPubHash: planchet.denomPubHash,
- denomSig,
- coinEvHash: planchet.coinEvHash,
- exchangeBaseUrl: exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Withdraw,
- coinIndex: coinIdx,
- reservePub: planchet.reservePub,
- withdrawalGroupId: withdrawalGroupId,
- },
- suspended: false,
- };
-
- const planchetCoinPub = planchet.coinPub;
-
- const firstSuccess = await ws.db
- .mktx((x) => ({
- coins: x.coins,
- withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
- planchets: x.planchets,
- }))
- .runReadWrite(async (tx) => {
- const ws = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!ws) {
- return false;
- }
- const p = await tx.planchets.get(planchetCoinPub);
- if (!p || p.withdrawalDone) {
- return false;
- }
- p.withdrawalDone = true;
- await tx.planchets.put(p);
- await tx.coins.add(coin);
- return true;
- });
-
- if (firstSuccess) {
- ws.notify({
- type: NotificationType.CoinWithdrawn,
- });
- }
-}
-
-export function denomSelectionInfoToState(
- dsi: DenominationSelectionInfo,
-): DenomSelectionState {
- return {
- selectedDenoms: dsi.selectedDenoms.map((x) => {
- return {
- count: x.count,
- denomPubHash: x.denom.denomPubHash,
- };
- }),
- totalCoinValue: dsi.totalCoinValue,
- totalWithdrawCost: dsi.totalWithdrawCost,
- };
-}
-
-/**
- * Make sure that denominations that currently can be used for withdrawal
- * are validated, and the result of validation is stored in the database.
- */
-export async function updateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- logger.trace(
- `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
- );
- const exchangeDetails = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- return ws.exchangeOps.getExchangeDetails(tx, exchangeBaseUrl);
- });
- if (!exchangeDetails) {
- logger.error("exchange details not available");
- throw Error(`exchange ${exchangeBaseUrl} details not available`);
- }
- // First do a pass where the validity of candidate denominations
- // is checked and the result is stored in the database.
- logger.trace("getting candidate denominations");
- const denominations = await getCandidateWithdrawalDenoms(ws, exchangeBaseUrl);
- logger.trace(`got ${denominations.length} candidate denominations`);
- const batchSize = 500;
- let current = 0;
-
- while (current < denominations.length) {
- const updatedDenominations: DenominationRecord[] = [];
- // Do a batch of batchSize
- for (
- let batchIdx = 0;
- batchIdx < batchSize && current < denominations.length;
- batchIdx++, current++
- ) {
- const denom = denominations[current];
- if (denom.verificationStatus === DenominationVerificationStatus.Unverified) {
- logger.trace(
- `Validating denomination (${current + 1}/${
- denominations.length
- }) signature of ${denom.denomPubHash}`,
- );
- const valid = await ws.cryptoApi.isValidDenom(
- denom,
- exchangeDetails.masterPublicKey,
- );
- logger.trace(`Done validating ${denom.denomPubHash}`);
- if (!valid) {
- logger.warn(
- `Signature check for denomination h=${denom.denomPubHash} failed`,
- );
- denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
- } else {
- denom.verificationStatus = DenominationVerificationStatus.VerifiedGood;
- }
- updatedDenominations.push(denom);
- }
- }
- if (updatedDenominations.length > 0) {
- logger.trace("writing denomination batch to db");
- await ws.db
- .mktx((x) => ({ denominations: x.denominations }))
- .runReadWrite(async (tx) => {
- for (let i = 0; i < updatedDenominations.length; i++) {
- const denom = updatedDenominations[i];
- await tx.denominations.put(denom);
- }
- });
- logger.trace("done with DB write");
- }
- }
-}
-
-async function incrementWithdrawalRetry(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
- .runReadWrite(async (tx) => {
- const wsr = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wsr) {
- return;
- }
- wsr.retryInfo.retryCounter++;
- updateRetryInfoTimeout(wsr.retryInfo);
- wsr.lastError = err;
- await tx.withdrawalGroups.put(wsr);
- });
- if (err) {
- ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
- }
-}
-
-export async function processWithdrawGroup(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementWithdrawalRetry(ws, withdrawalGroupId, e);
- await guardOperationException(
- () => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow),
- onOpErr,
- );
-}
-
-async function resetWithdrawalGroupRetry(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
- .runReadWrite(async (tx) => {
- const x = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.withdrawalGroups.put(x);
- }
- });
-}
-
-async function processWithdrawGroupImpl(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- forceNow: boolean,
-): Promise<void> {
- logger.trace("processing withdraw group", withdrawalGroupId);
- if (forceNow) {
- await resetWithdrawalGroupRetry(ws, withdrawalGroupId);
- }
- const withdrawalGroup = await ws.db
- .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
- .runReadOnly(async (tx) => {
- return tx.withdrawalGroups.get(withdrawalGroupId);
- });
- if (!withdrawalGroup) {
- logger.trace("withdraw session doesn't exist");
- return;
- }
-
- await ws.exchangeOps.updateExchangeFromUrl(
- ws,
- withdrawalGroup.exchangeBaseUrl,
- );
-
- const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
- .map((x) => x.count)
- .reduce((a, b) => a + b);
-
- let work: Promise<void>[] = [];
-
- for (let i = 0; i < numTotalCoins; i++) {
- work.push(processPlanchetGenerate(ws, withdrawalGroupId, i));
- }
-
- // Generate coins concurrently (parallelism only happens in the crypto API workers)
- await Promise.all(work);
-
- work = [];
-
- for (let coinIdx = 0; coinIdx < numTotalCoins; coinIdx++) {
- const resp = await processPlanchetExchangeRequest(
- ws,
- withdrawalGroupId,
- coinIdx,
- );
- if (!resp) {
- continue;
- }
- work.push(
- processPlanchetVerifyAndStoreCoin(ws, withdrawalGroupId, coinIdx, resp),
- );
- }
-
- await Promise.all(work);
-
- let numFinished = 0;
- let finishedForFirstTime = false;
- let errorsPerCoin: Record<number, TalerErrorDetails> = {};
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
- planchets: x.planchets,
- }))
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return;
- }
-
- await tx.planchets.indexes.byGroup
- .iter(withdrawalGroupId)
- .forEach((x) => {
- if (x.withdrawalDone) {
- numFinished++;
- }
- if (x.lastError) {
- errorsPerCoin[x.coinIdx] = x.lastError;
- }
- });
- logger.trace(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
- if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
- finishedForFirstTime = true;
- wg.timestampFinish = getTimestampNow();
- wg.lastError = undefined;
- wg.retryInfo = initRetryInfo();
- }
-
- await tx.withdrawalGroups.put(wg);
- });
-
- if (numFinished != numTotalCoins) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
- `withdrawal did not finish (${numFinished} / ${numTotalCoins} coins withdrawn)`,
- {
- errorsPerCoin,
- },
- );
- }
-
- if (finishedForFirstTime) {
- ws.notify({
- type: NotificationType.WithdrawGroupFinished,
- reservePub: withdrawalGroup.reservePub,
- });
- }
-}
-
-export async function getExchangeWithdrawalInfo(
- ws: InternalWalletState,
- baseUrl: string,
- amount: AmountJson,
-): Promise<ExchangeWithdrawDetails> {
- const {
- exchange,
- exchangeDetails,
- } = await ws.exchangeOps.updateExchangeFromUrl(ws, baseUrl);
- await updateWithdrawalDenoms(ws, baseUrl);
- const denoms = await getCandidateWithdrawalDenoms(ws, baseUrl);
- const selectedDenoms = selectWithdrawalDenominations(amount, denoms);
- const exchangeWireAccounts: string[] = [];
- for (const account of exchangeDetails.wireInfo.accounts) {
- exchangeWireAccounts.push(account.payto_uri);
- }
-
- const { isTrusted, isAudited } = await ws.exchangeOps.getExchangeTrust(
- ws,
- exchange,
- );
-
- let earliestDepositExpiration =
- selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit;
- for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) {
- const expireDeposit =
- selectedDenoms.selectedDenoms[i].denom.stampExpireDeposit;
- if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
- earliestDepositExpiration = expireDeposit;
- }
- }
-
- const possibleDenoms = await ws.db
- .mktx((x) => ({ denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- return tx.denominations.indexes.byExchangeBaseUrl
- .iter()
- .filter((d) => d.isOffered);
- });
-
- let versionMatch;
- if (exchangeDetails.protocolVersion) {
- versionMatch = compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- exchangeDetails.protocolVersion,
- );
-
- if (
- versionMatch &&
- !versionMatch.compatible &&
- versionMatch.currentCmp === -1
- ) {
- console.warn(
- `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
- );
- }
- }
-
- let tosAccepted = false;
-
- if (exchangeDetails.termsOfServiceLastEtag) {
- if (
- exchangeDetails.termsOfServiceAcceptedEtag ===
- exchangeDetails.termsOfServiceLastEtag
- ) {
- tosAccepted = true;
- }
- }
-
- const withdrawFee = Amounts.sub(
- selectedDenoms.totalWithdrawCost,
- selectedDenoms.totalCoinValue,
- ).amount;
-
- const ret: ExchangeWithdrawDetails = {
- earliestDepositExpiration,
- exchangeInfo: exchange,
- exchangeDetails,
- exchangeWireAccounts,
- exchangeVersion: exchangeDetails.protocolVersion || "unknown",
- isAudited,
- isTrusted,
- numOfferedDenoms: possibleDenoms.length,
- overhead: Amounts.sub(amount, selectedDenoms.totalWithdrawCost).amount,
- selectedDenoms,
- // FIXME: delete this field / replace by something we can display to the user
- trustedAuditorPubs: [],
- versionMatch,
- walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- withdrawFee,
- termsOfServiceAccepted: tosAccepted,
- };
- return ret;
-}
-
-export async function getWithdrawalDetailsForUri(
- ws: InternalWalletState,
- talerWithdrawUri: string,
-): Promise<WithdrawUriInfoResponse> {
- logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
- const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
- logger.trace(`got bank info`);
- if (info.suggestedExchange) {
- // FIXME: right now the exchange gets permanently added,
- // we might want to only temporarily add it.
- try {
- await ws.exchangeOps.updateExchangeFromUrl(ws, info.suggestedExchange);
- } catch (e) {
- // We still continued if it failed, as other exchanges might be available.
- // We don't want to fail if the bank-suggested exchange is broken/offline.
- logger.trace(
- `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
- );
- }
- }
-
- const exchanges: ExchangeListItem[] = [];
-
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const details = await ws.exchangeOps.getExchangeDetails(tx, r.baseUrl);
- if (details) {
- exchanges.push({
- exchangeBaseUrl: details.exchangeBaseUrl,
- currency: details.currency,
- paytoUris: details.wireInfo.accounts.map((x) => x.payto_uri),
- });
- }
- }
- });
-
- return {
- amount: Amounts.stringify(info.amount),
- defaultExchangeBaseUrl: info.suggestedExchange,
- possibleExchanges: exchanges,
- };
-}
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
new file mode 100644
index 000000000..4a2ef009a
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -0,0 +1,3502 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2023 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * Implementation of the payment operation, including downloading and
+ * claiming of proposals.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbortingCoin,
+ AbortRequest,
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ AmountString,
+ assertUnreachable,
+ AsyncFlag,
+ checkDbInvariant,
+ codecForAbortResponse,
+ codecForMerchantContractTerms,
+ codecForMerchantOrderStatusPaid,
+ codecForMerchantPayResponse,
+ codecForMerchantPostOrderResponse,
+ codecForProposal,
+ codecForWalletRefundResponse,
+ CoinDepositPermission,
+ CoinRefreshRequest,
+ ConfirmPayResult,
+ ConfirmPayResultType,
+ ContractTermsUtil,
+ Duration,
+ encodeCrock,
+ ForcedCoinSel,
+ getRandomBytes,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ makePendingOperationFailedError,
+ MerchantCoinRefundStatus,
+ MerchantContractTerms,
+ MerchantPayResponse,
+ MerchantUsingTemplateDetails,
+ NotificationType,
+ parsePayTemplateUri,
+ parsePayUri,
+ parseTalerUri,
+ PreparePayResult,
+ PreparePayResultType,
+ PreparePayTemplateRequest,
+ randomBytes,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ SharePaymentResult,
+ StartRefundQueryForUriResponse,
+ stringifyPayUri,
+ stringifyTalerUri,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TalerUriAction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletContractData,
+} from "@gnu-taler/taler-util";
+import {
+ getHttpResponseErrorDetails,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ readUnexpectedResponseDetails,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js";
+import {
+ constructTaskIdentifier,
+ PendingTaskType,
+ spendCoins,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResultType,
+} from "./common.js";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ DbCoinSelection,
+ DenominationRecord,
+ PurchaseRecord,
+ PurchaseStatus,
+ RefundGroupRecord,
+ RefundGroupStatus,
+ RefundItemRecord,
+ RefundItemStatus,
+ RefundReason,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletStoresV1,
+} from "./db.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
+import {
+ calculateRefreshOutput,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ getDenomInfo,
+ WalletExecutionContext,
+} from "./wallet.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("pay-merchant.ts");
+
+export class PayMerchantTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public proposalId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
+ }
+
+ /**
+ * Transition a payment transition.
+ */
+ async transition(
+ f: (rec: PurchaseRecord) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ /**
+ * Transition a payment transition.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PurchaseRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["purchases", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ const ws = this.wex;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await ws.db.runReadWriteTx(
+ { storeNames: ["purchases", ...extraStores] },
+ async (tx) => {
+ const purchaseRec = await tx.purchases.get(this.proposalId);
+ if (!purchaseRec) {
+ throw Error("purchase not found anymore");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchaseRec);
+ const res = await f(purchaseRec, tx);
+ switch (res) {
+ case TransitionResultType.Transition: {
+ await tx.purchases.put(purchaseRec);
+ const newTxState = computePayMerchantTransactionState(purchaseRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(ws, this.transactionId, transitionInfo);
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex: ws, proposalId } = this;
+ await ws.db.runReadWriteTx(
+ { storeNames: ["purchases", "tombstones"] },
+ async (tx) => {
+ let found = false;
+ const purchase = await tx.purchases.get(proposalId);
+ if (purchase) {
+ found = true;
+ await tx.purchases.delete(proposalId);
+ }
+ if (found) {
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePayment + ":" + proposalId,
+ });
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionSuspend[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ const oldStatus = purchase.purchaseStatus;
+ switch (oldStatus) {
+ case PurchaseStatus.Done:
+ return;
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.SuspendedPaying: {
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+ if (purchase.payInfo && purchase.payInfo.payCoinSelection) {
+ const coinSel = purchase.payInfo.payCoinSelection;
+ const currency = Amounts.currencyOf(
+ purchase.payInfo.totalPayCost,
+ );
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < coinSel.coinPubs.length; i++) {
+ refreshCoins.push({
+ amount: coinSel.coinContributions[i],
+ coinPub: coinSel.coinPubs[i],
+ });
+ }
+ await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortPay,
+ this.transactionId,
+ );
+ }
+ break;
+ }
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.PendingAcceptRefund:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.PendingQueryingRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ if (!purchase.timestampFirstSuccessfulPay) {
+ throw Error("invalid state");
+ }
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ break;
+ case PurchaseStatus.DialogProposed:
+ purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ break;
+ default:
+ return;
+ }
+ await tx.purchases.put(purchase);
+ await tx.operationRetries.delete(this.taskId);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionResume[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newState: PurchaseStatus | undefined = undefined;
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.AbortingWithRefund:
+ newState = PurchaseStatus.FailedAbort;
+ break;
+ }
+ if (newState) {
+ purchase.purchaseStatus = newState;
+ await tx.purchases.put(purchase);
+ }
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ }
+}
+
+export class RefundTransactionContext implements TransactionContext {
+ public transactionId: TransactionIdStr;
+ public taskId: TaskIdStr | undefined = undefined;
+ constructor(
+ public wex: WalletExecutionContext,
+ public refundGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, refundGroupId, transactionId } = this;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refundGroups", "tombstones"] },
+ async (tx) => {
+ const refundRecord = await tx.refundGroups.get(refundGroupId);
+ if (!refundRecord) {
+ return;
+ }
+ await tx.refundGroups.delete(refundGroupId);
+ await tx.tombstones.put({ id: transactionId });
+ // FIXME: Also tombstone the refund items, so that they won't reappear.
+ },
+ );
+ }
+
+ suspendTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ abortTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ resumeTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ failTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+}
+
+/**
+ * Compute the total cost of a payment to the customer.
+ *
+ * This includes the amount taken by the merchant, fees (wire/deposit) contributed
+ * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
+ * of coins that are too small to spend.
+ */
+export async function getTotalPaymentCost(
+ wex: WalletExecutionContext,
+ currency: string,
+ pcs: SelectedProspectiveCoin[],
+): Promise<AmountJson> {
+ return wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await tx.denominations.get([
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
+ }
+ const zero = Amounts.zeroOfCurrency(currency);
+ return Amounts.sum([zero, ...costs]).amount;
+ },
+ );
+}
+
+async function failProposalPermanently(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ // FIXME: We don't store the error detail here?!
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
+ return Duration.multiply(
+ { d_ms: 15000 },
+ 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5,
+ );
+}
+
+/**
+ * Return the proposal download data for a purchase, throw if not available.
+ */
+export async function expectProposalDownload(
+ wex: WalletExecutionContext,
+ p: PurchaseRecord,
+ parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>,
+): Promise<{
+ contractData: WalletContractData;
+ contractTermsRaw: any;
+}> {
+ if (!p.download) {
+ throw Error("expected proposal to be downloaded");
+ }
+ const download = p.download;
+
+ async function getFromTransaction(
+ tx: Exclude<typeof parentTx, undefined>,
+ ): Promise<ReturnType<typeof expectProposalDownload>> {
+ const contractTerms = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTerms) {
+ throw Error("contract terms not found");
+ }
+ return {
+ contractData: extractContractData(
+ contractTerms.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ ),
+ contractTermsRaw: contractTerms.contractTermsRaw,
+ };
+ }
+
+ if (parentTx) {
+ return getFromTransaction(parentTx);
+ }
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ getFromTransaction,
+ );
+}
+
+export function extractContractData(
+ parsedContractTerms: MerchantContractTerms,
+ contractTermsHash: string,
+ merchantSig: string,
+): WalletContractData {
+ const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
+ return {
+ amount: Amounts.stringify(amount),
+ contractTermsHash: contractTermsHash,
+ fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
+ merchantBaseUrl: parsedContractTerms.merchant_base_url,
+ merchantPub: parsedContractTerms.merchant_pub,
+ merchantSig,
+ orderId: parsedContractTerms.order_id,
+ summary: parsedContractTerms.summary,
+ autoRefund: parsedContractTerms.auto_refund,
+ payDeadline: parsedContractTerms.pay_deadline,
+ refundDeadline: parsedContractTerms.refund_deadline,
+ allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
+ exchangeBaseUrl: x.url,
+ exchangePub: x.master_pub,
+ })),
+ timestamp: parsedContractTerms.timestamp,
+ wireMethod: parsedContractTerms.wire_method,
+ wireInfoHash: parsedContractTerms.h_wire,
+ maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee),
+ merchant: parsedContractTerms.merchant,
+ summaryI18n: parsedContractTerms.summary_i18n,
+ minimumAge: parsedContractTerms.minimum_age,
+ };
+}
+
+async function processDownloadProposal(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return await tx.purchases.get(proposalId);
+ },
+ );
+
+ if (!proposal) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
+ logger.error(
+ `unexpected state ${proposal.purchaseStatus}/${
+ PurchaseStatus[proposal.purchaseStatus]
+ } for ${ctx.transactionId} in processDownloadProposal`,
+ );
+ return TaskRunResult.finished();
+ }
+
+ const transactionId = ctx.transactionId;
+
+ const orderClaimUrl = new URL(
+ `orders/${proposal.orderId}/claim`,
+ proposal.merchantBaseUrl,
+ ).href;
+ logger.trace("downloading contract from '" + orderClaimUrl + "'");
+
+ const requestBody: {
+ nonce: string;
+ token?: string;
+ } = {
+ nonce: proposal.noncePub,
+ };
+ if (proposal.claimToken) {
+ requestBody.token = proposal.claimToken;
+ }
+
+ const httpResponse = await wex.http.fetch(orderClaimUrl, {
+ method: "POST",
+ body: requestBody,
+ cancellationToken: wex.cancellationToken,
+ });
+ const r = await readSuccessResponseJsonOrErrorCode(
+ httpResponse,
+ codecForProposal(),
+ );
+ if (r.isError) {
+ switch (r.talerErrorResponse.code) {
+ case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
+ {
+ orderId: proposal.orderId,
+ claimUrl: orderClaimUrl,
+ },
+ "order already claimed (likely by other wallet)",
+ );
+ default:
+ throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
+ }
+ }
+ const proposalResp = r.response;
+
+ // The proposalResp contains the contract terms as raw JSON,
+ // as the code to parse them doesn't necessarily round-trip.
+ // We need this raw JSON to compute the contract terms hash.
+
+ // FIXME: Do better error handling, check if the
+ // contract terms have all their forgettable information still
+ // present. The wallet should never accept contract terms
+ // with missing information from the merchant.
+
+ const isWellFormed = ContractTermsUtil.validateForgettable(
+ proposalResp.contract_terms,
+ );
+
+ if (!isWellFormed) {
+ logger.trace(
+ `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
+ );
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ "validation for well-formedness failed",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ proposalResp.contract_terms,
+ );
+
+ logger.info(`Contract terms hash: ${contractTermsHash}`);
+
+ let parsedContractTerms: MerchantContractTerms;
+
+ try {
+ parsedContractTerms = codecForMerchantContractTerms().decode(
+ proposalResp.contract_terms,
+ );
+ } catch (e) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ `schema validation failed: ${e}`,
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const sigValid = await wex.cryptoApi.isValidContractTermsSignature({
+ contractTermsHash,
+ merchantPub: parsedContractTerms.merchant_pub,
+ sig: proposalResp.sig,
+ });
+
+ if (!sigValid) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
+ {
+ merchantPub: parsedContractTerms.merchant_pub,
+ orderId: parsedContractTerms.order_id,
+ },
+ "merchant's signature on contract terms is invalid",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const fulfillmentUrl = parsedContractTerms.fulfillment_url;
+
+ const baseUrlForDownload = proposal.merchantBaseUrl;
+ const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
+
+ if (baseUrlForDownload !== baseUrlFromContractTerms) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
+ {
+ baseUrlForDownload,
+ baseUrlFromContractTerms,
+ },
+ "merchant base URL mismatch",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractData = extractContractData(
+ parsedContractTerms,
+ contractTermsHash,
+ proposalResp.sig,
+ );
+
+ logger.trace(`extracted contract data: ${j2s(contractData)}`);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases", "contractTerms"] },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.download = {
+ contractTermsHash,
+ contractTermsMerchantSig: contractData.merchantSig,
+ currency: Amounts.currencyOf(contractData.amount),
+ fulfillmentUrl: contractData.fulfillmentUrl,
+ };
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: proposalResp.contract_terms,
+ });
+ const isResourceFulfillmentUrl =
+ fulfillmentUrl &&
+ (fulfillmentUrl.startsWith("http://") ||
+ fulfillmentUrl.startsWith("https://"));
+ let otherPurchase: PurchaseRecord | undefined;
+ if (isResourceFulfillmentUrl) {
+ otherPurchase =
+ await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
+ }
+ // FIXME: Adjust this to account for refunds, don't count as repurchase
+ // if original order is refunded.
+ if (
+ otherPurchase &&
+ (otherPurchase.purchaseStatus == PurchaseStatus.Done ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay)
+ ) {
+ logger.warn("repurchase detected");
+ p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
+ p.repurchaseProposalId = otherPurchase.proposalId;
+ await tx.purchases.put(p);
+ } else {
+ p.purchaseStatus = p.shared
+ ? PurchaseStatus.DialogShared
+ : PurchaseStatus.DialogProposed;
+ await tx.purchases.put(p);
+ }
+ const newTxState = computePayMerchantTransactionState(p);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return TaskRunResult.progress();
+}
+
+/**
+ * Create a new purchase transaction if necessary. If a purchase
+ * record for the provided arguments already exists,
+ * return the old proposal ID.
+ */
+async function createOrReusePurchase(
+ wex: WalletExecutionContext,
+ merchantBaseUrl: string,
+ orderId: string,
+ sessionId: string | undefined,
+ claimToken: string | undefined,
+ noncePriv: string | undefined,
+): Promise<string> {
+ const oldProposals = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.getAll([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ },
+ );
+
+ const oldProposal = oldProposals.find((p) => {
+ return (
+ p.downloadSessionId === sessionId &&
+ (!noncePriv || p.noncePriv === noncePriv) &&
+ p.claimToken === claimToken
+ );
+ });
+ // If we have already claimed this proposal with the same sessionId
+ // nonce and claim token, reuse it. */
+ if (
+ oldProposal &&
+ oldProposal.downloadSessionId === sessionId &&
+ (!noncePriv || oldProposal.noncePriv === noncePriv) &&
+ oldProposal.claimToken === claimToken
+ ) {
+ logger.info(
+ `Found old proposal (status=${
+ PurchaseStatus[oldProposal.purchaseStatus]
+ }) for order ${orderId} at ${merchantBaseUrl}`,
+ );
+ if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) {
+ const download = await expectProposalDownload(wex, oldProposal);
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ false,
+ );
+ logger.info(`old proposal paid: ${paid}`);
+ if (paid) {
+ // if this transaction was shared and the order is paid then it
+ // means that another wallet already paid the proposal
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(oldProposal.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: oldProposal.proposalId,
+ });
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+ }
+ return oldProposal.proposalId;
+ }
+
+ let noncePair: EddsaKeypair;
+ let shared = false;
+ if (noncePriv) {
+ shared = true;
+ noncePair = {
+ priv: noncePriv,
+ pub: (await wex.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
+ };
+ } else {
+ noncePair = await wex.cryptoApi.createEddsaKeypair({});
+ }
+
+ const { priv, pub } = noncePair;
+ const proposalId = encodeCrock(getRandomBytes(32));
+
+ const proposalRecord: PurchaseRecord = {
+ download: undefined,
+ noncePriv: priv,
+ noncePub: pub,
+ claimToken,
+ timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ merchantBaseUrl,
+ orderId,
+ proposalId: proposalId,
+ purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
+ repurchaseProposalId: undefined,
+ downloadSessionId: sessionId,
+ autoRefundDeadline: undefined,
+ lastSessionId: undefined,
+ merchantPaySig: undefined,
+ payInfo: undefined,
+ refundAmountAwaiting: undefined,
+ timestampAccept: undefined,
+ timestampFirstSuccessfulPay: undefined,
+ timestampLastRefundStatus: undefined,
+ pendingRemovedCoinPubs: undefined,
+ posConfirmation: undefined,
+ shared: shared,
+ };
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ await tx.purchases.put(proposalRecord);
+ const oldTxState: TransactionState = {
+ major: TransactionMajorState.None,
+ };
+ const newTxState = computePayMerchantTransactionState(proposalRecord);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ notifyTransition(wex, transactionId, transitionInfo);
+ return proposalId;
+}
+
+async function storeFirstPaySuccess(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId: string | undefined,
+ payResponse: MerchantPayResponse,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (!isFirst) {
+ logger.warn("payment success already stored");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(now);
+ purchase.lastSessionId = sessionId;
+ purchase.merchantPaySig = payResponse.sig;
+ purchase.posConfirmation = payResponse.pos_confirmation;
+ const dl = purchase.download;
+ checkDbInvariant(!!dl);
+ const contractTermsRecord = await tx.contractTerms.get(
+ dl.contractTermsHash,
+ );
+ checkDbInvariant(!!contractTermsRecord);
+ const contractData = extractContractData(
+ contractTermsRecord.contractTermsRaw,
+ dl.contractTermsHash,
+ dl.contractTermsMerchantSig,
+ );
+ const protoAr = contractData.autoRefund;
+ if (protoAr) {
+ const ar = Duration.fromTalerProtocolDuration(protoAr);
+ logger.info("auto_refund present");
+ purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
+ purchase.autoRefundDeadline = timestampProtocolToDb(
+ AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
+ ),
+ );
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+async function storePayReplaySuccess(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId: string | undefined,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (isFirst) {
+ throw Error("invalid payment state");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ if (
+ purchase.purchaseStatus === PurchaseStatus.PendingPaying ||
+ purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay
+ ) {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ purchase.lastSessionId = sessionId;
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+/**
+ * Handle a 409 Conflict response from the merchant.
+ *
+ * We do this by going through the coin history provided by the exchange and
+ * (1) verifying the signatures from the exchange
+ * (2) adjusting the remaining coin value and refreshing it
+ * (3) re-do coin selection with the bad coin removed
+ */
+async function handleInsufficientFunds(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ logger.trace("handling insufficient funds, trying to re-select coins");
+
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+ if (!proposal) {
+ return;
+ }
+
+ logger.trace(`got error details: ${j2s(err)}`);
+
+ const exchangeReply = (err as any).exchange_reply;
+ if (
+ exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
+ ) {
+ // FIXME: set as failed
+ if (logger.shouldLogTrace()) {
+ logger.trace("got exchange error reply (see below)");
+ logger.trace(j2s(exchangeReply));
+ }
+ throw Error(`unable to handle /pay error response (${exchangeReply.code})`);
+ }
+
+ const brokenCoinPub = (exchangeReply as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ throw new TalerProtocolViolationError();
+ }
+
+ const { contractData } = await expectProposalDownload(wex, proposal);
+
+ const prevPayCoins: PreviousPayCoins = [];
+
+ const payInfo = proposal.payInfo;
+ if (!payInfo) {
+ return;
+ }
+
+ const payCoinSelection = payInfo.payCoinSelection;
+ if (!payCoinSelection) {
+ return;
+ }
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const contrib = payCoinSelection.coinContributions[i];
+ prevPayCoins.push({
+ coinPub,
+ contribution: Amounts.parseOrThrow(contrib),
+ });
+ }
+ },
+ );
+
+ const res = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+
+ switch (res.type) {
+ case "failure":
+ logger.trace("insufficient funds for coin re-selection");
+ return;
+ case "prospective":
+ return;
+ case "success":
+ break;
+ default:
+ assertUnreachable(res);
+ }
+
+ logger.trace("re-selected coins");
+
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const payInfo = p.payInfo;
+ if (!payInfo) {
+ return;
+ }
+ // Convert to DB format
+ payInfo.payCoinSelection = {
+ coinContributions: res.coinSel.coins.map((x) => x.contribution),
+ coinPubs: res.coinSel.coins.map((x) => x.coinPub),
+ };
+ payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ await tx.purchases.put(p);
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:proposal:${p.proposalId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: payInfo.payCoinSelection.coinPubs,
+ contributions: payInfo.payCoinSelection.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ }),
+ });
+}
+
+// FIXME: Should take a transaction ID instead of a proposal ID
+// FIXME: Does way more than checking the payment
+// FIXME: Should return immediately.
+async function checkPaymentByProposalId(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId?: string,
+): Promise<PreparePayResult> {
+ let proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+ if (!proposal) {
+ throw Error(`could not get proposal ${proposalId}`);
+ }
+ if (proposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) {
+ const existingProposalId = proposal.repurchaseProposalId;
+ if (existingProposalId) {
+ logger.trace("using existing purchase for same product");
+ const oldProposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(existingProposalId);
+ },
+ );
+ if (oldProposal) {
+ proposal = oldProposal;
+ }
+ }
+ }
+ const d = await expectProposalDownload(wex, proposal);
+ const contractData = d.contractData;
+ const merchantSig = d.contractData.merchantSig;
+ if (!merchantSig) {
+ throw Error("BUG: proposal is in invalid state");
+ }
+
+ proposalId = proposal.proposalId;
+
+ const currency = Amounts.currencyOf(contractData.amount);
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ const transactionId = ctx.transactionId;
+
+ const talerUri = stringifyTalerUri({
+ type: TalerUriAction.Pay,
+ merchantBaseUrl: proposal.merchantBaseUrl,
+ orderId: proposal.orderId,
+ sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
+ claimToken: proposal.claimToken,
+ });
+
+ // First check if we already paid for it.
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+
+ if (
+ !purchase ||
+ purchase.purchaseStatus === PurchaseStatus.DialogProposed ||
+ purchase.purchaseStatus === PurchaseStatus.DialogShared
+ ) {
+ const instructedAmount = Amounts.parseOrThrow(contractData.amount);
+ // If not already paid, check if we could pay for it.
+ const res = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ contractTermsAmount: instructedAmount,
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ restrictWireMethod: contractData.wireMethod,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (res.type) {
+ case "failure": {
+ logger.info("not allowing payment, insufficient coins");
+ logger.info(
+ `insufficient balance details: ${j2s(
+ res.insufficientBalanceDetails,
+ )}`,
+ );
+ return {
+ status: PreparePayResultType.InsufficientBalance,
+ contractTerms: d.contractTermsRaw,
+ proposalId: proposal.proposalId,
+ transactionId,
+ amountRaw: Amounts.stringify(d.contractData.amount),
+ talerUri,
+ balanceDetails: res.insufficientBalanceDetails,
+ };
+ }
+ case "prospective":
+ coins = res.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = res.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(res);
+ }
+
+ const totalCost = await getTotalPaymentCost(wex, currency, coins);
+ logger.trace("costInfo", totalCost);
+ logger.trace("coinsForPayment", res);
+
+ return {
+ status: PreparePayResultType.PaymentPossible,
+ contractTerms: d.contractTermsRaw,
+ transactionId,
+ proposalId: proposal.proposalId,
+ amountEffective: Amounts.stringify(totalCost),
+ amountRaw: Amounts.stringify(instructedAmount),
+ contractTermsHash: d.contractData.contractTermsHash,
+ talerUri,
+ };
+ }
+
+ if (
+ purchase.purchaseStatus === PurchaseStatus.Done &&
+ purchase.lastSessionId !== sessionId
+ ) {
+ logger.trace(
+ "automatically re-submitting payment with different session ID",
+ );
+ logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.lastSessionId = sessionId;
+ p.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ await tx.purchases.put(p);
+ const newTxState = computePayMerchantTransactionState(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Consider changing the API here so that we don't have to
+ // wait inline for the repurchase.
+
+ await waitPaymentResult(wex, proposalId, sessionId);
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: true,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ } else if (!purchase.timestampFirstSuccessfulPay) {
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: purchase.purchaseStatus === PurchaseStatus.FailedPaidByOther,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ } else {
+ const paid =
+ purchase.purchaseStatus === PurchaseStatus.Done ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ ...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ }
+}
+
+export async function getContractTermsDetails(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<WalletContractData> {
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(wex, proposal);
+
+ return d.contractData;
+}
+
+/**
+ * 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 preparePayForUri(
+ wex: WalletExecutionContext,
+ talerPayUri: string,
+): Promise<PreparePayResult> {
+ const uriResult = parsePayUri(talerPayUri);
+
+ if (!uriResult) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
+ {
+ talerPayUri,
+ },
+ `invalid taler://pay URI (${talerPayUri})`,
+ );
+ }
+
+ const proposalId = await createOrReusePurchase(
+ wex,
+ uriResult.merchantBaseUrl,
+ uriResult.orderId,
+ uriResult.sessionId,
+ uriResult.claimToken,
+ uriResult.noncePriv,
+ );
+
+ await waitProposalDownloaded(wex, proposalId);
+
+ return checkPaymentByProposalId(wex, proposalId, uriResult.sessionId);
+}
+
+/**
+ * Wait until a proposal is at least downloaded.
+ */
+async function waitProposalDownloaded(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ // FIXME: This doesn't support cancellation yet
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ logger.info(`waiting for ${ctx.transactionId} to be downloaded`);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const payNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ payNotifFlag.raise();
+ }
+ });
+
+ try {
+ await internalWaitProposalDownloaded(ctx, payNotifFlag);
+ logger.info(`done waiting for ${ctx.transactionId} to be downloaded`);
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function internalWaitProposalDownloaded(
+ ctx: PayMerchantTransactionContext,
+ payNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["purchases", "operationRetries"] },
+ async (tx) => {
+ return {
+ purchase: await tx.purchases.get(ctx.proposalId),
+ retryInfo: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+ if (!purchase) {
+ throw Error("purchase does not exist anymore");
+ }
+ if (purchase.download) {
+ return;
+ }
+ if (retryInfo) {
+ if (retryInfo.lastError) {
+ throw TalerError.fromUncheckedDetail(retryInfo.lastError);
+ } else {
+ throw Error("transient error while waiting for proposal download");
+ }
+ }
+ await payNotifFlag.wait();
+ payNotifFlag.reset();
+ }
+}
+
+export async function preparePayForTemplate(
+ wex: WalletExecutionContext,
+ req: PreparePayTemplateRequest,
+): Promise<PreparePayResult> {
+ const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
+ const templateDetails: MerchantUsingTemplateDetails = {};
+ if (!parsedUri) {
+ throw Error("invalid taler-template URI");
+ }
+ logger.trace(`parsed URI: ${j2s(parsedUri)}`);
+
+ const amountFromUri = parsedUri.templateParams.amount;
+ if (amountFromUri != null) {
+ const templateParamsAmount = req.templateParams?.amount;
+ if (templateParamsAmount != null) {
+ templateDetails.amount = templateParamsAmount as AmountString;
+ } else {
+ if (Amounts.isCurrency(amountFromUri)) {
+ throw Error(
+ "Amount from template URI only has a currency without value. The value must be provided in the templateParams.",
+ );
+ } else {
+ templateDetails.amount = amountFromUri as AmountString;
+ }
+ }
+ }
+ if (
+ parsedUri.templateParams.summary !== undefined &&
+ typeof parsedUri.templateParams.summary === "string"
+ ) {
+ templateDetails.summary =
+ req.templateParams?.summary ?? parsedUri.templateParams.summary;
+ }
+ const reqUrl = new URL(
+ `templates/${parsedUri.templateId}`,
+ parsedUri.merchantBaseUrl,
+ );
+ const httpReq = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: templateDetails,
+ });
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpReq,
+ codecForMerchantPostOrderResponse(),
+ );
+
+ const payUri = stringifyPayUri({
+ merchantBaseUrl: parsedUri.merchantBaseUrl,
+ orderId: resp.order_id,
+ sessionId: "",
+ claimToken: resp.token,
+ });
+
+ return await preparePayForUri(wex, payUri);
+}
+
+/**
+ * Generate deposit permissions for a purchase.
+ *
+ * Accesses the database and the crypto worker.
+ */
+export async function generateDepositPermissions(
+ wex: WalletExecutionContext,
+ payCoinSel: DbCoinSelection,
+ contractData: WalletContractData,
+): Promise<CoinDepositPermission[]> {
+ const depositPermissions: CoinDepositPermission[] = [];
+ const coinWithDenom: Array<{
+ coin: CoinRecord;
+ denom: DenominationRecord;
+ }> = [];
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
+ const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't pay, allocated coin not found anymore");
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't pay, denomination of allocated coin not found anymore",
+ );
+ }
+ coinWithDenom.push({ coin, denom });
+ }
+ },
+ );
+
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
+ const { coin, denom } = coinWithDenom[i];
+ let wireInfoHash: string;
+ wireInfoHash = contractData.wireInfoHash;
+ const dp = await wex.cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash: contractData.contractTermsHash,
+ denomPubHash: coin.denomPubHash,
+ denomKeyType: denom.denomPub.cipher,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ merchantPub: contractData.merchantPub,
+ refundDeadline: contractData.refundDeadline,
+ spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
+ timestamp: contractData.timestamp,
+ wireInfoHash,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ depositPermissions.push(dp);
+ }
+ return depositPermissions;
+}
+
+async function internalWaitPaymentResult(
+ ctx: PayMerchantTransactionContext,
+ purchaseNotifFlag: AsyncFlag,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ while (true) {
+ const txRes = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["purchases", "operationRetries"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(ctx.proposalId);
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+ return { purchase, retryRecord };
+ },
+ );
+
+ if (!txRes.purchase) {
+ throw Error("purchase gone");
+ }
+
+ const purchase = txRes.purchase;
+
+ logger.info(
+ `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
+ );
+
+ const d = await expectProposalDownload(ctx.wex, purchase);
+
+ if (txRes.purchase.timestampFirstSuccessfulPay) {
+ if (
+ waitSessionId == null ||
+ txRes.purchase.lastSessionId === waitSessionId
+ ) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+ }
+
+ if (txRes.retryRecord) {
+ return {
+ type: ConfirmPayResultType.Pending,
+ lastError: txRes.retryRecord.lastError,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ await purchaseNotifFlag.wait();
+ purchaseNotifFlag.reset();
+ }
+}
+
+/**
+ * Wait until either:
+ * a) the payment succeeded (if provided under the {@param waitSessionId}), or
+ * b) the attempt to pay failed (merchant unavailable, etc.)
+ */
+async function waitPaymentResult(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ // FIXME: We don't support cancelletion yet!
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const purchaseNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our purchase.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ purchaseNotifFlag.raise();
+ }
+ });
+
+ try {
+ logger.info(`waiting for first payment success on ${ctx.transactionId}`);
+ const res = await internalWaitPaymentResult(
+ ctx,
+ purchaseNotifFlag,
+ waitSessionId,
+ );
+ logger.info(
+ `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`,
+ );
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+/**
+ * Confirm payment for a proposal previously claimed by the wallet.
+ */
+export async function confirmPay(
+ wex: WalletExecutionContext,
+ transactionId: string,
+ sessionIdOverride?: string,
+ forcedCoinSel?: ForcedCoinSel,
+): Promise<ConfirmPayResult> {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+ if (parsedTx?.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ const proposalId = parsedTx.proposalId;
+ logger.trace(
+ `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
+ );
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(wex, proposal);
+ if (!d) {
+ throw Error("proposal is in invalid state");
+ }
+
+ const existingPurchase = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (
+ purchase &&
+ sessionIdOverride !== undefined &&
+ sessionIdOverride != purchase.lastSessionId
+ ) {
+ logger.trace(`changing session ID to ${sessionIdOverride}`);
+ purchase.lastSessionId = sessionIdOverride;
+ if (purchase.purchaseStatus === PurchaseStatus.Done) {
+ purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ }
+ await tx.purchases.put(purchase);
+ }
+ return purchase;
+ },
+ );
+
+ if (existingPurchase && existingPurchase.payInfo) {
+ logger.trace("confirmPay: submitting payment for existing purchase");
+ const ctx = new PayMerchantTransactionContext(
+ wex,
+ existingPurchase.proposalId,
+ );
+ await wex.taskScheduler.resetTaskRetries(ctx.taskId);
+ return waitPaymentResult(wex, proposalId);
+ }
+
+ logger.trace("confirmPay: purchase record does not exist yet");
+
+ const contractData = d.contractData;
+
+ const currency = Amounts.currencyOf(contractData.amount);
+
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ forcedSelection: forcedCoinSel,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ coins = selectCoinsResult.result.prospectiveCoins;
+ break;
+ }
+ case "success":
+ coins = selectCoinsResult.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(wex, currency, coins);
+
+ let sessionId: string | undefined;
+ if (sessionIdOverride) {
+ sessionId = sessionIdOverride;
+ } else {
+ sessionId = proposal.downloadSessionId;
+ }
+
+ logger.trace(
+ `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
+ );
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const p = await tx.purchases.get(proposal.proposalId);
+ if (!p) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ };
+ if (selectCoinsResult.type === "success") {
+ p.payInfo.payCoinSelection = {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ }
+ p.lastSessionId = sessionId;
+ p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+ if (p.payInfo.payCoinSelection) {
+ const sel = p.payInfo.payCoinSelection;
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: sel.coinPubs,
+ contributions: sel.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ }
+
+ break;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ const newTxState = computePayMerchantTransactionState(p);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ // In case we're sharing the payment and we're long-polling
+ wex.taskScheduler.stopShepherdTask(ctx.taskId);
+
+ // Wait until we have completed the first attempt to pay.
+ return waitPaymentResult(wex, proposalId);
+}
+
+export async function processPurchase(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+ if (!purchase) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ when: AbsoluteTime.now(),
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.PendingDownloadingProposal:
+ return processDownloadProposal(wex, proposalId);
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ return processPurchasePay(wex, proposalId);
+ case PurchaseStatus.PendingQueryingRefund:
+ return processPurchaseQueryRefund(wex, purchase);
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ return processPurchaseAutoRefund(wex, purchase);
+ case PurchaseStatus.AbortingWithRefund:
+ return processPurchaseAbortingRefund(wex, purchase);
+ case PurchaseStatus.PendingAcceptRefund:
+ return processPurchaseAcceptRefund(wex, purchase);
+ case PurchaseStatus.DialogShared:
+ return processPurchaseDialogShared(wex, purchase);
+ case PurchaseStatus.FailedClaim:
+ case PurchaseStatus.Done:
+ case PurchaseStatus.DoneRepurchaseDetected:
+ case PurchaseStatus.DialogProposed:
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedIncompletePayment:
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ case PurchaseStatus.SuspendedPaying:
+ case PurchaseStatus.SuspendedPayingReplay:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ case PurchaseStatus.FailedAbort:
+ case PurchaseStatus.FailedPaidByOther:
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(purchase.purchaseStatus);
+ }
+}
+
+async function processPurchasePay(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+ if (!purchase) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ when: AbsoluteTime.now(),
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ break;
+ default:
+ return TaskRunResult.finished();
+ }
+ logger.trace(`processing purchase pay ${proposalId}`);
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ const sessionId = purchase.lastSessionId;
+
+ logger.trace(`paying with session ID ${sessionId}`);
+ const payInfo = purchase.payInfo;
+ checkDbInvariant(!!payInfo, "payInfo");
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ if (purchase.shared) {
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ false,
+ );
+
+ if (paid) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, {
+ orderId: purchase.orderId,
+ fulfillmentUrl: download.contractData.fulfillmentUrl,
+ }),
+ };
+ }
+ }
+
+ const contractData = download.contractData;
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ if (!payInfo.payCoinSelection) {
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ throw Error("insufficient balance (pending refresh)");
+ }
+ case "success":
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(
+ wex,
+ currency,
+ selectCoinsResult.coinSel.coins,
+ );
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return false;
+ }
+ if (p.payInfo?.payCoinSelection) {
+ return false;
+ }
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ payCoinSelection: {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ },
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ contributions: selectCoinsResult.coinSel.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ return true;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ return false;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ if (!purchase.merchantPaySig) {
+ const payUrl = new URL(
+ `orders/${download.contractData.orderId}/pay`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+
+ let depositPermissions: CoinDepositPermission[];
+ // FIXME: Cache!
+ depositPermissions = await generateDepositPermissions(
+ wex,
+ payInfo.payCoinSelection,
+ download.contractData,
+ );
+
+ const reqBody = {
+ coins: depositPermissions,
+ session_id: purchase.lastSessionId,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`making pay request ... ${j2s(reqBody)}`);
+ }
+
+ const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ wex.http.fetch(payUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getPayRequestTimeout(purchase),
+ cancellationToken: wex.cancellationToken,
+ }),
+ );
+
+ logger.trace(`got resp ${JSON.stringify(resp)}`);
+
+ if (resp.status >= 500 && resp.status <= 599) {
+ const errDetails = await readUnexpectedResponseDetails(resp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
+ {
+ requestError: errDetails,
+ },
+ ),
+ };
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ const err = await readTalerErrorResponse(resp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
+ ) {
+ // Do this in the background, as it might take some time
+ // FIXME: Why? We're already in a (background) task!
+ handleInsufficientFunds(wex, proposalId, err).catch(async (e) => {
+ logger.error("handling insufficient funds failed");
+ logger.error(`${e.toString()}`);
+ });
+
+ // FIXME: Should we really consider this to be pending?
+
+ return TaskRunResult.backoff();
+ }
+ }
+
+ if (resp.status >= 400 && resp.status <= 499) {
+ logger.trace("got generic 4xx from merchant");
+ const err = await readTalerErrorResponse(resp);
+ if (logger.shouldLogTrace()) {
+ logger.trace(`error body: ${j2s(err)}`);
+ }
+ throwUnexpectedRequestError(resp, err);
+ }
+
+ const merchantResp = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantPayResponse(),
+ );
+
+ logger.trace("got success from pay URL", merchantResp);
+
+ const merchantPub = download.contractData.merchantPub;
+ const { valid } = await wex.cryptoApi.isValidPaymentSignature({
+ contractHash: download.contractData.contractTermsHash,
+ merchantPub,
+ sig: merchantResp.sig,
+ });
+
+ if (!valid) {
+ logger.error("merchant payment signature invalid");
+ // FIXME: properly display error
+ throw Error("merchant payment signature invalid");
+ }
+
+ await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp);
+ } else {
+ const payAgainUrl = new URL(
+ `orders/${download.contractData.orderId}/paid`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+ const reqBody = {
+ sig: purchase.merchantPaySig,
+ h_contract: download.contractData.contractTermsHash,
+ session_id: sessionId ?? "",
+ };
+ logger.trace(`/paid request body: ${j2s(reqBody)}`);
+ const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ wex.http.fetch(payAgainUrl, {
+ method: "POST",
+ body: reqBody,
+ cancellationToken: wex.cancellationToken,
+ }),
+ );
+ logger.trace(`/paid response status: ${resp.status}`);
+ if (
+ resp.status !== HttpStatusCode.NoContent &&
+ resp.status != HttpStatusCode.Ok
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ getHttpResponseErrorDetails(resp),
+ "/paid failed",
+ );
+ }
+ await storePayReplaySuccess(wex, proposalId, sessionId);
+ }
+
+ return TaskRunResult.progress();
+}
+
+export async function refuseProposal(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const proposal = await tx.purchases.get(proposalId);
+ if (!proposal) {
+ logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
+ return undefined;
+ }
+ if (
+ proposal.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ proposal.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(proposal);
+ proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ const newTxState = computePayMerchantTransactionState(proposal);
+ await tx.purchases.put(proposal);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+const transitionSuspend: {
+ [x in PurchaseStatus]?: {
+ next: PurchaseStatus | undefined;
+ };
+} = {
+ [PurchaseStatus.PendingDownloadingProposal]: {
+ next: PurchaseStatus.SuspendedDownloadingProposal,
+ },
+ [PurchaseStatus.AbortingWithRefund]: {
+ next: PurchaseStatus.SuspendedAbortingWithRefund,
+ },
+ [PurchaseStatus.PendingPaying]: {
+ next: PurchaseStatus.SuspendedPaying,
+ },
+ [PurchaseStatus.PendingPayingReplay]: {
+ next: PurchaseStatus.SuspendedPayingReplay,
+ },
+ [PurchaseStatus.PendingQueryingAutoRefund]: {
+ next: PurchaseStatus.SuspendedQueryingAutoRefund,
+ },
+};
+
+const transitionResume: {
+ [x in PurchaseStatus]?: {
+ next: PurchaseStatus | undefined;
+ };
+} = {
+ [PurchaseStatus.SuspendedDownloadingProposal]: {
+ next: PurchaseStatus.PendingDownloadingProposal,
+ },
+ [PurchaseStatus.SuspendedAbortingWithRefund]: {
+ next: PurchaseStatus.AbortingWithRefund,
+ },
+ [PurchaseStatus.SuspendedPaying]: {
+ next: PurchaseStatus.PendingPaying,
+ },
+ [PurchaseStatus.SuspendedPayingReplay]: {
+ next: PurchaseStatus.PendingPayingReplay,
+ },
+ [PurchaseStatus.SuspendedQueryingAutoRefund]: {
+ next: PurchaseStatus.PendingQueryingAutoRefund,
+ },
+};
+
+export function computePayMerchantTransactionState(
+ purchaseRecord: PurchaseRecord,
+): TransactionState {
+ switch (purchaseRecord.purchaseStatus) {
+ // Pending States
+ case PurchaseStatus.PendingDownloadingProposal:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.PendingPaying:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.SubmitPayment,
+ };
+ case PurchaseStatus.PendingPayingReplay:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.PendingQueryingRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CheckRefund,
+ };
+ case PurchaseStatus.PendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ // Suspended Pending States
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.SuspendedPaying:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.SubmitPayment,
+ };
+ case PurchaseStatus.SuspendedPayingReplay:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.SuspendedQueryingRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CheckRefund,
+ };
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ // Aborting States
+ case PurchaseStatus.AbortingWithRefund:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ // Suspended Aborting States
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ };
+ // Dialog States
+ case PurchaseStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ case PurchaseStatus.DialogShared:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ // Final States
+ case PurchaseStatus.AbortedProposalRefused:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Refused,
+ };
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PurchaseStatus.DoneRepurchaseDetected:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Repurchase,
+ };
+ case PurchaseStatus.AbortedIncompletePayment:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.FailedClaim:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.FailedAbort:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.AbortingBank,
+ };
+ case PurchaseStatus.FailedPaidByOther:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.PaidByOther,
+ };
+ default:
+ assertUnreachable(purchaseRecord.purchaseStatus);
+ }
+}
+
+export function computePayMerchantTransactionActions(
+ purchaseRecord: PurchaseRecord,
+): TransactionAction[] {
+ switch (purchaseRecord.purchaseStatus) {
+ // Pending States
+ case PurchaseStatus.PendingDownloadingProposal:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingPaying:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingPayingReplay:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingQueryingRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingAcceptRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ // Suspended Pending States
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPaying:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPayingReplay:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedQueryingRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ // Aborting States
+ case PurchaseStatus.AbortingWithRefund:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ return [TransactionAction.Fail, TransactionAction.Resume];
+ // Dialog States
+ case PurchaseStatus.DialogProposed:
+ return [];
+ case PurchaseStatus.DialogShared:
+ return [];
+ // Final States
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.Done:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.DoneRepurchaseDetected:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.AbortedIncompletePayment:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedClaim:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedAbort:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedPaidByOther:
+ return [TransactionAction.Delete];
+ default:
+ assertUnreachable(purchaseRecord.purchaseStatus);
+ }
+}
+
+export async function sharePayment(
+ wex: WalletExecutionContext,
+ merchantBaseUrl: string,
+ orderId: string,
+): Promise<SharePaymentResult> {
+ const result = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.indexes.byUrlAndOrderId.get([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (
+ p.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ p.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ // FIXME: purchase can be shared before being paid
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
+ p.purchaseStatus = PurchaseStatus.DialogShared;
+ p.shared = true;
+ await tx.purchases.put(p);
+ }
+
+ const newTxState = computePayMerchantTransactionState(p);
+
+ return {
+ proposalId: p.proposalId,
+ nonce: p.noncePriv,
+ session: p.lastSessionId ?? p.downloadSessionId,
+ token: p.claimToken,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ },
+ );
+
+ if (result === undefined) {
+ throw Error("This purchase can't be shared");
+ }
+
+ const ctx = new PayMerchantTransactionContext(wex, result.proposalId);
+
+ notifyTransition(wex, ctx.transactionId, result.transitionInfo);
+
+ // schedule a task to watch for the status
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ const privatePayUri = stringifyPayUri({
+ merchantBaseUrl,
+ orderId,
+ sessionId: result.session ?? "",
+ noncePriv: result.nonce,
+ claimToken: result.token,
+ });
+
+ return { privatePayUri };
+}
+
+async function checkIfOrderIsAlreadyPaid(
+ wex: WalletExecutionContext,
+ contract: WalletContractData,
+ doLongPolling: boolean,
+) {
+ const requestUrl = new URL(
+ `orders/${contract.orderId}`,
+ contract.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
+
+ if (doLongPolling) {
+ requestUrl.searchParams.set("timeout_ms", "30000");
+ }
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (
+ resp.status === HttpStatusCode.Ok ||
+ resp.status === HttpStatusCode.Accepted ||
+ resp.status === HttpStatusCode.Found
+ ) {
+ return true;
+ } else if (resp.status === HttpStatusCode.PaymentRequired) {
+ return false;
+ }
+ // forbidden, not found, not acceptable
+ throw Error(`this order cant be paid: ${resp.status}`);
+}
+
+async function processPurchaseDialogShared(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing dialog-shared for proposal ${proposalId}`);
+ const download = await expectProposalDownload(wex, purchase);
+
+ if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) {
+ return TaskRunResult.finished();
+ }
+
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ true,
+ );
+ if (paid) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processPurchaseAutoRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing auto-refund for proposal ${proposalId}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ const noAutoRefundOrExpired =
+ !purchase.autoRefundDeadline ||
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(purchase.autoRefundDeadline),
+ ),
+ );
+
+ const totalKnownRefund = await wex.db.runReadOnlyTx(
+ { storeNames: ["refundGroups"] },
+ async (tx) => {
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+ const am = Amounts.parseOrThrow(download.contractData.amount);
+ return refunds.reduce((prev, cur) => {
+ if (
+ cur.status === RefundGroupStatus.Done ||
+ cur.status === RefundGroupStatus.Pending
+ ) {
+ return Amounts.add(prev, cur.amountEffective).amount;
+ }
+ return prev;
+ }, Amounts.zeroOfAmount(am));
+ },
+ );
+
+ const refundedIsLessThanPrice =
+ Amounts.cmp(download.contractData.amount, totalKnownRefund) === +1;
+ const nothingMoreToRefund = !refundedIsLessThanPrice;
+
+ if (noAutoRefundOrExpired || nothingMoreToRefund) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ }
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+
+ requestUrl.searchParams.set("timeout_ms", "10000");
+ requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund));
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+
+ // FIXME: Check other status codes!
+
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ if (orderStatus.refund_pending) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+
+ return TaskRunResult.longpollReturnedPending();
+}
+
+async function processPurchaseAbortingRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ const download = await expectProposalDownload(wex, purchase);
+ logger.trace(`processing aborting-refund for proposal ${proposalId}`);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/abort`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ const abortingCoins: AbortingCoin[] = [];
+
+ const payCoinSelection = purchase.payInfo?.payCoinSelection;
+ if (!payCoinSelection) {
+ throw Error("can't abort, no coins selected");
+ }
+
+ await wex.db.runReadOnlyTx({ storeNames: ["coins"] }, async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
+ });
+
+ const abortReq: AbortRequest = {
+ h_contract: download.contractData.contractTermsHash,
+ coins: abortingCoins,
+ };
+
+ logger.trace(`making order abort request to ${requestUrl.href}`);
+
+ const abortHttpResp = await wex.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: abortReq,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (abortHttpResp.status === HttpStatusCode.NotFound) {
+ const err = await readTalerErrorResponse(abortHttpResp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
+ ) {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ await ctx.transition(async (rec) => {
+ if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
+ rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+ return TransitionResultType.Transition;
+ }
+ return TransitionResultType.Stay;
+ });
+ }
+ }
+
+ const abortResp = await readSuccessResponseJsonOrThrow(
+ abortHttpResp,
+ codecForAbortResponse(),
+ );
+
+ const refunds: MerchantCoinRefundStatus[] = [];
+
+ if (abortResp.refunds.length != abortingCoins.length) {
+ // FIXME: define error code!
+ throw Error("invalid order abort response");
+ }
+
+ for (let i = 0; i < abortResp.refunds.length; i++) {
+ const r = abortResp.refunds[i];
+ refunds.push({
+ ...r,
+ coin_pub: payCoinSelection.coinPubs[i],
+ refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ rtransaction_id: 0,
+ execution_time: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.fromProtocolTimestamp(download.contractData.timestamp),
+ Duration.fromSpec({ seconds: 1 }),
+ ),
+ ),
+ });
+ }
+ return await storeRefunds(wex, purchase, refunds, RefundReason.AbortRefund);
+}
+
+async function processPurchaseQueryRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing query-refund for proposal ${proposalId}`);
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ if (!orderStatus.refund_pending) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else {
+ const refundAwaiting = Amounts.sub(
+ Amounts.parseOrThrow(orderStatus.refund_amount),
+ Amounts.parseOrThrow(orderStatus.refund_taken),
+ ).amount;
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+}
+
+async function processPurchaseAcceptRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const download = await expectProposalDownload(wex, purchase);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/refund`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ logger.trace(`making refund request to ${requestUrl.href}`);
+
+ const request = await wex.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: {
+ h_contract: download.contractData.contractTermsHash,
+ },
+ cancellationToken: wex.cancellationToken,
+ });
+
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForWalletRefundResponse(),
+ );
+ return await storeRefunds(
+ wex,
+ purchase,
+ refundResponse.refunds,
+ RefundReason.AbortRefund,
+ );
+}
+
+export async function startRefundQueryForUri(
+ wex: WalletExecutionContext,
+ talerUri: string,
+): Promise<StartRefundQueryForUriResponse> {
+ const parsedUri = parseTalerUri(talerUri);
+ if (!parsedUri) {
+ throw Error("invalid taler:// URI");
+ }
+ if (parsedUri.type !== TalerUriAction.Refund) {
+ throw Error("expected taler://refund URI");
+ }
+ const purchaseRecord = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.get([
+ parsedUri.merchantBaseUrl,
+ parsedUri.orderId,
+ ]);
+ },
+ );
+ if (!purchaseRecord) {
+ logger.error(
+ `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`,
+ );
+ throw Error("no purchase found, can't refund");
+ }
+ const proposalId = purchaseRecord.proposalId;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ await startQueryRefund(wex, proposalId);
+ return {
+ transactionId,
+ };
+}
+
+export async function startQueryRefund(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.warn(`purchase ${proposalId} does not exist anymore`);
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.Done) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingQueryingRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+async function computeRefreshRequest(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["coins", "denominations"]>,
+ items: RefundItemRecord[],
+): Promise<CoinRefreshRequest[]> {
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const item of items) {
+ const coin = await tx.coins.get(item.coinPub);
+ if (!coin) {
+ throw Error("coin not found");
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error("denom not found");
+ }
+ if (item.status === RefundItemStatus.Done) {
+ const refundedAmount = Amounts.sub(
+ item.refundAmount,
+ denomInfo.feeRefund,
+ ).amount;
+ refreshCoins.push({
+ amount: Amounts.stringify(refundedAmount),
+ coinPub: item.coinPub,
+ });
+ }
+ }
+ return refreshCoins;
+}
+
+/**
+ * Compute the refund item status based on the merchant's response.
+ */
+function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
+ if (rf.type === "success") {
+ return RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ return RefundItemStatus.Pending;
+ } else {
+ return RefundItemStatus.Failed;
+ }
+ }
+}
+
+/**
+ * Store refunds, possibly creating a new refund group.
+ */
+async function storeRefunds(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<TaskRunResult> {
+ logger.info(`storing refunds: ${j2s(refunds)}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchase.proposalId,
+ });
+
+ const newRefundGroupId = encodeCrock(randomBytes(32));
+ const now = TalerPreciseTimestamp.now();
+
+ const download = await expectProposalDownload(wex, purchase);
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ const result = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "purchases",
+ "refundItems",
+ "refundGroups",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (tx) => {
+ const myPurchase = await tx.purchases.get(purchase.proposalId);
+ if (!myPurchase) {
+ logger.warn("purchase group not found anymore");
+ return;
+ }
+ let isAborting: boolean;
+ switch (myPurchase.purchaseStatus) {
+ case PurchaseStatus.PendingAcceptRefund:
+ isAborting = false;
+ break;
+ case PurchaseStatus.AbortingWithRefund:
+ isAborting = true;
+ break;
+ default:
+ logger.warn("wrong state, not accepting refund");
+ return;
+ }
+
+ let newGroup: RefundGroupRecord | undefined = undefined;
+ // Pending, but not part of an aborted refund group.
+ let numPendingItemsTotal = 0;
+ const newGroupRefunds: RefundItemRecord[] = [];
+
+ for (const rf of refunds) {
+ const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
+ rf.coin_pub,
+ rf.rtransaction_id,
+ ]);
+ if (oldItem) {
+ logger.info("already have refund in database");
+ if (oldItem.status === RefundItemStatus.Done) {
+ continue;
+ }
+ if (rf.type === "success") {
+ oldItem.status = RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ oldItem.status = RefundItemStatus.Pending;
+ numPendingItemsTotal += 1;
+ } else {
+ oldItem.status = RefundItemStatus.Failed;
+ }
+ }
+ await tx.refundItems.put(oldItem);
+ } else {
+ // Put refund item into a new group!
+ if (!newGroup) {
+ newGroup = {
+ proposalId: purchase.proposalId,
+ refundGroupId: newRefundGroupId,
+ status: RefundGroupStatus.Pending,
+ timestampCreated: timestampPreciseToDb(now),
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfCurrency(currency),
+ ),
+ amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
+ };
+ }
+ const status: RefundItemStatus = getItemStatus(rf);
+ const newItem: RefundItemRecord = {
+ coinPub: rf.coin_pub,
+ executionTime: timestampProtocolToDb(rf.execution_time),
+ obtainedTime: timestampPreciseToDb(now),
+ refundAmount: rf.refund_amount,
+ refundGroupId: newGroup.refundGroupId,
+ rtxid: rf.rtransaction_id,
+ status,
+ };
+ if (status === RefundItemStatus.Pending) {
+ numPendingItemsTotal += 1;
+ }
+ newGroupRefunds.push(newItem);
+ await tx.refundItems.put(newItem);
+ }
+ }
+
+ // Now that we know all the refunds for the new refund group,
+ // we can compute the raw/effective amounts.
+ if (newGroup) {
+ const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
+ const refreshCoins = await computeRefreshRequest(
+ wex,
+ tx,
+ newGroupRefunds,
+ );
+ const outInfo = await calculateRefreshOutput(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ );
+ newGroup.amountEffective = Amounts.stringify(
+ Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
+ );
+ newGroup.amountRaw = Amounts.stringify(
+ Amounts.sumOrZero(currency, amountsRaw).amount,
+ );
+ await tx.refundGroups.put(newGroup);
+ }
+
+ const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
+ myPurchase.proposalId,
+ );
+
+ for (const refundGroup of refundGroups) {
+ switch (refundGroup.status) {
+ case RefundGroupStatus.Aborted:
+ case RefundGroupStatus.Expired:
+ case RefundGroupStatus.Failed:
+ case RefundGroupStatus.Done:
+ continue;
+ case RefundGroupStatus.Pending:
+ break;
+ default:
+ assertUnreachable(refundGroup.status);
+ }
+ const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
+ refundGroup.refundGroupId,
+ ]);
+ let numPending = 0;
+ let numFailed = 0;
+ for (const item of items) {
+ if (item.status === RefundItemStatus.Pending) {
+ numPending++;
+ }
+ if (item.status === RefundItemStatus.Failed) {
+ numFailed++;
+ }
+ }
+ if (numPending === 0) {
+ // We're done for this refund group!
+ if (numFailed === 0) {
+ refundGroup.status = RefundGroupStatus.Done;
+ } else {
+ refundGroup.status = RefundGroupStatus.Failed;
+ }
+ await tx.refundGroups.put(refundGroup);
+ const refreshCoins = await computeRefreshRequest(wex, tx, items);
+ await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(download.contractData.amount),
+ refreshCoins,
+ RefreshReason.Refund,
+ // Since refunds are really just pseudo-transactions,
+ // the originating transaction for the refresh is the payment transaction.
+ constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: myPurchase.proposalId,
+ }),
+ );
+ }
+ }
+
+ const oldTxState = computePayMerchantTransactionState(myPurchase);
+
+ const shouldCheckAutoRefund =
+ myPurchase.autoRefundDeadline &&
+ !AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(myPurchase.autoRefundDeadline),
+ ),
+ );
+
+ if (numPendingItemsTotal === 0) {
+ if (isAborting) {
+ myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
+ } else if (shouldCheckAutoRefund) {
+ myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
+ } else {
+ myPurchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ myPurchase.refundAmountAwaiting = undefined;
+ }
+ await tx.purchases.put(myPurchase);
+ const newTxState = computePayMerchantTransactionState(myPurchase);
+
+ return {
+ numPendingItemsTotal,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ },
+ );
+
+ if (!result) {
+ return TaskRunResult.finished();
+ }
+
+ notifyTransition(wex, transactionId, result.transitionInfo);
+
+ if (result.numPendingItemsTotal > 0) {
+ return TaskRunResult.backoff();
+ } else {
+ return TaskRunResult.progress();
+ }
+}
+
+export function computeRefundTransactionState(
+ refundGroupRecord: RefundGroupRecord,
+): TransactionState {
+ switch (refundGroupRecord.status) {
+ case RefundGroupStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case RefundGroupStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RefundGroupStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case RefundGroupStatus.Pending:
+ return {
+ major: TransactionMajorState.Pending,
+ };
+ case RefundGroupStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
new file mode 100644
index 000000000..bfd39b657
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -0,0 +1,163 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 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 {
+ AmountJson,
+ AmountString,
+ Amounts,
+ Codec,
+ SelectedProspectiveCoin,
+ TalerProtocolTimestamp,
+ buildCodecForObject,
+ checkDbInvariant,
+ codecForAmountString,
+ codecForTimestamp,
+ codecOptional,
+} from "@gnu-taler/taler-util";
+import { SpendCoinDetails } from "./crypto/cryptoImplementation.js";
+import { DbPeerPushPaymentCoinSelection, ReserveRecord } from "./db.js";
+import { getTotalRefreshCost } from "./refresh.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+/**
+ * Get information about the coin selected for signatures.
+ */
+export async function queryCoinInfosForSelection(
+ wex: WalletExecutionContext,
+ csel: DbPeerPushPaymentCoinSelection,
+): Promise<SpendCoinDetails[]> {
+ let infos: SpendCoinDetails[] = [];
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < csel.coinPubs.length; i++) {
+ const coin = await tx.coins.get(csel.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin not found anymore");
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("denom for coin not found anymore");
+ }
+ infos.push({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ contribution: csel.contributions[i],
+ });
+ }
+ },
+ );
+ return infos;
+}
+
+export async function getTotalPeerPaymentCost(
+ wex: WalletExecutionContext,
+ pcs: SelectedProspectiveCoin[],
+): Promise<AmountJson> {
+ return wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const amountLeft = Amounts.sub(
+ denomInfo.value,
+ pcs[i].contribution,
+ ).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ denomInfo,
+ amountLeft,
+ );
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
+ }
+ const zero = Amounts.zeroOfAmount(pcs[0].contribution);
+ return Amounts.sum([zero, ...costs]).amount;
+ },
+ );
+}
+
+interface ExchangePurseStatus {
+ balance: AmountString;
+ deposit_timestamp?: TalerProtocolTimestamp;
+ merge_timestamp?: TalerProtocolTimestamp;
+}
+
+export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
+ buildCodecForObject<ExchangePurseStatus>()
+ .property("balance", codecForAmountString())
+ .property("deposit_timestamp", codecOptional(codecForTimestamp))
+ .property("merge_timestamp", codecOptional(codecForTimestamp))
+ .build("ExchangePurseStatus");
+
+export async function getMergeReserveInfo(
+ wex: WalletExecutionContext,
+ req: {
+ exchangeBaseUrl: string;
+ },
+): Promise<ReserveRecord> {
+ // We have to eagerly create the key pair outside of the transaction,
+ // due to the async crypto API.
+ const newReservePair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const mergeReserveRecord: ReserveRecord = await wex.db.runReadWriteTx(
+ { storeNames: ["exchanges", "reserves"] },
+ async (tx) => {
+ const ex = await tx.exchanges.get(req.exchangeBaseUrl);
+ checkDbInvariant(!!ex);
+ if (ex.currentMergeReserveRowId != null) {
+ const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
+ checkDbInvariant(!!reserve);
+ return reserve;
+ }
+ const reserve: ReserveRecord = {
+ reservePriv: newReservePair.priv,
+ reservePub: newReservePair.pub,
+ };
+ const insertResp = await tx.reserves.put(reserve);
+ checkDbInvariant(typeof insertResp.key === "number");
+ reserve.rowId = insertResp.key;
+ ex.currentMergeReserveRowId = reserve.rowId;
+ await tx.exchanges.put(ex);
+ return reserve;
+ },
+ );
+
+ return mergeReserveRecord;
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
new file mode 100644
index 000000000..840c244d0
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -0,0 +1,1215 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ 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 {
+ AbsoluteTime,
+ Amounts,
+ CheckPeerPullCreditRequest,
+ CheckPeerPullCreditResponse,
+ ContractTermsUtil,
+ ExchangeReservePurseRequest,
+ HttpStatusCode,
+ InitiatePeerPullCreditRequest,
+ InitiatePeerPullCreditResponse,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TalerUriAction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ WalletAccountMergeFlags,
+ WalletKycUuid,
+ assertUnreachable,
+ checkDbInvariant,
+ codecForAny,
+ codecForWalletKycUuid,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ stringifyTalerUri,
+ talerPaytoFromExchangeReserve,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ KycPendingInfo,
+ KycUserType,
+ PeerPullCreditRecord,
+ PeerPullPaymentCreditStatus,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+} from "./db.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ codecForExchangePurseStatus,
+ getMergeReserveInfo,
+} from "./pay-peer-common.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+import {
+ getExchangeWithdrawalInfo,
+ internalCreateWithdrawalGroup,
+ waitWithdrawalFinal,
+} from "./withdraw.js";
+
+const logger = new Logger("pay-peer-pull-credit.ts");
+
+export class PeerPullCreditTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public pursePub: string,
+ ) {
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub,
+ });
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex: ws, pursePub } = this;
+ await ws.db.runReadWriteTx(
+ { storeNames: ["withdrawalGroups", "peerPullCredit", "tombstones"] },
+ async (tx) => {
+ const pullIni = await tx.peerPullCredit.get(pursePub);
+ if (!pullIni) {
+ return;
+ }
+ if (pullIni.withdrawalGroupId) {
+ const withdrawalGroupId = pullIni.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPullCredit.delete(pursePub);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
+ });
+ },
+ );
+
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse;
+ break;
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPullPaymentCreditStatus.PendingReady:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedReady;
+ break;
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ newStatus =
+ PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ break;
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPullPaymentCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.Aborted:
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ newStatus = PeerPullPaymentCreditStatus.PendingReady;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ throw Error("can't abort anymore");
+ case PeerPullPaymentCreditStatus.PendingReady:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+async function queryPurseForPeerPullCredit(
+ wex: WalletExecutionContext,
+ pullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ const purseDepositUrl = new URL(
+ `purses/${pullIni.pursePub}/deposit`,
+ pullIni.exchangeBaseUrl,
+ );
+ purseDepositUrl.searchParams.set("timeout_ms", "30000");
+ logger.info(`querying purse status via ${purseDepositUrl.href}`);
+ const resp = await wex.http.fetch(purseDepositUrl.href, {
+ timeout: { d_ms: 60000 },
+ cancellationToken: wex.cancellationToken,
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+
+ logger.info(`purse status code: HTTP ${resp.status}`);
+
+ switch (resp.status) {
+ case HttpStatusCode.Gone: {
+ // Exchange says that purse doesn't exist anymore => expired!
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!finPi) {
+ logger.warn("peerPullCredit not found anymore");
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(finPi);
+ if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
+ finPi.status = PeerPullPaymentCreditStatus.Expired;
+ }
+ await tx.peerPullCredit.put(finPi);
+ const newTxState = computePeerPullCreditTransactionState(finPi);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.NotFound:
+ // FIXME: Maybe check error code? 404 could also mean something else.
+ return TaskRunResult.longpollReturnedPending();
+ }
+
+ const result = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangePurseStatus(),
+ );
+
+ logger.trace(`purse status: ${j2s(result)}`);
+
+ const depositTimestamp = result.deposit_timestamp;
+
+ if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) {
+ logger.info("purse not ready yet (no deposit)");
+ return TaskRunResult.backoff();
+ }
+
+ const reserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
+ return await tx.reserves.get(pullIni.mergeReserveRowId);
+ },
+ );
+
+ if (!reserve) {
+ throw Error("reserve for peer pull credit not found in wallet DB");
+ }
+
+ await internalCreateWithdrawalGroup(wex, {
+ amount: Amounts.parseOrThrow(pullIni.amount),
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.PeerPullCredit,
+ contractPriv: pullIni.contractPriv,
+ },
+ forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
+ exchangeBaseUrl: pullIni.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ priv: reserve.reservePriv,
+ pub: reserve.reservePub,
+ },
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!finPi) {
+ logger.warn("peerPullCredit not found anymore");
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(finPi);
+ if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
+ finPi.status = PeerPullPaymentCreditStatus.PendingWithdrawing;
+ }
+ await tx.peerPullCredit.put(finPi);
+ const newTxState = computePeerPullCreditTransactionState(finPi);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+}
+
+async function longpollKycStatus(
+ wex: WalletExecutionContext,
+ pursePub: string,
+ exchangeUrl: string,
+ kycInfo: KycPendingInfo,
+ userType: KycUserType,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const peerIni = await tx.peerPullCredit.get(pursePub);
+ if (!peerIni) {
+ return;
+ }
+ if (
+ peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired
+ ) {
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(peerIni);
+ peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse;
+ const newTxState = computePeerPullCreditTransactionState(peerIni);
+ await tx.peerPullCredit.put(peerIni);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function processPeerPullCreditAbortingDeletePurse(
+ wex: WalletExecutionContext,
+ peerPullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ const { pursePub, pursePriv } = peerPullIni;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+
+ const sigResp = await wex.cryptoApi.signDeletePurse({
+ pursePriv,
+ });
+ const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl);
+ const resp = await wex.http.fetch(purseUrl.href, {
+ method: "DELETE",
+ headers: {
+ "taler-purse-signature": sigResp.sig,
+ },
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`deleted purse with response status ${resp.status}`);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPullCredit",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const ppiRec = await tx.peerPullCredit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) {
+ return undefined;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(ppiRec);
+ ppiRec.status = PeerPullPaymentCreditStatus.Aborted;
+ await tx.peerPullCredit.put(ppiRec);
+ const newTxState = computePeerPullCreditTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return TaskRunResult.backoff();
+}
+
+async function handlePeerPullCreditWithdrawing(
+ wex: WalletExecutionContext,
+ pullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ if (!pullIni.withdrawalGroupId) {
+ throw Error("invalid db state (withdrawing, but no withdrawal group ID");
+ }
+ await waitWithdrawalFinal(wex, pullIni.withdrawalGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+ const wgId = pullIni.withdrawalGroupId;
+ let finished: boolean = false;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit", "withdrawalGroups"] },
+ async (tx) => {
+ const ppi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!ppi) {
+ finished = true;
+ return;
+ }
+ if (ppi.status !== PeerPullPaymentCreditStatus.PendingWithdrawing) {
+ finished = true;
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(ppi);
+ const wg = await tx.withdrawalGroups.get(wgId);
+ if (!wg) {
+ // FIXME: Fail the operation instead?
+ return undefined;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.Done:
+ finished = true;
+ ppi.status = PeerPullPaymentCreditStatus.Done;
+ break;
+ // FIXME: Also handle other final states!
+ }
+ await tx.peerPullCredit.put(ppi);
+ const newTxState = computePeerPullCreditTransactionState(ppi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ if (finished) {
+ return TaskRunResult.finished();
+ } else {
+ // FIXME: Return indicator that we depend on the other operation!
+ return TaskRunResult.backoff();
+ }
+}
+
+async function handlePeerPullCreditCreatePurse(
+ wex: WalletExecutionContext,
+ pullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
+ const pursePub = pullIni.pursePub;
+ const mergeReserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
+ return tx.reserves.get(pullIni.mergeReserveRowId);
+ },
+ );
+
+ if (!mergeReserve) {
+ throw Error("merge reserve for peer pull payment not found in database");
+ }
+
+ const contractTermsRecord = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ async (tx) => {
+ return tx.contractTerms.get(pullIni.contractTermsHash);
+ },
+ );
+
+ if (!contractTermsRecord) {
+ throw Error("contract terms for peer pull payment not found in database");
+ }
+
+ const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw;
+
+ const reservePayto = talerPaytoFromExchangeReserve(
+ pullIni.exchangeBaseUrl,
+ mergeReserve.reservePub,
+ );
+
+ const econtractResp = await wex.cryptoApi.encryptContractForDeposit({
+ contractPriv: pullIni.contractPriv,
+ contractPub: pullIni.contractPub,
+ contractTerms: contractTermsRecord.contractTermsRaw,
+ pursePriv: pullIni.pursePriv,
+ pursePub: pullIni.pursePub,
+ nonce: pullIni.contractEncNonce,
+ });
+
+ const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp);
+
+ const purseExpiration = contractTerms.purse_expiration;
+ const sigRes = await wex.cryptoApi.signReservePurseCreate({
+ contractTermsHash: pullIni.contractTermsHash,
+ flags: WalletAccountMergeFlags.CreateWithPurseFee,
+ mergePriv: pullIni.mergePriv,
+ mergeTimestamp: TalerPreciseTimestamp.round(mergeTimestamp),
+ purseAmount: pullIni.amount,
+ purseExpiration: purseExpiration,
+ purseFee: purseFee,
+ pursePriv: pullIni.pursePriv,
+ pursePub: pullIni.pursePub,
+ reservePayto,
+ reservePriv: mergeReserve.reservePriv,
+ });
+
+ const reservePurseReqBody: ExchangeReservePurseRequest = {
+ merge_sig: sigRes.mergeSig,
+ merge_timestamp: TalerPreciseTimestamp.round(mergeTimestamp),
+ h_contract_terms: pullIni.contractTermsHash,
+ merge_pub: pullIni.mergePub,
+ min_age: 0,
+ purse_expiration: purseExpiration,
+ purse_fee: purseFee,
+ purse_pub: pullIni.pursePub,
+ purse_sig: sigRes.purseSig,
+ purse_value: pullIni.amount,
+ reserve_sig: sigRes.accountSig,
+ econtract: econtractResp.econtract,
+ };
+
+ logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
+
+ const reservePurseMergeUrl = new URL(
+ `reserves/${mergeReserve.reservePub}/purse`,
+ pullIni.exchangeBaseUrl,
+ );
+
+ const httpResp = await wex.http.fetch(reservePurseMergeUrl.href, {
+ method: "POST",
+ body: reservePurseReqBody,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ const respJson = await httpResp.json();
+ const kycPending = codecForWalletKycUuid().decode(respJson);
+ logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+ return processPeerPullCreditKycRequired(wex, pullIni, kycPending);
+ }
+
+ const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
+
+ logger.info(`reserve merge response: ${j2s(resp)}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const pi2 = await tx.peerPullCredit.get(pursePub);
+ if (!pi2) {
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(pi2);
+ pi2.status = PeerPullPaymentCreditStatus.PendingReady;
+ await tx.peerPullCredit.put(pi2);
+ const newTxState = computePeerPullCreditTransactionState(pi2);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+}
+
+export async function processPeerPullCredit(
+ wex: WalletExecutionContext,
+ pursePub: string,
+): Promise<TaskRunResult> {
+ const pullIni = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ return tx.peerPullCredit.get(pursePub);
+ },
+ );
+ if (!pullIni) {
+ throw Error("peer pull payment initiation not found in database");
+ }
+
+ const retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub,
+ });
+
+ logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
+
+ switch (pullIni.status) {
+ case PeerPullPaymentCreditStatus.Done: {
+ return TaskRunResult.finished();
+ }
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return queryPurseForPeerPullCredit(wex, pullIni);
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired: {
+ if (!pullIni.kycInfo) {
+ throw Error("invalid state, kycInfo required");
+ }
+ return await longpollKycStatus(
+ wex,
+ pursePub,
+ pullIni.exchangeBaseUrl,
+ pullIni.kycInfo,
+ "individual",
+ );
+ }
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return handlePeerPullCreditCreatePurse(wex, pullIni);
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return await processPeerPullCreditAbortingDeletePurse(wex, pullIni);
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return handlePeerPullCreditWithdrawing(wex, pullIni);
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ break;
+ default:
+ assertUnreachable(pullIni.status);
+ }
+
+ return TaskRunResult.finished();
+}
+
+async function processPeerPullCreditKycRequired(
+ wex: WalletExecutionContext,
+ peerIni: PeerPullCreditRecord,
+ kycPending: WalletKycUuid,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: peerIni.pursePub,
+ });
+ const { pursePub } = peerIni;
+
+ const userType = "individual";
+ const url = new URL(
+ `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
+ peerIni.exchangeBaseUrl,
+ );
+
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.backoff();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const { transitionInfo, result } = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ const peerInc = await tx.peerPullCredit.get(pursePub);
+ if (!peerInc) {
+ return {
+ transitionInfo: undefined,
+ result: TaskRunResult.finished(),
+ };
+ }
+ const oldTxState = computePeerPullCreditTransactionState(peerInc);
+ peerInc.kycInfo = {
+ paytoHash: kycPending.h_payto,
+ requirementRow: kycPending.requirement_row,
+ };
+ peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
+ const newTxState = computePeerPullCreditTransactionState(peerInc);
+ await tx.peerPullCredit.put(peerInc);
+ // We'll remove this eventually! New clients should rely on the
+ // kycUrl field of the transaction, not the error code.
+ const res: TaskRunResult = {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
+ {
+ kycUrl: kycStatus.kyc_url,
+ },
+ ),
+ };
+ return {
+ transitionInfo: { oldTxState, newTxState },
+ result: res,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+/**
+ * Check fees and available exchanges for a peer push payment initiation.
+ */
+export async function checkPeerPullPaymentInitiation(
+ wex: WalletExecutionContext,
+ req: CheckPeerPullCreditRequest,
+): Promise<CheckPeerPullCreditResponse> {
+ // FIXME: We don't support exchanges with purse fees yet.
+ // Select an exchange where we have money in the specified currency
+ // FIXME: How do we handle regional currency scopes here? Is it an additional input?
+
+ logger.trace("checking peer-pull-credit fees");
+
+ const currency = Amounts.currencyOf(req.amount);
+ let exchangeUrl;
+ if (req.exchangeBaseUrl) {
+ exchangeUrl = req.exchangeBaseUrl;
+ } else {
+ exchangeUrl = await getPreferredExchangeForCurrency(wex, currency);
+ }
+
+ if (!exchangeUrl) {
+ throw Error("no exchange found for initiating a peer pull payment");
+ }
+
+ logger.trace(`found ${exchangeUrl} as preferred exchange`);
+
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ exchangeUrl,
+ Amounts.parseOrThrow(req.amount),
+ undefined,
+ );
+
+ logger.trace(`got withdrawal info`);
+
+ let numCoins = 0;
+ for (let i = 0; i < wi.selectedDenoms.selectedDenoms.length; i++) {
+ numCoins += wi.selectedDenoms.selectedDenoms[i].count;
+ }
+
+ return {
+ exchangeBaseUrl: exchangeUrl,
+ amountEffective: wi.withdrawalAmountEffective,
+ amountRaw: req.amount,
+ numCoins,
+ };
+}
+
+/**
+ * Find a preferred exchange based on when we withdrew last from this exchange.
+ */
+async function getPreferredExchangeForCurrency(
+ wex: WalletExecutionContext,
+ currency: string,
+): Promise<string | undefined> {
+ // Find an exchange with the matching currency.
+ // Prefer exchanges with the most recent withdrawal.
+ const url = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges"] },
+ async (tx) => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ let candidate = undefined;
+ for (const e of exchanges) {
+ if (e.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ if (!candidate) {
+ candidate = e;
+ continue;
+ }
+ if (candidate.lastWithdrawal && !e.lastWithdrawal) {
+ continue;
+ }
+ const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
+ e.lastWithdrawal,
+ );
+ const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
+ candidate.lastWithdrawal,
+ );
+ if (exchangeLastWithdrawal && candidateLastWithdrawal) {
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
+ AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
+ ) > 0
+ ) {
+ candidate = e;
+ }
+ }
+ }
+ if (candidate) {
+ return candidate.baseUrl;
+ }
+ return undefined;
+ },
+ );
+ return url;
+}
+
+/**
+ * Initiate a peer pull payment.
+ */
+export async function initiatePeerPullPayment(
+ wex: WalletExecutionContext,
+ req: InitiatePeerPullCreditRequest,
+): Promise<InitiatePeerPullCreditResponse> {
+ const currency = Amounts.currencyOf(req.partialContractTerms.amount);
+ let maybeExchangeBaseUrl: string | undefined;
+ if (req.exchangeBaseUrl) {
+ maybeExchangeBaseUrl = req.exchangeBaseUrl;
+ } else {
+ maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(wex, currency);
+ }
+
+ if (!maybeExchangeBaseUrl) {
+ throw Error("no exchange found for initiating a peer pull payment");
+ }
+
+ const exchangeBaseUrl = maybeExchangeBaseUrl;
+
+ await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const mergeReserveInfo = await getMergeReserveInfo(wex, {
+ exchangeBaseUrl: exchangeBaseUrl,
+ });
+
+ const pursePair = await wex.cryptoApi.createEddsaKeypair({});
+ const mergePair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const contractTerms = req.partialContractTerms;
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+ const mergeReserveRowId = mergeReserveInfo.rowId;
+ checkDbInvariant(!!mergeReserveRowId);
+
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ exchangeBaseUrl,
+ Amounts.parseOrThrow(req.partialContractTerms.amount),
+ undefined,
+ );
+
+ const mergeTimestamp = TalerPreciseTimestamp.now();
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullCredit", "contractTerms"] },
+ async (tx) => {
+ const ppi: PeerPullCreditRecord = {
+ amount: req.partialContractTerms.amount,
+ contractTermsHash: hContractTerms,
+ exchangeBaseUrl: exchangeBaseUrl,
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ mergePriv: mergePair.priv,
+ mergePub: mergePair.pub,
+ status: PeerPullPaymentCreditStatus.PendingCreatePurse,
+ mergeTimestamp: timestampPreciseToDb(mergeTimestamp),
+ contractEncNonce,
+ mergeReserveRowId: mergeReserveRowId,
+ contractPriv: contractKeyPair.priv,
+ contractPub: contractKeyPair.pub,
+ withdrawalGroupId,
+ estimatedAmountEffective: wi.withdrawalAmountEffective,
+ };
+ await tx.peerPullCredit.put(ppi);
+ const oldTxState: TransactionState = {
+ major: TransactionMajorState.None,
+ };
+ const newTxState = computePeerPullCreditTransactionState(ppi);
+ await tx.contractTerms.put({
+ contractTermsRaw: contractTerms,
+ h: hContractTerms,
+ });
+ return { oldTxState, newTxState };
+ },
+ );
+
+ const ctx = new PeerPullCreditTransactionContext(wex, pursePair.pub);
+
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // The pending-incoming balance has changed.
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ return {
+ talerUri: stringifyTalerUri({
+ type: TalerUriAction.PayPull,
+ exchangeBaseUrl: exchangeBaseUrl,
+ contractPriv: contractKeyPair.priv,
+ }),
+ transactionId: ctx.transactionId,
+ };
+}
+
+export function computePeerPullCreditTransactionState(
+ pullCreditRecord: PeerPullCreditRecord,
+): TransactionState {
+ switch (pullCreditRecord.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPullPaymentCreditStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPullPaymentCreditStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPullPaymentCreditStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPullPaymentCreditStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ }
+}
+
+export function computePeerPullCreditTransactionActions(
+ pullCreditRecord: PeerPullCreditRecord,
+): TransactionAction[] {
+ switch (pullCreditRecord.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ return [TransactionAction.Abort, TransactionAction.Resume];
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.Failed:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.Expired:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
new file mode 100644
index 000000000..0355b58ad
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -0,0 +1,1019 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of the peer-pull-debit transaction, i.e.
+ * paying for an invoice the wallet received from another wallet.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AcceptPeerPullPaymentResponse,
+ Amounts,
+ CoinRefreshRequest,
+ ConfirmPeerPullDebitRequest,
+ ContractTermsUtil,
+ ExchangePurseDeposits,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ ObservabilityEventType,
+ PeerContractTerms,
+ PreparePeerPullDebitRequest,
+ PreparePeerPullDebitResponse,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ assertUnreachable,
+ checkLogicInvariant,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ parsePayPullUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ TransitionResultType,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import {
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentIncomingRecord,
+ RefreshOperationStatus,
+ WalletStoresV1,
+ timestampPreciseToDb,
+} from "./db.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
+import { createRefreshGroup } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("pay-peer-pull-debit.ts");
+
+/**
+ * Common context for a peer-pull-debit transaction.
+ */
+export class PeerPullDebitTransactionContext implements TransactionContext {
+ wex: WalletExecutionContext;
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+ peerPullDebitId: string;
+
+ constructor(wex: WalletExecutionContext, peerPullDebitId: string) {
+ this.wex = wex;
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.peerPullDebitId = peerPullDebitId;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const transactionId = this.transactionId;
+ const ws = this.wex;
+ const peerPullDebitId = this.peerPullDebitId;
+ await ws.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "tombstones"] },
+ async (tx) => {
+ const debit = await tx.peerPullDebit.get(peerPullDebitId);
+ if (debit) {
+ await tx.peerPullDebit.delete(peerPullDebitId);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const taskId = this.taskId;
+ const transactionId = this.transactionId;
+ const wex = this.wex;
+ const peerPullDebitId = this.peerPullDebitId;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pullDebitRec) {
+ logger.warn(`peer pull debit ${peerPullDebitId} not found`);
+ return;
+ }
+ let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+ switch (pullDebitRec.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ break;
+ case PeerPullDebitRecordStatus.Done:
+ break;
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
+ break;
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ break;
+ case PeerPullDebitRecordStatus.Aborted:
+ break;
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
+ break;
+ case PeerPullDebitRecordStatus.Failed:
+ break;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ break;
+ default:
+ assertUnreachable(pullDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
+ pullDebitRec.status = newStatus;
+ const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
+ await tx.peerPullDebit.put(pullDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ return TransitionResultType.Transition;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ return TransitionResultType.Transition;
+ case PeerPullDebitRecordStatus.Aborted:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.Failed:
+ case PeerPullDebitRecordStatus.DialogProposed:
+ case PeerPullDebitRecordStatus.Done:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return TransitionResultType.Stay;
+ }
+ });
+ this.wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ // FIXME: Should we also abort the corresponding refresh session?!
+ pi.status = PeerPullDebitRecordStatus.Failed;
+ return TransitionResultType.Transition;
+ default:
+ return TransitionResultType.Stay;
+ }
+ });
+ this.wex.taskScheduler.stopShepherdTask(this.taskId);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transitionExtra(
+ {
+ extraStores: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "coinAvailability",
+ ],
+ },
+ async (pi, tx) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ break;
+ default:
+ return TransitionResultType.Stay;
+ }
+ const currency = Amounts.currencyOf(pi.totalCostEstimated);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (!pi.coinSel) {
+ throw Error("invalid db state");
+ }
+
+ for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: pi.coinSel.contributions[i],
+ coinPub: pi.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ ctx.wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPullDebit,
+ this.transactionId,
+ );
+
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ pi.abortRefreshGroupId = refresh.refreshGroupId;
+ return TransitionResultType.Transition;
+ },
+ );
+ }
+
+ async transition(
+ f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PeerPullPaymentIncomingRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["peerPullDebit", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ const wex = this.wex;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", ...extraStores] },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
+ if (!pi) {
+ throw Error("peer pull payment not found anymore");
+ }
+ const oldTxState = computePeerPullDebitTransactionState(pi);
+ const res = await f(pi, tx);
+ switch (res) {
+ case TransitionResultType.Transition: {
+ await tx.peerPullDebit.put(pi);
+ const newTxState = computePeerPullDebitTransactionState(pi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(wex, this.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+}
+
+async function handlePurseCreationConflict(
+ ctx: PeerPullDebitTransactionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const ws = ctx.wex;
+ const errResp = await readTalerErrorResponse(resp);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const sel = peerPullInc.coinSel;
+ if (!sel) {
+ throw Error("invalid state (coin selection expected)");
+ }
+
+ const repair: PreviousPayCoins = [];
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.push({
+ coinPub: sel.coinPubs[i],
+ contribution: Amounts.parseOrThrow(sel.contributions[i]),
+ });
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(ws, {
+ instructedAmount,
+ repair,
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "prospective":
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending (blocked on refresh)",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+
+ await ws.db.runReadWriteTx({ storeNames: ["peerPullDebit"] }, async (tx) => {
+ const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.SuspendedDeposit: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPullDebit.put(myPpi);
+ });
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPullDebitPendingDeposit(
+ wex: WalletExecutionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const ctx = new PeerPullDebitTransactionContext(
+ wex,
+ peerPullInc.peerPullDebitId,
+ );
+
+ const pursePub = peerPullInc.pursePub;
+
+ const coinSel = peerPullInc.coinSel;
+
+ if (!coinSel) {
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient balance (locked behind refresh)");
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ // FIXME: Missing notification here!
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ return false;
+ }
+ if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return false;
+ }
+ if (pi.coinSel) {
+ return false;
+ }
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ pi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ await tx.peerPullDebit.put(pi);
+ return true;
+ },
+ );
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ const purseDepositUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPullInc.exchangeBaseUrl,
+ );
+
+ // FIXME: We could skip batches that we've already submitted.
+
+ const coins = await queryCoinInfosForSelection(wex, coinSel);
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < coins.length; i += maxBatchSize) {
+ const batchSize = Math.min(maxBatchSize, coins.length - i);
+
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Depositing batch at ${i}/${coins.length} of size ${batchSize}`,
+ });
+
+ const batchCoins = coins.slice(i, i + batchSize);
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
+ pursePub: peerPullInc.pursePub,
+ coins: batchCoins,
+ });
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
+ }
+
+ const httpResp = await wex.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok: {
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForAny(),
+ );
+ logger.trace(`purse deposit response: ${j2s(resp)}`);
+ continue;
+ }
+ case HttpStatusCode.Gone: {
+ await ctx.abortTransaction();
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.Conflict: {
+ return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+ }
+
+ // All batches succeeded, we can transition!
+
+ await ctx.transition(async (r) => {
+ if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return TransitionResultType.Stay;
+ }
+ r.status = PeerPullDebitRecordStatus.Done;
+ return TransitionResultType.Transition;
+ });
+ return TaskRunResult.finished();
+}
+
+async function processPeerPullDebitAbortingRefresh(
+ wex: WalletExecutionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "refreshGroups"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPullDebitRecordStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPullDebitRecordStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPullDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPullDebitTransactionState(newDg);
+ await tx.peerPullDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+export async function processPeerPullDebit(
+ wex: WalletExecutionContext,
+ peerPullDebitId: string,
+): Promise<TaskRunResult> {
+ const peerPullInc = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+ if (!peerPullInc) {
+ throw Error("peer pull debit not found");
+ }
+
+ switch (peerPullInc.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return await processPeerPullDebitPendingDeposit(wex, peerPullInc);
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return await processPeerPullDebitAbortingRefresh(wex, peerPullInc);
+ }
+ return TaskRunResult.finished();
+}
+
+export async function confirmPeerPullDebit(
+ wex: WalletExecutionContext,
+ req: ConfirmPeerPullDebitRequest,
+): Promise<AcceptPeerPullPaymentResponse> {
+ let peerPullDebitId: string;
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
+ throw Error("invalid peer-pull-debit transaction identifier");
+ }
+ peerPullDebitId = parsedTx.peerPullDebitId;
+
+ const peerPullInc = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit"] },
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+
+ if (!peerPullInc) {
+ throw Error(
+ `can't accept unknown incoming p2p pull payment (${req.transactionId})`,
+ );
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ // FIXME: Missing notification here!
+
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ throw Error();
+ }
+ if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) {
+ return;
+ }
+ if (coinSelRes.type == "success") {
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ pi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ }
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ await tx.peerPullDebit.put(pi);
+ },
+ );
+
+ const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
+
+ const transactionId = ctx.transactionId;
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId,
+ };
+}
+
+/**
+ * Look up information about an incoming peer pull payment.
+ * Store the results in the wallet DB.
+ */
+export async function preparePeerPullDebit(
+ wex: WalletExecutionContext,
+ req: PreparePeerPullDebitRequest,
+): Promise<PreparePeerPullDebitResponse> {
+ const uri = parsePayPullUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-pull URI");
+ }
+
+ const existing = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullDebit", "contractTerms"] },
+ async (tx) => {
+ const peerPullDebitRecord =
+ await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!peerPullDebitRecord) {
+ return;
+ }
+ const contractTerms = await tx.contractTerms.get(
+ peerPullDebitRecord.contractTermsHash,
+ );
+ if (!contractTerms) {
+ return;
+ }
+ return { peerPullDebitRecord, contractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.peerPullDebitRecord.amount,
+ amountRaw: existing.peerPullDebitRecord.amount,
+ amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
+ contractTerms: existing.contractTerms.contractTermsRaw,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ }),
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await wex.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await wex.cryptoApi.decryptContractForDeposit({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
+
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ const peerPullDebitId = encodeCrock(getRandomBytes(32));
+
+ let contractTerms: PeerContractTerms;
+
+ if (dec.contractTerms) {
+ contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+ // FIXME: Check that the purseStatus balance matches contract terms amount
+ } else {
+ // FIXME: In this case, where do we get the purse expiration from?!
+ // https://bugs.gnunet.org/view.php?id=7706
+ throw Error("pull payments without contract terms not supported yet");
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ // FIXME: Why don't we compute the totalCost here?!
+
+ const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "contractTerms"] },
+ async (tx) => {
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: contractTerms,
+ }),
+ await tx.peerPullDebit.add({
+ peerPullDebitId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ pursePub: pursePub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ amount: contractTerms.amount,
+ status: PeerPullDebitRecordStatus.DialogProposed,
+ totalCostEstimated: Amounts.stringify(totalAmount),
+ });
+ },
+ );
+
+ return {
+ amount: contractTerms.amount,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: contractTerms.amount,
+ contractTerms: contractTerms,
+ peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: peerPullDebitId,
+ }),
+ };
+}
+
+export function computePeerPullDebitTransactionState(
+ pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionState {
+ switch (pullDebitRecord.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Deposit,
+ };
+ case PeerPullDebitRecordStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Deposit,
+ };
+ case PeerPullDebitRecordStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPullDebitRecordStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ }
+}
+
+export function computePeerPullDebitTransactionActions(
+ pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionAction[] {
+ switch (pullDebitRecord.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return [];
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPullDebitRecordStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.Failed:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
new file mode 100644
index 000000000..93f1a63a7
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -0,0 +1,1036 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ 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 {
+ AcceptPeerPushPaymentResponse,
+ Amounts,
+ ConfirmPeerPushCreditRequest,
+ ContractTermsUtil,
+ ExchangePurseMergeRequest,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ PreparePeerPushCreditRequest,
+ PreparePeerPushCreditResponse,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ WalletAccountMergeFlags,
+ WalletKycUuid,
+ assertUnreachable,
+ checkDbInvariant,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ codecForWalletKycUuid,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parsePayPushUri,
+ talerPaytoFromExchangeReserve,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ KycPendingInfo,
+ KycUserType,
+ PeerPushCreditStatus,
+ PeerPushPaymentIncomingRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampPreciseToDb,
+} from "./db.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ codecForExchangePurseStatus,
+ getMergeReserveInfo,
+} from "./pay-peer-common.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+import {
+ PerformCreateWithdrawalGroupResult,
+ getExchangeWithdrawalInfo,
+ internalPerformCreateWithdrawalGroup,
+ internalPrepareCreateWithdrawalGroup,
+ waitWithdrawalFinal,
+} from "./withdraw.js";
+
+const logger = new Logger("pay-peer-push-credit.ts");
+
+export class PeerPushCreditTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public peerPushCreditId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, peerPushCreditId } = this;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["withdrawalGroups", "peerPushCredit", "tombstones"] },
+ async (tx) => {
+ const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushInc) {
+ return;
+ }
+ if (pushInc.withdrawalGroupId) {
+ const withdrawalGroupId = pushInc.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPushCredit.delete(peerPushCreditId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
+ });
+ },
+ );
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.SuspendedMerge;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ // FIXME: Suspend internal withdrawal transaction!
+ newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Done:
+ break;
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ newStatus = PeerPushCreditStatus.PendingMerge;
+ break;
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ // FIXME: resume underlying "internal-withdrawal" transaction.
+ newStatus = PeerPushCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.Aborted:
+ case PeerPushCreditStatus.Failed:
+ // Already in a final state.
+ return;
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+export async function preparePeerPushCredit(
+ wex: WalletExecutionContext,
+ req: PreparePeerPushCreditRequest,
+): Promise<PreparePeerPushCreditResponse> {
+ const uri = parsePayPushUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-push URI");
+ }
+
+ const existing = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
+ const existingPushInc =
+ await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!existingPushInc) {
+ return;
+ }
+ const existingContractTermsRec = await tx.contractTerms.get(
+ existingPushInc.contractTermsHash,
+ );
+ if (!existingContractTermsRec) {
+ throw Error(
+ "contract terms for peer push payment credit not found in database",
+ );
+ }
+ const existingContractTerms = codecForPeerContractTerms().decode(
+ existingContractTermsRec.contractTermsRaw,
+ );
+ return { existingPushInc, existingContractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.existingContractTerms.amount,
+ amountEffective: existing.existingPushInc.estimatedAmountEffective,
+ amountRaw: existing.existingContractTerms.amount,
+ contractTerms: existing.existingContractTerms,
+ peerPushCreditId: existing.existingPushInc.peerPushCreditId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: existing.existingPushInc.peerPushCreditId,
+ }),
+ exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl,
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+
+ await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await wex.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await wex.cryptoApi.decryptContractForMerge({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
+
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
+
+ const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ logger.info(
+ `peer push credit, purse balance ${purseStatus.balance}, contract amount ${contractTerms.amount}`,
+ );
+
+ const peerPushCreditId = encodeCrock(getRandomBytes(32));
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ dec.contractTerms,
+ );
+
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ exchangeBaseUrl,
+ Amounts.parseOrThrow(purseStatus.balance),
+ undefined,
+ );
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
+ const rec: PeerPushPaymentIncomingRecord = {
+ peerPushCreditId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ mergePriv: dec.mergePriv,
+ pursePub: pursePub,
+ timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ status: PeerPushCreditStatus.DialogProposed,
+ withdrawalGroupId,
+ currency: Amounts.currencyOf(purseStatus.balance),
+ estimatedAmountEffective: Amounts.stringify(
+ wi.withdrawalAmountEffective,
+ ),
+ };
+ await tx.peerPushCredit.add(rec);
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: dec.contractTerms,
+ });
+
+ const newTxState = computePeerPushCreditTransactionState(rec);
+
+ return {
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ } satisfies TransitionInfo;
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ return {
+ amount: purseStatus.balance,
+ amountEffective: wi.withdrawalAmountEffective,
+ amountRaw: purseStatus.balance,
+ contractTerms: dec.contractTerms,
+ peerPushCreditId,
+ transactionId,
+ exchangeBaseUrl,
+ };
+}
+
+async function longpollKycStatus(
+ wex: WalletExecutionContext,
+ peerPushCreditId: string,
+ exchangeUrl: string,
+ kycInfo: KycPendingInfo,
+ userType: KycUserType,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "30000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function processPeerPushCreditKycRequired(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+ kycPending: WalletKycUuid,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: peerInc.peerPushCreditId,
+ });
+ const { peerPushCreditId } = peerInc;
+
+ const userType = "individual";
+ const url = new URL(
+ `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
+ peerInc.exchangeBaseUrl,
+ );
+
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.finished();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const { transitionInfo, result } = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return {
+ transitionInfo: undefined,
+ result: TaskRunResult.finished(),
+ };
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.kycInfo = {
+ paytoHash: kycPending.h_payto,
+ requirementRow: kycPending.requirement_row,
+ };
+ peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ // We'll remove this eventually! New clients should rely on the
+ // kycUrl field of the transaction, not the error code.
+ const res: TaskRunResult = {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
+ {
+ kycUrl: kycStatus.kyc_url,
+ },
+ ),
+ };
+ return {
+ transitionInfo: { oldTxState, newTxState },
+ result: res,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return result;
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function handlePendingMerge(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+ contractTerms: PeerContractTerms,
+): Promise<TaskRunResult> {
+ const { peerPushCreditId } = peerInc;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ const amount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const mergeReserveInfo = await getMergeReserveInfo(wex, {
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ });
+
+ const mergeTimestamp = TalerProtocolTimestamp.now();
+
+ const reservePayto = talerPaytoFromExchangeReserve(
+ peerInc.exchangeBaseUrl,
+ mergeReserveInfo.reservePub,
+ );
+
+ const sigRes = await wex.cryptoApi.signPurseMerge({
+ contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
+ flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
+ mergePriv: peerInc.mergePriv,
+ mergeTimestamp: mergeTimestamp,
+ purseAmount: Amounts.stringify(amount),
+ purseExpiration: contractTerms.purse_expiration,
+ purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
+ pursePub: peerInc.pursePub,
+ reservePayto,
+ reservePriv: mergeReserveInfo.reservePriv,
+ });
+
+ const mergePurseUrl = new URL(
+ `purses/${peerInc.pursePub}/merge`,
+ peerInc.exchangeBaseUrl,
+ );
+
+ const mergeReq: ExchangePurseMergeRequest = {
+ payto_uri: reservePayto,
+ merge_timestamp: mergeTimestamp,
+ merge_sig: sigRes.mergeSig,
+ reserve_sig: sigRes.accountSig,
+ };
+
+ const mergeHttpResp = await wex.http.fetch(mergePurseUrl.href, {
+ method: "POST",
+ body: mergeReq,
+ });
+
+ if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ const respJson = await mergeHttpResp.json();
+ const kycPending = codecForWalletKycUuid().decode(respJson);
+ logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+ return processPeerPushCreditKycRequired(wex, peerInc, kycPending);
+ }
+
+ logger.trace(`merge request: ${j2s(mergeReq)}`);
+ const res = await readSuccessResponseJsonOrThrow(
+ mergeHttpResp,
+ codecForAny(),
+ );
+ logger.trace(`merge response: ${j2s(res)}`);
+
+ const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(wex, {
+ amount,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.PeerPushCredit,
+ },
+ forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ priv: mergeReserveInfo.reservePriv,
+ pub: mergeReserveInfo.reservePub,
+ },
+ });
+
+ const txRes = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "contractTerms",
+ "peerPushCredit",
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ ],
+ },
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return undefined;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined =
+ undefined;
+ switch (peerInc.status) {
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingMergeKycRequired: {
+ peerInc.status = PeerPushCreditStatus.PendingWithdrawing;
+ wgCreateRes = await internalPerformCreateWithdrawalGroup(
+ wex,
+ tx,
+ withdrawalGroupPrep,
+ );
+ peerInc.withdrawalGroupId =
+ wgCreateRes.withdrawalGroup.withdrawalGroupId;
+ break;
+ }
+ }
+ await tx.peerPushCredit.put(peerInc);
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ return {
+ peerPushCreditTransition: { oldTxState, newTxState },
+ wgCreateRes,
+ };
+ },
+ );
+ // Transaction was committed, now we can emit notifications.
+ if (txRes?.wgCreateRes?.exchangeNotif) {
+ wex.ws.notify(txRes.wgCreateRes.exchangeNotif);
+ }
+ notifyTransition(
+ wex,
+ withdrawalGroupPrep.transactionId,
+ txRes?.wgCreateRes?.transitionInfo,
+ );
+ notifyTransition(wex, transactionId, txRes?.peerPushCreditTransition);
+
+ return TaskRunResult.backoff();
+}
+
+async function handlePendingWithdrawing(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ if (!peerInc.withdrawalGroupId) {
+ throw Error("invalid db state (withdrawing, but no withdrawal group ID");
+ }
+ await waitWithdrawalFinal(wex, peerInc.withdrawalGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: peerInc.peerPushCreditId,
+ });
+ const wgId = peerInc.withdrawalGroupId;
+ let finished: boolean = false;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit", "withdrawalGroups"] },
+ async (tx) => {
+ const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
+ if (!ppi) {
+ finished = true;
+ return;
+ }
+ if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) {
+ finished = true;
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(ppi);
+ const wg = await tx.withdrawalGroups.get(wgId);
+ if (!wg) {
+ // FIXME: Fail the operation instead?
+ return undefined;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.Done:
+ finished = true;
+ ppi.status = PeerPushCreditStatus.Done;
+ break;
+ // FIXME: Also handle other final states!
+ }
+ await tx.peerPushCredit.put(ppi);
+ const newTxState = computePeerPushCreditTransactionState(ppi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ if (finished) {
+ return TaskRunResult.finished();
+ } else {
+ // FIXME: Return indicator that we depend on the other operation!
+ return TaskRunResult.backoff();
+ }
+}
+
+export async function processPeerPushCredit(
+ wex: WalletExecutionContext,
+ peerPushCreditId: string,
+): Promise<TaskRunResult> {
+ let peerInc: PeerPushPaymentIncomingRecord | undefined;
+ let contractTerms: PeerContractTerms | undefined;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
+ peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
+ if (ctRec) {
+ contractTerms = ctRec.contractTermsRaw;
+ }
+ await tx.peerPushCredit.put(peerInc);
+ },
+ );
+
+ if (!peerInc) {
+ throw Error(
+ `can't accept unknown incoming p2p push payment (${peerPushCreditId})`,
+ );
+ }
+
+ logger.info(
+ `processing peerPushCredit in state ${peerInc.status.toString(16)}`,
+ );
+
+ checkDbInvariant(!!contractTerms);
+
+ switch (peerInc.status) {
+ case PeerPushCreditStatus.PendingMergeKycRequired: {
+ if (!peerInc.kycInfo) {
+ throw Error("invalid state, kycInfo required");
+ }
+ return await longpollKycStatus(
+ wex,
+ peerPushCreditId,
+ peerInc.exchangeBaseUrl,
+ peerInc.kycInfo,
+ "individual",
+ );
+ }
+
+ case PeerPushCreditStatus.PendingMerge:
+ return handlePendingMerge(wex, peerInc, contractTerms);
+
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return handlePendingWithdrawing(wex, peerInc);
+
+ default:
+ return TaskRunResult.finished();
+ }
+}
+
+export async function confirmPeerPushCredit(
+ wex: WalletExecutionContext,
+ req: ConfirmPeerPushCreditRequest,
+): Promise<AcceptPeerPushPaymentResponse> {
+ let peerInc: PeerPushPaymentIncomingRecord | undefined;
+ let peerPushCreditId: string;
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx) {
+ throw Error("invalid transaction ID");
+ }
+ if (parsedTx.tag !== TransactionType.PeerPushCredit) {
+ throw Error("invalid transaction ID type");
+ }
+ peerPushCreditId = parsedTx.peerPushCreditId;
+
+ logger.trace(`confirming peer-push-credit ${peerPushCreditId}`);
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
+ peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status === PeerPushCreditStatus.DialogProposed) {
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ }
+ await tx.peerPushCredit.put(peerInc);
+ },
+ );
+
+ if (!peerInc) {
+ throw Error(
+ `can't accept unknown incoming p2p push payment (${req.transactionId})`,
+ );
+ }
+
+ const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ return {
+ transactionId,
+ };
+}
+
+export function computePeerPushCreditTransactionState(
+ pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionState {
+ switch (pushCreditRecord.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case PeerPushCreditStatus.PendingMerge:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Merge,
+ };
+ case PeerPushCreditStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPushCreditStatus.SuspendedMerge:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Merge,
+ };
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPushCreditStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPushCreditStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ default:
+ assertUnreachable(pushCreditRecord.status);
+ }
+}
+
+export function computePeerPushCreditTransactionActions(
+ pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionAction[] {
+ switch (pushCreditRecord.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.PendingMerge:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushCreditStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushCreditStatus.SuspendedMerge:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushCreditStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.Failed:
+ return [TransactionAction.Delete];
+ default:
+ assertUnreachable(pushCreditRecord.status);
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
new file mode 100644
index 000000000..6452407ff
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -0,0 +1,1322 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ 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 {
+ Amounts,
+ CheckPeerPushDebitRequest,
+ CheckPeerPushDebitResponse,
+ CoinRefreshRequest,
+ ContractTermsUtil,
+ ExchangePurseDeposits,
+ HttpStatusCode,
+ InitiatePeerPushDebitRequest,
+ InitiatePeerPushDebitResponse,
+ Logger,
+ NotificationType,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ assertUnreachable,
+ checkDbInvariant,
+ checkLogicInvariant,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import { EncryptContractRequest } from "./crypto/cryptoTypes.js";
+import {
+ PeerPushDebitRecord,
+ PeerPushDebitStatus,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import { createRefreshGroup, waitRefreshFinal } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("pay-peer-push-debit.ts");
+
+export class PeerPushDebitTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public pursePub: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId } = this;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit", "tombstones"] },
+ async (tx) => {
+ const debit = await tx.peerPushDebit.get(pursePub);
+ if (debit) {
+ await tx.peerPushDebit.delete(pursePub);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ newStatus = PeerPushDebitStatus.SuspendedCreatePurse;
+ break;
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshDeleted;
+ break;
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshExpired;
+ break;
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.PendingReady:
+ newStatus = PeerPushDebitStatus.SuspendedReady;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ // Network request might already be in-flight!
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Expired:
+ case PeerPushDebitStatus.Failed:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ newStatus = PeerPushDebitStatus.AbortingRefreshDeleted;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ newStatus = PeerPushDebitStatus.AbortingRefreshExpired;
+ break;
+ case PeerPushDebitStatus.SuspendedReady:
+ newStatus = PeerPushDebitStatus.PendingReady;
+ break;
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ newStatus = PeerPushDebitStatus.PendingCreatePurse;
+ break;
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.startShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ // FIXME: What to do about the refresh group?
+ newStatus = PeerPushDebitStatus.Failed;
+ break;
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ newStatus = PeerPushDebitStatus.Failed;
+ break;
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+export async function checkPeerPushDebit(
+ wex: WalletExecutionContext,
+ req: CheckPeerPushDebitRequest,
+): Promise<CheckPeerPushDebitResponse> {
+ const instructedAmount = Amounts.parseOrThrow(req.amount);
+ logger.trace(
+ `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
+ );
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+ logger.trace(`selected peer coins (len=${coins.length})`);
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+ logger.trace("computed total peer payment cost");
+ return {
+ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: req.amount,
+ maxExpirationDate: coinSelRes.result.maxExpirationDate,
+ };
+}
+
+async function handlePurseCreationConflict(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const errResp = await readTalerErrorResponse(resp);
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
+ const sel = peerPushInitiation.coinSel;
+
+ checkDbInvariant(!!sel);
+
+ const repair: PreviousPayCoins = [];
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.push({
+ coinPub: sel.coinPubs[i],
+ contribution: Amounts.parseOrThrow(sel.contributions[i]),
+ });
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ repair,
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ case "prospective":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ await wex.db.runReadWriteTx({ storeNames: ["peerPushDebit"] }, async (tx) => {
+ const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.SuspendedCreatePurse: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPushDebit.put(myPpi);
+ });
+ return TaskRunResult.progress();
+}
+
+async function processPeerPushDebitCreateReserve(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const purseExpiration = peerPushInitiation.purseExpiration;
+ const hContractTerms = peerPushInitiation.contractTermsHash;
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+ const transactionId = ctx.transactionId;
+
+ logger.trace(`processing ${transactionId} pending(create-reserve)`);
+
+ const contractTermsRecord = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ async (tx) => {
+ return tx.contractTerms.get(hContractTerms);
+ },
+ );
+
+ if (!contractTermsRecord) {
+ throw Error(
+ `db invariant failed, contract terms for ${transactionId} missing`,
+ );
+ }
+
+ if (!peerPushInitiation.coinSel) {
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount),
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient funds (blocked on refresh)");
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+ const transitionDone = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPushDebit",
+ ],
+ },
+ async (tx) => {
+ const ppi = await tx.peerPushDebit.get(pursePub);
+ if (!ppi) {
+ return false;
+ }
+ if (ppi.coinSel) {
+ return false;
+ }
+
+ ppi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ };
+ // FIXME: Instead of directly doing a spendCoin here,
+ // we might want to mark the coins as used and spend them
+ // after we've been able to create the purse.
+ await spendCoins(wex, tx, {
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPush,
+ });
+
+ await tx.peerPushDebit.put(ppi);
+ return true;
+ },
+ );
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ }
+ return TaskRunResult.backoff();
+ }
+
+ const purseSigResp = await wex.cryptoApi.signPurseCreation({
+ hContractTerms,
+ mergePub: peerPushInitiation.mergePub,
+ minAge: 0,
+ purseAmount: peerPushInitiation.amount,
+ purseExpiration: timestampProtocolFromDb(purseExpiration),
+ pursePriv: peerPushInitiation.pursePriv,
+ });
+
+ const coins = await queryCoinInfosForSelection(
+ wex,
+ peerPushInitiation.coinSel,
+ );
+
+ const encryptContractRequest: EncryptContractRequest = {
+ contractTerms: contractTermsRecord.contractTermsRaw,
+ mergePriv: peerPushInitiation.mergePriv,
+ pursePriv: peerPushInitiation.pursePriv,
+ pursePub: peerPushInitiation.pursePub,
+ contractPriv: peerPushInitiation.contractPriv,
+ contractPub: peerPushInitiation.contractPub,
+ nonce: peerPushInitiation.contractEncNonce,
+ };
+
+ const econtractResp = await wex.cryptoApi.encryptContractForMerge(
+ encryptContractRequest,
+ );
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < coins.length; i += maxBatchSize) {
+ const batchSize = Math.min(maxBatchSize, coins.length - i);
+ const batchCoins = coins.slice(i, i + batchSize);
+
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
+ pursePub: peerPushInitiation.pursePub,
+ coins: batchCoins,
+ });
+
+ if (i == 0) {
+ // First batch creates the purse!
+
+ logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
+
+ const createPurseUrl = new URL(
+ `purses/${peerPushInitiation.pursePub}/create`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const reqBody = {
+ amount: peerPushInitiation.amount,
+ merge_pub: peerPushInitiation.mergePub,
+ purse_sig: purseSigResp.sig,
+ h_contract_terms: hContractTerms,
+ purse_expiration: timestampProtocolFromDb(purseExpiration),
+ deposits: depositSigsResp.deposits,
+ min_age: 0,
+ econtract: econtractResp.econtract,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`request body: ${j2s(reqBody)}`);
+ }
+
+ const httpResp = await wex.http.fetch(createPurseUrl.href, {
+ method: "POST",
+ body: reqBody,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok:
+ // Possibly on to the next batch.
+ continue;
+ case HttpStatusCode.Forbidden: {
+ // FIXME: Store this error!
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Conflict: {
+ // Handle double-spending
+ return handlePurseCreationConflict(wex, peerPushInitiation, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+ } else {
+ const purseDepositUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ const httpResp = await wex.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok:
+ // Possibly on to the next batch.
+ continue;
+ case HttpStatusCode.Forbidden: {
+ // FIXME: Store this error!
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Conflict: {
+ // Handle double-spending
+ return handlePurseCreationConflict(wex, peerPushInitiation, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+ }
+ }
+
+ // All batches done!
+
+ await transitionPeerPushDebitTransaction(wex, pursePub, {
+ stFrom: PeerPushDebitStatus.PendingCreatePurse,
+ stTo: PeerPushDebitStatus.PendingReady,
+ });
+
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushDebitAbortingDeletePurse(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const { pursePub, pursePriv } = peerPushInitiation;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+
+ const sigResp = await wex.cryptoApi.signDeletePurse({
+ pursePriv,
+ });
+ const purseUrl = new URL(
+ `purses/${pursePub}`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+ const resp = await wex.http.fetch(purseUrl.href, {
+ method: "DELETE",
+ headers: {
+ "taler-purse-signature": sigResp.sig,
+ },
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`deleted purse with response status ${resp.status}`);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPushDebit",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(ppiRec.amount);
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (!ppiRec.coinSel) {
+ return undefined;
+ }
+
+ for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: ppiRec.coinSel.contributions[i],
+ coinPub: ppiRec.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPushDebit,
+ transactionId,
+ );
+ ppiRec.status = PeerPushDebitStatus.AbortingRefreshDeleted;
+ ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return TaskRunResult.backoff();
+}
+
+interface SimpleTransition {
+ stFrom: PeerPushDebitStatus;
+ stTo: PeerPushDebitStatus;
+}
+
+// FIXME: This should be a transition on the peer push debit transaction context!
+async function transitionPeerPushDebitTransaction(
+ wex: WalletExecutionContext,
+ pursePub: string,
+ transitionSpec: SimpleTransition,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== transitionSpec.stFrom) {
+ return undefined;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ ppiRec.status = transitionSpec.stTo;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+async function processPeerPushDebitAbortingRefreshDeleted(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: peerPushInitiation.pursePub,
+ });
+ if (peerPushInitiation.abortRefreshGroupId) {
+ await waitRefreshFinal(wex, peerPushInitiation.abortRefreshGroupId);
+ }
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["refreshGroups", "peerPushDebit"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPushDebitStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPushDebitStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPushDebitStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPushDebitStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPushDebit.get(pursePub);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPushDebitTransactionState(newDg);
+ await tx.peerPushDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushDebitAbortingRefreshExpired(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: peerPushInitiation.pursePub,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit", "refreshGroups"] },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPushDebitStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPushDebitStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPushDebitStatus.Expired;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPushDebitStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPushDebit.get(pursePub);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPushDebitTransactionState(newDg);
+ await tx.peerPushDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Process the "pending(ready)" state of a peer-push-debit transaction.
+ */
+async function processPeerPushDebitReady(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ logger.trace("processing peer-push-debit pending(ready)");
+ const pursePub = peerPushInitiation.pursePub;
+ const transactionId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub,
+ });
+ const mergeUrl = new URL(
+ `purses/${pursePub}/merge`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+ mergeUrl.searchParams.set("timeout_ms", "30000");
+ logger.info(`long-polling on purse status at ${mergeUrl.href}`);
+ const resp = await wex.http.fetch(mergeUrl.href, {
+ // timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ if (resp.status === HttpStatusCode.Ok) {
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangePurseStatus(),
+ );
+ const mergeTimestamp = purseStatus.merge_timestamp;
+ logger.info(`got purse status ${j2s(purseStatus)}`);
+ if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) {
+ return TaskRunResult.backoff();
+ } else {
+ await transitionPeerPushDebitTransaction(
+ wex,
+ peerPushInitiation.pursePub,
+ {
+ stFrom: PeerPushDebitStatus.PendingReady,
+ stTo: PeerPushDebitStatus.Done,
+ },
+ );
+ return TaskRunResult.progress();
+ }
+ } else if (resp.status === HttpStatusCode.Gone) {
+ logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPushDebit",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPushDebitStatus.PendingReady) {
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(ppiRec.amount);
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (ppiRec.coinSel) {
+ for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: ppiRec.coinSel.contributions[i],
+ coinPub: ppiRec.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPushDebit,
+ transactionId,
+ );
+
+ ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
+ }
+ ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ } else {
+ logger.warn(`unexpected HTTP status for purse: ${resp.status}`);
+ return TaskRunResult.longpollReturnedPending();
+ }
+}
+
+export async function processPeerPushDebit(
+ wex: WalletExecutionContext,
+ pursePub: string,
+): Promise<TaskRunResult> {
+ const peerPushInitiation = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPushDebit"] },
+ async (tx) => {
+ return tx.peerPushDebit.get(pursePub);
+ },
+ );
+ if (!peerPushInitiation) {
+ throw Error("peer push payment not found");
+ }
+
+ switch (peerPushInitiation.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return processPeerPushDebitCreateReserve(wex, peerPushInitiation);
+ case PeerPushDebitStatus.PendingReady:
+ return processPeerPushDebitReady(wex, peerPushInitiation);
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return processPeerPushDebitAbortingDeletePurse(wex, peerPushInitiation);
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return processPeerPushDebitAbortingRefreshDeleted(
+ wex,
+ peerPushInitiation,
+ );
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return processPeerPushDebitAbortingRefreshExpired(
+ wex,
+ peerPushInitiation,
+ );
+ default: {
+ const txState = computePeerPushDebitTransactionState(peerPushInitiation);
+ logger.warn(
+ `not processing peer-push-debit transaction in state ${j2s(txState)}`,
+ );
+ }
+ }
+
+ return TaskRunResult.finished();
+}
+
+/**
+ * Initiate sending a peer-to-peer push payment.
+ */
+export async function initiatePeerPushDebit(
+ wex: WalletExecutionContext,
+ req: InitiatePeerPushDebitRequest,
+): Promise<InitiatePeerPushDebitResponse> {
+ const instructedAmount = Amounts.parseOrThrow(
+ req.partialContractTerms.amount,
+ );
+ const purseExpiration = req.partialContractTerms.purse_expiration;
+ const contractTerms = req.partialContractTerms;
+
+ const pursePair = await wex.cryptoApi.createEddsaKeypair({});
+ const mergePair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const sel = coinSelRes.result;
+
+ logger.info(`selected p2p coins (push):`);
+ logger.trace(`${j2s(coinSelRes)}`);
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ logger.info(`computed total peer payment cost`);
+
+ const pursePub = pursePair.pub;
+
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+
+ const transactionId = ctx.transactionId;
+
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPushDebit",
+ ],
+ },
+ async (tx) => {
+ const ppi: PeerPushDebitRecord = {
+ amount: Amounts.stringify(instructedAmount),
+ contractPriv: contractKeyPair.priv,
+ contractPub: contractKeyPair.pub,
+ contractTermsHash: hContractTerms,
+ exchangeBaseUrl: sel.exchangeBaseUrl,
+ mergePriv: mergePair.priv,
+ mergePub: mergePair.pub,
+ purseExpiration: timestampProtocolToDb(purseExpiration),
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ status: PeerPushDebitStatus.PendingCreatePurse,
+ contractEncNonce,
+ totalCost: Amounts.stringify(totalAmount),
+ };
+
+ if (coinSelRes.type === "success") {
+ ppi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ };
+ // FIXME: Instead of directly doing a spendCoin here,
+ // we might want to mark the coins as used and spend them
+ // after we've been able to create the purse.
+ await spendCoins(wex, tx, {
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pursePair.pub,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPush,
+ });
+ }
+
+ await tx.peerPushDebit.add(ppi);
+
+ await tx.contractTerms.put({
+ h: hContractTerms,
+ contractTermsRaw: contractTerms,
+ });
+
+ const newTxState = computePeerPushDebitTransactionState(ppi);
+ return {
+ oldTxState: { major: TransactionMajorState.None },
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ contractPriv: contractKeyPair.priv,
+ mergePriv: mergePair.priv,
+ pursePub: pursePair.pub,
+ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pursePair.pub,
+ }),
+ };
+}
+
+export function computePeerPushDebitTransactionActions(
+ ppiRecord: PeerPushDebitRecord,
+): TransactionAction[] {
+ switch (ppiRecord.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushDebitStatus.PendingReady:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushDebitStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushDebitStatus.SuspendedReady:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushDebitStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.Expired:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.Failed:
+ return [TransactionAction.Delete];
+ }
+}
+
+export function computePeerPushDebitTransactionState(
+ ppiRecord: PeerPushDebitRecord,
+): TransactionState {
+ switch (ppiRecord.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPushDebitStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPushDebitStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.RefreshExpired,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.RefreshExpired,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPushDebitStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPushDebitStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPushDebitStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPushDebitStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ }
+}
diff --git a/packages/taler-wallet-core/src/pending-types.ts b/packages/taler-wallet-core/src/pending-types.ts
deleted file mode 100644
index 5033163a1..000000000
--- a/packages/taler-wallet-core/src/pending-types.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-/*
- 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/>
- */
-
-/**
- * Type and schema definitions for pending tasks in the wallet.
- *
- * These are only used internally, and are not part of the stable public
- * interface to the wallet.
- */
-
-/**
- * Imports.
- */
-import {
- TalerErrorDetails,
- BalancesResponse,
- Timestamp,
-} from "@gnu-taler/taler-util";
-import { ReserveRecordStatus } from "./db.js";
-import { RetryInfo } from "./util/retries.js";
-
-export enum PendingTaskType {
- ExchangeUpdate = "exchange-update",
- ExchangeCheckRefresh = "exchange-check-refresh",
- Pay = "pay",
- ProposalChoice = "proposal-choice",
- ProposalDownload = "proposal-download",
- Refresh = "refresh",
- Reserve = "reserve",
- Recoup = "recoup",
- RefundQuery = "refund-query",
- TipPickup = "tip-pickup",
- Withdraw = "withdraw",
- Deposit = "deposit",
- Backup = "backup",
-}
-
-/**
- * Information about a pending operation.
- */
-export type PendingTaskInfo = PendingTaskInfoCommon &
- (
- | PendingExchangeUpdateTask
- | PendingExchangeCheckRefreshTask
- | PendingPayTask
- | PendingProposalDownloadTask
- | PendingRefreshTask
- | PendingRefundQueryTask
- | PendingReserveTask
- | PendingTipPickupTask
- | PendingWithdrawTask
- | PendingRecoupTask
- | PendingDepositTask
- | PendingBackupTask
- );
-
-export interface PendingBackupTask {
- type: PendingTaskType.Backup;
- backupProviderBaseUrl: string;
- lastError: TalerErrorDetails | undefined;
-}
-
-/**
- * The wallet is currently updating information about an exchange.
- */
-export interface PendingExchangeUpdateTask {
- type: PendingTaskType.ExchangeUpdate;
- exchangeBaseUrl: string;
- lastError: TalerErrorDetails | undefined;
-}
-
-/**
- * The wallet should check whether coins from this exchange
- * need to be auto-refreshed.
- */
-export interface PendingExchangeCheckRefreshTask {
- type: PendingTaskType.ExchangeCheckRefresh;
- exchangeBaseUrl: string;
-}
-
-export enum ReserveType {
- /**
- * Manually created.
- */
- Manual = "manual",
- /**
- * Withdrawn from a bank that has "tight" Taler integration
- */
- TalerBankWithdraw = "taler-bank-withdraw",
-}
-
-/**
- * Status of processing a reserve.
- *
- * Does *not* include the withdrawal operation that might result
- * from this.
- */
-export interface PendingReserveTask {
- type: PendingTaskType.Reserve;
- retryInfo: RetryInfo | undefined;
- stage: ReserveRecordStatus;
- timestampCreated: Timestamp;
- reserveType: ReserveType;
- reservePub: string;
- bankWithdrawConfirmUrl?: string;
-}
-
-/**
- * Status of an ongoing withdrawal operation.
- */
-export interface PendingRefreshTask {
- type: PendingTaskType.Refresh;
- lastError?: TalerErrorDetails;
- refreshGroupId: string;
- finishedPerCoin: boolean[];
- retryInfo: RetryInfo;
-}
-
-/**
- * Status of downloading signed contract terms from a merchant.
- */
-export interface PendingProposalDownloadTask {
- type: PendingTaskType.ProposalDownload;
- merchantBaseUrl: string;
- proposalTimestamp: Timestamp;
- proposalId: string;
- orderId: string;
- lastError?: TalerErrorDetails;
- retryInfo?: RetryInfo;
-}
-
-/**
- * User must choose whether to accept or reject the merchant's
- * proposed contract terms.
- */
-export interface PendingProposalChoiceOperation {
- type: PendingTaskType.ProposalChoice;
- merchantBaseUrl: string;
- proposalTimestamp: Timestamp;
- proposalId: string;
-}
-
-/**
- * The wallet is picking up a tip that the user has accepted.
- */
-export interface PendingTipPickupTask {
- type: PendingTaskType.TipPickup;
- tipId: string;
- merchantBaseUrl: string;
- merchantTipId: string;
-}
-
-/**
- * The wallet is signing coins and then sending them to
- * the merchant.
- */
-export interface PendingPayTask {
- type: PendingTaskType.Pay;
- proposalId: string;
- isReplay: boolean;
- retryInfo?: RetryInfo;
- lastError: TalerErrorDetails | undefined;
-}
-
-/**
- * The wallet is querying the merchant about whether any refund
- * permissions are available for a purchase.
- */
-export interface PendingRefundQueryTask {
- type: PendingTaskType.RefundQuery;
- proposalId: string;
- retryInfo: RetryInfo;
- lastError: TalerErrorDetails | undefined;
-}
-
-export interface PendingRecoupTask {
- type: PendingTaskType.Recoup;
- recoupGroupId: string;
- retryInfo: RetryInfo;
- lastError: TalerErrorDetails | undefined;
-}
-
-/**
- * Status of an ongoing withdrawal operation.
- */
-export interface PendingWithdrawTask {
- type: PendingTaskType.Withdraw;
- lastError: TalerErrorDetails | undefined;
- retryInfo: RetryInfo;
- withdrawalGroupId: string;
-}
-
-/**
- * Status of an ongoing deposit operation.
- */
-export interface PendingDepositTask {
- type: PendingTaskType.Deposit;
- lastError: TalerErrorDetails | undefined;
- retryInfo: RetryInfo | undefined;
- depositGroupId: string;
-}
-
-/**
- * Fields that are present in every pending operation.
- */
-export interface PendingTaskInfoCommon {
- /**
- * Type of the pending operation.
- */
- type: PendingTaskType;
-
- /**
- * Set to true if the operation indicates that something is really in progress,
- * as opposed to some regular scheduled operation that can be tried later.
- */
- givesLifeness: boolean;
-
- /**
- * Timestamp when the pending operation should be executed next.
- */
- timestampDue: Timestamp;
-
- /**
- * Retry info. Currently used to stop the wallet after any operation
- * exceeds a number of retries.
- */
- retryInfo?: RetryInfo;
-}
-
-/**
- * Response returned from the pending operations API.
- */
-export interface PendingOperationsResponse {
- /**
- * List of pending operations.
- */
- pendingOperations: PendingTaskInfo[];
-
- /**
- * Current wallet balance, including pending balances.
- */
- walletBalance: BalancesResponse;
-}
diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts
new file mode 100644
index 000000000..dc15bbdd1
--- /dev/null
+++ b/packages/taler-wallet-core/src/query.ts
@@ -0,0 +1,1004 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @fileoverview
+ * Query helpers for IndexedDB databases.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ IDBCursor,
+ IDBDatabase,
+ IDBFactory,
+ IDBKeyPath,
+ IDBKeyRange,
+ IDBRequest,
+ IDBTransaction,
+ IDBTransactionMode,
+ IDBValidKey,
+ IDBVersionChangeEvent,
+} from "@gnu-taler/idb-bridge";
+import {
+ CancellationToken,
+ Codec,
+ Logger,
+ openPromise,
+} from "@gnu-taler/taler-util";
+
+const logger = new Logger("query.ts");
+
+/**
+ * Exception that should be thrown by client code to abort a transaction.
+ */
+export const TransactionAbort = Symbol("transaction_abort");
+
+/**
+ * Options for an index.
+ */
+export interface IndexOptions {
+ /**
+ * If true and the path resolves to an array, create an index entry for
+ * each member of the array (instead of one index entry containing the full array).
+ *
+ * Defaults to false.
+ */
+ multiEntry?: boolean;
+
+ /**
+ * Database version that this store was added in, or
+ * undefined if added in the first version.
+ */
+ versionAdded?: number;
+
+ /**
+ * Does this index enforce unique keys?
+ *
+ * Defaults to false.
+ */
+ unique?: boolean;
+}
+
+function requestToPromise(req: IDBRequest): Promise<any> {
+ const stack = Error("Failed request was started here.");
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => {
+ resolve(req.result);
+ };
+ req.onerror = () => {
+ console.error("error in DB request", req.error);
+ reject(req.error);
+ console.error("Request failed:", stack);
+ };
+ });
+}
+
+type CursorResult<T> = CursorEmptyResult<T> | CursorValueResult<T>;
+
+interface CursorEmptyResult<T> {
+ hasValue: false;
+}
+
+interface CursorValueResult<T> {
+ hasValue: true;
+ value: T;
+}
+
+class TransactionAbortedError extends Error {
+ constructor(m: string) {
+ super(m);
+
+ // Set the prototype explicitly.
+ Object.setPrototypeOf(this, TransactionAbortedError.prototype);
+ }
+}
+
+class ResultStream<T> {
+ private currentPromise: Promise<void>;
+ private gotCursorEnd = false;
+ private awaitingResult = false;
+
+ constructor(private req: IDBRequest) {
+ this.awaitingResult = true;
+ let p = openPromise<void>();
+ this.currentPromise = p.promise;
+ req.onsuccess = () => {
+ if (!this.awaitingResult) {
+ throw Error("BUG: invariant violated");
+ }
+ const cursor = req.result;
+ if (cursor) {
+ this.awaitingResult = false;
+ p.resolve();
+ p = openPromise<void>();
+ this.currentPromise = p.promise;
+ } else {
+ this.gotCursorEnd = true;
+ p.resolve();
+ }
+ };
+ req.onerror = () => {
+ p.reject(req.error);
+ };
+ }
+
+ async toArray(): Promise<T[]> {
+ const arr: T[] = [];
+ while (true) {
+ const x = await this.next();
+ if (x.hasValue) {
+ arr.push(x.value);
+ } else {
+ break;
+ }
+ }
+ return arr;
+ }
+
+ async map<R>(f: (x: T) => R): Promise<R[]> {
+ const arr: R[] = [];
+ while (true) {
+ const x = await this.next();
+ if (x.hasValue) {
+ arr.push(f(x.value));
+ } else {
+ break;
+ }
+ }
+ return arr;
+ }
+
+ async mapAsync<R>(f: (x: T) => Promise<R>): Promise<R[]> {
+ const arr: R[] = [];
+ while (true) {
+ const x = await this.next();
+ if (x.hasValue) {
+ arr.push(await f(x.value));
+ } else {
+ break;
+ }
+ }
+ return arr;
+ }
+
+ async forEachAsync(f: (x: T) => Promise<void>): Promise<void> {
+ while (true) {
+ const x = await this.next();
+ if (x.hasValue) {
+ await f(x.value);
+ } else {
+ break;
+ }
+ }
+ }
+
+ async forEach(f: (x: T) => void): Promise<void> {
+ while (true) {
+ const x = await this.next();
+ if (x.hasValue) {
+ f(x.value);
+ } else {
+ break;
+ }
+ }
+ }
+
+ async filter(f: (x: T) => boolean): Promise<T[]> {
+ const arr: T[] = [];
+ while (true) {
+ const x = await this.next();
+ if (x.hasValue) {
+ if (f(x.value)) {
+ arr.push(x.value);
+ }
+ } else {
+ break;
+ }
+ }
+ return arr;
+ }
+
+ async next(): Promise<CursorResult<T>> {
+ if (this.gotCursorEnd) {
+ return { hasValue: false };
+ }
+ if (!this.awaitingResult) {
+ const cursor: IDBCursor | undefined = this.req.result;
+ if (!cursor) {
+ throw Error("assertion failed");
+ }
+ this.awaitingResult = true;
+ cursor.continue();
+ }
+ await this.currentPromise;
+ if (this.gotCursorEnd) {
+ return { hasValue: false };
+ }
+ const cursor = this.req.result;
+ if (!cursor) {
+ throw Error("assertion failed");
+ }
+ return { hasValue: true, value: cursor.value };
+ }
+}
+
+/**
+ * Return a promise that resolves to the opened IndexedDB database.
+ */
+export function openDatabase(
+ idbFactory: IDBFactory,
+ databaseName: string,
+ databaseVersion: number | undefined,
+ onVersionChange: () => void,
+ onUpgradeNeeded: (
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+ ) => void,
+): Promise<IDBDatabase> {
+ return new Promise<IDBDatabase>((resolve, reject) => {
+ const req = idbFactory.open(databaseName, databaseVersion);
+ req.onerror = (event) => {
+ // @ts-expect-error
+ reject(new Error(`database opening error`, { cause: req.error }));
+ };
+ req.onsuccess = (e) => {
+ req.result.onversionchange = (evt: IDBVersionChangeEvent) => {
+ logger.info(
+ `handling versionchange on ${databaseName} from ${evt.oldVersion} to ${evt.newVersion}`,
+ );
+ req.result.close();
+ onVersionChange();
+ };
+ resolve(req.result);
+ };
+ req.onupgradeneeded = (e) => {
+ const db = req.result;
+ const newVersion = e.newVersion;
+ if (!newVersion) {
+ // @ts-expect-error
+ throw Error("upgrade needed, but new version unknown", {
+ cause: req.error,
+ });
+ }
+ const transaction = req.transaction;
+ if (!transaction) {
+ // @ts-expect-error
+ throw Error("no transaction handle available in upgrade handler", {
+ cause: req.error,
+ });
+ }
+ logger.info(
+ `handling upgradeneeded event on ${databaseName} from ${e.oldVersion} to ${e.newVersion}`,
+ );
+ onUpgradeNeeded(db, e.oldVersion, newVersion, transaction);
+ };
+ });
+}
+
+export interface IndexDescriptor {
+ name: string;
+ keyPath: IDBKeyPath | IDBKeyPath[];
+ multiEntry?: boolean;
+ unique?: boolean;
+ versionAdded?: number;
+}
+
+export interface StoreDescriptor<RecordType> {
+ _dummy: undefined & RecordType;
+ keyPath?: IDBKeyPath | IDBKeyPath[];
+ autoIncrement?: boolean;
+ /**
+ * Database version that this store was added in, or
+ * undefined if added in the first version.
+ */
+ versionAdded?: number;
+}
+
+export interface StoreOptions {
+ keyPath?: IDBKeyPath | IDBKeyPath[];
+ autoIncrement?: boolean;
+
+ /**
+ * First minor database version that this store was added in, or
+ * undefined if added in the first version.
+ */
+ versionAdded?: number;
+}
+
+export function describeContents<RecordType = never>(
+ options: StoreOptions,
+): StoreDescriptor<RecordType> {
+ return {
+ keyPath: options.keyPath,
+ _dummy: undefined as any,
+ autoIncrement: options.autoIncrement,
+ versionAdded: options.versionAdded,
+ };
+}
+
+export function describeIndex(
+ name: string,
+ keyPath: IDBKeyPath | IDBKeyPath[],
+ options: IndexOptions = {},
+): IndexDescriptor {
+ return {
+ keyPath,
+ name,
+ multiEntry: options.multiEntry,
+ unique: options.unique,
+ versionAdded: options.versionAdded,
+ };
+}
+
+interface IndexReadOnlyAccessor<RecordType> {
+ iter(query?: IDBKeyRange | IDBValidKey): ResultStream<RecordType>;
+ get(query: IDBValidKey): Promise<RecordType | undefined>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
+ getAllKeys(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<IDBValidKey[]>;
+ count(query?: IDBValidKey): Promise<number>;
+}
+
+type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
+ [P in keyof IndexMap]: IndexReadOnlyAccessor<RecordType>;
+};
+
+interface IndexReadWriteAccessor<RecordType> {
+ iter(query: IDBKeyRange | IDBValidKey): ResultStream<RecordType>;
+ get(query: IDBValidKey): Promise<RecordType | undefined>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
+ getAllKeys(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<IDBValidKey[]>;
+ count(query?: IDBValidKey): Promise<number>;
+}
+
+type GetIndexReadWriteAccess<RecordType, IndexMap> = {
+ [P in keyof IndexMap]: IndexReadWriteAccessor<RecordType>;
+};
+
+export interface StoreReadOnlyAccessor<RecordType, IndexMap> {
+ get(key: IDBValidKey): Promise<RecordType | undefined>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
+ iter(query?: IDBValidKey): ResultStream<RecordType>;
+ indexes: GetIndexReadOnlyAccess<RecordType, IndexMap>;
+}
+
+export interface InsertResponse {
+ /**
+ * Key of the newly inserted (via put/add) record.
+ */
+ key: IDBValidKey;
+}
+
+export interface StoreReadWriteAccessor<RecordType, IndexMap> {
+ get(key: IDBValidKey): Promise<RecordType | undefined>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
+ iter(query?: IDBValidKey): ResultStream<RecordType>;
+ put(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>;
+ add(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>;
+ delete(key: IDBValidKey): Promise<void>;
+ indexes: GetIndexReadWriteAccess<RecordType, IndexMap>;
+}
+
+export interface StoreWithIndexes<
+ StoreName extends string,
+ RecordType,
+ IndexMap,
+> {
+ storeName: StoreName;
+ store: StoreDescriptor<RecordType>;
+ indexMap: IndexMap;
+
+ /**
+ * Type marker symbol, to check that the descriptor
+ * has been created through the right function.
+ */
+ mark: Symbol;
+}
+
+const storeWithIndexesSymbol = Symbol("StoreWithIndexesMark");
+
+export function describeStore<StoreName extends string, RecordType, IndexMap>(
+ name: StoreName,
+ s: StoreDescriptor<RecordType>,
+ m: IndexMap,
+): StoreWithIndexes<StoreName, RecordType, IndexMap> {
+ return {
+ storeName: name,
+ store: s,
+ indexMap: m,
+ mark: storeWithIndexesSymbol,
+ };
+}
+
+export function describeStoreV2<
+ StoreName extends string,
+ RecordType,
+ IndexMap extends { [x: string]: IndexDescriptor } = {},
+>(args: {
+ storeName: StoreName;
+ recordCodec: Codec<RecordType>;
+ keyPath?: IDBKeyPath | IDBKeyPath[];
+ autoIncrement?: boolean;
+ /**
+ * Database version that this store was added in, or
+ * undefined if added in the first version.
+ */
+ versionAdded?: number;
+ indexes?: IndexMap;
+}): StoreWithIndexes<StoreName, RecordType, IndexMap> {
+ return {
+ storeName: args.storeName,
+ store: {
+ _dummy: undefined as any,
+ autoIncrement: args.autoIncrement,
+ keyPath: args.keyPath,
+ versionAdded: args.versionAdded,
+ },
+ indexMap: args.indexes ?? ({} as IndexMap),
+ mark: storeWithIndexesSymbol,
+ };
+}
+
+type KeyPathComponents = string | number;
+
+/**
+ * Follow a key path (dot-separated) in an object.
+ */
+type DerefKeyPath<T, P> = P extends `${infer PX extends keyof T &
+ KeyPathComponents}`
+ ? T[PX]
+ : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}`
+ ? DerefKeyPath<T[P0], Rest>
+ : unknown;
+
+/**
+ * Return a path if it is a valid dot-separate path to an object.
+ * Otherwise, return "never".
+ */
+type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T &
+ KeyPathComponents}`
+ ? PX
+ : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}`
+ ? `${P0}.${ValidateKeyPath<T[P0], Rest>}`
+ : never;
+
+// function foo<T, P>(
+// x: T,
+// p: P extends ValidateKeyPath<T, P> ? P : never,
+// ): void {}
+
+// foo({x: [0,1,2]}, "x.0");
+
+export type StoreNames<StoreMap> = StoreMap extends {
+ [P in keyof StoreMap]: StoreWithIndexes<infer SN1, infer SD1, infer IM1>;
+}
+ ? keyof StoreMap
+ : unknown;
+
+export type DbReadWriteTransaction<
+ StoreMap,
+ StoresArr extends Array<StoreNames<StoreMap>>,
+> = StoreMap extends {
+ [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>;
+}
+ ? {
+ [X in StoresArr[number] &
+ keyof StoreMap]: StoreMap[X] extends StoreWithIndexes<
+ infer _StoreName,
+ infer RecordType,
+ infer IndexMap
+ >
+ ? StoreReadWriteAccessor<RecordType, IndexMap>
+ : unknown;
+ }
+ : never;
+
+export type DbReadOnlyTransaction<
+ StoreMap,
+ StoresArr extends Array<StoreNames<StoreMap>>,
+> = StoreMap extends {
+ [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>;
+}
+ ? {
+ [X in StoresArr[number] &
+ keyof StoreMap]: StoreMap[X] extends StoreWithIndexes<
+ infer _StoreName,
+ infer RecordType,
+ infer IndexMap
+ >
+ ? StoreReadOnlyAccessor<RecordType, IndexMap>
+ : unknown;
+ }
+ : never;
+
+/**
+ * Convert the type of an array to a union of the contents.
+ *
+ * Example:
+ * Input ["foo", "bar"]
+ * Output "foo" | "bar"
+ */
+export type UnionFromArray<Arr> = Arr extends {
+ [X in keyof Arr]: Arr[X] & string;
+}
+ ? Arr[keyof Arr & number]
+ : unknown;
+
+function runTx<Arg, Res>(
+ tx: IDBTransaction,
+ arg: Arg,
+ f: (t: Arg, t2: IDBTransaction) => Promise<Res>,
+ triggerContext: InternalTriggerContext,
+): Promise<Res> {
+ const stack = Error("Failed transaction was started here.");
+ return new Promise((resolve, reject) => {
+ let funResult: any = undefined;
+ let gotFunResult = false;
+ let transactionException: any = undefined;
+ tx.oncomplete = () => {
+ // This is a fatal error: The transaction completed *before*
+ // the transaction function returned. Likely, the transaction
+ // function waited on a promise that is *not* resolved in the
+ // microtask queue, thus triggering the auto-commit behavior.
+ // Unfortunately, the auto-commit behavior of IDB can't be switched
+ // of. There are some proposals to add this functionality in the future.
+ if (!gotFunResult) {
+ const msg =
+ "BUG: transaction closed before transaction function returned";
+ logger.error(msg);
+ logger.error(`${stack.stack}`);
+ reject(Error(msg));
+ }
+ triggerContext.handleAfterCommit();
+ resolve(funResult);
+ };
+ tx.onerror = () => {
+ logger.error("error in transaction");
+ logger.error(`${stack.stack}`);
+ };
+ tx.onabort = () => {
+ let msg: string;
+ if (tx.error) {
+ msg = `Transaction aborted (transaction error): ${tx.error}`;
+ } else if (transactionException !== undefined) {
+ msg = `Transaction aborted (exception thrown): ${transactionException}`;
+ } else {
+ msg = "Transaction aborted (no DB error)";
+ }
+ logger.error(msg);
+ logger.error(`${stack.stack}`);
+ reject(new TransactionAbortedError(msg));
+ };
+ const resP = Promise.resolve().then(() => f(arg, tx));
+ resP
+ .then((result) => {
+ gotFunResult = true;
+ funResult = result;
+ })
+ .catch((e) => {
+ if (e == TransactionAbort) {
+ logger.trace("aborting transaction");
+ } else {
+ transactionException = e;
+ console.error("Transaction failed:", e);
+ console.error(stack);
+ tx.abort();
+ }
+ })
+ .catch((e) => {
+ console.error("fatal: aborting transaction failed", e);
+ });
+ });
+}
+
+function makeReadContext(
+ tx: IDBTransaction,
+ storePick: { [n: string]: StoreWithIndexes<any, any, any> },
+ triggerContext: InternalTriggerContext,
+): any {
+ const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {};
+ for (const storeAlias in storePick) {
+ const indexes: { [s: string]: IndexReadOnlyAccessor<any> } = {};
+ const swi = storePick[storeAlias];
+ const storeName = swi.storeName;
+ for (const indexAlias in storePick[storeAlias].indexMap) {
+ const indexDescriptor: IndexDescriptor =
+ storePick[storeAlias].indexMap[indexAlias];
+ const indexName = indexDescriptor.name;
+ indexes[indexAlias] = {
+ get(key) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).index(indexName).get(key);
+ return requestToPromise(req);
+ },
+ iter(query) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .openCursor(query);
+ return new ResultStream<any>(req);
+ },
+ getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAll(query, count);
+ return requestToPromise(req);
+ },
+ getAllKeys(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAllKeys(query, count);
+ return requestToPromise(req);
+ },
+ count(query) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).index(indexName).count(query);
+ return requestToPromise(req);
+ },
+ };
+ }
+ ctx[storeAlias] = {
+ indexes,
+ get(key) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).get(key);
+ return requestToPromise(req);
+ },
+ getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).getAll(query, count);
+ return requestToPromise(req);
+ },
+ iter(query) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).openCursor(query);
+ return new ResultStream<any>(req);
+ },
+ };
+ }
+ return ctx;
+}
+
+function makeWriteContext(
+ tx: IDBTransaction,
+ storePick: { [n: string]: StoreWithIndexes<any, any, any> },
+ triggerContext: InternalTriggerContext,
+): any {
+ const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {};
+ for (const storeAlias in storePick) {
+ const indexes: { [s: string]: IndexReadWriteAccessor<any> } = {};
+ const swi = storePick[storeAlias];
+ const storeName = swi.storeName;
+ for (const indexAlias in storePick[storeAlias].indexMap) {
+ const indexDescriptor: IndexDescriptor =
+ storePick[storeAlias].indexMap[indexAlias];
+ const indexName = indexDescriptor.name;
+ indexes[indexAlias] = {
+ get(key) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).index(indexName).get(key);
+ return requestToPromise(req);
+ },
+ iter(query) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .openCursor(query);
+ return new ResultStream<any>(req);
+ },
+ getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAll(query, count);
+ return requestToPromise(req);
+ },
+ getAllKeys(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAllKeys(query, count);
+ return requestToPromise(req);
+ },
+ count(query) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).index(indexName).count(query);
+ return requestToPromise(req);
+ },
+ };
+ }
+ ctx[storeAlias] = {
+ indexes,
+ get(key) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).get(key);
+ return requestToPromise(req);
+ },
+ getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).getAll(query, count);
+ return requestToPromise(req);
+ },
+ iter(query) {
+ triggerContext.storesAccessed.add(storeName);
+ const req = tx.objectStore(storeName).openCursor(query);
+ return new ResultStream<any>(req);
+ },
+ async add(r, k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
+ const req = tx.objectStore(storeName).add(r, k);
+ const key = await requestToPromise(req);
+ return {
+ key: key,
+ };
+ },
+ async put(r, k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
+ const req = tx.objectStore(storeName).put(r, k);
+ const key = await requestToPromise(req);
+ return {
+ key: key,
+ };
+ },
+ delete(k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
+ const req = tx.objectStore(storeName).delete(k);
+ return requestToPromise(req);
+ },
+ };
+ }
+ return ctx;
+}
+
+export interface DbAccess<StoreMap> {
+ idbHandle(): IDBDatabase;
+
+ runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T>;
+
+ runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T>;
+
+ runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T>;
+
+ runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T>;
+}
+
+export interface AfterCommitInfo {
+ mode: IDBTransactionMode;
+ scope: Set<string>;
+ accessedStores: Set<string>;
+ modifiedStores: Set<string>;
+}
+
+export interface TriggerSpec {
+ /**
+ * Trigger run after every successful commit, run outside of the transaction.
+ */
+ afterCommit?: (info: AfterCommitInfo) => void;
+
+ // onRead(store, value)
+ // initState<State> () => State
+ // beforeCommit<State>? (tx: Transaction, s: State | undefined) => Promise<void>;
+}
+
+class InternalTriggerContext {
+ storesScope: Set<string>;
+ storesAccessed: Set<string> = new Set();
+ storesModified: Set<string> = new Set();
+
+ constructor(
+ private triggerSpec: TriggerSpec,
+ private mode: IDBTransactionMode,
+ scope: string[],
+ ) {
+ this.storesScope = new Set(scope);
+ }
+
+ handleAfterCommit() {
+ if (this.triggerSpec.afterCommit) {
+ this.triggerSpec.afterCommit({
+ mode: this.mode,
+ accessedStores: this.storesAccessed,
+ modifiedStores: this.storesModified,
+ scope: this.storesScope,
+ });
+ }
+ }
+}
+
+/**
+ * Type-safe access to a database with a particular store map.
+ *
+ * A store map is the metadata that describes the store.
+ */
+export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> {
+ constructor(
+ private db: IDBDatabase,
+ private stores: StoreMap,
+ private triggers: TriggerSpec = {},
+ private cancellationToken: CancellationToken,
+ ) {}
+
+ idbHandle(): IDBDatabase {
+ return this.db;
+ }
+
+ runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of Object.keys(this.stores as any)) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
+ }
+ const mode = "readwrite";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeWriteContext(tx, accessibleStores, triggerContext);
+ return runTx(tx, writeContext, txf, triggerContext);
+ }
+
+ async runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of Object.keys(this.stores as any)) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
+ }
+ const mode = "readonly";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeReadContext(tx, accessibleStores, triggerContext);
+ const res = await runTx(tx, writeContext, txf, triggerContext);
+ return res;
+ }
+
+ async runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
+ },
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of opts.storeNames) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
+ }
+ const mode = "readwrite";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeWriteContext(tx, accessibleStores, triggerContext);
+ const res = await runTx(tx, writeContext, txf, triggerContext);
+ return res;
+ }
+
+ runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
+ },
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of opts.storeNames) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
+ }
+ const mode = "readonly";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const readContext = makeReadContext(tx, accessibleStores, triggerContext);
+ const res = runTx(tx, readContext, txf, triggerContext);
+ return res;
+ }
+}
diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts
new file mode 100644
index 000000000..6a09f9a0e
--- /dev/null
+++ b/packages/taler-wallet-core/src/recoup.ts
@@ -0,0 +1,555 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ 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/>
+ */
+
+/**
+ * Implementation of the recoup operation, which allows to recover the
+ * value of coins held in a revoked denomination.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Amounts,
+ CoinStatus,
+ Logger,
+ RefreshReason,
+ TalerPreciseTimestamp,
+ TransactionIdStr,
+ TransactionType,
+ URL,
+ checkDbInvariant,
+ codecForRecoupConfirmation,
+ codecForReserveStatus,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ RecoupGroupRecord,
+ RecoupOperationStatus,
+ RefreshCoinSource,
+ WalletDbReadWriteTransaction,
+ WithdrawCoinSource,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampPreciseToDb,
+} from "./db.js";
+import { createRefreshGroup } from "./refresh.js";
+import { constructTransactionIdentifier } from "./transactions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+import { internalCreateWithdrawalGroup } from "./withdraw.js";
+
+export const logger = new Logger("operations/recoup.ts");
+
+/**
+ * Store a recoup group record in the database after marking
+ * a coin in the group as finished.
+ */
+export async function putGroupAsFinished(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["recoupGroups", "denominations", "refreshGroups", "coins"]
+ >,
+ recoupGroup: RecoupGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ logger.trace(
+ `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
+ );
+ if (recoupGroup.timestampFinished) {
+ return;
+ }
+ recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
+ await tx.recoupGroups.put(recoupGroup);
+}
+
+async function recoupRewardCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+): Promise<void> {
+ // We can't really recoup a coin we got via tipping.
+ // Thus we just put the coin to sleep.
+ // FIXME: somehow report this to the user
+ await wex.db.runReadWriteTx(
+ { storeNames: ["recoupGroups", "denominations", "refreshGroups", "coins"] },
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+async function recoupRefreshCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: RefreshCoinSource,
+): Promise<void> {
+ const d = await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ return;
+ }
+ return { denomInfo };
+ },
+ );
+ if (!d) {
+ // FIXME: We should at least emit some pending operation / warning for this?
+ return;
+ }
+
+ const recoupRequest = await wex.cryptoApi.createRecoupRefreshRequest({
+ blindingKey: coin.blindingKey,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPub: d.denomInfo.denomPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ });
+ const reqUrl = new URL(
+ `/coins/${coin.coinPub}/recoup-refresh`,
+ coin.exchangeBaseUrl,
+ );
+ logger.trace(`making recoup request for ${coin.coinPub}`);
+
+ const resp = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: recoupRequest,
+ });
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
+ throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
+ }
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "denominations", "recoupGroups", "refreshGroups"] },
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const oldCoin = await tx.coins.get(cs.oldCoinPub);
+ const revokedCoin = await tx.coins.get(coin.coinPub);
+ if (!revokedCoin) {
+ logger.warn("revoked coin for recoup not found");
+ return;
+ }
+ if (!oldCoin) {
+ logger.warn("refresh old coin for recoup not found");
+ return;
+ }
+ const oldCoinDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ const revokedCoinDenom = await getDenomInfo(
+ wex,
+ tx,
+ revokedCoin.exchangeBaseUrl,
+ revokedCoin.denomPubHash,
+ );
+ checkDbInvariant(!!oldCoinDenom);
+ checkDbInvariant(!!revokedCoinDenom);
+ revokedCoin.status = CoinStatus.Dormant;
+ if (!revokedCoin.spendAllocation) {
+ // We don't know what happened to this coin
+ logger.error(
+ `can't refresh-recoup coin ${revokedCoin.coinPub}, no spendAllocation known`,
+ );
+ } else {
+ let residualAmount = Amounts.sub(
+ revokedCoinDenom.value,
+ revokedCoin.spendAllocation.amount,
+ ).amount;
+ recoupGroup.scheduleRefreshCoins.push({
+ coinPub: oldCoin.coinPub,
+ amount: Amounts.stringify(residualAmount),
+ });
+ }
+ await tx.coins.put(revokedCoin);
+ await tx.coins.put(oldCoin);
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+export async function recoupWithdrawCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: WithdrawCoinSource,
+): Promise<void> {
+ const reservePub = cs.reservePub;
+ const denomInfo = await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ return denomInfo;
+ },
+ );
+ if (!denomInfo) {
+ // FIXME: We should at least emit some pending operation / warning for this?
+ return;
+ }
+
+ const recoupRequest = await wex.cryptoApi.createRecoupRequest({
+ blindingKey: coin.blindingKey,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPub: denomInfo.denomPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ });
+ const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
+ logger.trace(`requesting recoup via ${reqUrl.href}`);
+ const resp = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: recoupRequest,
+ });
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`);
+
+ if (recoupConfirmation.reserve_pub !== reservePub) {
+ throw Error(`Coin's reserve doesn't match reserve on recoup`);
+ }
+
+ // FIXME: verify that our expectations about the amount match
+ await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "denominations", "recoupGroups", "refreshGroups"] },
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const updatedCoin = await tx.coins.get(coin.coinPub);
+ if (!updatedCoin) {
+ return;
+ }
+ updatedCoin.status = CoinStatus.Dormant;
+ await tx.coins.put(updatedCoin);
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+export async function processRecoupGroup(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+): Promise<TaskRunResult> {
+ let recoupGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["recoupGroups"] },
+ async (tx) => {
+ return tx.recoupGroups.get(recoupGroupId);
+ },
+ );
+ if (!recoupGroup) {
+ return TaskRunResult.finished();
+ }
+ if (recoupGroup.timestampFinished) {
+ logger.trace("recoup group finished");
+ return TaskRunResult.finished();
+ }
+ const ps = recoupGroup.coinPubs.map(async (x, i) => {
+ try {
+ await processRecoupForCoin(wex, recoupGroupId, i);
+ } catch (e) {
+ logger.warn(`processRecoup failed: ${e}`);
+ throw e;
+ }
+ });
+ await Promise.all(ps);
+
+ recoupGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["recoupGroups"] },
+ async (tx) => {
+ return tx.recoupGroups.get(recoupGroupId);
+ },
+ );
+ if (!recoupGroup) {
+ return TaskRunResult.finished();
+ }
+
+ for (const b of recoupGroup.recoupFinishedPerCoin) {
+ if (!b) {
+ return TaskRunResult.finished();
+ }
+ }
+
+ logger.info("all recoups of recoup group are finished");
+
+ const reserveSet = new Set<string>();
+ const reservePrivMap: Record<string, string> = {};
+ for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
+ const coinPub = recoupGroup.coinPubs[i];
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "reserves"] },
+ async (tx) => {
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request recoup`);
+ }
+ if (coin.coinSource.type === CoinSourceType.Withdraw) {
+ const reserve = await tx.reserves.indexes.byReservePub.get(
+ coin.coinSource.reservePub,
+ );
+ if (!reserve) {
+ return;
+ }
+ reserveSet.add(coin.coinSource.reservePub);
+ reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
+ }
+ },
+ );
+ }
+
+ for (const reservePub of reserveSet) {
+ const reserveUrl = new URL(
+ `reserves/${reservePub}`,
+ recoupGroup.exchangeBaseUrl,
+ );
+ logger.info(`querying reserve status for recoup via ${reserveUrl}`);
+
+ const resp = await wex.http.fetch(reserveUrl.href);
+
+ const result = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForReserveStatus(),
+ );
+ await internalCreateWithdrawalGroup(wex, {
+ amount: Amounts.parseOrThrow(result.balance),
+ exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ pub: reservePub,
+ priv: reservePrivMap[reservePub],
+ },
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.Recoup,
+ },
+ });
+ }
+
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "recoupGroups",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ const rg2 = await tx.recoupGroups.get(recoupGroupId);
+ if (!rg2) {
+ return;
+ }
+ rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ rg2.operationStatus = RecoupOperationStatus.Finished;
+ if (rg2.scheduleRefreshCoins.length > 0) {
+ await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
+ rg2.scheduleRefreshCoins,
+ RefreshReason.Recoup,
+ constructTransactionIdentifier({
+ tag: TransactionType.Recoup,
+ recoupGroupId: rg2.recoupGroupId,
+ }),
+ );
+ }
+ await tx.recoupGroups.put(rg2);
+ },
+ );
+ return TaskRunResult.finished();
+}
+
+export class RecoupTransactionContext implements TransactionContext {
+ abortTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ deleteTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ public transactionId: TransactionIdStr;
+ public taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ private recoupGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Recoup,
+ recoupGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId,
+ });
+ }
+}
+
+export async function createRecoupGroup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["recoupGroups", "denominations", "refreshGroups", "coins"]
+ >,
+ exchangeBaseUrl: string,
+ coinPubs: string[],
+): Promise<string> {
+ const recoupGroupId = encodeCrock(getRandomBytes(32));
+
+ const recoupGroup: RecoupGroupRecord = {
+ recoupGroupId,
+ exchangeBaseUrl: exchangeBaseUrl,
+ coinPubs: coinPubs,
+ timestampFinished: undefined,
+ timestampStarted: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ recoupFinishedPerCoin: coinPubs.map(() => false),
+ scheduleRefreshCoins: [],
+ operationStatus: RecoupOperationStatus.Pending,
+ };
+
+ for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
+ const coinPub = coinPubs[coinIdx];
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ continue;
+ }
+ await tx.coins.put(coin);
+ }
+
+ await tx.recoupGroups.put(recoupGroup);
+
+ const ctx = new RecoupTransactionContext(wex, recoupGroupId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return recoupGroupId;
+}
+
+/**
+ * Run the recoup protocol for a single coin in a recoup group.
+ */
+async function processRecoupForCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+): Promise<void> {
+ const coin = await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "recoupGroups"] },
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.timestampFinished) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+
+ const coinPub = recoupGroup.coinPubs[coinIdx];
+
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request recoup`);
+ }
+ return coin;
+ },
+ );
+
+ if (!coin) {
+ return;
+ }
+
+ const cs = coin.coinSource;
+
+ switch (cs.type) {
+ case CoinSourceType.Reward:
+ return recoupRewardCoin(wex, recoupGroupId, coinIdx, coin);
+ case CoinSourceType.Refresh:
+ return recoupRefreshCoin(wex, recoupGroupId, coinIdx, coin, cs);
+ case CoinSourceType.Withdraw:
+ return recoupWithdrawCoin(wex, recoupGroupId, coinIdx, coin, cs);
+ default:
+ throw Error("unknown coin source type");
+ }
+}
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
new file mode 100644
index 000000000..7800967e6
--- /dev/null
+++ b/packages/taler-wallet-core/src/refresh.ts
@@ -0,0 +1,1883 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2024 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of the refresh transaction.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AgeCommitment,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ amountToPretty,
+ assertUnreachable,
+ AsyncFlag,
+ checkDbInvariant,
+ codecForCoinHistoryResponse,
+ codecForExchangeMeltResponse,
+ codecForExchangeRevealResponse,
+ CoinPublicKeyString,
+ CoinRefreshRequest,
+ CoinStatus,
+ DenominationInfo,
+ DenomKeyType,
+ Duration,
+ encodeCrock,
+ ExchangeMeltRequest,
+ ExchangeProtocolVersion,
+ ExchangeRefreshRevealRequest,
+ fnutil,
+ ForceRefreshRequest,
+ getErrorDetailFromException,
+ getRandomBytes,
+ HashCodeString,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ NotificationType,
+ RefreshReason,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import {
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import {
+ constructTaskIdentifier,
+ makeCoinsVisible,
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResult,
+ TransitionResultType,
+} from "./common.js";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
+import {
+ DerivedRefreshSession,
+ RefreshNewDenomInfo,
+} from "./crypto/cryptoTypes.js";
+import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js";
+import {
+ CoinAvailabilityRecord,
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ RefreshCoinStatus,
+ RefreshGroupPerExchangeInfo,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ RefreshSessionRecord,
+ timestampPreciseToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletDbStoresArr,
+} from "./db.js";
+import { selectWithdrawalDenominations } from "./denomSelection.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ TransitionInfo,
+} from "./transactions.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ getDenomInfo,
+ WalletExecutionContext,
+} from "./wallet.js";
+import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
+
+const logger = new Logger("refresh.ts");
+
+/**
+ * Update the materialized refresh transaction based
+ * on the refresh group record.
+ */
+async function updateRefreshTransaction(
+ ctx: RefreshTransactionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "refreshGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+): Promise<void> {}
+
+export class RefreshTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public refreshGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId,
+ });
+ }
+
+ /**
+ * Transition a withdrawal transaction.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transition<StoreNameArray extends WalletDbStoresArr = []>(
+ opts: { extraStores?: StoreNameArray; transactionLabel?: string },
+ f: (
+ rec: RefreshGroupRecord | undefined,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "refreshGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ...StoreNameArray,
+ ]
+ >,
+ ) => Promise<TransitionResult<RefreshGroupRecord>>,
+ ): Promise<TransitionInfo | undefined> {
+ const baseStores = [
+ "refreshGroups" as const,
+ "transactions" as const,
+ "operationRetries" as const,
+ "exchanges" as const,
+ "exchangeDetails" as const,
+ ];
+ let stores = opts.extraStores
+ ? [...baseStores, ...opts.extraStores]
+ : baseStores;
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ { storeNames: stores },
+ async (tx) => {
+ const wgRec = await tx.refreshGroups.get(this.refreshGroupId);
+ let oldTxState: TransactionState;
+ if (wgRec) {
+ oldTxState = computeRefreshTransactionState(wgRec);
+ } else {
+ oldTxState = {
+ major: TransactionMajorState.None,
+ };
+ }
+ const res = await f(wgRec, tx);
+ switch (res.type) {
+ case TransitionResultType.Transition: {
+ await tx.refreshGroups.put(res.rec);
+ await updateRefreshTransaction(this, tx);
+ const newTxState = computeRefreshTransactionState(res.rec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ case TransitionResultType.Delete:
+ await tx.refreshGroups.delete(this.refreshGroupId);
+ await updateRefreshTransaction(this, tx);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.None,
+ },
+ };
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ return transitionInfo;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ await this.transition(
+ {
+ extraStores: ["tombstones"],
+ },
+ async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteRefreshGroup + ":" + this.refreshGroupId,
+ });
+ return TransitionResult.delete();
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Suspended:
+ case RefreshOperationStatus.Failed:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Pending: {
+ rec.operationStatus = RefreshOperationStatus.Suspended;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+
+ async abortTransaction(): Promise<void> {
+ // Refresh transactions only support fail, not abort.
+ throw new Error("refresh transactions cannot be aborted");
+ }
+
+ async resumeTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Failed:
+ case RefreshOperationStatus.Pending:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Suspended: {
+ rec.operationStatus = RefreshOperationStatus.Pending;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+
+ async failTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Failed:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended: {
+ rec.operationStatus = RefreshOperationStatus.Failed;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+}
+
+export async function getTotalRefreshCost(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ refreshedDenom: DenominationInfo,
+ amountLeft: AmountJson,
+): Promise<AmountJson> {
+ const cacheKey = `denom=${refreshedDenom.exchangeBaseUrl}/${
+ refreshedDenom.denomPubHash
+ };left=${Amounts.stringify(amountLeft)}`;
+ const cacheRes = wex.ws.refreshCostCache.get(cacheKey);
+ if (cacheRes) {
+ return cacheRes;
+ }
+ const allDenoms = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ refreshedDenom.exchangeBaseUrl,
+ Amounts.currencyOf(amountLeft),
+ );
+ const res = getTotalRefreshCostInternal(
+ allDenoms,
+ refreshedDenom,
+ amountLeft,
+ );
+ wex.ws.refreshCostCache.put(cacheKey, res);
+ return res;
+}
+
+/**
+ * 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 getTotalRefreshCostInternal(
+ denoms: DenominationRecord[],
+ refreshedDenom: DenominationInfo,
+ amountLeft: AmountJson,
+): AmountJson {
+ const withdrawAmount = Amounts.sub(
+ amountLeft,
+ refreshedDenom.feeRefresh,
+ ).amount;
+ const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
+ const withdrawDenoms = selectWithdrawalDenominations(
+ withdrawAmount,
+ denoms,
+ false,
+ );
+ const resultingAmount = Amounts.add(
+ Amounts.zeroOfCurrency(withdrawAmount.currency),
+ ...withdrawDenoms.selectedDenoms.map(
+ (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
+ ),
+ ).amount;
+ const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
+ logger.trace(
+ `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
+ totalCost,
+ )}`,
+ );
+ return totalCost;
+}
+
+async function getCoinAvailabilityForDenom(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coins", "coinAvailability", "denominations"]
+ >,
+ denom: DenominationInfo,
+ ageRestriction: number,
+): Promise<CoinAvailabilityRecord> {
+ checkDbInvariant(!!denom);
+ let car = await tx.coinAvailability.get([
+ denom.exchangeBaseUrl,
+ denom.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ car = {
+ maxAge: ageRestriction,
+ value: denom.value,
+ currency: Amounts.currencyOf(denom.value),
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ freshCoinCount: 0,
+ visibleCoinCount: 0,
+ };
+ }
+ return car;
+}
+
+/**
+ * Create a refresh session for one particular coin inside a refresh group.
+ */
+async function initRefreshSession(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["refreshSessions", "coinAvailability", "coins", "denominations"]
+ >,
+ refreshGroup: RefreshGroupRecord,
+ coinIndex: number,
+): Promise<void> {
+ const refreshGroupId = refreshGroup.refreshGroupId;
+ logger.trace(
+ `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
+ );
+ const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
+ const oldCoin = await tx.coins.get(oldCoinPub);
+ if (!oldCoin) {
+ throw Error("Can't refresh, coin not found");
+ }
+
+ const exchangeBaseUrl = oldCoin.exchangeBaseUrl;
+
+ const sessionSecretSeed = encodeCrock(getRandomBytes(64));
+
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+
+ if (!oldDenom) {
+ throw Error("db inconsistent: denomination for coin not found");
+ }
+
+ const currency = refreshGroup.currency;
+
+ const availableDenoms = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+
+ const availableAmount = Amounts.sub(
+ refreshGroup.inputPerCoin[coinIndex],
+ oldDenom.feeRefresh,
+ ).amount;
+
+ const newCoinDenoms = selectWithdrawalDenominations(
+ availableAmount,
+ availableDenoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+
+ if (newCoinDenoms.selectedDenoms.length === 0) {
+ logger.trace(
+ `not refreshing, available amount ${amountToPretty(
+ availableAmount,
+ )} too small`,
+ );
+ refreshGroup.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ return;
+ }
+
+ for (let i = 0; i < newCoinDenoms.selectedDenoms.length; i++) {
+ const dph = newCoinDenoms.selectedDenoms[i].denomPubHash;
+ const denom = await getDenomInfo(wex, tx, oldDenom.exchangeBaseUrl, dph);
+ if (!denom) {
+ logger.error(`denom ${dph} not in DB`);
+ continue;
+ }
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denom,
+ oldCoin.maxAge,
+ );
+ car.pendingRefreshOutputCount =
+ (car.pendingRefreshOutputCount ?? 0) +
+ newCoinDenoms.selectedDenoms[i].count;
+ await tx.coinAvailability.put(car);
+ }
+
+ const newSession: RefreshSessionRecord = {
+ coinIndex,
+ refreshGroupId,
+ norevealIndex: undefined,
+ sessionSecretSeed: sessionSecretSeed,
+ newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
+ count: x.count,
+ denomPubHash: x.denomPubHash,
+ })),
+ amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue),
+ };
+ await tx.refreshSessions.put(newSession);
+}
+
+/**
+ * Uninitialize a refresh session.
+ *
+ * Adjust the coin availability of involved coins.
+ */
+async function destroyRefreshSession(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coinAvailability", "coins"]
+ >,
+ refreshGroup: RefreshGroupRecord,
+ refreshSession: RefreshSessionRecord,
+): Promise<void> {
+ for (let i = 0; i < refreshSession.newDenoms.length; i++) {
+ const oldCoin = await tx.coins.get(
+ refreshGroup.oldCoinPubs[refreshSession.coinIndex],
+ );
+ if (!oldCoin) {
+ continue;
+ }
+ const dph = refreshSession.newDenoms[i].denomPubHash;
+ const denom = await getDenomInfo(wex, tx, oldCoin.exchangeBaseUrl, dph);
+ if (!denom) {
+ logger.error(`denom ${dph} not in DB`);
+ continue;
+ }
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denom,
+ oldCoin.maxAge,
+ );
+ checkDbInvariant(car.pendingRefreshOutputCount != null);
+ car.pendingRefreshOutputCount =
+ car.pendingRefreshOutputCount - refreshSession.newDenoms[i].count;
+ await tx.coinAvailability.put(car);
+ }
+}
+
+function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
+ return Duration.fromSpec({
+ seconds: 5,
+ });
+}
+
+/**
+ * Run the melt step of a refresh session.
+ *
+ * If the melt step succeeds or fails permanently,
+ * the status in the refresh group is updated.
+ *
+ * When a transient error occurs, an exception is thrown.
+ */
+async function refreshMelt(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ const d = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ ],
+ },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ return;
+ }
+ if (refreshSession.norevealIndex !== undefined) {
+ return;
+ }
+
+ const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
+ checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!oldDenom,
+ "denomination for melted coin doesn't exist",
+ );
+
+ const newCoinDenoms: RefreshNewDenomInfo[] = [];
+
+ for (const dh of refreshSession.newDenoms) {
+ const newDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ dh.denomPubHash,
+ );
+ checkDbInvariant(
+ !!newDenom,
+ "new denomination for refresh not in database",
+ );
+ newCoinDenoms.push({
+ count: dh.count,
+ denomPub: newDenom.denomPub,
+ denomPubHash: newDenom.denomPubHash,
+ feeWithdraw: newDenom.feeWithdraw,
+ value: Amounts.stringify(newDenom.value),
+ });
+ }
+ return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
+
+ let exchangeProtocolVersion: ExchangeProtocolVersion;
+ switch (d.oldDenom.denomPub.cipher) {
+ case DenomKeyType.Rsa: {
+ exchangeProtocolVersion = ExchangeProtocolVersion.V12;
+ break;
+ }
+ default:
+ throw Error("unsupported key type");
+ }
+
+ const derived = await wex.cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion,
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ newCoinDenoms,
+ sessionSecretSeed: refreshSession.sessionSecretSeed,
+ });
+
+ const reqUrl = new URL(
+ `coins/${oldCoin.coinPub}/melt`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ let maybeAch: HashCodeString | undefined;
+ if (oldCoin.ageCommitmentProof) {
+ maybeAch = AgeRestriction.hashCommitment(
+ oldCoin.ageCommitmentProof.commitment,
+ );
+ }
+
+ const meltReqBody: ExchangeMeltRequest = {
+ coin_pub: oldCoin.coinPub,
+ confirm_sig: derived.confirmSig,
+ denom_pub_hash: oldCoin.denomPubHash,
+ denom_sig: oldCoin.denomSig,
+ rc: derived.hash,
+ value_with_fee: Amounts.stringify(derived.meltValueWithFee),
+ age_commitment_hash: maybeAch,
+ };
+
+ const resp = await wex.ws.runSequentialized(
+ [EXCHANGE_COINS_LOCK],
+ async () => {
+ return await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: meltReqBody,
+ timeout: getRefreshRequestTimeout(refreshGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ },
+ );
+
+ switch (resp.status) {
+ case HttpStatusCode.NotFound: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltNotFound(ctx, coinIndex, errDetail);
+ return;
+ }
+ case HttpStatusCode.Gone: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltGone(ctx, coinIndex, errDetail);
+ return;
+ }
+ case HttpStatusCode.Conflict: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltConflict(
+ ctx,
+ coinIndex,
+ errDetail,
+ derived,
+ oldCoin,
+ );
+ return;
+ }
+ case HttpStatusCode.Ok:
+ break;
+ default: {
+ const errDetail = await readTalerErrorResponse(resp);
+ throwUnexpectedRequestError(resp, errDetail);
+ }
+ }
+
+ const meltResponse = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeMeltResponse(),
+ );
+
+ const norevealIndex = meltResponse.noreveal_index;
+
+ refreshSession.norevealIndex = norevealIndex;
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refreshGroups", "refreshSessions"] },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ if (!rs) {
+ return;
+ }
+ if (rs.norevealIndex !== undefined) {
+ return;
+ }
+ rs.norevealIndex = norevealIndex;
+ await tx.refreshSessions.put(rs);
+ },
+ );
+}
+
+async function handleRefreshMeltGone(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ // const expiredMsg = codecForDenominationExpiredMessage().decode(errDetails);
+
+ // FIXME: Validate signature.
+
+ await ctx.wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ refreshSession.lastError = errDetails;
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+async function handleRefreshMeltConflict(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+ derived: DerivedRefreshSession,
+ oldCoin: CoinRecord,
+): Promise<void> {
+ // Just log for better diagnostics here, error status
+ // will be handled later.
+ logger.error(
+ `melt request for ${Amounts.stringify(
+ derived.meltValueWithFee,
+ )} failed in refresh group ${ctx.refreshGroupId} due to conflict`,
+ );
+
+ const historySig = await ctx.wex.cryptoApi.signCoinHistoryRequest({
+ coinPriv: oldCoin.coinPriv,
+ coinPub: oldCoin.coinPub,
+ startOffset: 0,
+ });
+
+ const historyUrl = new URL(
+ `coins/${oldCoin.coinPub}/history`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const historyResp = await ctx.wex.http.fetch(historyUrl.href, {
+ method: "GET",
+ headers: {
+ "Taler-Coin-History-Signature": historySig.sig,
+ },
+ cancellationToken: ctx.wex.cancellationToken,
+ });
+
+ const historyJson = await readSuccessResponseJsonOrThrow(
+ historyResp,
+ codecForCoinHistoryResponse(),
+ );
+ logger.info(`coin history: ${j2s(historyJson)}`);
+
+ // FIXME: If response seems wrong, report to auditor (in the future!);
+
+ await ctx.wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ if (Amounts.isZero(historyJson.balance)) {
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error(
+ "db invariant failed: missing refresh session in database",
+ );
+ }
+ refreshSession.lastError = errDetails;
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ } else {
+ // Try again with new denoms!
+ rg.inputPerCoin[coinIndex] = historyJson.balance;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error(
+ "db invariant failed: missing refresh session in database",
+ );
+ }
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshSessions.delete([ctx.refreshGroupId, coinIndex]);
+ await initRefreshSession(ctx.wex, tx, rg, coinIndex);
+ }
+ },
+ );
+}
+
+async function handleRefreshMeltNotFound(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ // FIXME: Validate the exchange's error response
+ await ctx.wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ refreshSession.lastError = errDetails;
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+export async function assembleRefreshRevealRequest(args: {
+ cryptoApi: TalerCryptoInterface;
+ derived: DerivedRefreshSession;
+ norevealIndex: number;
+ oldCoinPub: CoinPublicKeyString;
+ oldCoinPriv: string;
+ newDenoms: {
+ denomPubHash: string;
+ count: number;
+ }[];
+ oldAgeCommitment?: AgeCommitment;
+}): Promise<ExchangeRefreshRevealRequest> {
+ const {
+ derived,
+ norevealIndex,
+ cryptoApi,
+ oldCoinPriv,
+ oldCoinPub,
+ newDenoms,
+ } = args;
+ const privs = Array.from(derived.transferPrivs);
+ privs.splice(norevealIndex, 1);
+
+ const planchets = derived.planchetsForGammas[norevealIndex];
+ if (!planchets) {
+ throw Error("refresh index error");
+ }
+
+ const newDenomsFlat: string[] = [];
+ const linkSigs: string[] = [];
+
+ for (let i = 0; i < newDenoms.length; i++) {
+ const dsel = newDenoms[i];
+ for (let j = 0; j < dsel.count; j++) {
+ const newCoinIndex = linkSigs.length;
+ const linkSig = await cryptoApi.signCoinLink({
+ coinEv: planchets[newCoinIndex].coinEv,
+ newDenomHash: dsel.denomPubHash,
+ oldCoinPriv: oldCoinPriv,
+ oldCoinPub: oldCoinPub,
+ transferPub: derived.transferPubs[norevealIndex],
+ });
+ linkSigs.push(linkSig.sig);
+ newDenomsFlat.push(dsel.denomPubHash);
+ }
+ }
+
+ const req: ExchangeRefreshRevealRequest = {
+ coin_evs: planchets.map((x) => x.coinEv),
+ new_denoms_h: newDenomsFlat,
+ transfer_privs: privs,
+ transfer_pub: derived.transferPubs[norevealIndex],
+ link_sigs: linkSigs,
+ old_age_commitment: args.oldAgeCommitment?.publicKeys,
+ };
+ return req;
+}
+
+async function refreshReveal(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`,
+ );
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ const d = await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ ],
+ },
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ return;
+ }
+ const norevealIndex = refreshSession.norevealIndex;
+ if (norevealIndex === undefined) {
+ throw Error("can't reveal without melting first");
+ }
+
+ const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
+ checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!oldDenom,
+ "denomination for melted coin doesn't exist",
+ );
+
+ const newCoinDenoms: RefreshNewDenomInfo[] = [];
+
+ for (const dh of refreshSession.newDenoms) {
+ const newDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ dh.denomPubHash,
+ );
+ checkDbInvariant(
+ !!newDenom,
+ "new denomination for refresh not in database",
+ );
+ newCoinDenoms.push({
+ count: dh.count,
+ denomPub: newDenom.denomPub,
+ denomPubHash: newDenom.denomPubHash,
+ feeWithdraw: newDenom.feeWithdraw,
+ value: Amounts.stringify(newDenom.value),
+ });
+ }
+ return {
+ oldCoin,
+ oldDenom,
+ newCoinDenoms,
+ refreshSession,
+ refreshGroup,
+ norevealIndex,
+ };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const {
+ oldCoin,
+ oldDenom,
+ newCoinDenoms,
+ refreshSession,
+ refreshGroup,
+ norevealIndex,
+ } = d;
+
+ let exchangeProtocolVersion: ExchangeProtocolVersion;
+ switch (d.oldDenom.denomPub.cipher) {
+ case DenomKeyType.Rsa: {
+ exchangeProtocolVersion = ExchangeProtocolVersion.V12;
+ break;
+ }
+ default:
+ throw Error("unsupported key type");
+ }
+
+ const derived = await wex.cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion,
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ newCoinDenoms,
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ sessionSecretSeed: refreshSession.sessionSecretSeed,
+ });
+
+ const reqUrl = new URL(
+ `refreshes/${derived.hash}/reveal`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const req = await assembleRefreshRevealRequest({
+ cryptoApi: wex.cryptoApi,
+ derived,
+ newDenoms: newCoinDenoms,
+ norevealIndex: norevealIndex,
+ oldCoinPriv: oldCoin.coinPriv,
+ oldCoinPub: oldCoin.coinPub,
+ oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment,
+ });
+
+ const resp = await wex.ws.runSequentialized(
+ [EXCHANGE_COINS_LOCK],
+ async () => {
+ return await wex.http.fetch(reqUrl.href, {
+ body: req,
+ method: "POST",
+ timeout: getRefreshRequestTimeout(refreshGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ },
+ );
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ break;
+ case HttpStatusCode.Conflict:
+ case HttpStatusCode.Gone: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshRevealError(ctx, coinIndex, errDetail);
+ return;
+ }
+ default: {
+ const errDetail = await readTalerErrorResponse(resp);
+ throwUnexpectedRequestError(resp, errDetail);
+ }
+ }
+
+ const reveal = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeRevealResponse(),
+ );
+
+ const coins: CoinRecord[] = [];
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+
+ for (let i = 0; i < refreshSession.newDenoms.length; i++) {
+ const ncd = newCoinDenoms[i];
+ for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
+ const newCoinIndex = coins.length;
+ const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
+ if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error("cipher unsupported");
+ }
+ const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
+ const denomSig = await wex.cryptoApi.unblindDenominationSignature({
+ planchet: {
+ blindingKey: pc.blindingKey,
+ denomPub: ncd.denomPub,
+ },
+ evSig,
+ });
+ const coin: CoinRecord = {
+ blindingKey: pc.blindingKey,
+ coinPriv: pc.coinPriv,
+ coinPub: pc.coinPub,
+ denomPubHash: ncd.denomPubHash,
+ denomSig,
+ exchangeBaseUrl: oldCoin.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Refresh,
+ refreshGroupId,
+ oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
+ },
+ sourceTransactionId: transactionId,
+ coinEvHash: pc.coinEvHash,
+ maxAge: pc.maxAge,
+ ageCommitmentProof: pc.ageCommitmentProof,
+ spendAllocation: undefined,
+ };
+
+ coins.push(coin);
+ }
+ }
+
+ await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ logger.warn("no refresh session found");
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ if (!rs) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ for (const coin of coins) {
+ const existingCoin = await tx.coins.get(coin.coinPub);
+ if (existingCoin) {
+ continue;
+ }
+ await tx.coins.add(coin);
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denomInfo);
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denomInfo,
+ coin.maxAge,
+ );
+ checkDbInvariant(
+ car.pendingRefreshOutputCount != null &&
+ car.pendingRefreshOutputCount > 0,
+ );
+ car.pendingRefreshOutputCount--;
+ car.freshCoinCount++;
+ await tx.coinAvailability.put(car);
+ }
+ await tx.refreshGroups.put(rg);
+ },
+ );
+ logger.trace("refresh finished (end of reveal)");
+}
+
+async function handleRefreshRevealError(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ await ctx.wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ refreshSession.lastError = errDetails;
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+export async function processRefreshGroup(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+): Promise<TaskRunResult> {
+ logger.trace(`processing refresh group ${refreshGroupId}`);
+
+ const refreshGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["refreshGroups"] },
+ async (tx) => tx.refreshGroups.get(refreshGroupId),
+ );
+ if (!refreshGroup) {
+ return TaskRunResult.finished();
+ }
+ if (refreshGroup.timestampFinished) {
+ return TaskRunResult.finished();
+ }
+
+ if (
+ wex.ws.config.testing.devModeActive &&
+ wex.ws.devExperimentState.blockRefreshes
+ ) {
+ throw Error("refresh blocked");
+ }
+
+ // Process refresh sessions of the group in parallel.
+ logger.trace(
+ `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`,
+ );
+ let errors: TalerErrorDetail[] = [];
+ let inShutdown = false;
+ const ps = refreshGroup.oldCoinPubs.map((x, i) =>
+ processRefreshSession(wex, refreshGroupId, i).catch((x) => {
+ if (x instanceof CryptoApiStoppedError) {
+ inShutdown = true;
+ logger.info(
+ "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
+ );
+ return;
+ }
+ if (x instanceof TalerError) {
+ logger.warn("process refresh session got exception (TalerError)");
+ logger.warn(`exc ${x}`);
+ logger.warn(`exc stack ${x.stack}`);
+ logger.warn(`error detail: ${j2s(x.errorDetail)}`);
+ } else {
+ logger.warn("process refresh session got exception");
+ logger.warn(`exc ${x}`);
+ logger.warn(`exc stack ${x.stack}`);
+ }
+ errors.push(getErrorDetailFromException(x));
+ }),
+ );
+ await Promise.all(ps);
+ if (inShutdown) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+
+ // We've processed all refresh session and can now update the
+ // status of the whole refresh group.
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "coinAvailability", "refreshGroups"] },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Pending:
+ break;
+ default:
+ return undefined;
+ }
+ const oldTxState = computeRefreshTransactionState(rg);
+ const allFinal = fnutil.all(
+ rg.statusPerCoin,
+ (x) =>
+ x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
+ );
+ const anyFailed = fnutil.any(
+ rg.statusPerCoin,
+ (x) => x === RefreshCoinStatus.Failed,
+ );
+ if (allFinal) {
+ if (anyFailed) {
+ rg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ rg.operationStatus = RefreshOperationStatus.Failed;
+ } else {
+ rg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ rg.operationStatus = RefreshOperationStatus.Finished;
+ }
+ await makeCoinsVisible(wex, tx, ctx.transactionId);
+ await tx.refreshGroups.put(rg);
+ const newTxState = computeRefreshTransactionState(rg);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+
+ if (transitionInfo) {
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+
+ if (errors.length > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE,
+ {
+ numErrors: errors.length,
+ errors: errors.slice(0, 5),
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processRefreshSession(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
+ );
+ let { refreshGroup, refreshSession } = await wex.db.runReadOnlyTx(
+ { storeNames: ["refreshGroups", "refreshSessions"] },
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ return {
+ refreshGroup: rg,
+ refreshSession: rs,
+ };
+ },
+ );
+ if (!refreshGroup) {
+ return;
+ }
+ if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
+ return;
+ }
+ if (!refreshSession) {
+ // No refresh session for that coin.
+ return;
+ }
+ if (refreshSession.norevealIndex === undefined) {
+ await refreshMelt(wex, refreshGroupId, coinIndex);
+ }
+ await refreshReveal(wex, refreshGroupId, coinIndex);
+}
+
+export interface RefreshOutputInfo {
+ outputPerCoin: AmountJson[];
+ perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>;
+}
+
+export async function calculateRefreshOutput(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+): Promise<RefreshOutputInfo> {
+ const estimatedOutputPerCoin: AmountJson[] = [];
+
+ const denomsPerExchange: Record<string, DenominationRecord[]> = {};
+
+ const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {};
+
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
+ const refreshAmount = ocp.amount;
+ const cost = await getTotalRefreshCost(
+ wex,
+ tx,
+ denom,
+ Amounts.parseOrThrow(refreshAmount),
+ );
+ const output = Amounts.sub(refreshAmount, cost).amount;
+ let exchInfo = infoPerExchange[coin.exchangeBaseUrl];
+ if (!exchInfo) {
+ infoPerExchange[coin.exchangeBaseUrl] = exchInfo = {
+ outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)),
+ };
+ }
+ exchInfo.outputEffective = Amounts.stringify(
+ Amounts.add(exchInfo.outputEffective, output).amount,
+ );
+ estimatedOutputPerCoin.push(output);
+ }
+
+ return {
+ outputPerCoin: estimatedOutputPerCoin,
+ perExchangeInfo: infoPerExchange,
+ };
+}
+
+async function applyRefreshToOldCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshGroupId: string,
+): Promise<void> {
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
+ switch (coin.status) {
+ case CoinStatus.Dormant:
+ break;
+ case CoinStatus.Fresh: {
+ coin.status = CoinStatus.Dormant;
+ const coinAv = await tx.coinAvailability.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ coin.maxAge,
+ ]);
+ checkDbInvariant(!!coinAv);
+ checkDbInvariant(coinAv.freshCoinCount > 0);
+ coinAv.freshCoinCount--;
+ await tx.coinAvailability.put(coinAv);
+ break;
+ }
+ case CoinStatus.FreshSuspended: {
+ // For suspended coins, we don't have to adjust coin
+ // availability, as they are not counted as available.
+ coin.status = CoinStatus.Dormant;
+ break;
+ }
+ case CoinStatus.DenomLoss:
+ break;
+ default:
+ assertUnreachable(coin.status);
+ }
+ if (!coin.spendAllocation) {
+ coin.spendAllocation = {
+ amount: Amounts.stringify(ocp.amount),
+ // id: `txn:refresh:${refreshGroupId}`,
+ id: constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ }),
+ };
+ }
+ await tx.coins.put(coin);
+ }
+}
+
+export interface CreateRefreshGroupResult {
+ refreshGroupId: string;
+ notifications: WalletNotification[];
+}
+
+/**
+ * Create a refresh group for a list of coins.
+ *
+ * Refreshes the remaining amount on the coin, effectively capturing the remaining
+ * value in the refresh group.
+ *
+ * The caller must also ensure that the coins that should be refreshed exist
+ * in the current database transaction.
+ */
+export async function createRefreshGroup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "coinAvailability",
+ ]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshReason: RefreshReason,
+ originatingTransactionId: string | undefined,
+): Promise<CreateRefreshGroupResult> {
+ // FIXME: Check that involved exchanges are reasonably up-to-date.
+ // Otherwise, error out.
+
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+
+ const outInfo = await calculateRefreshOutput(wex, tx, currency, oldCoinPubs);
+
+ const estimatedOutputPerCoin = outInfo.outputPerCoin;
+
+ await applyRefreshToOldCoins(wex, tx, oldCoinPubs, refreshGroupId);
+
+ const refreshGroup: RefreshGroupRecord = {
+ operationStatus: RefreshOperationStatus.Pending,
+ currency,
+ timestampFinished: undefined,
+ statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
+ oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
+ originatingTransactionId,
+ reason: refreshReason,
+ refreshGroupId,
+ inputPerCoin: oldCoinPubs.map((x) => x.amount),
+ expectedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
+ Amounts.stringify(x),
+ ),
+ infoPerExchange: outInfo.perExchangeInfo,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+
+ if (oldCoinPubs.length == 0) {
+ logger.warn("created refresh group with zero coins");
+ refreshGroup.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ refreshGroup.operationStatus = RefreshOperationStatus.Finished;
+ }
+
+ for (let i = 0; i < oldCoinPubs.length; i++) {
+ await initRefreshSession(wex, tx, refreshGroup, i);
+ }
+
+ await tx.refreshGroups.put(refreshGroup);
+
+ const newTxState = computeRefreshTransactionState(refreshGroup);
+
+ logger.trace(`created refresh group ${refreshGroupId}`);
+
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+
+ // Shepherd the task.
+ // If the current transaction fails to commit the refresh
+ // group to the DB, the shepherd will give up.
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ refreshGroupId,
+ notifications: [
+ {
+ type: NotificationType.TransactionStateTransition,
+ transactionId: ctx.transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ },
+ ],
+ };
+}
+
+export function computeRefreshTransactionState(
+ rg: RefreshGroupRecord,
+): TransactionState {
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RefreshOperationStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case RefreshOperationStatus.Pending:
+ return {
+ major: TransactionMajorState.Pending,
+ };
+ case RefreshOperationStatus.Suspended:
+ return {
+ major: TransactionMajorState.Suspended,
+ };
+ }
+}
+
+export function computeRefreshTransactionActions(
+ rg: RefreshGroupRecord,
+): TransactionAction[] {
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return [TransactionAction.Delete];
+ case RefreshOperationStatus.Failed:
+ return [TransactionAction.Delete];
+ case RefreshOperationStatus.Pending:
+ return [
+ TransactionAction.Retry,
+ TransactionAction.Suspend,
+ TransactionAction.Fail,
+ ];
+ case RefreshOperationStatus.Suspended:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
+
+export function getRefreshesForTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<string[]> {
+ return wex.db.runReadOnlyTx({ storeNames: ["refreshGroups"] }, async (tx) => {
+ const groups =
+ await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll(
+ transactionId,
+ );
+ return groups.map((x) =>
+ constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: x.refreshGroupId,
+ }),
+ );
+ });
+}
+
+export interface ForceRefreshResult {
+ refreshGroupId: string;
+}
+
+export async function forceRefresh(
+ wex: WalletExecutionContext,
+ req: ForceRefreshRequest,
+): Promise<ForceRefreshResult> {
+ if (req.refreshCoinSpecs.length == 0) {
+ throw Error("refusing to create empty refresh group");
+ }
+ const res = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "refreshGroups",
+ "coinAvailability",
+ "refreshSessions",
+ "denominations",
+ "coins",
+ ],
+ },
+ async (tx) => {
+ let coinPubs: CoinRefreshRequest[] = [];
+ for (const c of req.refreshCoinSpecs) {
+ const coin = await tx.coins.get(c.coinPub);
+ if (!coin) {
+ throw Error(`coin (pubkey ${c}) not found`);
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ coinPubs.push({
+ coinPub: c.coinPub,
+ amount: c.amount ?? denom.value,
+ });
+ }
+ return await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(coinPubs[0].amount),
+ coinPubs,
+ RefreshReason.Manual,
+ undefined,
+ );
+ },
+ );
+
+ for (const notif of res.notifications) {
+ wex.ws.notify(notif);
+ }
+
+ return {
+ refreshGroupId: res.refreshGroupId,
+ };
+}
+
+/**
+ * Wait until a refresh operation is final.
+ */
+export async function waitRefreshFinal(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+): Promise<void> {
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const refreshNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ refreshNotifFlag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ refreshNotifFlag.raise();
+ });
+
+ try {
+ await internalWaitRefreshFinal(ctx, refreshNotifFlag);
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+async function internalWaitRefreshFinal(
+ ctx: RefreshTransactionContext,
+ flag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ if (ctx.wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+
+ // Check if refresh is final
+ const res = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["refreshGroups", "operationRetries"] },
+ async (tx) => {
+ return {
+ rg: await tx.refreshGroups.get(ctx.refreshGroupId),
+ };
+ },
+ );
+ const { rg } = res;
+ if (!rg) {
+ // Must've been deleted, we consider that final.
+ return;
+ }
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Failed:
+ case RefreshOperationStatus.Finished:
+ // Transaction is final
+ return;
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended:
+ break;
+ }
+
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+}
diff --git a/packages/taler-wallet-core/src/remote.ts b/packages/taler-wallet-core/src/remote.ts
new file mode 100644
index 000000000..d7623baab
--- /dev/null
+++ b/packages/taler-wallet-core/src/remote.ts
@@ -0,0 +1,191 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ 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 {
+ CoreApiRequestEnvelope,
+ CoreApiResponse,
+ Logger,
+ OpenedPromise,
+ openPromise,
+ TalerError,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { connectRpc, JsonMessage } from "@gnu-taler/taler-util/twrpc";
+import { WalletCoreApiClient } from "./wallet-api-types.js";
+
+const logger = new Logger("remote.ts");
+
+export interface RemoteWallet {
+ /**
+ * Low-level interface for making API requests to wallet-core.
+ */
+ makeCoreApiRequest(
+ operation: string,
+ payload: unknown,
+ ): Promise<CoreApiResponse>;
+
+ /**
+ * Close the connection to the remote wallet.
+ */
+ close(): void;
+}
+
+export interface RemoteWalletConnectArgs {
+ name?: string;
+ socketFilename: string;
+ notificationHandler?: (n: WalletNotification) => void;
+}
+
+export async function createRemoteWallet(
+ args: RemoteWalletConnectArgs,
+): Promise<RemoteWallet> {
+ let nextRequestId = 1;
+ let requestMap: Map<
+ string,
+ {
+ promiseCapability: OpenedPromise<CoreApiResponse>;
+ }
+ > = new Map();
+
+ const ctx = await connectRpc<RemoteWallet>({
+ socketFilename: args.socketFilename,
+ onEstablished(connection) {
+ const ctx: RemoteWallet = {
+ makeCoreApiRequest(operation, payload) {
+ const id = `req-${nextRequestId}`;
+ nextRequestId += 1;
+ const req: CoreApiRequestEnvelope = {
+ operation,
+ id,
+ args: payload,
+ };
+ const promiseCap = openPromise<CoreApiResponse>();
+ requestMap.set(id, {
+ promiseCapability: promiseCap,
+ });
+ connection.sendMessage(req as unknown as JsonMessage);
+ return promiseCap.promise;
+ },
+ close() {
+ connection.close();
+ },
+ };
+ return {
+ result: ctx,
+ onDisconnect() {
+ logger.info(`${args.name}: remote wallet disconnected`);
+ },
+ onMessage(m) {
+ // FIXME: use a codec for parsing the response envelope!
+
+ if (typeof m !== "object" || m == null) {
+ logger.warn(`${args.name}: message not understood (wrong type)`);
+ return;
+ }
+ const type = (m as any).type;
+ if (type === "response" || type === "error") {
+ const id = (m as any).id;
+ if (typeof id !== "string") {
+ logger.warn(
+ `${args.name}: message not understood (no id in response)`,
+ );
+ return;
+ }
+ const h = requestMap.get(id);
+ if (!h) {
+ logger.warn(
+ `${args.name}: no handler registered for response id ${id}`,
+ );
+ return;
+ }
+ h.promiseCapability.resolve(m as any);
+ } else if (type === "notification") {
+ if (args.notificationHandler) {
+ args.notificationHandler((m as any).payload);
+ }
+ } else {
+ logger.warn(`${args.name}: message not understood`);
+ }
+ },
+ };
+ },
+ });
+ return ctx;
+}
+
+/**
+ * Get a high-level API client from a remove wallet.
+ */
+export function getClientFromRemoteWallet(
+ w: RemoteWallet,
+): WalletCoreApiClient {
+ const client: WalletCoreApiClient = {
+ async call(op, payload): Promise<any> {
+ const res = await w.makeCoreApiRequest(op, payload);
+ switch (res.type) {
+ case "error":
+ throw TalerError.fromUncheckedDetail(res.error);
+ case "response":
+ return res.result;
+ }
+ },
+ };
+ return client;
+}
+
+export interface WalletNotificationWaiter {
+ notify(wn: WalletNotification): void;
+ waitForNotificationCond<T>(
+ cond: (n: WalletNotification) => T | false | undefined,
+ ): Promise<T>;
+}
+
+interface NotificationCondEntry<T> {
+ condition: (n: WalletNotification) => T | false | undefined;
+ promiseCapability: OpenedPromise<T>;
+}
+
+/**
+ * Helper that allows creating a promise that resolves when the
+ * wallet
+ */
+export function makeNotificationWaiter(): WalletNotificationWaiter {
+ // Bookkeeping for waiting on notification conditions
+ let nextCondIndex = 1;
+ const condMap: Map<number, NotificationCondEntry<any>> = new Map();
+ function onNotification(n: WalletNotification) {
+ condMap.forEach((cond, condKey) => {
+ const res = cond.condition(n);
+ if (res) {
+ cond.promiseCapability.resolve(res);
+ }
+ });
+ }
+ function waitForNotificationCond<T>(
+ cond: (n: WalletNotification) => T | false | undefined,
+ ) {
+ const promCap = openPromise<T>();
+ condMap.set(nextCondIndex++, {
+ condition: cond,
+ promiseCapability: promCap,
+ });
+ return promCap.promise;
+ }
+ return {
+ waitForNotificationCond,
+ notify: onNotification,
+ };
+}
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
new file mode 100644
index 000000000..3b160d97f
--- /dev/null
+++ b/packages/taler-wallet-core/src/shepherd.ts
@@ -0,0 +1,1128 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems SA
+
+ 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 { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AsyncCondition,
+ CancellationToken,
+ Duration,
+ Logger,
+ NotificationType,
+ ObservabilityContext,
+ ObservabilityEventType,
+ TalerErrorDetail,
+ TaskThrottler,
+ TransactionIdStr,
+ TransactionState,
+ TransactionType,
+ WalletNotification,
+ assertUnreachable,
+ getErrorDetailFromException,
+ j2s,
+ safeStringifyException,
+} from "@gnu-taler/taler-util";
+import { processBackupForProvider } from "./backup/index.js";
+import {
+ DbRetryInfo,
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ constructTaskIdentifier,
+ getExchangeState,
+ parseTaskIdentifier,
+} from "./common.js";
+import {
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ OperationRetryRecord,
+ WalletDbAllStoresReadOnlyTransaction,
+ WalletDbReadOnlyTransaction,
+ timestampAbsoluteFromDb,
+} from "./db.js";
+import {
+ computeDepositTransactionStatus,
+ processDepositGroup,
+} from "./deposits.js";
+import {
+ computeDenomLossTransactionStatus,
+ updateExchangeFromUrlHandler,
+} from "./exchanges.js";
+import {
+ computePayMerchantTransactionState,
+ computeRefundTransactionState,
+ processPurchase,
+} from "./pay-merchant.js";
+import {
+ computePeerPullCreditTransactionState,
+ processPeerPullCredit,
+} from "./pay-peer-pull-credit.js";
+import {
+ computePeerPullDebitTransactionState,
+ processPeerPullDebit,
+} from "./pay-peer-pull-debit.js";
+import {
+ computePeerPushCreditTransactionState,
+ processPeerPushCredit,
+} from "./pay-peer-push-credit.js";
+import {
+ computePeerPushDebitTransactionState,
+ processPeerPushDebit,
+} from "./pay-peer-push-debit.js";
+import { processRecoupGroup } from "./recoup.js";
+import {
+ computeRefreshTransactionState,
+ processRefreshGroup,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ InternalWalletState,
+ WalletExecutionContext,
+ getNormalWalletExecutionContext,
+ getObservedWalletExecutionContext,
+} from "./wallet.js";
+import {
+ computeWithdrawalTransactionStatus,
+ processWithdrawalGroup,
+} from "./withdraw.js";
+
+const logger = new Logger("shepherd.ts");
+
+/**
+ * Info about one task being shepherded.
+ */
+interface ShepherdInfo {
+ cts: CancellationToken.Source;
+}
+
+/**
+ * Check if a task is alive, i.e. whether it prevents
+ * the main task loop from exiting.
+ */
+function taskGivesLiveness(taskId: string): boolean {
+ const parsedTaskId = parseTaskIdentifier(taskId);
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.Backup:
+ case PendingTaskType.ExchangeUpdate:
+ return false;
+ case PendingTaskType.Deposit:
+ case PendingTaskType.PeerPullCredit:
+ case PendingTaskType.PeerPullDebit:
+ case PendingTaskType.PeerPushCredit:
+ case PendingTaskType.Refresh:
+ case PendingTaskType.Recoup:
+ case PendingTaskType.RewardPickup:
+ case PendingTaskType.Withdraw:
+ case PendingTaskType.PeerPushDebit:
+ case PendingTaskType.Purchase:
+ return true;
+ default:
+ assertUnreachable(parsedTaskId);
+ }
+}
+
+export interface TaskScheduler {
+ ensureRunning(): Promise<void>;
+ startShepherdTask(taskId: TaskIdStr): void;
+ stopShepherdTask(taskId: TaskIdStr): void;
+ resetTaskRetries(taskId: TaskIdStr): Promise<void>;
+ reload(): Promise<void>;
+ getActiveTasks(): TaskIdStr[];
+ isIdle(): boolean;
+ shutdown(): Promise<void>;
+}
+
+export class TaskSchedulerImpl implements TaskScheduler {
+ private sheps: Map<TaskIdStr, ShepherdInfo> = new Map();
+
+ private iterCond = new AsyncCondition();
+
+ private throttler = new TaskThrottler();
+
+ isRunning: boolean = false;
+
+ constructor(private ws: InternalWalletState) {}
+
+ private async loadTasksFromDb(): Promise<void> {
+ const activeTasks = await getActiveTaskIds(this.ws);
+
+ logger.info(`active tasks from DB: ${j2s(activeTasks)}`);
+
+ for (const tid of activeTasks.taskIds) {
+ this.startShepherdTask(tid);
+ }
+ }
+
+ getActiveTasks(): TaskIdStr[] {
+ return [...this.sheps.keys()];
+ }
+
+ async shutdown(): Promise<void> {
+ const tasksIds = [...this.sheps.keys()];
+ logger.info(`Stopping task shepherd.`);
+ for (const taskId of tasksIds) {
+ this.stopShepherdTask(taskId);
+ }
+ }
+
+ async ensureRunning(): Promise<void> {
+ if (this.isRunning) {
+ return;
+ }
+ this.isRunning = true;
+ try {
+ await this.loadTasksFromDb();
+ } catch (e) {
+ this.isRunning = false;
+ throw e;
+ }
+ this.run()
+ .catch((e) => {
+ logger.error("error running task loop");
+ logger.error(`err: ${e}`);
+ })
+ .then(() => {
+ logger.trace("done running task loop");
+ this.isRunning = false;
+ });
+ }
+
+ isIdle(): boolean {
+ let alive = false;
+ const taskIds = [...this.sheps.keys()];
+ for (const taskId of taskIds) {
+ if (taskGivesLiveness(taskId)) {
+ alive = true;
+ break;
+ }
+ }
+ // We're idle if no task is alive anymore.
+ return !alive;
+ }
+
+ private async run(): Promise<void> {
+ logger.trace("Running task loop.");
+ logger.trace(`sheps: ${this.sheps.size}`);
+ while (true) {
+ if (this.ws.stopped) {
+ logger.trace("Breaking out of task loop (wallet stopped).");
+ break;
+ }
+
+ if (this.isIdle()) {
+ this.ws.notify({
+ type: NotificationType.Idle,
+ });
+ }
+
+ await this.iterCond.wait();
+ }
+ logger.trace("Done with task loop.");
+ }
+
+ startShepherdTask(taskId: TaskIdStr): void {
+ this.ensureRunning().catch((e) => {
+ logger.error(`error running scheduler: ${safeStringifyException(e)}`);
+ });
+ // Run in the background, no await!
+ this.internalStartShepherdTask(taskId);
+ }
+
+ /**
+ * Stop and re-load all existing tasks.
+ *
+ * Mostly useful to interrupt all waits when time-travelling.
+ */
+ async reload(): Promise<void> {
+ await this.ensureRunning();
+ const tasksIds = [...this.sheps.keys()];
+ logger.info(`reloading sheperd with ${tasksIds.length} tasks`);
+ for (const taskId of tasksIds) {
+ this.stopShepherdTask(taskId);
+ }
+ for (const taskId of tasksIds) {
+ this.startShepherdTask(taskId);
+ }
+ }
+
+ private async internalStartShepherdTask(taskId: TaskIdStr): Promise<void> {
+ logger.trace(`Starting to shepherd task ${taskId}`);
+ const oldShep = this.sheps.get(taskId);
+ if (oldShep) {
+ logger.trace(`Already have a shepherd for ${taskId}`);
+ return;
+ }
+ logger.trace(`Creating new shepherd for ${taskId}`);
+ const newShep: ShepherdInfo = {
+ cts: CancellationToken.create(),
+ };
+ this.sheps.set(taskId, newShep);
+ try {
+ await this.internalShepherdTask(taskId, newShep);
+ } finally {
+ logger.trace(`Done shepherding ${taskId}`);
+ this.sheps.delete(taskId);
+ this.iterCond.trigger();
+ }
+ }
+
+ stopShepherdTask(taskId: TaskIdStr): void {
+ logger.trace(`Stopping shepherding of ${taskId}`);
+ const oldShep = this.sheps.get(taskId);
+ if (oldShep) {
+ logger.trace(`Cancelling old shepherd for ${taskId}`);
+ oldShep.cts.cancel();
+ this.sheps.delete(taskId);
+ this.iterCond.trigger();
+ }
+ }
+
+ restartShepherdTask(taskId: TaskIdStr): void {
+ this.stopShepherdTask(taskId);
+ this.startShepherdTask(taskId);
+ }
+
+ async resetTaskRetries(taskId: TaskIdStr): Promise<void> {
+ const maybeNotification = await this.ws.db.runAllStoresReadWriteTx(
+ {},
+ async (tx) => {
+ await tx.operationRetries.delete(taskId);
+ return taskToRetryNotification(this.ws, tx, taskId, undefined);
+ },
+ );
+ this.stopShepherdTask(taskId);
+ if (maybeNotification) {
+ this.ws.notify(maybeNotification);
+ }
+ this.startShepherdTask(taskId);
+ }
+
+ private async wait(
+ taskId: TaskIdStr,
+ info: ShepherdInfo,
+ delay: Duration,
+ ): Promise<void> {
+ try {
+ await info.cts.token.racePromise(this.ws.timerGroup.resolveAfter(delay));
+ } catch (e) {
+ logger.info(`waiting for ${taskId} interrupted`);
+ }
+ }
+
+ private async internalShepherdTask(
+ taskId: TaskIdStr,
+ info: ShepherdInfo,
+ ): Promise<void> {
+ while (true) {
+ if (this.ws.stopped) {
+ logger.trace(`Shepherd for ${taskId} stopping as wallet is stopped`);
+ return;
+ }
+ if (info.cts.token.isCancelled) {
+ logger.trace(`Shepherd for ${taskId} got cancelled`);
+ return;
+ }
+ const isThrottled = this.throttler.applyThrottle(taskId);
+ if (isThrottled) {
+ logger.warn(
+ `task ${taskId} throttled, this is very likely a bug in wallet-core, please report`,
+ );
+ logger.warn("waiting for 60 seconds");
+ await this.ws.timerGroup.resolveAfter(
+ Duration.fromSpec({ seconds: 60 }),
+ );
+ }
+ const wex = getWalletExecutionContextForTask(
+ this.ws,
+ taskId,
+ info.cts.token,
+ );
+ const startTime = AbsoluteTime.now();
+ logger.trace(`Shepherd for ${taskId} will call handler`);
+ let res: TaskRunResult;
+ try {
+ res = await callOperationHandlerForTaskId(wex, taskId);
+ } catch (e) {
+ res = {
+ type: TaskRunResultType.Error,
+ errorDetail: getErrorDetailFromException(e),
+ };
+ }
+ if (info.cts.token.isCancelled) {
+ logger.trace("task cancelled, not processing result");
+ return;
+ }
+ if (this.ws.stopped) {
+ logger.trace("wallet stopped, not processing result");
+ return;
+ }
+ wex.oc.observe({
+ type: ObservabilityEventType.ShepherdTaskResult,
+ resultType: res.type,
+ });
+ switch (res.type) {
+ case TaskRunResultType.Error: {
+ logger.trace(`Shepherd for ${taskId} got error result.`);
+ const retryRecord = await storePendingTaskError(
+ this.ws,
+ taskId,
+ res.errorDetail,
+ );
+ const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry);
+ const delay = AbsoluteTime.remaining(t);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Backoff: {
+ logger.trace(`Shepherd for ${taskId} got backoff result.`);
+ const retryRecord = await storePendingTaskPending(this.ws, taskId);
+ const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry);
+ const delay = AbsoluteTime.remaining(t);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Progress: {
+ logger.trace(
+ `Shepherd for ${taskId} got progress result, re-running immediately.`,
+ );
+ await storeTaskProgress(this.ws, taskId);
+ break;
+ }
+ case TaskRunResultType.ScheduleLater: {
+ logger.trace(`Shepherd for ${taskId} got schedule-later result.`);
+ await storeTaskProgress(this.ws, taskId);
+ const delay = AbsoluteTime.remaining(res.runAt);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Finished:
+ logger.trace(`Shepherd for ${taskId} got finished result.`);
+ await storePendingTaskFinished(this.ws, taskId);
+ return;
+ case TaskRunResultType.LongpollReturnedPending: {
+ await storeTaskProgress(this.ws, taskId);
+ // Make sure that we are waiting a bit if long-polling returned too early.
+ const endTime = AbsoluteTime.now();
+ const taskDuration = AbsoluteTime.difference(endTime, startTime);
+ if (
+ Duration.cmp(taskDuration, Duration.fromSpec({ seconds: 20 })) < 0
+ ) {
+ logger.info(
+ `long-poller for ${taskId} returned unexpectedly early (${taskDuration.d_ms} ms), waiting 10 seconds`,
+ );
+ await this.wait(taskId, info, Duration.fromSpec({ seconds: 10 }));
+ } else {
+ logger.info(`task ${taskId} will long-poll again`);
+ }
+ break;
+ }
+ default:
+ assertUnreachable(res);
+ }
+ }
+ }
+}
+
+async function storePendingTaskError(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+ e: TalerErrorDetail,
+): Promise<OperationRetryRecord> {
+ logger.info(`storing pending task error for ${pendingTaskId}`);
+ const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => {
+ let retryRecord = await tx.operationRetries.get(pendingTaskId);
+ if (!retryRecord) {
+ retryRecord = {
+ id: pendingTaskId,
+ lastError: e,
+ retryInfo: DbRetryInfo.reset(),
+ };
+ } else {
+ retryRecord.lastError = e;
+ retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
+ }
+ await tx.operationRetries.put(retryRecord);
+ return {
+ notification: await taskToRetryNotification(ws, tx, pendingTaskId, e),
+ retryRecord,
+ };
+ });
+ if (res?.notification) {
+ ws.notify(res.notification);
+ }
+ return res.retryRecord;
+}
+
+/**
+ * Task made progress, clear error.
+ */
+async function storeTaskProgress(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<void> {
+ await ws.db.runReadWriteTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ },
+ );
+}
+
+async function storePendingTaskPending(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<OperationRetryRecord> {
+ const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => {
+ let retryRecord = await tx.operationRetries.get(pendingTaskId);
+ let hadError = false;
+ if (!retryRecord) {
+ retryRecord = {
+ id: pendingTaskId,
+ retryInfo: DbRetryInfo.reset(),
+ };
+ } else {
+ if (retryRecord.lastError) {
+ hadError = true;
+ }
+ delete retryRecord.lastError;
+ retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
+ }
+ await tx.operationRetries.put(retryRecord);
+ let notification: WalletNotification | undefined = undefined;
+ if (hadError) {
+ notification = await taskToRetryNotification(
+ ws,
+ tx,
+ pendingTaskId,
+ undefined,
+ );
+ }
+ return {
+ notification,
+ retryRecord,
+ };
+ });
+ if (res.notification) {
+ ws.notify(res.notification);
+ }
+ return res.retryRecord;
+}
+
+async function storePendingTaskFinished(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<void> {
+ await ws.db.runReadWriteTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ },
+ );
+}
+
+function getWalletExecutionContextForTask(
+ ws: InternalWalletState,
+ taskId: TaskIdStr,
+ cancellationToken: CancellationToken,
+): WalletExecutionContext {
+ let oc: ObservabilityContext;
+ let wex: WalletExecutionContext;
+
+ if (ws.config.testing.emitObservabilityEvents) {
+ oc = {
+ observe(evt) {
+ if (ws.config.testing.emitObservabilityEvents) {
+ ws.notify({
+ type: NotificationType.TaskObservabilityEvent,
+ taskId,
+ event: evt,
+ });
+ }
+ },
+ };
+
+ wex = getObservedWalletExecutionContext(ws, cancellationToken, oc);
+ } else {
+ oc = {
+ observe(evt) {},
+ };
+ wex = getNormalWalletExecutionContext(ws, cancellationToken, oc);
+ }
+ return wex;
+}
+
+async function callOperationHandlerForTaskId(
+ wex: WalletExecutionContext,
+ taskId: TaskIdStr,
+): Promise<TaskRunResult> {
+ const pending = parseTaskIdentifier(taskId);
+ switch (pending.tag) {
+ case PendingTaskType.ExchangeUpdate:
+ return await updateExchangeFromUrlHandler(wex, pending.exchangeBaseUrl);
+ case PendingTaskType.Refresh:
+ return await processRefreshGroup(wex, pending.refreshGroupId);
+ case PendingTaskType.Withdraw:
+ return await processWithdrawalGroup(wex, pending.withdrawalGroupId);
+ case PendingTaskType.Purchase:
+ return await processPurchase(wex, pending.proposalId);
+ case PendingTaskType.Recoup:
+ return await processRecoupGroup(wex, pending.recoupGroupId);
+ case PendingTaskType.Deposit:
+ return await processDepositGroup(wex, pending.depositGroupId);
+ case PendingTaskType.Backup:
+ return await processBackupForProvider(wex, pending.backupProviderBaseUrl);
+ case PendingTaskType.PeerPushDebit:
+ return await processPeerPushDebit(wex, pending.pursePub);
+ case PendingTaskType.PeerPullCredit:
+ return await processPeerPullCredit(wex, pending.pursePub);
+ case PendingTaskType.PeerPullDebit:
+ return await processPeerPullDebit(wex, pending.peerPullDebitId);
+ case PendingTaskType.PeerPushCredit:
+ return await processPeerPushCredit(wex, pending.peerPushCreditId);
+ case PendingTaskType.RewardPickup:
+ throw Error("not supported anymore");
+ default:
+ return assertUnreachable(pending);
+ }
+ throw Error(`not reached ${pending.tag}`);
+}
+
+/**
+ * Generate an appropriate error transition notification
+ * for applicable tasks.
+ *
+ * Namely, transition notifications are generated for:
+ * - exchange update errors
+ * - transactions
+ */
+async function taskToRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ const parsedTaskId = parseTaskIdentifier(pendingTaskId);
+
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.ExchangeUpdate:
+ return makeExchangeRetryNotification(ws, tx, pendingTaskId, e);
+ case PendingTaskType.PeerPullCredit:
+ case PendingTaskType.PeerPullDebit:
+ case PendingTaskType.Withdraw:
+ case PendingTaskType.PeerPushCredit:
+ case PendingTaskType.Deposit:
+ case PendingTaskType.Refresh:
+ case PendingTaskType.RewardPickup:
+ case PendingTaskType.PeerPushDebit:
+ case PendingTaskType.Purchase:
+ return makeTransactionRetryNotification(ws, tx, pendingTaskId, e);
+ case PendingTaskType.Backup:
+ case PendingTaskType.Recoup:
+ return undefined;
+ }
+}
+
+async function getTransactionState(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "depositGroups",
+ "withdrawalGroups",
+ "purchases",
+ "refundGroups",
+ "peerPullCredit",
+ "peerPullDebit",
+ "peerPushDebit",
+ "peerPushCredit",
+ "rewards",
+ "refreshGroups",
+ "denomLossEvents",
+ ]
+ >,
+ transactionId: string,
+): Promise<TransactionState | undefined> {
+ const parsedTxId = parseTransactionIdentifier(transactionId);
+ if (!parsedTxId) {
+ throw Error("invalid tx identifier");
+ }
+ switch (parsedTxId.tag) {
+ case TransactionType.Deposit: {
+ const rec = await tx.depositGroups.get(parsedTxId.depositGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeDepositTransactionStatus(rec);
+ }
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal: {
+ const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeWithdrawalTransactionStatus(rec);
+ }
+ case TransactionType.Payment: {
+ const rec = await tx.purchases.get(parsedTxId.proposalId);
+ if (!rec) {
+ return;
+ }
+ return computePayMerchantTransactionState(rec);
+ }
+ case TransactionType.Refund: {
+ const rec = await tx.refundGroups.get(parsedTxId.refundGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeRefundTransactionState(rec);
+ }
+ case TransactionType.PeerPullCredit: {
+ const rec = await tx.peerPullCredit.get(parsedTxId.pursePub);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPullCreditTransactionState(rec);
+ }
+ case TransactionType.PeerPullDebit: {
+ const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPullDebitTransactionState(rec);
+ }
+ case TransactionType.PeerPushCredit: {
+ const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPushCreditTransactionState(rec);
+ }
+ case TransactionType.PeerPushDebit: {
+ const rec = await tx.peerPushDebit.get(parsedTxId.pursePub);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPushDebitTransactionState(rec);
+ }
+ case TransactionType.Refresh: {
+ const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeRefreshTransactionState(rec);
+ }
+ case TransactionType.Recoup:
+ throw Error("not yet supported");
+ case TransactionType.DenomLoss: {
+ const rec = await tx.denomLossEvents.get(parsedTxId.denomLossEventId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeDenomLossTransactionStatus(rec);
+ }
+ default:
+ assertUnreachable(parsedTxId);
+ }
+}
+
+async function makeTransactionRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ const txId = convertTaskToTransactionId(pendingTaskId);
+ if (!txId) {
+ return undefined;
+ }
+ const txState = await getTransactionState(ws, tx, txId);
+ if (!txState) {
+ return undefined;
+ }
+ const notif: WalletNotification = {
+ type: NotificationType.TransactionStateTransition,
+ transactionId: txId,
+ oldTxState: txState,
+ newTxState: txState,
+ };
+ if (e) {
+ notif.errorInfo = {
+ code: e.code as number,
+ hint: e.hint,
+ };
+ }
+ return notif;
+}
+
+async function makeExchangeRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ logger.info("making exchange retry notification");
+ const parsedTaskId = parseTaskIdentifier(pendingTaskId);
+ if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) {
+ throw Error("invalid task identifier");
+ }
+ const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl);
+
+ if (!rec) {
+ logger.info(`exchange ${parsedTaskId.exchangeBaseUrl} not found`);
+ return undefined;
+ }
+
+ const notif: WalletNotification = {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: parsedTaskId.exchangeBaseUrl,
+ oldExchangeState: getExchangeState(rec),
+ newExchangeState: getExchangeState(rec),
+ };
+ if (e) {
+ notif.errorInfo = {
+ code: e.code as number,
+ hint: e.hint,
+ };
+ }
+ return notif;
+}
+
+export function listTaskForTransactionId(transactionId: string): TaskIdStr[] {
+ const tid = parseTransactionIdentifier(transactionId);
+ if (!tid) {
+ throw Error("invalid task ID");
+ }
+ switch (tid.tag) {
+ case TransactionType.Deposit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: tid.depositGroupId,
+ }),
+ ];
+ case TransactionType.InternalWithdrawal:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: tid.withdrawalGroupId,
+ }),
+ ];
+ case TransactionType.Payment:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: tid.proposalId,
+ }),
+ ];
+ case TransactionType.PeerPullCredit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.pursePub,
+ }),
+ ];
+ case TransactionType.PeerPullDebit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: tid.peerPullDebitId,
+ }),
+ ];
+ case TransactionType.PeerPushCredit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.peerPushCreditId,
+ }),
+ ];
+ case TransactionType.PeerPushDebit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.pursePub,
+ }),
+ ];
+ case TransactionType.Recoup:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: tid.recoupGroupId,
+ }),
+ ];
+ case TransactionType.Refresh:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: tid.refreshGroupId,
+ }),
+ ];
+ case TransactionType.Refund:
+ return [];
+ case TransactionType.Withdrawal:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: tid.withdrawalGroupId,
+ }),
+ ];
+ case TransactionType.DenomLoss:
+ return [];
+ default:
+ assertUnreachable(tid);
+ }
+}
+
+/**
+ * Convert the task ID for a task that processes a transaction int
+ * the ID for the transaction.
+ */
+export function convertTaskToTransactionId(
+ taskId: string,
+): TransactionIdStr | undefined {
+ const parsedTaskId = parseTaskIdentifier(taskId);
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.PeerPullCredit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: parsedTaskId.pursePub,
+ });
+ case PendingTaskType.PeerPullDebit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: parsedTaskId.peerPullDebitId,
+ });
+ // FIXME: This doesn't distinguish internal-withdrawal.
+ // Maybe we should have a different task type for that as well?
+ // Or maybe transaction IDs should be valid task identifiers?
+ case PendingTaskType.Withdraw:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: parsedTaskId.withdrawalGroupId,
+ });
+ case PendingTaskType.PeerPushCredit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: parsedTaskId.peerPushCreditId,
+ });
+ case PendingTaskType.Deposit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: parsedTaskId.depositGroupId,
+ });
+ case PendingTaskType.Refresh:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: parsedTaskId.refreshGroupId,
+ });
+ case PendingTaskType.PeerPushDebit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: parsedTaskId.pursePub,
+ });
+ case PendingTaskType.Purchase:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: parsedTaskId.proposalId,
+ });
+ default:
+ return undefined;
+ }
+}
+
+export interface ActiveTaskIdsResult {
+ taskIds: TaskIdStr[];
+}
+
+export async function getActiveTaskIds(
+ ws: InternalWalletState,
+): Promise<ActiveTaskIdsResult> {
+ const res: ActiveTaskIdsResult = {
+ taskIds: [],
+ };
+ await ws.db.runReadWriteTx(
+ {
+ storeNames: [
+ "exchanges",
+ "refreshGroups",
+ "withdrawalGroups",
+ "purchases",
+ "depositGroups",
+ "recoupGroups",
+ "peerPullCredit",
+ "peerPushDebit",
+ "peerPullDebit",
+ "peerPushCredit",
+ ],
+ },
+ async (tx) => {
+ const active = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+
+ // Withdrawals
+
+ {
+ const activeRecs =
+ await tx.withdrawalGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: rec.withdrawalGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Deposits
+
+ {
+ const activeRecs =
+ await tx.depositGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: rec.depositGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Refreshes
+
+ {
+ const activeRecs =
+ await tx.refreshGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: rec.refreshGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Purchases
+
+ {
+ const activeRecs = await tx.purchases.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: rec.proposalId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-push-debit
+
+ {
+ const activeRecs =
+ await tx.peerPushDebit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub: rec.pursePub,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-push-credit
+
+ {
+ const activeRecs =
+ await tx.peerPushCredit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId: rec.peerPushCreditId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-pull-debit
+
+ {
+ const activeRecs =
+ await tx.peerPullDebit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: rec.peerPullDebitId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-pull-credit
+
+ {
+ const activeRecs =
+ await tx.peerPullCredit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: rec.pursePub,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // recoup
+
+ {
+ const activeRecs =
+ await tx.recoupGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: rec.recoupGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // exchange update
+
+ {
+ const exchanges = await tx.exchanges.getAll();
+ for (const rec of exchanges) {
+ const taskIdUpdate = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: rec.baseUrl,
+ });
+ res.taskIds.push(taskIdUpdate);
+ }
+ }
+
+ // FIXME: Recoup!
+ },
+ );
+
+ return res;
+}
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
new file mode 100644
index 000000000..899c4a8b2
--- /dev/null
+++ b/packages/taler-wallet-core/src/testing.ts
@@ -0,0 +1,871 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ 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/>
+ */
+
+/**
+ * @file
+ * Implementation of wallet-core operations that are used for testing,
+ * but typically not in the production wallet.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ addPaytoQueryParams,
+ Amounts,
+ AmountString,
+ checkLogicInvariant,
+ CheckPaymentResponse,
+ codecForAny,
+ codecForCheckPaymentResponse,
+ ConfirmPayResultType,
+ Duration,
+ IntegrationTestArgs,
+ IntegrationTestV2Args,
+ j2s,
+ Logger,
+ NotificationType,
+ parsePaytoUri,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+ TestPayArgs,
+ TestPayResult,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WithdrawTestBalanceRequest,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
+import { getBalances } from "./balance.js";
+import { genericWaitForState } from "./common.js";
+import { createDepositGroup } from "./deposits.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ confirmPay,
+ preparePayForUri,
+ startRefundQueryForUri,
+} from "./pay-merchant.js";
+import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
+import {
+ confirmPeerPullDebit,
+ preparePeerPullDebit,
+} from "./pay-peer-pull-debit.js";
+import {
+ confirmPeerPushCredit,
+ preparePeerPushCredit,
+} from "./pay-peer-push-credit.js";
+import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
+import { getRefreshesForTransaction } from "./refresh.js";
+import { getTransactionById, getTransactions } from "./transactions.js";
+import type { WalletExecutionContext } from "./wallet.js";
+import { acceptWithdrawalFromUri } from "./withdraw.js";
+
+const logger = new Logger("operations/testing.ts");
+
+interface MerchantBackendInfo {
+ baseUrl: string;
+ authToken?: string;
+}
+
+export interface WithdrawTestBalanceResult {
+ /**
+ * Transaction ID of the newly created withdrawal transaction.
+ */
+ transactionId: string;
+
+ /**
+ * Account of the user registered for the withdrawal.
+ */
+ accountPaytoUri: string;
+}
+
+export async function withdrawTestBalance(
+ wex: WalletExecutionContext,
+ req: WithdrawTestBalanceRequest,
+): Promise<WithdrawTestBalanceResult> {
+ const amount = req.amount;
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ const corebankApiBaseUrl = req.corebankApiBaseUrl;
+
+ logger.trace(
+ `Registering bank user, bank access base url ${corebankApiBaseUrl}`,
+ );
+
+ const corebankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
+
+ const bankUser = await corebankClient.createRandomBankUser();
+ logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
+
+ corebankClient.setAuth(bankUser);
+
+ const wresp = await corebankClient.createWithdrawalOperation(
+ bankUser.username,
+ amount,
+ );
+
+ const acceptResp = await acceptWithdrawalFromUri(wex, {
+ talerWithdrawUri: wresp.taler_withdraw_uri,
+ selectedExchange: exchangeBaseUrl,
+ forcedDenomSel: req.forcedDenomSel,
+ });
+
+ await corebankClient.confirmWithdrawalOperation(bankUser.username, {
+ withdrawalOperationId: wresp.withdrawal_id,
+ });
+
+ return {
+ transactionId: acceptResp.transactionId,
+ accountPaytoUri: bankUser.accountPaytoUri,
+ };
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+function getMerchantAuthHeader(m: MerchantBackendInfo): Record<string, string> {
+ if (m.authToken) {
+ return {
+ Authorization: `Bearer ${m.authToken}`,
+ };
+ }
+ return {};
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function refund(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ orderId: string,
+ reason: string,
+ refundAmount: string,
+): Promise<string> {
+ const reqUrl = new URL(
+ `private/orders/${orderId}/refund`,
+ merchantBackend.baseUrl,
+ );
+ const refundReq = {
+ order_id: orderId,
+ reason,
+ refund: refundAmount,
+ };
+ const resp = await http.fetch(reqUrl.href, {
+ method: "POST",
+ body: refundReq,
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ const refundUri = r.taler_refund_uri;
+ if (!refundUri) {
+ throw Error("no refund URI in response");
+ }
+ return refundUri;
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function createOrder(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ amount: string,
+ summary: string,
+ fulfillmentUrl: string,
+): Promise<{ orderId: string }> {
+ const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
+ const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
+ const orderReq = {
+ order: {
+ amount,
+ summary,
+ fulfillment_url: fulfillmentUrl,
+ refund_deadline: { t_s: t },
+ wire_transfer_deadline: { t_s: t },
+ },
+ };
+ const resp = await http.fetch(reqUrl, {
+ method: "POST",
+ body: orderReq,
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ const orderId = r.order_id;
+ if (!orderId) {
+ throw Error("no order id in response");
+ }
+ return { orderId };
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function checkPayment(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ orderId: string,
+): Promise<CheckPaymentResponse> {
+ const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
+ reqUrl.searchParams.set("order_id", orderId);
+ const resp = await http.fetch(reqUrl.href, {
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
+}
+
+interface MakePaymentResult {
+ orderId: string;
+ paymentTransactionId: string;
+}
+
+async function makePayment(
+ wex: WalletExecutionContext,
+ merchant: MerchantBackendInfo,
+ amount: string,
+ summary: string,
+): Promise<MakePaymentResult> {
+ const orderResp = await createOrder(
+ wex.http,
+ merchant,
+ amount,
+ summary,
+ "taler://fulfillment-success/thx",
+ );
+
+ logger.trace("created order with orderId", orderResp.orderId);
+
+ let paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId);
+
+ logger.trace("payment status", paymentStatus);
+
+ const talerPayUri = paymentStatus.taler_pay_uri;
+ if (!talerPayUri) {
+ throw Error("no taler://pay/ URI in payment response");
+ }
+
+ const preparePayResult = await preparePayForUri(wex, talerPayUri);
+
+ logger.trace("prepare pay result", preparePayResult);
+
+ if (preparePayResult.status != "payment-possible") {
+ throw Error("payment not possible");
+ }
+
+ const confirmPayResult = await confirmPay(
+ wex,
+ preparePayResult.transactionId,
+ undefined,
+ );
+
+ logger.trace("confirmPayResult", confirmPayResult);
+
+ paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId);
+
+ logger.trace("payment status after wallet payment:", paymentStatus);
+
+ if (paymentStatus.order_status !== "paid") {
+ throw Error("payment did not succeed");
+ }
+
+ return {
+ orderId: orderResp.orderId,
+ paymentTransactionId: preparePayResult.transactionId,
+ };
+}
+
+export async function runIntegrationTest(
+ wex: WalletExecutionContext,
+ args: IntegrationTestArgs,
+): Promise<void> {
+ logger.info("running test with arguments", args);
+
+ const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
+ const currency = parsedSpendAmount.currency;
+
+ logger.info("withdrawing test balance");
+ const withdrawRes1 = await withdrawTestBalance(wex, {
+ amount: args.amountToWithdraw,
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+ await waitUntilGivenTransactionsFinal(wex, [withdrawRes1.transactionId]);
+ logger.info("done withdrawing test balance");
+
+ const balance = await getBalances(wex);
+
+ logger.trace(JSON.stringify(balance, null, 2));
+
+ const myMerchant: MerchantBackendInfo = {
+ baseUrl: args.merchantBaseUrl,
+ authToken: args.merchantAuthToken,
+ };
+
+ const makePaymentRes = await makePayment(
+ wex,
+ myMerchant,
+ args.amountToSpend,
+ "hello world",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes.paymentTransactionId,
+ );
+
+ logger.trace("withdrawing test balance for refund");
+ const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
+ const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
+ const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
+ const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
+
+ const withdrawRes2 = await withdrawTestBalance(wex, {
+ amount: Amounts.stringify(withdrawAmountTwo),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+
+ await waitUntilGivenTransactionsFinal(wex, [withdrawRes2.transactionId]);
+
+ const { orderId: refundOrderId } = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(spendAmountTwo),
+ "order that will be refunded",
+ );
+
+ const refundUri = await refund(
+ wex.http,
+ myMerchant,
+ refundOrderId,
+ "test refund",
+ Amounts.stringify(refundAmount),
+ );
+
+ logger.trace("refund URI", refundUri);
+
+ const refundResp = await startRefundQueryForUri(wex, refundUri);
+
+ logger.trace("integration test: applied refund");
+
+ // Wait until the refund is done
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ refundResp.transactionId,
+ );
+
+ logger.trace("integration test: making payment after refund");
+
+ const paymentResp2 = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(spendAmountThree),
+ "payment after refund",
+ );
+
+ logger.trace("integration test: make payment done");
+
+ await waitUntilGivenTransactionsFinal(wex, [
+ paymentResp2.paymentTransactionId,
+ ]);
+ await waitUntilGivenTransactionsFinal(
+ wex,
+ await getRefreshesForTransaction(wex, paymentResp2.paymentTransactionId),
+ );
+
+ logger.trace("integration test: all done!");
+}
+
+/**
+ * Wait until all transactions are in a final state.
+ */
+export async function waitUntilAllTransactionsFinal(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ logger.info("waiting until all transactions are in a final state");
+ await wex.taskScheduler.ensureRunning();
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ return false;
+ default:
+ return true;
+ }
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
+ }
+ return true;
+ },
+ });
+ logger.info("done waiting until all transactions are in a final state");
+}
+
+export async function waitTasksDone(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ await genericWaitForState(wex, {
+ async checkState() {
+ return wex.taskScheduler.isIdle();
+ },
+ filterNotification(notif) {
+ return notif.type === NotificationType.Idle;
+ },
+ });
+}
+
+/**
+ * Wait until all chosen transactions are in a final state.
+ */
+export async function waitUntilGivenTransactionsFinal(
+ wex: WalletExecutionContext,
+ transactionIds: string[],
+): Promise<void> {
+ logger.info(
+ `waiting until given ${transactionIds.length} transactions are in a final state`,
+ );
+ logger.info(`transaction IDs are: ${j2s(transactionIds)}`);
+ if (transactionIds.length === 0) {
+ return;
+ }
+
+ const txIdSet = new Set(transactionIds);
+
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
+ if (!txIdSet.has(notif.transactionId)) {
+ return false;
+ }
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ return false;
+ }
+ return true;
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ if (!txIdSet.has(tx.transactionId)) {
+ // Don't look at this transaction, we're not interested in it.
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
+ }
+ // No transaction is pending, we're done waiting!
+ return true;
+ },
+ });
+ logger.info("done waiting until given transactions are in a final state");
+}
+
+export async function waitUntilRefreshesDone(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ logger.info("waiting until all refresh transactions are in a final state");
+
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ return false;
+ default:
+ return true;
+ }
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ if (tx.type !== TransactionType.Refresh) {
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
+ }
+ return true;
+ },
+ });
+ logger.info("done waiting until all refreshes are in a final state");
+}
+
+async function waitUntilTransactionPendingReady(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ return await waitTransactionState(wex, transactionId, {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ });
+}
+
+/**
+ * Wait until a transaction is in a particular state.
+ */
+export async function waitTransactionState(
+ wex: WalletExecutionContext,
+ transactionId: string,
+ txState: TransactionState,
+): Promise<void> {
+ logger.info(
+ `starting waiting for ${transactionId} to be in ${JSON.stringify(
+ txState,
+ )})`,
+ );
+ await genericWaitForState(wex, {
+ async checkState() {
+ const tx = await getTransactionById(wex, {
+ transactionId,
+ });
+ return (
+ tx.txState.major === txState.major && tx.txState.minor === txState.minor
+ );
+ },
+ filterNotification(notif) {
+ return notif.type === NotificationType.TransactionStateTransition;
+ },
+ });
+ logger.info(
+ `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
+ );
+}
+
+export async function waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ await waitUntilGivenTransactionsFinal(wex, [transactionId]);
+ await waitUntilGivenTransactionsFinal(
+ wex,
+ await getRefreshesForTransaction(wex, transactionId),
+ );
+}
+
+export async function waitUntilTransactionFinal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ await waitUntilGivenTransactionsFinal(wex, [transactionId]);
+}
+
+export async function runIntegrationTest2(
+ wex: WalletExecutionContext,
+ args: IntegrationTestV2Args,
+): Promise<void> {
+ await wex.taskScheduler.ensureRunning();
+ logger.info("running test with arguments", args);
+
+ const exchangeInfo = await fetchFreshExchange(wex, args.exchangeBaseUrl);
+
+ const currency = exchangeInfo.currency;
+
+ const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`);
+ const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);
+
+ logger.info("withdrawing test balance");
+ const withdrawalRes = await withdrawTestBalance(wex, {
+ amount: Amounts.stringify(amountToWithdraw),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+ await waitUntilTransactionFinal(wex, withdrawalRes.transactionId);
+ logger.info("done withdrawing test balance");
+
+ const balance = await getBalances(wex);
+
+ logger.trace(JSON.stringify(balance, null, 2));
+
+ const myMerchant: MerchantBackendInfo = {
+ baseUrl: args.merchantBaseUrl,
+ authToken: args.merchantAuthToken,
+ };
+
+ const makePaymentRes = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(amountToSpend),
+ "hello world",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes.paymentTransactionId,
+ );
+
+ logger.trace("withdrawing test balance for refund");
+ const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
+ const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
+ const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
+ const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
+
+ const withdrawalRes2 = await withdrawTestBalance(wex, {
+ amount: Amounts.stringify(withdrawAmountTwo),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+
+ // Wait until the withdraw is done
+ await waitUntilTransactionFinal(wex, withdrawalRes2.transactionId);
+
+ const { orderId: refundOrderId } = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(spendAmountTwo),
+ "order that will be refunded",
+ );
+
+ const refundUri = await refund(
+ wex.http,
+ myMerchant,
+ refundOrderId,
+ "test refund",
+ Amounts.stringify(refundAmount),
+ );
+
+ logger.trace("refund URI", refundUri);
+
+ const refundResp = await startRefundQueryForUri(wex, refundUri);
+
+ logger.trace("integration test: applied refund");
+
+ // Wait until the refund is done
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ refundResp.transactionId,
+ );
+
+ logger.trace("integration test: making payment after refund");
+
+ const makePaymentRes2 = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(spendAmountThree),
+ "payment after refund",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes2.paymentTransactionId,
+ );
+
+ logger.trace("integration test: make payment done");
+
+ const peerPushInit = await initiatePeerPushDebit(wex, {
+ partialContractTerms: {
+ amount: `${currency}:1` as AmountString,
+ summary: "Payment Peer Push Test",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ await waitUntilTransactionPendingReady(wex, peerPushInit.transactionId);
+ const txDetails = await getTransactionById(wex, {
+ transactionId: peerPushInit.transactionId,
+ });
+
+ if (txDetails.type !== TransactionType.PeerPushDebit) {
+ throw Error("internal invariant failed");
+ }
+
+ if (!txDetails.talerUri) {
+ throw Error("internal invariant failed");
+ }
+
+ const peerPushCredit = await preparePeerPushCredit(wex, {
+ talerUri: txDetails.talerUri,
+ });
+
+ await confirmPeerPushCredit(wex, {
+ transactionId: peerPushCredit.transactionId,
+ });
+
+ const peerPullInit = await initiatePeerPullPayment(wex, {
+ partialContractTerms: {
+ amount: `${currency}:1` as AmountString,
+ summary: "Payment Peer Pull Test",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ await waitUntilTransactionPendingReady(wex, peerPullInit.transactionId);
+
+ const peerPullInc = await preparePeerPullDebit(wex, {
+ talerUri: peerPullInit.talerUri,
+ });
+
+ await confirmPeerPullDebit(wex, {
+ transactionId: peerPullInc.transactionId,
+ });
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPullInc.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPullInit.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPushCredit.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPushInit.transactionId,
+ );
+
+ let depositPayto = withdrawalRes.accountPaytoUri;
+
+ const parsedPayto = parsePaytoUri(depositPayto);
+ if (!parsedPayto) {
+ throw Error("invalid payto");
+ }
+
+ // Work around libeufin-bank bug where receiver-name is missing
+ if (!parsedPayto.params["receiver-name"]) {
+ depositPayto = addPaytoQueryParams(depositPayto, {
+ "receiver-name": "Test",
+ });
+ }
+
+ await createDepositGroup(wex, {
+ amount: `${currency}:5` as AmountString,
+ depositPaytoUri: depositPayto,
+ });
+
+ logger.trace("integration test: all done!");
+}
+
+export async function testPay(
+ wex: WalletExecutionContext,
+ args: TestPayArgs,
+): Promise<TestPayResult> {
+ logger.trace("creating order");
+ const merchant = {
+ authToken: args.merchantAuthToken,
+ baseUrl: args.merchantBaseUrl,
+ };
+ const orderResp = await createOrder(
+ wex.http,
+ merchant,
+ args.amount,
+ args.summary,
+ "taler://fulfillment-success/thank+you",
+ );
+ logger.trace("created new order with order ID", orderResp.orderId);
+ const checkPayResp = await checkPayment(
+ wex.http,
+ merchant,
+ orderResp.orderId,
+ );
+ const talerPayUri = checkPayResp.taler_pay_uri;
+ if (!talerPayUri) {
+ console.error("fatal: no taler pay URI received from backend");
+ process.exit(1);
+ }
+ logger.trace("taler pay URI:", talerPayUri);
+ const result = await preparePayForUri(wex, talerPayUri);
+ if (result.status !== PreparePayResultType.PaymentPossible) {
+ throw Error(`unexpected prepare pay status: ${result.status}`);
+ }
+ const r = await confirmPay(
+ wex,
+ result.transactionId,
+ undefined,
+ args.forcedCoinSel,
+ );
+ if (r.type != ConfirmPayResultType.Done) {
+ throw Error("payment not done");
+ }
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(result.proposalId);
+ },
+ );
+ checkLogicInvariant(!!purchase);
+ return {
+ numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0,
+ };
+}
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
new file mode 100644
index 000000000..dc555c12a
--- /dev/null
+++ b/packages/taler-wallet-core/src/transactions.ts
@@ -0,0 +1,2045 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ 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 { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ Amounts,
+ assertUnreachable,
+ checkDbInvariant,
+ DepositTransactionTrackingState,
+ j2s,
+ Logger,
+ NotificationType,
+ OrderShortInfo,
+ PeerContractTerms,
+ RefundInfoShort,
+ RefundPaymentInfo,
+ ScopeType,
+ stringifyPayPullUri,
+ stringifyPayPushUri,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ Transaction,
+ TransactionAction,
+ TransactionByIdRequest,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionRecordFilter,
+ TransactionsRequest,
+ TransactionsResponse,
+ TransactionState,
+ TransactionType,
+ TransactionWithdrawal,
+ WalletContractData,
+ WithdrawalTransactionByURIRequest,
+ WithdrawalType,
+} from "@gnu-taler/taler-util";
+import {
+ constructTaskIdentifier,
+ PendingTaskType,
+ TaskIdentifiers,
+ TaskIdStr,
+ TransactionContext,
+} from "./common.js";
+import {
+ DenomLossEventRecord,
+ DepositElementStatus,
+ DepositGroupRecord,
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ OperationRetryRecord,
+ PeerPullCreditRecord,
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentIncomingRecord,
+ PeerPushCreditStatus,
+ PeerPushDebitRecord,
+ PeerPushDebitStatus,
+ PeerPushPaymentIncomingRecord,
+ PurchaseRecord,
+ PurchaseStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ RefundGroupRecord,
+ timestampPreciseFromDb,
+ timestampProtocolFromDb,
+ WalletDbReadOnlyTransaction,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+} from "./db.js";
+import {
+ computeDepositTransactionActions,
+ computeDepositTransactionStatus,
+ DepositTransactionContext,
+} from "./deposits.js";
+import {
+ computeDenomLossTransactionStatus,
+ DenomLossTransactionContext,
+ ExchangeWireDetails,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import {
+ computePayMerchantTransactionActions,
+ computePayMerchantTransactionState,
+ computeRefundTransactionState,
+ expectProposalDownload,
+ extractContractData,
+ PayMerchantTransactionContext,
+ RefundTransactionContext,
+} from "./pay-merchant.js";
+import {
+ computePeerPullCreditTransactionActions,
+ computePeerPullCreditTransactionState,
+ PeerPullCreditTransactionContext,
+} from "./pay-peer-pull-credit.js";
+import {
+ computePeerPullDebitTransactionActions,
+ computePeerPullDebitTransactionState,
+ PeerPullDebitTransactionContext,
+} from "./pay-peer-pull-debit.js";
+import {
+ computePeerPushCreditTransactionActions,
+ computePeerPushCreditTransactionState,
+ PeerPushCreditTransactionContext,
+} from "./pay-peer-push-credit.js";
+import {
+ computePeerPushDebitTransactionActions,
+ computePeerPushDebitTransactionState,
+ PeerPushDebitTransactionContext,
+} from "./pay-peer-push-debit.js";
+import {
+ computeRefreshTransactionActions,
+ computeRefreshTransactionState,
+ RefreshTransactionContext,
+} from "./refresh.js";
+import type { WalletExecutionContext } from "./wallet.js";
+import {
+ augmentPaytoUrisForWithdrawal,
+ computeWithdrawalTransactionActions,
+ computeWithdrawalTransactionStatus,
+ WithdrawTransactionContext,
+} from "./withdraw.js";
+
+const logger = new Logger("taler-wallet-core:transactions.ts");
+
+function shouldSkipCurrency(
+ transactionsRequest: TransactionsRequest | undefined,
+ currency: string,
+ exchangesInTransaction: string[],
+): boolean {
+ if (transactionsRequest?.scopeInfo) {
+ const sameCurrency = Amounts.isSameCurrency(
+ currency,
+ transactionsRequest.scopeInfo.currency,
+ );
+ switch (transactionsRequest.scopeInfo.type) {
+ case ScopeType.Global: {
+ return !sameCurrency;
+ }
+ case ScopeType.Exchange: {
+ return (
+ !sameCurrency ||
+ (exchangesInTransaction.length > 0 &&
+ !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url))
+ );
+ }
+ case ScopeType.Auditor: {
+ // same currency and same auditor
+ throw Error("filering balance in auditor scope is not implemented");
+ }
+ default:
+ assertUnreachable(transactionsRequest.scopeInfo);
+ }
+ }
+ // FIXME: remove next release
+ if (transactionsRequest?.currency) {
+ return (
+ transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
+ );
+ }
+ return false;
+}
+
+function shouldSkipSearch(
+ transactionsRequest: TransactionsRequest | undefined,
+ fields: string[],
+): boolean {
+ if (!transactionsRequest?.search) {
+ return false;
+ }
+ const needle = transactionsRequest.search.trim();
+ for (const f of fields) {
+ if (f.indexOf(needle) >= 0) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Fallback order of transactions that have the same timestamp.
+ */
+const txOrder: { [t in TransactionType]: number } = {
+ [TransactionType.Withdrawal]: 1,
+ [TransactionType.Payment]: 3,
+ [TransactionType.PeerPullCredit]: 4,
+ [TransactionType.PeerPullDebit]: 5,
+ [TransactionType.PeerPushCredit]: 6,
+ [TransactionType.PeerPushDebit]: 7,
+ [TransactionType.Refund]: 8,
+ [TransactionType.Deposit]: 9,
+ [TransactionType.Refresh]: 10,
+ [TransactionType.Recoup]: 11,
+ [TransactionType.InternalWithdrawal]: 12,
+ [TransactionType.DenomLoss]: 13,
+};
+
+export async function getTransactionById(
+ wex: WalletExecutionContext,
+ req: TransactionByIdRequest,
+): Promise<Transaction> {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+
+ if (!parsedTx) {
+ throw Error("invalid transaction ID");
+ }
+
+ switch (parsedTx.tag) {
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal: {
+ const withdrawalGroupId = parsedTx.withdrawalGroupId;
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+
+ if (!withdrawalGroupRecord) throw Error("not found");
+
+ const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
+ const ort = await tx.operationRetries.get(opId);
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ ort,
+ );
+ }
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ return buildTransactionForManualWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ },
+ );
+ }
+
+ case TransactionType.DenomLoss: {
+ const rec = await wex.db.runReadOnlyTx(
+ { storeNames: ["denomLossEvents"] },
+ async (tx) => {
+ return tx.denomLossEvents.get(parsedTx.denomLossEventId);
+ },
+ );
+ if (!rec) {
+ throw Error("denom loss record not found");
+ }
+ return buildTransactionForDenomLoss(rec);
+ }
+
+ case TransactionType.Recoup:
+ throw new Error("not yet supported");
+
+ case TransactionType.Payment: {
+ const proposalId = parsedTx.proposalId;
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "purchases",
+ "tombstones",
+ "operationRetries",
+ "contractTerms",
+ "refundGroups",
+ ],
+ },
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) throw Error("not found");
+ const download = await expectProposalDownload(wex, purchase, tx);
+ const contractData = download.contractData;
+ const payOpId = TaskIdentifiers.forPay(purchase);
+ const payRetryRecord = await tx.operationRetries.get(payOpId);
+
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+
+ return buildTransactionForPurchase(
+ purchase,
+ contractData,
+ refunds,
+ payRetryRecord,
+ );
+ },
+ );
+ }
+
+ case TransactionType.Refresh: {
+ // FIXME: We should return info about the refresh here!;
+ const refreshGroupId = parsedTx.refreshGroupId;
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["refreshGroups", "operationRetries"] },
+ async (tx) => {
+ const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroupRec) {
+ throw Error("not found");
+ }
+ const retries = await tx.operationRetries.get(
+ TaskIdentifiers.forRefresh(refreshGroupRec),
+ );
+ return buildTransactionForRefresh(refreshGroupRec, retries);
+ },
+ );
+ }
+
+ case TransactionType.Deposit: {
+ const depositGroupId = parsedTx.depositGroupId;
+ return await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "operationRetries"] },
+ async (tx) => {
+ const depositRecord = await tx.depositGroups.get(depositGroupId);
+ if (!depositRecord) throw Error("not found");
+
+ const retries = await tx.operationRetries.get(
+ TaskIdentifiers.forDeposit(depositRecord),
+ );
+ return buildTransactionForDeposit(depositRecord, retries);
+ },
+ );
+ }
+
+ case TransactionType.Refund: {
+ return await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "refundGroups",
+ "purchases",
+ "operationRetries",
+ "contractTerms",
+ ],
+ },
+ async (tx) => {
+ const refundRecord = await tx.refundGroups.get(
+ parsedTx.refundGroupId,
+ );
+ if (!refundRecord) {
+ throw Error("not found");
+ }
+ const contractData = await lookupMaybeContractData(
+ tx,
+ refundRecord?.proposalId,
+ );
+ return buildTransactionForRefund(refundRecord, contractData);
+ },
+ );
+ }
+ case TransactionType.PeerPullDebit: {
+ return await wex.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "contractTerms"] },
+ async (tx) => {
+ const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
+ if (!debit) throw Error("not found");
+ const contractTermsRec = await tx.contractTerms.get(
+ debit.contractTermsHash,
+ );
+ if (!contractTermsRec)
+ throw Error("contract terms for peer-pull-debit not found");
+ return buildTransactionForPullPaymentDebit(
+ debit,
+ contractTermsRec.contractTermsRaw,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPushDebit: {
+ return await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit", "contractTerms"] },
+ async (tx) => {
+ const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
+ if (!debit) throw Error("not found");
+ const ct = await tx.contractTerms.get(debit.contractTermsHash);
+ checkDbInvariant(!!ct);
+ return buildTransactionForPushPaymentDebit(
+ debit,
+ ct.contractTermsRaw,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPushCredit: {
+ const peerPushCreditId = parsedTx.peerPushCreditId;
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPushCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushInc) throw Error("not found");
+ const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
+ checkDbInvariant(!!ct);
+
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pushInc.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ return buildTransactionForPeerPushCredit(
+ pushInc,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPullCredit: {
+ const pursePub = parsedTx.pursePub;
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "peerPullCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const pushInc = await tx.peerPullCredit.get(pursePub);
+ if (!pushInc) throw Error("not found");
+ const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
+ checkDbInvariant(!!ct);
+
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pushInc.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId =
+ TaskIdentifiers.forPeerPullPaymentInitiation(pushInc);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ return buildTransactionForPeerPullCredit(
+ pushInc,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ );
+ },
+ );
+ }
+ }
+}
+
+function buildTransactionForPushPaymentDebit(
+ pi: PeerPushDebitRecord,
+ contractTerms: PeerContractTerms,
+ ort?: OperationRetryRecord,
+): Transaction {
+ let talerUri: string | undefined = undefined;
+ switch (pi.status) {
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ talerUri = stringifyPayPushUri({
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ contractPriv: pi.contractPriv,
+ });
+ }
+ const txState = computePeerPushDebitTransactionState(pi);
+ return {
+ type: TransactionType.PeerPushDebit,
+ txState,
+ txActions: computePeerPushDebitTransactionActions(pi),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost))
+ : pi.totalCost,
+ amountRaw: pi.amount,
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ info: {
+ expiration: contractTerms.purse_expiration,
+ summary: contractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(pi.timestampCreated),
+ talerUri,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pi.pursePub,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForPullPaymentDebit(
+ pi: PeerPullPaymentIncomingRecord,
+ contractTerms: PeerContractTerms,
+ ort?: OperationRetryRecord,
+): Transaction {
+ const txState = computePeerPullDebitTransactionState(pi);
+ return {
+ type: TransactionType.PeerPullDebit,
+ txState,
+ txActions: computePeerPullDebitTransactionActions(pi),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(pi.amount))
+ : pi.coinSel?.totalCost
+ ? pi.coinSel?.totalCost
+ : Amounts.stringify(pi.amount),
+ amountRaw: Amounts.stringify(pi.amount),
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ info: {
+ expiration: contractTerms.purse_expiration,
+ summary: contractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(pi.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: pi.peerPullDebitId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForPeerPullCredit(
+ pullCredit: PeerPullCreditRecord,
+ pullCreditOrt: OperationRetryRecord | undefined,
+ peerContractTerms: PeerContractTerms,
+ wsr: WithdrawalGroupRecord | undefined,
+ wsrOrt: OperationRetryRecord | undefined,
+): Transaction {
+ if (wsr) {
+ if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) {
+ throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`);
+ }
+ /**
+ * FIXME: this should be handled in the withdrawal process.
+ * PeerPull withdrawal fails until reserve have funds but it is not
+ * an error from the user perspective.
+ */
+ const silentWithdrawalErrorForInvoice =
+ wsrOrt?.lastError &&
+ wsrOrt.lastError.code ===
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
+ Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => {
+ return (
+ e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
+ e.httpStatusCode === 409
+ );
+ });
+ const txState = computePeerPullCreditTransactionState(pullCredit);
+ return {
+ type: TransactionType.PeerPullCredit,
+ txState,
+ txActions: computePeerPullCreditTransactionActions(pullCredit),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
+ : Amounts.stringify(wsr.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wsr.instructedAmount),
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ talerUri: stringifyPayPullUri({
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ contractPriv: wsr.wgInfo.contractPriv,
+ }),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullCredit.pursePub,
+ }),
+ kycUrl: pullCredit.kycUrl,
+ ...(wsrOrt?.lastError
+ ? {
+ error: silentWithdrawalErrorForInvoice
+ ? undefined
+ : wsrOrt.lastError,
+ }
+ : {}),
+ };
+ }
+
+ const txState = computePeerPullCreditTransactionState(pullCredit);
+ return {
+ type: TransactionType.PeerPullCredit,
+ txState,
+ txActions: computePeerPullCreditTransactionActions(pullCredit),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
+ : Amounts.stringify(pullCredit.estimatedAmountEffective),
+ amountRaw: Amounts.stringify(peerContractTerms.amount),
+ exchangeBaseUrl: pullCredit.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ talerUri: stringifyPayPullUri({
+ exchangeBaseUrl: pullCredit.exchangeBaseUrl,
+ contractPriv: pullCredit.contractPriv,
+ }),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullCredit.pursePub,
+ }),
+ kycUrl: pullCredit.kycUrl,
+ ...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
+ };
+}
+
+function buildTransactionForPeerPushCredit(
+ pushInc: PeerPushPaymentIncomingRecord,
+ pushOrt: OperationRetryRecord | undefined,
+ peerContractTerms: PeerContractTerms,
+ wsr: WithdrawalGroupRecord | undefined,
+ wsrOrt: OperationRetryRecord | undefined,
+): Transaction {
+ if (wsr) {
+ if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
+ throw Error("invalid withdrawal group type for push payment credit");
+ }
+
+ const txState = computePeerPushCreditTransactionState(pushInc);
+ return {
+ type: TransactionType.PeerPushCredit,
+ txState,
+ txActions: computePeerPushCreditTransactionActions(pushInc),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
+ : Amounts.stringify(wsr.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wsr.instructedAmount),
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(wsr.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: pushInc.peerPushCreditId,
+ }),
+ kycUrl: pushInc.kycUrl,
+ ...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
+ };
+ }
+
+ const txState = computePeerPushCreditTransactionState(pushInc);
+ return {
+ type: TransactionType.PeerPushCredit,
+ txState,
+ txActions: computePeerPushCreditTransactionActions(pushInc),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
+ : // FIXME: This is wrong, needs to consider fees!
+ Amounts.stringify(peerContractTerms.amount),
+ amountRaw: Amounts.stringify(peerContractTerms.amount),
+ exchangeBaseUrl: pushInc.exchangeBaseUrl,
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ kycUrl: pushInc.kycUrl,
+ timestamp: timestampPreciseFromDb(pushInc.timestamp),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: pushInc.peerPushCreditId,
+ }),
+ ...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}),
+ };
+}
+
+function buildTransactionForBankIntegratedWithdraw(
+ wgRecord: WithdrawalGroupRecord,
+ ort?: OperationRetryRecord,
+): TransactionWithdrawal {
+ if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
+ throw Error("");
+
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+ return {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reservePub: wgRecord.reservePub,
+ bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: wgRecord.withdrawalGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+export function isUnsuccessfulTransaction(state: TransactionState): boolean {
+ return (
+ state.major === TransactionMajorState.Aborted ||
+ state.major === TransactionMajorState.Expired ||
+ state.major === TransactionMajorState.Aborting ||
+ state.major === TransactionMajorState.Deleted ||
+ state.major === TransactionMajorState.Failed
+ );
+}
+
+function buildTransactionForManualWithdraw(
+ withdrawalGroup: WithdrawalGroupRecord,
+ exchangeDetails: ExchangeWireDetails,
+ ort?: OperationRetryRecord,
+): TransactionWithdrawal {
+ if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
+ throw Error("");
+
+ const plainPaytoUris =
+ exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+
+ const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ withdrawalGroup.reservePub,
+ withdrawalGroup.instructedAmount,
+ );
+
+ const txState = computeWithdrawalTransactionStatus(withdrawalGroup);
+
+ return {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(withdrawalGroup),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(
+ Amounts.zeroOfAmount(withdrawalGroup.instructedAmount),
+ )
+ : Amounts.stringify(withdrawalGroup.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris,
+ exchangeCreditAccountDetails:
+ withdrawalGroup.wgInfo.exchangeCreditAccounts,
+ reserveIsReady:
+ withdrawalGroup.status === WithdrawalGroupStatus.Done ||
+ withdrawalGroup.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: withdrawalGroup.kycUrl,
+ exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForRefund(
+ refundRecord: RefundGroupRecord,
+ maybeContractData: WalletContractData | undefined,
+): Transaction {
+ let paymentInfo: RefundPaymentInfo | undefined = undefined;
+
+ if (maybeContractData) {
+ paymentInfo = {
+ merchant: maybeContractData.merchant,
+ summary: maybeContractData.summary,
+ summary_i18n: maybeContractData.summaryI18n,
+ };
+ }
+
+ const txState = computeRefundTransactionState(refundRecord);
+ return {
+ type: TransactionType.Refund,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
+ : refundRecord.amountEffective,
+ amountRaw: refundRecord.amountRaw,
+ refundedTransactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: refundRecord.proposalId,
+ }),
+ timestamp: timestampPreciseFromDb(refundRecord.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId: refundRecord.refundGroupId,
+ }),
+ txState,
+ txActions: [],
+ paymentInfo,
+ };
+}
+
+function buildTransactionForRefresh(
+ refreshGroupRecord: RefreshGroupRecord,
+ ort?: OperationRetryRecord,
+): Transaction {
+ const inputAmount = Amounts.sumOrZero(
+ refreshGroupRecord.currency,
+ refreshGroupRecord.inputPerCoin,
+ ).amount;
+ const outputAmount = Amounts.sumOrZero(
+ refreshGroupRecord.currency,
+ refreshGroupRecord.expectedOutputPerCoin,
+ ).amount;
+ const txState = computeRefreshTransactionState(refreshGroupRecord);
+ return {
+ type: TransactionType.Refresh,
+ txState,
+ txActions: computeRefreshTransactionActions(refreshGroupRecord),
+ refreshReason: refreshGroupRecord.reason,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(inputAmount))
+ : Amounts.stringify(Amounts.sub(outputAmount, inputAmount).amount),
+ amountRaw: Amounts.stringify(
+ Amounts.zeroOfCurrency(refreshGroupRecord.currency),
+ ),
+ refreshInputAmount: Amounts.stringify(inputAmount),
+ refreshOutputAmount: Amounts.stringify(outputAmount),
+ originatingTransactionId: refreshGroupRecord.originatingTransactionId,
+ timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: refreshGroupRecord.refreshGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForDenomLoss(rec: DenomLossEventRecord): Transaction {
+ const txState = computeDenomLossTransactionStatus(rec);
+ return {
+ type: TransactionType.DenomLoss,
+ txState,
+ txActions: [TransactionAction.Delete],
+ amountRaw: Amounts.stringify(rec.amount),
+ amountEffective: Amounts.stringify(rec.amount),
+ timestamp: timestampPreciseFromDb(rec.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId: rec.denomLossEventId,
+ }),
+ lossEventType: rec.eventType,
+ exchangeBaseUrl: rec.exchangeBaseUrl,
+ };
+}
+
+function buildTransactionForDeposit(
+ dg: DepositGroupRecord,
+ ort?: OperationRetryRecord,
+): Transaction {
+ let deposited = true;
+ if (dg.statusPerCoin) {
+ for (const d of dg.statusPerCoin) {
+ if (d == DepositElementStatus.DepositPending) {
+ deposited = false;
+ }
+ }
+ } else {
+ deposited = false;
+ }
+
+ const trackingState: DepositTransactionTrackingState[] = [];
+
+ for (const ts of Object.values(dg.trackingState ?? {})) {
+ trackingState.push({
+ amountRaw: ts.amountRaw,
+ timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
+ wireFee: ts.wireFee,
+ wireTransferId: ts.wireTransferId,
+ });
+ }
+
+ let wireTransferProgress = 0;
+ if (dg.statusPerCoin) {
+ wireTransferProgress =
+ (100 *
+ dg.statusPerCoin.reduce(
+ (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
+ 0,
+ )) /
+ dg.statusPerCoin.length;
+ }
+
+ const txState = computeDepositTransactionStatus(dg);
+ return {
+ type: TransactionType.Deposit,
+ txState,
+ txActions: computeDepositTransactionActions(dg),
+ amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
+ : Amounts.stringify(dg.totalPayCost),
+ timestamp: timestampPreciseFromDb(dg.timestampCreated),
+ targetPaytoUri: dg.wire.payto_uri,
+ wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: dg.depositGroupId,
+ }),
+ wireTransferProgress,
+ depositGroupId: dg.depositGroupId,
+ trackingState,
+ deposited,
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+async function lookupMaybeContractData(
+ tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>,
+ proposalId: string,
+): Promise<WalletContractData | undefined> {
+ let contractData: WalletContractData | undefined = undefined;
+ const purchaseTx = await tx.purchases.get(proposalId);
+ if (purchaseTx && purchaseTx.download) {
+ const download = purchaseTx.download;
+ const contractTermsRecord = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTermsRecord) {
+ return;
+ }
+ contractData = extractContractData(
+ contractTermsRecord?.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ );
+ }
+
+ return contractData;
+}
+
+async function buildTransactionForPurchase(
+ purchaseRecord: PurchaseRecord,
+ contractData: WalletContractData,
+ refundsInfo: RefundGroupRecord[],
+ ort?: OperationRetryRecord,
+): Promise<Transaction> {
+ const zero = Amounts.zeroOfAmount(contractData.amount);
+
+ const info: OrderShortInfo = {
+ merchant: {
+ name: contractData.merchant.name,
+ address: contractData.merchant.address,
+ email: contractData.merchant.email,
+ jurisdiction: contractData.merchant.jurisdiction,
+ website: contractData.merchant.website,
+ },
+ orderId: contractData.orderId,
+ summary: contractData.summary,
+ summary_i18n: contractData.summaryI18n,
+ contractTermsHash: contractData.contractTermsHash,
+ };
+
+ if (contractData.fulfillmentUrl !== "") {
+ info.fulfillmentUrl = contractData.fulfillmentUrl;
+ }
+
+ const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
+ amountEffective: r.amountEffective,
+ amountRaw: r.amountRaw,
+ timestamp: TalerPreciseTimestamp.round(
+ timestampPreciseFromDb(r.timestampCreated),
+ ),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId: r.refundGroupId,
+ }),
+ }));
+
+ const timestamp = purchaseRecord.timestampAccept;
+ checkDbInvariant(!!timestamp);
+ checkDbInvariant(!!purchaseRecord.payInfo);
+
+ const txState = computePayMerchantTransactionState(purchaseRecord);
+ return {
+ type: TransactionType.Payment,
+ txState,
+ txActions: computePayMerchantTransactionActions(purchaseRecord),
+ amountRaw: Amounts.stringify(contractData.amount),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(zero)
+ : Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
+ totalRefundRaw: Amounts.stringify(zero), // FIXME!
+ totalRefundEffective: Amounts.stringify(zero), // FIXME!
+ refundPending:
+ purchaseRecord.refundAmountAwaiting === undefined
+ ? undefined
+ : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
+ refunds,
+ posConfirmation: purchaseRecord.posConfirmation,
+ timestamp: timestampPreciseFromDb(timestamp),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchaseRecord.proposalId,
+ }),
+ proposalId: purchaseRecord.proposalId,
+ info,
+ refundQueryActive:
+ purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+export async function getWithdrawalTransactionByUri(
+ wex: WalletExecutionContext,
+ request: WithdrawalTransactionByURIRequest,
+): Promise<TransactionWithdrawal | undefined> {
+ return await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ request.talerWithdrawUri,
+ );
+
+ if (!withdrawalGroupRecord) {
+ return undefined;
+ }
+
+ const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
+ const ort = await tx.operationRetries.get(opId);
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ ort,
+ );
+ }
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ return buildTransactionForManualWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ },
+ );
+}
+
+/**
+ * Retrieve the full event history for this wallet.
+ */
+export async function getTransactions(
+ wex: WalletExecutionContext,
+ transactionsRequest?: TransactionsRequest,
+): Promise<TransactionsResponse> {
+ const transactions: Transaction[] = [];
+
+ const filter: TransactionRecordFilter = {};
+ if (transactionsRequest?.filterByState) {
+ filter.onlyState = transactionsRequest.filterByState;
+ }
+
+ await wex.db.runReadOnlyTx(
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ "peerPullDebit",
+ "peerPushDebit",
+ "peerPushCredit",
+ "peerPullCredit",
+ "planchets",
+ "purchases",
+ "contractTerms",
+ "recoupGroups",
+ "rewards",
+ "tombstones",
+ "withdrawalGroups",
+ "refreshGroups",
+ "refundGroups",
+ "denomLossEvents",
+ ],
+ },
+ async (tx) => {
+ await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
+ const amount = Amounts.parseOrThrow(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw),
+ );
+ });
+
+ await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
+ const amount = Amounts.parseOrThrow(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ if (
+ pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
+ pi.status !== PeerPullDebitRecordStatus.Done
+ ) {
+ // FIXME: Why?!
+ return;
+ }
+
+ const contractTermsRec = await tx.contractTerms.get(
+ pi.contractTermsHash,
+ );
+ if (!contractTermsRec) {
+ return;
+ }
+
+ transactions.push(
+ buildTransactionForPullPaymentDebit(
+ pi,
+ contractTermsRec.contractTermsRaw,
+ ),
+ );
+ });
+
+ await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
+ if (!pi.currency) {
+ // Legacy transaction
+ return;
+ }
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx)
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ if (pi.status === PeerPushCreditStatus.DialogProposed) {
+ // We don't report proposed push credit transactions, user needs
+ // to scan URI again and confirm to see it.
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pi.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPeerPushCredit(
+ pi,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ ),
+ );
+ });
+
+ await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
+ const currency = Amounts.currencyOf(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pi.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPeerPullCredit(
+ pi,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ ),
+ );
+ });
+
+ await iterRecordsForRefund(tx, filter, async (refundGroup) => {
+ const currency = Amounts.currencyOf(refundGroup.amountRaw);
+
+ const exchangesInTx: string[] = [];
+ const p = await tx.purchases.get(refundGroup.proposalId);
+ if (!p || !p.payInfo || !p.payInfo.payCoinSelection) {
+ //refund with no payment
+ return;
+ }
+
+ // FIXME: This is very slow, should become obsolete with materialized transactions.
+ for (const cp of p.payInfo.payCoinSelection.coinPubs) {
+ const c = await tx.coins.get(cp);
+ if (c?.exchangeBaseUrl) {
+ exchangesInTx.push(c.exchangeBaseUrl);
+ }
+ }
+
+ if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
+ return;
+ }
+ const contractData = await lookupMaybeContractData(
+ tx,
+ refundGroup.proposalId,
+ );
+ transactions.push(buildTransactionForRefund(refundGroup, contractData));
+ });
+
+ await iterRecordsForRefresh(tx, filter, async (rg) => {
+ const exchangesInTx = rg.infoPerExchange
+ ? Object.keys(rg.infoPerExchange)
+ : [];
+ if (
+ shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx)
+ ) {
+ return;
+ }
+ let required = false;
+ const opId = TaskIdentifiers.forRefresh(rg);
+ if (transactionsRequest?.includeRefreshes) {
+ required = true;
+ } else if (rg.operationStatus !== RefreshOperationStatus.Finished) {
+ const ort = await tx.operationRetries.get(opId);
+ if (ort) {
+ required = true;
+ }
+ }
+ if (required) {
+ const ort = await tx.operationRetries.get(opId);
+ transactions.push(buildTransactionForRefresh(rg, ort));
+ }
+ });
+
+ await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
+ const exchangesInTx = [wsr.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ Amounts.currencyOf(wsr.rawWithdrawalAmount),
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+
+ const opId = TaskIdentifiers.forWithdrawal(wsr);
+ const ort = await tx.operationRetries.get(opId);
+
+ switch (wsr.wgInfo.withdrawalType) {
+ case WithdrawalRecordType.PeerPullCredit:
+ // Will be reported by the corresponding p2p transaction.
+ // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
+ // FIXME: Still report if requested with verbose option?
+ return;
+ case WithdrawalRecordType.PeerPushCredit:
+ // Will be reported by the corresponding p2p transaction.
+ // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
+ // FIXME: Still report if requested with verbose option?
+ return;
+ case WithdrawalRecordType.BankIntegrated:
+ transactions.push(
+ buildTransactionForBankIntegratedWithdraw(wsr, ort),
+ );
+ return;
+ case WithdrawalRecordType.BankManual: {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ wsr.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ // FIXME: report somehow
+ return;
+ }
+
+ transactions.push(
+ buildTransactionForManualWithdraw(wsr, exchangeDetails, ort),
+ );
+ return;
+ }
+ case WithdrawalRecordType.Recoup:
+ // FIXME: Do we also report a transaction here?
+ return;
+ }
+ });
+
+ await iterRecordsForDenomLoss(tx, filter, async (rec) => {
+ const amount = Amounts.parseOrThrow(rec.amount);
+ const exchangesInTx = [rec.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ transactions.push(buildTransactionForDenomLoss(rec));
+ });
+
+ await iterRecordsForDeposit(tx, filter, async (dg) => {
+ const amount = Amounts.parseOrThrow(dg.amount);
+ const exchangesInTx = dg.infoPerExchange
+ ? Object.keys(dg.infoPerExchange)
+ : [];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ const opId = TaskIdentifiers.forDeposit(dg);
+ const retryRecord = await tx.operationRetries.get(opId);
+
+ transactions.push(buildTransactionForDeposit(dg, retryRecord));
+ });
+
+ await iterRecordsForPurchase(tx, filter, async (purchase) => {
+ const download = purchase.download;
+ if (!download) {
+ return;
+ }
+ if (!purchase.payInfo) {
+ return;
+ }
+
+ const exchangesInTx: string[] = [];
+ for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) {
+ const c = await tx.coins.get(cp);
+ if (c?.exchangeBaseUrl) {
+ exchangesInTx.push(c.exchangeBaseUrl);
+ }
+ }
+
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ download.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ const contractTermsRecord = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTermsRecord) {
+ return;
+ }
+ if (
+ shouldSkipSearch(transactionsRequest, [
+ contractTermsRecord?.contractTermsRaw?.summary || "",
+ ])
+ ) {
+ return;
+ }
+
+ const contractData = extractContractData(
+ contractTermsRecord?.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ );
+
+ const payOpId = TaskIdentifiers.forPay(purchase);
+ const payRetryRecord = await tx.operationRetries.get(payOpId);
+
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+
+ transactions.push(
+ await buildTransactionForPurchase(
+ purchase,
+ contractData,
+ refunds,
+ payRetryRecord,
+ ),
+ );
+ });
+ },
+ );
+
+ // One-off checks, because of a bug where the wallet previously
+ // did not migrate the DB correctly and caused these amounts
+ // to be missing sometimes.
+ for (let tx of transactions) {
+ if (!tx.amountEffective) {
+ logger.warn(`missing amountEffective in ${j2s(tx)}`);
+ }
+ if (!tx.amountRaw) {
+ logger.warn(`missing amountRaw in ${j2s(tx)}`);
+ }
+ if (!tx.timestamp) {
+ logger.warn(`missing timestamp in ${j2s(tx)}`);
+ }
+ }
+
+ const isPending = (x: Transaction) =>
+ x.txState.major === TransactionMajorState.Pending ||
+ x.txState.major === TransactionMajorState.Aborting ||
+ x.txState.major === TransactionMajorState.Dialog;
+
+ let sortSign: number;
+ if (transactionsRequest?.sort == "descending") {
+ sortSign = -1;
+ } else {
+ sortSign = 1;
+ }
+
+ const txCmp = (h1: Transaction, h2: Transaction) => {
+ // Order transactions by timestamp. Newest transactions come first.
+ const tsCmp = AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
+ AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
+ );
+ // If the timestamp is exactly the same, order by transaction type.
+ if (tsCmp === 0) {
+ return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
+ }
+ return sortSign * tsCmp;
+ };
+
+ if (transactionsRequest?.sort === "stable-ascending") {
+ transactions.sort(txCmp);
+ return { transactions };
+ }
+
+ const txPending = transactions.filter((x) => isPending(x));
+ const txNotPending = transactions.filter((x) => !isPending(x));
+
+ txPending.sort(txCmp);
+ txNotPending.sort(txCmp);
+
+ return { transactions: [...txPending, ...txNotPending] };
+}
+
+export type ParsedTransactionIdentifier =
+ | { tag: TransactionType.Deposit; depositGroupId: string }
+ | { tag: TransactionType.Payment; proposalId: string }
+ | { tag: TransactionType.PeerPullDebit; peerPullDebitId: string }
+ | { tag: TransactionType.PeerPullCredit; pursePub: string }
+ | { tag: TransactionType.PeerPushCredit; peerPushCreditId: string }
+ | { tag: TransactionType.PeerPushDebit; pursePub: string }
+ | { tag: TransactionType.Refresh; refreshGroupId: string }
+ | { tag: TransactionType.Refund; refundGroupId: string }
+ | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
+ | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string }
+ | { tag: TransactionType.Recoup; recoupGroupId: string }
+ | { tag: TransactionType.DenomLoss; denomLossEventId: string };
+
+export function constructTransactionIdentifier(
+ pTxId: ParsedTransactionIdentifier,
+): TransactionIdStr {
+ switch (pTxId.tag) {
+ case TransactionType.Deposit:
+ return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr;
+ case TransactionType.Payment:
+ return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr;
+ case TransactionType.PeerPullCredit:
+ return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
+ case TransactionType.PeerPullDebit:
+ return `txn:${pTxId.tag}:${pTxId.peerPullDebitId}` as TransactionIdStr;
+ case TransactionType.PeerPushCredit:
+ return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` as TransactionIdStr;
+ case TransactionType.PeerPushDebit:
+ return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
+ case TransactionType.Refresh:
+ return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
+ case TransactionType.Refund:
+ return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
+ case TransactionType.Withdrawal:
+ return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
+ case TransactionType.InternalWithdrawal:
+ return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
+ case TransactionType.Recoup:
+ return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr;
+ case TransactionType.DenomLoss:
+ return `txn:${pTxId.tag}:${pTxId.denomLossEventId}` as TransactionIdStr;
+ default:
+ assertUnreachable(pTxId);
+ }
+}
+
+/**
+ * Parse a transaction identifier string into a typed, structured representation.
+ */
+export function parseTransactionIdentifier(
+ transactionId: string,
+): ParsedTransactionIdentifier | undefined {
+ const txnParts = transactionId.split(":");
+
+ if (txnParts.length < 3) {
+ throw Error("id should have al least 3 parts separated by ':'");
+ }
+
+ const [prefix, type, ...rest] = txnParts;
+
+ if (prefix != "txn") {
+ throw Error("invalid transaction identifier");
+ }
+
+ switch (type) {
+ case TransactionType.Deposit:
+ return { tag: TransactionType.Deposit, depositGroupId: rest[0] };
+ case TransactionType.Payment:
+ return { tag: TransactionType.Payment, proposalId: rest[0] };
+ case TransactionType.PeerPullCredit:
+ return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] };
+ case TransactionType.PeerPullDebit:
+ return {
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: rest[0],
+ };
+ case TransactionType.PeerPushCredit:
+ return {
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: rest[0],
+ };
+ case TransactionType.PeerPushDebit:
+ return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] };
+ case TransactionType.Refresh:
+ return { tag: TransactionType.Refresh, refreshGroupId: rest[0] };
+ case TransactionType.Refund:
+ return {
+ tag: TransactionType.Refund,
+ refundGroupId: rest[0],
+ };
+ case TransactionType.Withdrawal:
+ return {
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: rest[0],
+ };
+ case TransactionType.DenomLoss:
+ return {
+ tag: TransactionType.DenomLoss,
+ denomLossEventId: rest[0],
+ };
+ default:
+ return undefined;
+ }
+}
+
+function maybeTaskFromTransaction(
+ transactionId: string,
+): TaskIdStr | undefined {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+
+ if (!parsedTx) {
+ throw Error("invalid transaction identifier");
+ }
+
+ // FIXME: We currently don't cancel active long-polling tasks here.
+
+ switch (parsedTx.tag) {
+ case TransactionType.PeerPullCredit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: parsedTx.pursePub,
+ });
+ case TransactionType.Deposit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: parsedTx.depositGroupId,
+ });
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: parsedTx.withdrawalGroupId,
+ });
+ case TransactionType.Payment:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: parsedTx.proposalId,
+ });
+ case TransactionType.Refresh:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: parsedTx.refreshGroupId,
+ });
+ case TransactionType.PeerPullDebit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: parsedTx.peerPullDebitId,
+ });
+ case TransactionType.PeerPushCredit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId: parsedTx.peerPushCreditId,
+ });
+ case TransactionType.PeerPushDebit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub: parsedTx.pursePub,
+ });
+ case TransactionType.Refund:
+ // Nothing to do for a refund transaction.
+ return undefined;
+ case TransactionType.Recoup:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: parsedTx.recoupGroupId,
+ });
+ case TransactionType.DenomLoss:
+ // Nothing to do for denom loss
+ return undefined;
+ default:
+ assertUnreachable(parsedTx);
+ }
+}
+
+/**
+ * Immediately retry the underlying operation
+ * of a transaction.
+ */
+export async function retryTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ logger.info(`resetting retry timeout for ${transactionId}`);
+ const taskId = maybeTaskFromTransaction(transactionId);
+ if (taskId) {
+ await wex.taskScheduler.resetTaskRetries(taskId);
+ }
+}
+
+async function getContextForTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<TransactionContext> {
+ const tx = parseTransactionIdentifier(transactionId);
+ if (!tx) {
+ throw Error("invalid transaction ID");
+ }
+ switch (tx.tag) {
+ case TransactionType.Deposit:
+ return new DepositTransactionContext(wex, tx.depositGroupId);
+ case TransactionType.Refresh:
+ return new RefreshTransactionContext(wex, tx.refreshGroupId);
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal:
+ return new WithdrawTransactionContext(wex, tx.withdrawalGroupId);
+ case TransactionType.Payment:
+ return new PayMerchantTransactionContext(wex, tx.proposalId);
+ case TransactionType.PeerPullCredit:
+ return new PeerPullCreditTransactionContext(wex, tx.pursePub);
+ case TransactionType.PeerPushDebit:
+ return new PeerPushDebitTransactionContext(wex, tx.pursePub);
+ case TransactionType.PeerPullDebit:
+ return new PeerPullDebitTransactionContext(wex, tx.peerPullDebitId);
+ case TransactionType.PeerPushCredit:
+ return new PeerPushCreditTransactionContext(wex, tx.peerPushCreditId);
+ case TransactionType.Refund:
+ return new RefundTransactionContext(wex, tx.refundGroupId);
+ case TransactionType.Recoup:
+ //return new RecoupTransactionContext(ws, tx.recoupGroupId);
+ throw new Error("not yet supported");
+ case TransactionType.DenomLoss:
+ return new DenomLossTransactionContext(wex, tx.denomLossEventId);
+ default:
+ assertUnreachable(tx);
+ }
+}
+
+/**
+ * Suspends a pending transaction, stopping any associated network activities,
+ * but with a chance of trying again at a later time. This could be useful if
+ * a user needs to save battery power or bandwidth and an operation is expected
+ * to take longer (such as a backup, recovery or very large withdrawal operation).
+ */
+export async function suspendTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.suspendTransaction();
+}
+
+export async function failTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.failTransaction();
+}
+
+/**
+ * Resume a suspended transaction.
+ */
+export async function resumeTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.resumeTransaction();
+}
+
+/**
+ * Permanently delete a transaction based on the transaction ID.
+ */
+export async function deleteTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.deleteTransaction();
+ if (ctx.taskId) {
+ wex.taskScheduler.stopShepherdTask(ctx.taskId);
+ }
+}
+
+export async function abortTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.abortTransaction();
+}
+
+export interface TransitionInfo {
+ oldTxState: TransactionState;
+ newTxState: TransactionState;
+}
+
+/**
+ * Notify of a state transition if necessary.
+ */
+export function notifyTransition(
+ wex: WalletExecutionContext,
+ transactionId: string,
+ transitionInfo: TransitionInfo | undefined,
+ experimentalUserData: any = undefined,
+): void {
+ if (
+ transitionInfo &&
+ !(
+ transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
+ transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
+ )
+ ) {
+ wex.ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ oldTxState: transitionInfo.oldTxState,
+ newTxState: transitionInfo.newTxState,
+ transactionId,
+ experimentalUserData,
+ });
+ }
+}
+
+/**
+ * Iterate refresh records based on a filter.
+ */
+async function iterRecordsForRefresh(
+ tx: WalletDbReadOnlyTransaction<["refreshGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RefreshGroupRecord) => Promise<void>,
+): Promise<void> {
+ let refreshGroups: RefreshGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ RefreshOperationStatus.Pending,
+ RefreshOperationStatus.Suspended,
+ );
+ refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
+ }
+
+ for (const r of refreshGroups) {
+ await f(r);
+ }
+}
+
+async function iterRecordsForWithdrawal(
+ tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: WithdrawalGroupRecord) => Promise<void>,
+): Promise<void> {
+ let withdrawalGroupRecords: WithdrawalGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ withdrawalGroupRecords =
+ await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ withdrawalGroupRecords =
+ await tx.withdrawalGroups.indexes.byStatus.getAll();
+ }
+ for (const wgr of withdrawalGroupRecords) {
+ await f(wgr);
+ }
+}
+
+async function iterRecordsForDeposit(
+ tx: WalletDbReadOnlyTransaction<["depositGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: DepositGroupRecord) => Promise<void>,
+): Promise<void> {
+ let dgs: DepositGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ dgs = await tx.depositGroups.indexes.byStatus.getAll();
+ }
+
+ for (const dg of dgs) {
+ await f(dg);
+ }
+}
+
+async function iterRecordsForDenomLoss(
+ tx: WalletDbReadOnlyTransaction<["denomLossEvents"]>,
+ filter: TransactionRecordFilter,
+ f: (r: DenomLossEventRecord) => Promise<void>,
+): Promise<void> {
+ let dgs: DenomLossEventRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange);
+ } else {
+ dgs = await tx.denomLossEvents.indexes.byStatus.getAll();
+ }
+
+ for (const dg of dgs) {
+ await f(dg);
+ }
+}
+
+async function iterRecordsForRefund(
+ tx: WalletDbReadOnlyTransaction<["refundGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RefundGroupRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.refundGroups.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPurchase(
+ tx: WalletDbReadOnlyTransaction<["purchases"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PurchaseRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPullCredit(
+ tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPullCreditRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPullDebit(
+ tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPullPaymentIncomingRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPushDebit(
+ tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPushDebitRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPushCredit(
+ tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPushPaymentIncomingRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
diff --git a/packages/taler-wallet-core/src/util/RequestThrottler.ts b/packages/taler-wallet-core/src/util/RequestThrottler.ts
deleted file mode 100644
index d79afe47a..000000000
--- a/packages/taler-wallet-core/src/util/RequestThrottler.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-/*
- 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.
-
- 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/>
- */
-
-/**
- * Implementation of token bucket throttling.
- */
-
-/**
- * Imports.
- */
-import {
- getTimestampNow,
- timestampDifference,
- timestampCmp,
- Logger,
- URL,
-} from "@gnu-taler/taler-util";
-
-const logger = new Logger("RequestThrottler.ts");
-
-/**
- * Maximum request per second, per origin.
- */
-const MAX_PER_SECOND = 100;
-
-/**
- * Maximum request per minute, per origin.
- */
-const MAX_PER_MINUTE = 500;
-
-/**
- * Maximum request per hour, per origin.
- */
-const MAX_PER_HOUR = 2000;
-
-/**
- * Throttling state for one origin.
- */
-class OriginState {
- tokensSecond: number = MAX_PER_SECOND;
- tokensMinute: number = MAX_PER_MINUTE;
- tokensHour: number = MAX_PER_HOUR;
- private lastUpdate = getTimestampNow();
-
- private refill(): void {
- const now = getTimestampNow();
- if (timestampCmp(now, this.lastUpdate) < 0) {
- // Did the system time change?
- this.lastUpdate = now;
- return;
- }
- const d = timestampDifference(now, this.lastUpdate);
- if (d.d_ms === "forever") {
- throw Error("assertion failed");
- }
- this.tokensSecond = Math.min(
- MAX_PER_SECOND,
- this.tokensSecond + d.d_ms / 1000,
- );
- this.tokensMinute = Math.min(
- MAX_PER_MINUTE,
- this.tokensMinute + d.d_ms / 1000 / 60,
- );
- this.tokensHour = Math.min(
- MAX_PER_HOUR,
- this.tokensHour + d.d_ms / 1000 / 60 / 60,
- );
- this.lastUpdate = now;
- }
-
- /**
- * Return true if the request for this origin should be throttled.
- * Otherwise, take a token out of the respective buckets.
- */
- applyThrottle(): boolean {
- this.refill();
- if (this.tokensSecond < 1) {
- logger.warn("request throttled (per second limit exceeded)");
- return true;
- }
- if (this.tokensMinute < 1) {
- logger.warn("request throttled (per minute limit exceeded)");
- return true;
- }
- if (this.tokensHour < 1) {
- logger.warn("request throttled (per hour limit exceeded)");
- return true;
- }
- this.tokensSecond--;
- this.tokensMinute--;
- this.tokensHour--;
- return false;
- }
-}
-
-/**
- * Request throttler, used as a "last layer of defense" when some
- * other part of the re-try logic is broken and we're sending too
- * many requests to the same exchange/bank/merchant.
- */
-export class RequestThrottler {
- private perOriginInfo: { [origin: string]: OriginState } = {};
-
- /**
- * Get the throttling state for an origin, or
- * initialize if no state is associated with the
- * origin yet.
- */
- private getState(origin: string): OriginState {
- const s = this.perOriginInfo[origin];
- if (s) {
- return s;
- }
- const ns = (this.perOriginInfo[origin] = new OriginState());
- return ns;
- }
-
- /**
- * Apply throttling to a request.
- *
- * @returns whether the request should be throttled.
- */
- applyThrottle(requestUrl: string): boolean {
- const origin = new URL(requestUrl).origin;
- return this.getState(origin).applyThrottle();
- }
-
- /**
- * Get the throttle statistics for a particular URL.
- */
- getThrottleStats(requestUrl: string): Record<string, unknown> {
- const origin = new URL(requestUrl).origin;
- const state = this.getState(origin);
- return {
- tokensHour: state.tokensHour,
- tokensMinute: state.tokensMinute,
- tokensSecond: state.tokensSecond,
- maxTokensHour: MAX_PER_HOUR,
- maxTokensMinute: MAX_PER_MINUTE,
- maxTokensSecond: MAX_PER_SECOND,
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/util/asyncMemo.ts b/packages/taler-wallet-core/src/util/asyncMemo.ts
deleted file mode 100644
index 6e88081b6..000000000
--- a/packages/taler-wallet-core/src/util/asyncMemo.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- 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 MemoEntry<T> {
- p: Promise<T>;
- t: number;
- n: number;
-}
-
-export class AsyncOpMemoMap<T> {
- private n = 0;
- private memoMap: { [k: string]: MemoEntry<T> } = {};
-
- private cleanUp(key: string, n: number): void {
- const r = this.memoMap[key];
- if (r && r.n === n) {
- delete this.memoMap[key];
- }
- }
-
- memo(key: string, pg: () => Promise<T>): Promise<T> {
- const res = this.memoMap[key];
- if (res) {
- return res.p;
- }
- const n = this.n++;
- // Wrap the operation in case it immediately throws
- const p = Promise.resolve().then(() => pg());
- this.memoMap[key] = {
- p,
- n,
- t: new Date().getTime(),
- };
- return p.finally(() => {
- this.cleanUp(key, n);
- });
- }
- clear(): void {
- this.memoMap = {};
- }
-}
-
-export class AsyncOpMemoSingle<T> {
- private n = 0;
- private memoEntry: MemoEntry<T> | undefined;
-
- private cleanUp(n: number): void {
- if (this.memoEntry && this.memoEntry.n === n) {
- this.memoEntry = undefined;
- }
- }
-
- memo(pg: () => Promise<T>): Promise<T> {
- const res = this.memoEntry;
- if (res) {
- return res.p;
- }
- const n = this.n++;
- // Wrap the operation in case it immediately throws
- const p = Promise.resolve().then(() => pg());
- p.finally(() => {
- this.cleanUp(n);
- });
- this.memoEntry = {
- p,
- n,
- t: new Date().getTime(),
- };
- return p;
- }
- clear(): void {
- this.memoEntry = undefined;
- }
-}
diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts
deleted file mode 100644
index ed48b8dd1..000000000
--- a/packages/taler-wallet-core/src/util/coinSelection.test.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- 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 test from "ava";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { AvailableCoinInfo, selectPayCoins } from "./coinSelection.js";
-
-function a(x: string): AmountJson {
- const amt = Amounts.parse(x);
- if (!amt) {
- throw Error("invalid amount");
- }
- return amt;
-}
-
-function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo {
- return {
- availableAmount: a(current),
- coinPub: "foobar",
- denomPub: "foobar",
- feeDeposit: a(feeDeposit),
- exchangeBaseUrl: "https://example.com/",
- };
-}
-
-test("coin selection 1", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.1"),
- fakeAci("EUR:1.0", "EUR:0.0"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.1"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- if (!res) {
- t.fail();
- return;
- }
- t.true(res.coinPubs.length === 2);
- t.pass();
-});
-
-test("coin selection 2", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.0"),
- // Merchant covers the fee, this one shouldn't be used
- fakeAci("EUR:1.0", "EUR:0.0"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.5"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- if (!res) {
- t.fail();
- return;
- }
- t.true(res.coinPubs.length === 2);
- t.pass();
-});
-
-test("coin selection 3", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- // this coin should be selected instead of previous one with fee
- fakeAci("EUR:1.0", "EUR:0.0"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.5"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- if (!res) {
- t.fail();
- return;
- }
- t.true(res.coinPubs.length === 2);
- t.pass();
-});
-
-test("coin selection 4", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.5"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- if (!res) {
- t.fail();
- return;
- }
- t.true(res.coinPubs.length === 3);
- t.pass();
-});
-
-test("coin selection 5", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:4.0"),
- depositFeeLimit: a("EUR:0.2"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- t.true(!res);
- t.pass();
-});
-
-test("coin selection 6", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- ];
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.2"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
- t.true(!res);
- t.pass();
-});
-
-test("coin selection 7", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.1"),
- fakeAci("EUR:1.0", "EUR:0.1"),
- ];
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.2"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
- t.truthy(res);
- t.true(Amounts.cmp(res!.customerDepositFees, "EUR:0.0") === 0);
- t.true(
- Amounts.cmp(Amounts.sum(res!.coinContributions).amount, "EUR:2.0") === 0,
- );
- t.pass();
-});
-
-test("coin selection 8", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.2"),
- fakeAci("EUR:0.1", "EUR:0.2"),
- fakeAci("EUR:0.05", "EUR:0.05"),
- fakeAci("EUR:0.05", "EUR:0.05"),
- ];
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:1.1"),
- depositFeeLimit: a("EUR:0.4"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
- t.truthy(res);
- t.true(res!.coinContributions.length === 3);
- t.pass();
-});
-
-test("coin selection 9", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.2"),
- fakeAci("EUR:0.2", "EUR:0.2"),
- ];
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:1.2"),
- depositFeeLimit: a("EUR:0.4"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
- t.truthy(res);
- t.true(res!.coinContributions.length === 2);
- t.true(
- Amounts.cmp(Amounts.sum(res!.coinContributions).amount, "EUR:1.2") === 0,
- );
- t.pass();
-});
diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts
deleted file mode 100644
index 500cee5d8..000000000
--- a/packages/taler-wallet-core/src/util/coinSelection.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Selection of coins for payments.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { strcmp, Logger } from "@gnu-taler/taler-util";
-
-const logger = new Logger("coinSelection.ts");
-
-/**
- * Result of selecting coins, contains the exchange, and selected
- * coins with their denomination.
- */
-export interface PayCoinSelection {
- /**
- * Amount requested by the merchant.
- */
- paymentAmount: AmountJson;
-
- /**
- * Public keys of the coins that were selected.
- */
- coinPubs: string[];
-
- /**
- * Amount that each coin contributes.
- */
- coinContributions: AmountJson[];
-
- /**
- * How much of the wire fees is the customer paying?
- */
- customerWireFees: AmountJson;
-
- /**
- * How much of the deposit fees is the customer paying?
- */
- customerDepositFees: AmountJson;
-}
-
-/**
- * Structure to describe a coin that is available to be
- * used in a payment.
- */
-export interface AvailableCoinInfo {
- /**
- * Public key of the coin.
- */
- coinPub: string;
-
- /**
- * Coin's denomination public key.
- */
- denomPub: string;
-
- /**
- * Amount still remaining (typically the full amount,
- * as coins are always refreshed after use.)
- */
- availableAmount: AmountJson;
-
- /**
- * Deposit fee for the coin.
- */
- feeDeposit: AmountJson;
-
- exchangeBaseUrl: string;
-}
-
-export type PreviousPayCoins = {
- coinPub: string;
- contribution: AmountJson;
- feeDeposit: AmountJson;
- exchangeBaseUrl: string;
-}[];
-
-export interface CoinCandidateSelection {
- candidateCoins: AvailableCoinInfo[];
- wireFeesPerExchange: Record<string, AmountJson>;
-}
-
-export interface SelectPayCoinRequest {
- candidates: CoinCandidateSelection;
- contractTermsAmount: AmountJson;
- depositFeeLimit: AmountJson;
- wireFeeLimit: AmountJson;
- wireFeeAmortization: number;
- prevPayCoins?: PreviousPayCoins;
-}
-
-interface CoinSelectionTally {
- /**
- * Amount that still needs to be paid.
- * May increase during the computation when fees need to be covered.
- */
- amountPayRemaining: AmountJson;
-
- /**
- * Allowance given by the merchant towards wire fees
- */
- amountWireFeeLimitRemaining: AmountJson;
-
- /**
- * Allowance given by the merchant towards deposit fees
- * (and wire fees after wire fee limit is exhausted)
- */
- amountDepositFeeLimitRemaining: AmountJson;
-
- customerDepositFees: AmountJson;
-
- customerWireFees: AmountJson;
-
- wireFeeCoveredForExchange: Set<string>;
-}
-
-/**
- * Account for the fees of spending a coin.
- */
-function tallyFees(
- tally: CoinSelectionTally,
- wireFeesPerExchange: Record<string, AmountJson>,
- wireFeeAmortization: number,
- exchangeBaseUrl: string,
- feeDeposit: AmountJson,
-): CoinSelectionTally {
- const currency = tally.amountPayRemaining.currency;
- let amountWireFeeLimitRemaining = tally.amountWireFeeLimitRemaining;
- let amountDepositFeeLimitRemaining = tally.amountDepositFeeLimitRemaining;
- let customerDepositFees = tally.customerDepositFees;
- let customerWireFees = tally.customerWireFees;
- let amountPayRemaining = tally.amountPayRemaining;
- const wireFeeCoveredForExchange = new Set(tally.wireFeeCoveredForExchange);
-
- if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
- const wf =
- wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.getZero(currency);
- const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf);
- amountWireFeeLimitRemaining = Amounts.sub(
- amountWireFeeLimitRemaining,
- wfForgiven,
- ).amount;
- // The remaining, amortized amount needs to be paid by the
- // wallet or covered by the deposit fee allowance.
- let wfRemaining = Amounts.divide(
- Amounts.sub(wf, wfForgiven).amount,
- wireFeeAmortization,
- );
-
- // This is the amount forgiven via the deposit fee allowance.
- const wfDepositForgiven = Amounts.min(
- amountDepositFeeLimitRemaining,
- wfRemaining,
- );
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
- wfDepositForgiven,
- ).amount;
-
- wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
- customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount;
- amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount;
-
- wireFeeCoveredForExchange.add(exchangeBaseUrl);
- }
-
- const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining);
-
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
- dfForgiven,
- ).amount;
-
- // How much does the user spend on deposit fees for this coin?
- const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount;
- customerDepositFees = Amounts.add(customerDepositFees, dfRemaining).amount;
- amountPayRemaining = Amounts.add(amountPayRemaining, dfRemaining).amount;
-
- return {
- amountDepositFeeLimitRemaining,
- amountPayRemaining,
- amountWireFeeLimitRemaining,
- customerDepositFees,
- customerWireFees,
- wireFeeCoveredForExchange,
- };
-}
-
-/**
- * Given a list of candidate coins, select coins to spend under the merchant's
- * constraints.
- *
- * The prevPayCoins can be specified to "repair" a coin selection
- * by adding additional coins, after a broken (e.g. double-spent) coin
- * has been removed from the selection.
- *
- * This function is only exported for the sake of unit tests.
- */
-export function selectPayCoins(
- req: SelectPayCoinRequest,
-): PayCoinSelection | undefined {
- const {
- candidates,
- contractTermsAmount,
- depositFeeLimit,
- wireFeeLimit,
- wireFeeAmortization,
- } = req;
-
- if (candidates.candidateCoins.length === 0) {
- return undefined;
- }
- const coinPubs: string[] = [];
- const coinContributions: AmountJson[] = [];
- const currency = contractTermsAmount.currency;
-
- let tally: CoinSelectionTally = {
- amountPayRemaining: contractTermsAmount,
- amountWireFeeLimitRemaining: wireFeeLimit,
- amountDepositFeeLimitRemaining: depositFeeLimit,
- customerDepositFees: Amounts.getZero(currency),
- customerWireFees: Amounts.getZero(currency),
- wireFeeCoveredForExchange: new Set(),
- };
-
- const prevPayCoins = req.prevPayCoins ?? [];
-
- // Look at existing pay coin selection and tally up
- for (const prev of prevPayCoins) {
- tally = tallyFees(
- tally,
- candidates.wireFeesPerExchange,
- wireFeeAmortization,
- prev.exchangeBaseUrl,
- prev.feeDeposit,
- );
- tally.amountPayRemaining = Amounts.sub(
- tally.amountPayRemaining,
- prev.contribution,
- ).amount;
-
- coinPubs.push(prev.coinPub);
- coinContributions.push(prev.contribution);
- }
-
- const prevCoinPubs = new Set(prevPayCoins.map((x) => x.coinPub));
-
- // Sort by available amount (descending), deposit fee (ascending) and
- // denomPub (ascending) if deposit fee is the same
- // (to guarantee deterministic results)
- const candidateCoins = [...candidates.candidateCoins].sort(
- (o1, o2) =>
- -Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
- Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
- strcmp(o1.denomPub, o2.denomPub),
- );
-
- // FIXME: Here, we should select coins in a smarter way.
- // Instead of always spending the next-largest coin,
- // we should try to find the smallest coin that covers the
- // amount.
-
- for (const aci of candidateCoins) {
- // Don't use this coin if depositing it is more expensive than
- // the amount it would give the merchant.
- if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) > 0) {
- continue;
- }
-
- if (Amounts.isZero(tally.amountPayRemaining)) {
- // We have spent enough!
- break;
- }
-
- // The same coin can't contribute twice to the same payment,
- // by a fundamental, intentional limitation of the protocol.
- if (prevCoinPubs.has(aci.coinPub)) {
- continue;
- }
-
- tally = tallyFees(
- tally,
- candidates.wireFeesPerExchange,
- wireFeeAmortization,
- aci.exchangeBaseUrl,
- aci.feeDeposit,
- );
-
- let coinSpend = Amounts.max(
- Amounts.min(tally.amountPayRemaining, aci.availableAmount),
- aci.feeDeposit,
- );
-
- tally.amountPayRemaining = Amounts.sub(
- tally.amountPayRemaining,
- coinSpend,
- ).amount;
- coinPubs.push(aci.coinPub);
- coinContributions.push(coinSpend);
- }
-
- if (Amounts.isZero(tally.amountPayRemaining)) {
- return {
- paymentAmount: contractTermsAmount,
- coinContributions,
- coinPubs,
- customerDepositFees: tally.customerDepositFees,
- customerWireFees: tally.customerWireFees,
- };
- }
- return undefined;
-}
diff --git a/packages/taler-wallet-core/src/util/contractTerms.test.ts b/packages/taler-wallet-core/src/util/contractTerms.test.ts
deleted file mode 100644
index 74cae4ca7..000000000
--- a/packages/taler-wallet-core/src/util/contractTerms.test.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- 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 test from "ava";
-import { ContractTermsUtil } from "./contractTerms.js";
-
-test("contract terms canon hashing", (t) => {
- const cReq = {
- foo: 42,
- bar: "hello",
- $forgettable: {
- foo: true,
- },
- };
-
- const c1 = ContractTermsUtil.saltForgettable(cReq);
- const c2 = ContractTermsUtil.saltForgettable(cReq);
- t.assert(typeof cReq.$forgettable.foo === "boolean");
- t.assert(typeof c1.$forgettable.foo === "string");
- t.assert(c1.$forgettable.foo !== c2.$forgettable.foo);
-
- const h1 = ContractTermsUtil.hashContractTerms(c1);
-
- const c3 = ContractTermsUtil.scrub(JSON.parse(JSON.stringify(c1)));
-
- t.assert(c3.foo === undefined);
- t.assert(c3.bar === cReq.bar);
-
- const h2 = ContractTermsUtil.hashContractTerms(c3);
-
- t.deepEqual(h1, h2);
-});
-
-test("contract terms canon hashing (nested)", (t) => {
- const cReq = {
- foo: 42,
- bar: {
- prop1: "hello, world",
- $forgettable: {
- prop1: true,
- },
- },
- $forgettable: {
- bar: true,
- },
- };
-
- const c1 = ContractTermsUtil.saltForgettable(cReq);
-
- t.is(typeof c1.$forgettable.bar, "string");
- t.is(typeof c1.bar.$forgettable.prop1, "string");
-
- const forgetPath = (x: any, s: string) =>
- ContractTermsUtil.forgetAll(x, (p) => p.join(".") === s);
-
- // Forget bar first
- const c2 = forgetPath(c1, "bar");
-
- // Forget bar.prop1 first
- const c3 = forgetPath(forgetPath(c1, "bar.prop1"), "bar");
-
- // Forget everything
- const c4 = ContractTermsUtil.scrub(c1);
-
- const h1 = ContractTermsUtil.hashContractTerms(c1);
- const h2 = ContractTermsUtil.hashContractTerms(c2);
- const h3 = ContractTermsUtil.hashContractTerms(c3);
- const h4 = ContractTermsUtil.hashContractTerms(c4);
-
- t.is(h1, h2);
- t.is(h1, h3);
- t.is(h1, h4);
-
- // Doesn't contain salt
- t.false(ContractTermsUtil.validateForgettable(cReq));
-
- t.true(ContractTermsUtil.validateForgettable(c1));
- t.true(ContractTermsUtil.validateForgettable(c2));
- t.true(ContractTermsUtil.validateForgettable(c3));
- t.true(ContractTermsUtil.validateForgettable(c4));
-});
-
-test("contract terms reference vector", (t) => {
- const j = {
- k1: 1,
- $forgettable: {
- k1: "SALT",
- },
- k2: {
- n1: true,
- $forgettable: {
- n1: "salt",
- },
- },
- k3: {
- n1: "string",
- },
- };
-
- const h = ContractTermsUtil.hashContractTerms(j);
-
- t.deepEqual(
- h,
- "VDE8JPX0AEEE3EX1K8E11RYEWSZQKGGZCV6BWTE4ST1C8711P7H850Z7F2Q2HSSYETX87ERC2JNHWB7GTDWTDWMM716VKPSRBXD7SRR",
- );
-});
diff --git a/packages/taler-wallet-core/src/util/contractTerms.ts b/packages/taler-wallet-core/src/util/contractTerms.ts
deleted file mode 100644
index b064079e9..000000000
--- a/packages/taler-wallet-core/src/util/contractTerms.ts
+++ /dev/null
@@ -1,231 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- 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 { canonicalJson, Logger } from "@gnu-taler/taler-util";
-import { kdf } from "@gnu-taler/taler-util";
-import {
- decodeCrock,
- encodeCrock,
- getRandomBytes,
- hash,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-
-const logger = new Logger("contractTerms.ts");
-
-export namespace ContractTermsUtil {
- export type PathPredicate = (path: string[]) => boolean;
-
- /**
- * Scrub all forgettable members from an object.
- */
- export function scrub(anyJson: any): any {
- return forgetAllImpl(anyJson, [], () => true);
- }
-
- /**
- * Recursively forget all forgettable members of an object,
- * where the path matches a predicate.
- */
- export function forgetAll(anyJson: any, pred: PathPredicate): any {
- return forgetAllImpl(anyJson, [], pred);
- }
-
- function forgetAllImpl(
- anyJson: any,
- path: string[],
- pred: PathPredicate,
- ): any {
- const dup = JSON.parse(JSON.stringify(anyJson));
- if (Array.isArray(dup)) {
- for (let i = 0; i < dup.length; i++) {
- dup[i] = forgetAllImpl(dup[i], [...path, `${i}`], pred);
- }
- } else if (typeof dup === "object" && dup != null) {
- if (typeof dup.$forgettable === "object") {
- for (const x of Object.keys(dup.$forgettable)) {
- if (!pred([...path, x])) {
- continue;
- }
- if (!dup.$forgotten) {
- dup.$forgotten = {};
- }
- if (!dup.$forgotten[x]) {
- const membValCanon = stringToBytes(
- canonicalJson(scrub(dup[x])) + "\0",
- );
- const membSalt = stringToBytes(dup.$forgettable[x] + "\0");
- const h = kdf(64, membValCanon, membSalt, new Uint8Array([]));
- dup.$forgotten[x] = encodeCrock(h);
- }
- delete dup[x];
- delete dup.$forgettable[x];
- }
- if (Object.keys(dup.$forgettable).length === 0) {
- delete dup.$forgettable;
- }
- }
- for (const x of Object.keys(dup)) {
- if (x.startsWith("$")) {
- continue;
- }
- dup[x] = forgetAllImpl(dup[x], [...path, x], pred);
- }
- }
- return dup;
- }
-
- /**
- * Generate a salt for all members marked as forgettable,
- * but which don't have an actual salt yet.
- */
- export function saltForgettable(anyJson: any): any {
- const dup = JSON.parse(JSON.stringify(anyJson));
- if (Array.isArray(dup)) {
- for (let i = 0; i < dup.length; i++) {
- dup[i] = saltForgettable(dup[i]);
- }
- } else if (typeof dup === "object" && dup !== null) {
- if (typeof dup.$forgettable === "object") {
- for (const k of Object.keys(dup.$forgettable)) {
- if (dup.$forgettable[k] === true) {
- dup.$forgettable[k] = encodeCrock(getRandomBytes(32));
- }
- }
- }
- for (const x of Object.keys(dup)) {
- if (x.startsWith("$")) {
- continue;
- }
- dup[x] = saltForgettable(dup[x]);
- }
- }
- return dup;
- }
-
- const nameRegex = /^[0-9A-Za-z_]+$/;
-
- /**
- * Check that the given JSON object is well-formed with regards
- * to forgettable fields and other restrictions for forgettable JSON.
- */
- export function validateForgettable(anyJson: any): boolean {
- if (typeof anyJson === "string") {
- return true;
- }
- if (typeof anyJson === "number") {
- return (
- Number.isInteger(anyJson) &&
- anyJson >= Number.MIN_SAFE_INTEGER &&
- anyJson <= Number.MAX_SAFE_INTEGER
- );
- }
- if (typeof anyJson === "boolean") {
- return true;
- }
- if (anyJson === null) {
- return true;
- }
- if (Array.isArray(anyJson)) {
- return anyJson.every((x) => validateForgettable(x));
- }
- if (typeof anyJson === "object") {
- for (const k of Object.keys(anyJson)) {
- if (k.match(nameRegex)) {
- if (validateForgettable(anyJson[k])) {
- continue;
- } else {
- return false;
- }
- }
- if (k === "$forgettable") {
- const fga = anyJson.$forgettable;
- if (!fga || typeof fga !== "object") {
- return false;
- }
- for (const fk of Object.keys(fga)) {
- if (!fk.match(nameRegex)) {
- return false;
- }
- if (!(fk in anyJson)) {
- return false;
- }
- const fv = anyJson.$forgettable[fk];
- if (typeof fv !== "string") {
- return false;
- }
- }
- } else if (k === "$forgotten") {
- const fgo = anyJson.$forgotten;
- if (!fgo || typeof fgo !== "object") {
- return false;
- }
- for (const fk of Object.keys(fgo)) {
- if (!fk.match(nameRegex)) {
- return false;
- }
- // Check that the value has actually been forgotten.
- if (fk in anyJson) {
- return false;
- }
- const fv = anyJson.$forgotten[fk];
- if (typeof fv !== "string") {
- return false;
- }
- try {
- const decFv = decodeCrock(fv);
- if (decFv.length != 64) {
- return false;
- }
- } catch (e) {
- return false;
- }
- // Check that salt has been deleted after forgetting.
- if (anyJson.$forgettable?.[k] !== undefined) {
- return false;
- }
- }
- } else {
- return false;
- }
- }
- return true;
- }
- return false;
- }
-
- /**
- * Check that no forgettable information has been forgotten.
- *
- * Must only be called on an object already validated with validateForgettable.
- */
- export function validateNothingForgotten(contractTerms: any): boolean {
- throw Error("not implemented yet");
- }
-
- /**
- * Hash a contract terms object. Forgettable fields
- * are scrubbed and JSON canonicalization is applied
- * before hashing.
- */
- export function hashContractTerms(contractTerms: unknown): string {
- const cleaned = scrub(contractTerms);
- const canon = canonicalJson(cleaned) + "\0";
- const bytes = stringToBytes(canon);
- logger.info(`contract terms before hashing: ${encodeCrock(bytes)}`);
- return encodeCrock(hash(bytes));
- }
-}
diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts
deleted file mode 100644
index d01f2ee42..000000000
--- a/packages/taler-wallet-core/src/util/http.ts
+++ /dev/null
@@ -1,342 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Helpers for doing XMLHttpRequest-s that are based on ES6 promises.
- * Allows for easy mocking for test cases.
- *
- * The API is inspired by the HTML5 fetch API.
- */
-
-/**
- * Imports
- */
-import { OperationFailedError, makeErrorDetails } from "../errors.js";
-import {
- Logger,
- Duration,
- Timestamp,
- getTimestampNow,
- timestampAddDuration,
- timestampMax,
- TalerErrorDetails,
- Codec,
-} from "@gnu-taler/taler-util";
-import { TalerErrorCode } from "@gnu-taler/taler-util";
-
-const logger = new Logger("http.ts");
-
-/**
- * An HTTP response that is returned by all request methods of this library.
- */
-export interface HttpResponse {
- requestUrl: string;
- requestMethod: string;
- status: number;
- headers: Headers;
- json(): Promise<any>;
- text(): Promise<string>;
- bytes(): Promise<ArrayBuffer>;
-}
-
-export interface HttpRequestOptions {
- method?: "POST" | "PUT" | "GET";
- headers?: { [name: string]: string };
- timeout?: Duration;
- body?: string | ArrayBuffer | ArrayBufferView;
-}
-
-export enum HttpResponseStatus {
- Ok = 200,
- NoContent = 204,
- Gone = 210,
- NotModified = 304,
- BadRequest = 400,
- PaymentRequired = 402,
- NotFound = 404,
- Conflict = 409,
-}
-
-/**
- * Headers, roughly modeled after the fetch API's headers object.
- */
-export class Headers {
- private headerMap = new Map<string, string>();
-
- get(name: string): string | null {
- const r = this.headerMap.get(name.toLowerCase());
- if (r) {
- return r;
- }
- return null;
- }
-
- set(name: string, value: string): void {
- const normalizedName = name.toLowerCase();
- const existing = this.headerMap.get(normalizedName);
- if (existing !== undefined) {
- this.headerMap.set(normalizedName, existing + "," + value);
- } else {
- this.headerMap.set(normalizedName, value);
- }
- }
-
- toJSON(): any {
- const m: Record<string, string> = {};
- this.headerMap.forEach((v, k) => (m[k] = v));
- return m;
- }
-}
-
-/**
- * Interface for the HTTP request library used by the wallet.
- *
- * The request library is bundled into an interface to make mocking and
- * request tunneling easy.
- */
-export interface HttpRequestLibrary {
- /**
- * Make an HTTP GET request.
- */
- get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
-
- /**
- * Make an HTTP POST request with a JSON body.
- */
- postJson(
- url: string,
- body: any,
- opt?: HttpRequestOptions,
- ): Promise<HttpResponse>;
-
- /**
- * Make an HTTP POST request with a JSON body.
- */
- fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
-}
-
-type TalerErrorResponse = {
- code: number;
-} & unknown;
-
-type ResponseOrError<T> =
- | { isError: false; response: T }
- | { isError: true; talerErrorResponse: TalerErrorResponse };
-
-export async function readTalerErrorResponse(
- httpResponse: HttpResponse,
-): Promise<TalerErrorDetails> {
- const errJson = await httpResponse.json();
- const talerErrorCode = errJson.code;
- if (typeof talerErrorCode !== "number") {
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Error response did not contain error code",
- {
- requestUrl: httpResponse.requestUrl,
- requestMethod: httpResponse.requestMethod,
- httpStatusCode: httpResponse.status,
- },
- ),
- );
- }
- return errJson;
-}
-
-export async function readUnexpectedResponseDetails(
- httpResponse: HttpResponse,
-): Promise<TalerErrorDetails> {
- const errJson = await httpResponse.json();
- const talerErrorCode = errJson.code;
- if (typeof talerErrorCode !== "number") {
- return makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Error response did not contain error code",
- {
- requestUrl: httpResponse.requestUrl,
- requestMethod: httpResponse.requestMethod,
- httpStatusCode: httpResponse.status,
- },
- );
- }
- return makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "Unexpected error code in response",
- {
- requestUrl: httpResponse.requestUrl,
- httpStatusCode: httpResponse.status,
- errorResponse: errJson,
- },
- );
-}
-
-export async function readSuccessResponseJsonOrErrorCode<T>(
- httpResponse: HttpResponse,
- codec: Codec<T>,
-): Promise<ResponseOrError<T>> {
- if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
- return {
- isError: true,
- talerErrorResponse: await readTalerErrorResponse(httpResponse),
- };
- }
- const respJson = await httpResponse.json();
- let parsedResponse: T;
- try {
- parsedResponse = codec.decode(respJson);
- } catch (e: any) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Response invalid",
- {
- requestUrl: httpResponse.requestUrl,
- httpStatusCode: httpResponse.status,
- validationError: e.toString(),
- },
- );
- }
- return {
- isError: false,
- response: parsedResponse,
- };
-}
-
-export function getHttpResponseErrorDetails(
- httpResponse: HttpResponse,
-): Record<string, unknown> {
- return {
- requestUrl: httpResponse.requestUrl,
- httpStatusCode: httpResponse.status,
- };
-}
-
-export function throwUnexpectedRequestError(
- httpResponse: HttpResponse,
- talerErrorResponse: TalerErrorResponse,
-): never {
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "Unexpected error code in response",
- {
- requestUrl: httpResponse.requestUrl,
- httpStatusCode: httpResponse.status,
- errorResponse: talerErrorResponse,
- },
- ),
- );
-}
-
-export async function readSuccessResponseJsonOrThrow<T>(
- httpResponse: HttpResponse,
- codec: Codec<T>,
-): Promise<T> {
- const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec);
- if (!r.isError) {
- return r.response;
- }
- throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
-}
-
-export async function readSuccessResponseTextOrErrorCode<T>(
- httpResponse: HttpResponse,
-): Promise<ResponseOrError<string>> {
- if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
- const errJson = await httpResponse.json();
- const talerErrorCode = errJson.code;
- if (typeof talerErrorCode !== "number") {
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Error response did not contain error code",
- {
- httpStatusCode: httpResponse.status,
- requestUrl: httpResponse.requestUrl,
- requestMethod: httpResponse.requestMethod,
- },
- ),
- );
- }
- return {
- isError: true,
- talerErrorResponse: errJson,
- };
- }
- const respJson = await httpResponse.text();
- return {
- isError: false,
- response: respJson,
- };
-}
-
-export async function checkSuccessResponseOrThrow(
- httpResponse: HttpResponse,
-): Promise<void> {
- if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
- const errJson = await httpResponse.json();
- const talerErrorCode = errJson.code;
- if (typeof talerErrorCode !== "number") {
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Error response did not contain error code",
- {
- httpStatusCode: httpResponse.status,
- requestUrl: httpResponse.requestUrl,
- requestMethod: httpResponse.requestMethod,
- },
- ),
- );
- }
- throwUnexpectedRequestError(httpResponse, errJson);
- }
-}
-
-export async function readSuccessResponseTextOrThrow<T>(
- httpResponse: HttpResponse,
-): Promise<string> {
- const r = await readSuccessResponseTextOrErrorCode(httpResponse);
- if (!r.isError) {
- return r.response;
- }
- throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
-}
-
-/**
- * Get the timestamp at which the response's content is considered expired.
- */
-export function getExpiryTimestamp(
- httpResponse: HttpResponse,
- opt: { minDuration?: Duration },
-): Timestamp {
- const expiryDateMs = new Date(
- httpResponse.headers.get("expiry") ?? "",
- ).getTime();
- let t: Timestamp;
- if (Number.isNaN(expiryDateMs)) {
- t = getTimestampNow();
- } else {
- t = {
- t_ms: expiryDateMs,
- };
- }
- if (opt.minDuration) {
- const t2 = timestampAddDuration(getTimestampNow(), opt.minDuration);
- return timestampMax(t, t2);
- }
- return t;
-}
diff --git a/packages/taler-wallet-core/src/util/promiseUtils.ts b/packages/taler-wallet-core/src/util/promiseUtils.ts
deleted file mode 100644
index d409686d9..000000000
--- a/packages/taler-wallet-core/src/util/promiseUtils.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-export interface OpenedPromise<T> {
- promise: Promise<T>;
- resolve: (val: T) => void;
- reject: (err: any) => void;
-}
-
-/**
- * Get an unresolved promise together with its extracted resolve / reject
- * function.
- */
-export function openPromise<T>(): OpenedPromise<T> {
- let resolve: ((x?: any) => void) | null = null;
- let reject: ((reason?: any) => void) | null = null;
- const promise = new Promise<T>((res, rej) => {
- resolve = res;
- reject = rej;
- });
- if (!(resolve && reject)) {
- // Never happens, unless JS implementation is broken
- throw Error();
- }
- return { resolve, reject, promise };
-}
-
-export class AsyncCondition {
- private _waitPromise: Promise<void>;
- private _resolveWaitPromise: (val: void) => void;
- constructor() {
- const op = openPromise<void>();
- this._waitPromise = op.promise;
- this._resolveWaitPromise = op.resolve;
- }
-
- wait(): Promise<void> {
- return this._waitPromise;
- }
-
- trigger(): void {
- this._resolveWaitPromise();
- const op = openPromise<void>();
- this._waitPromise = op.promise;
- this._resolveWaitPromise = op.resolve;
- }
-}
diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts
deleted file mode 100644
index a95cbf1ff..000000000
--- a/packages/taler-wallet-core/src/util/query.ts
+++ /dev/null
@@ -1,615 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Database query abstractions.
- * @module Query
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import { openPromise } from "./promiseUtils.js";
-import {
- IDBRequest,
- IDBTransaction,
- IDBValidKey,
- IDBDatabase,
- IDBFactory,
- IDBVersionChangeEvent,
- IDBCursor,
- IDBKeyPath,
-} from "@gnu-taler/idb-bridge";
-import { Logger } from "@gnu-taler/taler-util";
-import { performanceNow } from "./timer.js";
-
-const logger = new Logger("query.ts");
-
-/**
- * Exception that should be thrown by client code to abort a transaction.
- */
-export const TransactionAbort = Symbol("transaction_abort");
-
-/**
- * Options for an index.
- */
-export interface IndexOptions {
- /**
- * If true and the path resolves to an array, create an index entry for
- * each member of the array (instead of one index entry containing the full array).
- *
- * Defaults to false.
- */
- multiEntry?: boolean;
-
- /**
- * Database version that this store was added in, or
- * undefined if added in the first version.
- */
- versionAdded?: number;
-}
-
-function requestToPromise(req: IDBRequest): Promise<any> {
- const stack = Error("Failed request was started here.");
- return new Promise((resolve, reject) => {
- req.onsuccess = () => {
- resolve(req.result);
- };
- req.onerror = () => {
- console.error("error in DB request", req.error);
- reject(req.error);
- console.error("Request failed:", stack);
- };
- });
-}
-
-type CursorResult<T> = CursorEmptyResult<T> | CursorValueResult<T>;
-
-interface CursorEmptyResult<T> {
- hasValue: false;
-}
-
-interface CursorValueResult<T> {
- hasValue: true;
- value: T;
-}
-
-class TransactionAbortedError extends Error {
- constructor(m: string) {
- super(m);
-
- // Set the prototype explicitly.
- Object.setPrototypeOf(this, TransactionAbortedError.prototype);
- }
-}
-
-class ResultStream<T> {
- private currentPromise: Promise<void>;
- private gotCursorEnd = false;
- private awaitingResult = false;
-
- constructor(private req: IDBRequest) {
- this.awaitingResult = true;
- let p = openPromise<void>();
- this.currentPromise = p.promise;
- req.onsuccess = () => {
- if (!this.awaitingResult) {
- throw Error("BUG: invariant violated");
- }
- const cursor = req.result;
- if (cursor) {
- this.awaitingResult = false;
- p.resolve();
- p = openPromise<void>();
- this.currentPromise = p.promise;
- } else {
- this.gotCursorEnd = true;
- p.resolve();
- }
- };
- req.onerror = () => {
- p.reject(req.error);
- };
- }
-
- async toArray(): Promise<T[]> {
- const arr: T[] = [];
- while (true) {
- const x = await this.next();
- if (x.hasValue) {
- arr.push(x.value);
- } else {
- break;
- }
- }
- return arr;
- }
-
- async map<R>(f: (x: T) => R): Promise<R[]> {
- const arr: R[] = [];
- while (true) {
- const x = await this.next();
- if (x.hasValue) {
- arr.push(f(x.value));
- } else {
- break;
- }
- }
- return arr;
- }
-
- async forEachAsync(f: (x: T) => Promise<void>): Promise<void> {
- while (true) {
- const x = await this.next();
- if (x.hasValue) {
- await f(x.value);
- } else {
- break;
- }
- }
- }
-
- async forEach(f: (x: T) => void): Promise<void> {
- while (true) {
- const x = await this.next();
- if (x.hasValue) {
- f(x.value);
- } else {
- break;
- }
- }
- }
-
- async filter(f: (x: T) => boolean): Promise<T[]> {
- const arr: T[] = [];
- while (true) {
- const x = await this.next();
- if (x.hasValue) {
- if (f(x.value)) {
- arr.push(x.value);
- }
- } else {
- break;
- }
- }
- return arr;
- }
-
- async next(): Promise<CursorResult<T>> {
- if (this.gotCursorEnd) {
- return { hasValue: false };
- }
- if (!this.awaitingResult) {
- const cursor: IDBCursor | undefined = this.req.result;
- if (!cursor) {
- throw Error("assertion failed");
- }
- this.awaitingResult = true;
- cursor.continue();
- }
- await this.currentPromise;
- if (this.gotCursorEnd) {
- return { hasValue: false };
- }
- const cursor = this.req.result;
- if (!cursor) {
- throw Error("assertion failed");
- }
- return { hasValue: true, value: cursor.value };
- }
-}
-
-/**
- * Return a promise that resolves to the opened IndexedDB database.
- */
-export function openDatabase(
- idbFactory: IDBFactory,
- databaseName: string,
- databaseVersion: number,
- onVersionChange: () => void,
- onUpgradeNeeded: (
- db: IDBDatabase,
- oldVersion: number,
- newVersion: number,
- upgradeTransaction: IDBTransaction,
- ) => void,
-): Promise<IDBDatabase> {
- return new Promise<IDBDatabase>((resolve, reject) => {
- const req = idbFactory.open(databaseName, databaseVersion);
- req.onerror = (e) => {
- logger.error("database error", e);
- reject(new Error("database error"));
- };
- req.onsuccess = (e) => {
- req.result.onversionchange = (evt: IDBVersionChangeEvent) => {
- logger.info(
- `handling live db version change from ${evt.oldVersion} to ${evt.newVersion}`,
- );
- req.result.close();
- onVersionChange();
- };
- resolve(req.result);
- };
- req.onupgradeneeded = (e) => {
- const db = req.result;
- const newVersion = e.newVersion;
- if (!newVersion) {
- throw Error("upgrade needed, but new version unknown");
- }
- const transaction = req.transaction;
- if (!transaction) {
- throw Error("no transaction handle available in upgrade handler");
- }
- onUpgradeNeeded(db, e.oldVersion, newVersion, transaction);
- };
- });
-}
-
-export interface IndexDescriptor {
- name: string;
- keyPath: IDBKeyPath | IDBKeyPath[];
- multiEntry?: boolean;
-}
-
-export interface StoreDescriptor<RecordType> {
- _dummy: undefined & RecordType;
- name: string;
- keyPath?: IDBKeyPath | IDBKeyPath[];
- autoIncrement?: boolean;
-}
-
-export interface StoreOptions {
- keyPath?: IDBKeyPath | IDBKeyPath[];
- autoIncrement?: boolean;
-}
-
-export function describeContents<RecordType = never>(
- name: string,
- options: StoreOptions,
-): StoreDescriptor<RecordType> {
- return { name, keyPath: options.keyPath, _dummy: undefined as any };
-}
-
-export function describeIndex(
- name: string,
- keyPath: IDBKeyPath | IDBKeyPath[],
- options: IndexOptions = {},
-): IndexDescriptor {
- return {
- keyPath,
- name,
- multiEntry: options.multiEntry,
- };
-}
-
-interface IndexReadOnlyAccessor<RecordType> {
- iter(query?: IDBValidKey): ResultStream<RecordType>;
- get(query: IDBValidKey): Promise<RecordType | undefined>;
- getAll(query: IDBValidKey, count?: number): Promise<RecordType[]>;
-}
-
-type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
- [P in keyof IndexMap]: IndexReadOnlyAccessor<RecordType>;
-};
-
-interface IndexReadWriteAccessor<RecordType> {
- iter(query: IDBValidKey): ResultStream<RecordType>;
- get(query: IDBValidKey): Promise<RecordType | undefined>;
- getAll(query: IDBValidKey, count?: number): Promise<RecordType[]>;
-}
-
-type GetIndexReadWriteAccess<RecordType, IndexMap> = {
- [P in keyof IndexMap]: IndexReadWriteAccessor<RecordType>;
-};
-
-export interface StoreReadOnlyAccessor<RecordType, IndexMap> {
- get(key: IDBValidKey): Promise<RecordType | undefined>;
- iter(query?: IDBValidKey): ResultStream<RecordType>;
- indexes: GetIndexReadOnlyAccess<RecordType, IndexMap>;
-}
-
-export interface StoreReadWriteAccessor<RecordType, IndexMap> {
- get(key: IDBValidKey): Promise<RecordType | undefined>;
- iter(query?: IDBValidKey): ResultStream<RecordType>;
- put(r: RecordType): Promise<void>;
- add(r: RecordType): Promise<void>;
- delete(key: IDBValidKey): Promise<void>;
- indexes: GetIndexReadWriteAccess<RecordType, IndexMap>;
-}
-
-export interface StoreWithIndexes<
- SD extends StoreDescriptor<unknown>,
- IndexMap
-> {
- store: SD;
- indexMap: IndexMap;
-
- /**
- * Type marker symbol, to check that the descriptor
- * has been created through the right function.
- */
- mark: Symbol;
-}
-
-export type GetRecordType<T> = T extends StoreDescriptor<infer X> ? X : unknown;
-
-const storeWithIndexesSymbol = Symbol("StoreWithIndexesMark");
-
-export function describeStore<SD extends StoreDescriptor<unknown>, IndexMap>(
- s: SD,
- m: IndexMap,
-): StoreWithIndexes<SD, IndexMap> {
- return {
- store: s,
- indexMap: m,
- mark: storeWithIndexesSymbol,
- };
-}
-
-export type GetReadOnlyAccess<BoundStores> = {
- [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
- infer SD,
- infer IM
- >
- ? StoreReadOnlyAccessor<GetRecordType<SD>, IM>
- : unknown;
-};
-
-export type GetReadWriteAccess<BoundStores> = {
- [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
- infer SD,
- infer IM
- >
- ? StoreReadWriteAccessor<GetRecordType<SD>, IM>
- : unknown;
-};
-
-type ReadOnlyTransactionFunction<BoundStores, T> = (
- t: GetReadOnlyAccess<BoundStores>,
-) => Promise<T>;
-
-type ReadWriteTransactionFunction<BoundStores, T> = (
- t: GetReadWriteAccess<BoundStores>,
-) => Promise<T>;
-
-export interface TransactionContext<BoundStores> {
- runReadWrite<T>(f: ReadWriteTransactionFunction<BoundStores, T>): Promise<T>;
- runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>;
-}
-
-type CheckDescriptor<T> = T extends StoreWithIndexes<infer SD, infer IM>
- ? StoreWithIndexes<SD, IM>
- : unknown;
-
-type GetPickerType<F, SM> = F extends (x: SM) => infer Out
- ? { [P in keyof Out]: CheckDescriptor<Out[P]> }
- : unknown;
-
-function runTx<Arg, Res>(
- tx: IDBTransaction,
- arg: Arg,
- f: (t: Arg) => Promise<Res>,
-): Promise<Res> {
- const stack = Error("Failed transaction was started here.");
- return new Promise((resolve, reject) => {
- let funResult: any = undefined;
- let gotFunResult = false;
- let transactionException: any = undefined;
- tx.oncomplete = () => {
- // This is a fatal error: The transaction completed *before*
- // the transaction function returned. Likely, the transaction
- // function waited on a promise that is *not* resolved in the
- // microtask queue, thus triggering the auto-commit behavior.
- // Unfortunately, the auto-commit behavior of IDB can't be switched
- // of. There are some proposals to add this functionality in the future.
- if (!gotFunResult) {
- const msg =
- "BUG: transaction closed before transaction function returned";
- console.error(msg);
- reject(Error(msg));
- }
- resolve(funResult);
- };
- tx.onerror = () => {
- logger.error("error in transaction");
- logger.error(`${stack}`);
- };
- tx.onabort = () => {
- let msg: string;
- if (tx.error) {
- msg = `Transaction aborted (transaction error): ${tx.error}`;
- } else if (transactionException !== undefined) {
- msg = `Transaction aborted (exception thrown): ${transactionException}`;
- } else {
- msg = "Transaction aborted (no DB error)";
- }
- logger.error(msg);
- reject(new TransactionAbortedError(msg));
- };
- const resP = Promise.resolve().then(() => f(arg));
- resP
- .then((result) => {
- gotFunResult = true;
- funResult = result;
- })
- .catch((e) => {
- if (e == TransactionAbort) {
- logger.trace("aborting transaction");
- } else {
- transactionException = e;
- console.error("Transaction failed:", e);
- console.error(stack);
- tx.abort();
- }
- })
- .catch((e) => {
- console.error("fatal: aborting transaction failed", e);
- });
- });
-}
-
-function makeReadContext(
- tx: IDBTransaction,
- storePick: { [n: string]: StoreWithIndexes<any, any> },
-): any {
- const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {};
- for (const storeAlias in storePick) {
- const indexes: { [s: string]: IndexReadOnlyAccessor<any> } = {};
- const swi = storePick[storeAlias];
- const storeName = swi.store.name;
- for (const indexAlias in storePick[storeAlias].indexMap) {
- const indexDescriptor: IndexDescriptor =
- storePick[storeAlias].indexMap[indexAlias];
- const indexName = indexDescriptor.name;
- indexes[indexAlias] = {
- get(key) {
- const req = tx.objectStore(storeName).index(indexName).get(key);
- return requestToPromise(req);
- },
- iter(query) {
- const req = tx
- .objectStore(storeName)
- .index(indexName)
- .openCursor(query);
- return new ResultStream<any>(req);
- },
- getAll(query, count) {
- const req = tx.objectStore(storeName).index(indexName).getAll(query, count);
- return requestToPromise(req);
- }
- };
- }
- ctx[storeAlias] = {
- indexes,
- get(key) {
- const req = tx.objectStore(storeName).get(key);
- return requestToPromise(req);
- },
- iter(query) {
- const req = tx.objectStore(storeName).openCursor(query);
- return new ResultStream<any>(req);
- },
- };
- }
- return ctx;
-}
-
-function makeWriteContext(
- tx: IDBTransaction,
- storePick: { [n: string]: StoreWithIndexes<any, any> },
-): any {
- const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {};
- for (const storeAlias in storePick) {
- const indexes: { [s: string]: IndexReadWriteAccessor<any> } = {};
- const swi = storePick[storeAlias];
- const storeName = swi.store.name;
- for (const indexAlias in storePick[storeAlias].indexMap) {
- const indexDescriptor: IndexDescriptor =
- storePick[storeAlias].indexMap[indexAlias];
- const indexName = indexDescriptor.name;
- indexes[indexAlias] = {
- get(key) {
- const req = tx.objectStore(storeName).index(indexName).get(key);
- return requestToPromise(req);
- },
- iter(query) {
- const req = tx
- .objectStore(storeName)
- .index(indexName)
- .openCursor(query);
- return new ResultStream<any>(req);
- },
- getAll(query, count) {
- const req = tx.objectStore(storeName).index(indexName).getAll(query, count);
- return requestToPromise(req);
- }
- };
- }
- ctx[storeAlias] = {
- indexes,
- get(key) {
- const req = tx.objectStore(storeName).get(key);
- return requestToPromise(req);
- },
- iter(query) {
- const req = tx.objectStore(storeName).openCursor(query);
- return new ResultStream<any>(req);
- },
- add(r) {
- const req = tx.objectStore(storeName).add(r);
- return requestToPromise(req);
- },
- put(r) {
- const req = tx.objectStore(storeName).put(r);
- return requestToPromise(req);
- },
- delete(k) {
- const req = tx.objectStore(storeName).delete(k);
- return requestToPromise(req);
- },
- };
- }
- return ctx;
-}
-
-/**
- * Type-safe access to a database with a particular store map.
- *
- * A store map is the metadata that describes the store.
- */
-export class DbAccess<StoreMap> {
- constructor(private db: IDBDatabase, private stores: StoreMap) {}
-
- mktx<
- PickerType extends (x: StoreMap) => unknown,
- BoundStores extends GetPickerType<PickerType, StoreMap>
- >(f: PickerType): TransactionContext<BoundStores> {
- const storePick = f(this.stores) as any;
- if (typeof storePick !== "object" || storePick === null) {
- throw Error();
- }
- const storeNames: string[] = [];
- for (const storeAlias of Object.keys(storePick)) {
- const swi = (storePick as any)[storeAlias] as StoreWithIndexes<any, any>;
- if (swi.mark !== storeWithIndexesSymbol) {
- throw Error("invalid store descriptor returned from selector function");
- }
- storeNames.push(swi.store.name);
- }
-
- const runReadOnly = <T>(
- txf: ReadOnlyTransactionFunction<BoundStores, T>,
- ): Promise<T> => {
- const tx = this.db.transaction(storeNames, "readonly");
- const readContext = makeReadContext(tx, storePick);
- return runTx(tx, readContext, txf);
- };
-
- const runReadWrite = <T>(
- txf: ReadWriteTransactionFunction<BoundStores, T>,
- ): Promise<T> => {
- const tx = this.db.transaction(storeNames, "readwrite");
- const writeContext = makeWriteContext(tx, storePick);
- return runTx(tx, writeContext, txf);
- };
-
- return {
- runReadOnly,
- runReadWrite,
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/util/retries.ts b/packages/taler-wallet-core/src/util/retries.ts
deleted file mode 100644
index cac7b1b52..000000000
--- a/packages/taler-wallet-core/src/util/retries.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Helpers for dealing with retry timeouts.
- */
-
-/**
- * Imports.
- */
-import { Timestamp, Duration, getTimestampNow } from "@gnu-taler/taler-util";
-
-export interface RetryInfo {
- firstTry: Timestamp;
- nextRetry: Timestamp;
- retryCounter: number;
-}
-
-export interface RetryPolicy {
- readonly backoffDelta: Duration;
- readonly backoffBase: number;
-}
-
-const defaultRetryPolicy: RetryPolicy = {
- backoffBase: 1.5,
- backoffDelta: { d_ms: 200 },
-};
-
-export function updateRetryInfoTimeout(
- r: RetryInfo,
- p: RetryPolicy = defaultRetryPolicy,
-): void {
- const now = getTimestampNow();
- if (now.t_ms === "never") {
- throw Error("assertion failed");
- }
- if (p.backoffDelta.d_ms === "forever") {
- r.nextRetry = { t_ms: "never" };
- return;
- }
- const t =
- now.t_ms + p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
- r.nextRetry = { t_ms: t };
-}
-
-export function getRetryDuration(
- r: RetryInfo | undefined,
- p: RetryPolicy = defaultRetryPolicy,
-): Duration {
- if (!r) {
- // If we don't have any retry info, run immediately.
- return { d_ms: 0 };
- }
- if (p.backoffDelta.d_ms === "forever") {
- return { d_ms: "forever" };
- }
- const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
- return { d_ms: t };
-}
-
-export function initRetryInfo(
- p: RetryPolicy = defaultRetryPolicy,
-): RetryInfo {
- const now = getTimestampNow();
- const info = {
- firstTry: now,
- nextRetry: now,
- retryCounter: 0,
- };
- updateRetryInfoTimeout(info, p);
- return info;
-}
diff --git a/packages/taler-wallet-core/src/util/timer.ts b/packages/taler-wallet-core/src/util/timer.ts
deleted file mode 100644
index d9fe3439b..000000000
--- a/packages/taler-wallet-core/src/util/timer.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2017-2019 Taler Systems S.A.
-
- 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/>
- */
-
-/**
- * Cross-platform timers.
- *
- * NodeJS and the browser use slightly different timer API,
- * this abstracts over these differences.
- */
-
-/**
- * Imports.
- */
-import { Logger, Duration } from "@gnu-taler/taler-util";
-
-const logger = new Logger("timer.ts");
-
-/**
- * Cancelable timer.
- */
-export interface TimerHandle {
- clear(): void;
-
- /**
- * Make sure the event loop exits when the timer is the
- * only event left. Has no effect in the browser.
- */
- unref(): void;
-}
-
-class IntervalHandle {
- constructor(public h: any) {}
-
- clear(): void {
- clearInterval(this.h);
- }
-
- /**
- * Make sure the event loop exits when the timer is the
- * only event left. Has no effect in the browser.
- */
- unref(): void {
- if (typeof this.h === "object") {
- this.h.unref();
- }
- }
-}
-
-class TimeoutHandle {
- constructor(public h: any) {}
-
- clear(): void {
- clearTimeout(this.h);
- }
-
- /**
- * Make sure the event loop exits when the timer is the
- * only event left. Has no effect in the browser.
- */
- unref(): void {
- if (typeof this.h === "object") {
- this.h.unref();
- }
- }
-}
-
-/**
- * Get a performance counter in nanoseconds.
- */
-export const performanceNow: () => bigint = (() => {
- // @ts-ignore
- if (typeof process !== "undefined" && process.hrtime) {
- return () => {
- return process.hrtime.bigint();
- };
- }
-
- // @ts-ignore
- if (typeof performance !== "undefined") {
- // @ts-ignore
- return () => BigInt(Math.floor(performance.now() * 1000)) * BigInt(1000);
- }
-
- return () => BigInt(0);
-})();
-
-/**
- * Call a function every time the delay given in milliseconds passes.
- */
-export function every(delayMs: number, callback: () => void): TimerHandle {
- return new IntervalHandle(setInterval(callback, delayMs));
-}
-
-/**
- * Call a function after the delay given in milliseconds passes.
- */
-export function after(delayMs: number, callback: () => void): TimerHandle {
- return new TimeoutHandle(setTimeout(callback, delayMs));
-}
-
-const nullTimerHandle = {
- clear() {
- // do nothing
- return;
- },
- unref() {
- // do nothing
- return;
- },
-};
-
-/**
- * Group of timers that can be destroyed at once.
- */
-export class TimerGroup {
- private stopped = false;
-
- private timerMap: { [index: number]: TimerHandle } = {};
-
- private idGen = 1;
-
- stopCurrentAndFutureTimers(): void {
- this.stopped = true;
- for (const x in this.timerMap) {
- if (!this.timerMap.hasOwnProperty(x)) {
- continue;
- }
- this.timerMap[x].clear();
- delete this.timerMap[x];
- }
- }
-
- resolveAfter(delayMs: Duration): Promise<void> {
- return new Promise<void>((resolve, reject) => {
- if (delayMs.d_ms !== "forever") {
- this.after(delayMs.d_ms, () => {
- resolve();
- });
- }
- });
- }
-
- after(delayMs: number, callback: () => void): TimerHandle {
- if (this.stopped) {
- logger.warn("dropping timer since timer group is stopped");
- return nullTimerHandle;
- }
- const h = after(delayMs, callback);
- const myId = this.idGen++;
- this.timerMap[myId] = h;
-
- const tm = this.timerMap;
-
- return {
- clear() {
- h.clear();
- delete tm[myId];
- },
- unref() {
- h.unref();
- },
- };
- }
-
- every(delayMs: number, callback: () => void): TimerHandle {
- if (this.stopped) {
- logger.warn("dropping timer since timer group is stopped");
- return nullTimerHandle;
- }
- const h = every(delayMs, callback);
- const myId = this.idGen++;
- this.timerMap[myId] = h;
-
- const tm = this.timerMap;
-
- return {
- clear() {
- h.clear();
- delete tm[myId];
- },
- unref() {
- h.unref();
- },
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts
index b798871c2..d33a23cdd 100644
--- a/packages/taler-wallet-core/src/versions.ts
+++ b/packages/taler-wallet-core/src/versions.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
+ (C) 2019-2023 Taler Systems S.A.
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
@@ -19,27 +19,66 @@
*
* Uses libtool's current:revision:age versioning.
*/
-export const WALLET_EXCHANGE_PROTOCOL_VERSION = "9:0:0";
+export const WALLET_EXCHANGE_PROTOCOL_VERSION = "17:0:0";
/**
* Protocol version spoken with the merchant.
*
* Uses libtool's current:revision:age versioning.
*/
-export const WALLET_MERCHANT_PROTOCOL_VERSION = "1:0:0";
+export const WALLET_MERCHANT_PROTOCOL_VERSION = "5:0:1";
/**
- * Protocol version spoken with the merchant.
+ * Protocol version spoken with the bank (bank integration API).
+ *
+ * Uses libtool's current:revision:age versioning.
+ */
+export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "1:0:0";
+
+/**
+ * Protocol version spoken with the bank (corebank API).
+ *
+ * Uses libtool's current:revision:age versioning.
+ */
+export const WALLET_COREBANK_API_PROTOCOL_VERSION = "2:0:0";
+
+/**
+ * Protocol version spoken with the bank (conversion API).
*
* Uses libtool's current:revision:age versioning.
*/
-export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "0:0:0";
+export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0";
+
+/**
+ * Libtool version of the wallet-core API.
+ */
+export const WALLET_CORE_API_PROTOCOL_VERSION = "5:0:0";
/**
- * Cache breaker that is appended to queries such as /keys and /wire
- * to break through caching, if it has been accidentally/badly configured
- * by the exchange.
+ * Libtool rules:
*
- * This is only a temporary measure.
+ * If the library source code has changed at all since the last update,
+ * then increment revision (‘c:r:a’ becomes ‘c:r+1:a’).
+ * If any interfaces have been added, removed, or changed since the last
+ * update, increment current, and set revision to 0.
+ * If any interfaces have been added since the last public release, then
+ * increment age.
+ * If any interfaces have been removed or changed since the last public
+ * release, then set age to 0.
*/
-export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "3";
+
+// Provided either by bundler or in the next lines.
+declare global {
+ const walletCoreBuildInfo: {
+ implementationSemver: string;
+ implementationGitHash: string;
+ };
+}
+
+// Provide walletCoreBuildInfo if the bundler does not override it.
+if (!("walletCoreBuildInfo" in globalThis)) {
+ (globalThis as any).walletCoreBuildInfo = {
+ implementationSemver: "unknown",
+ implementationGitHash: "unknown",
+ } satisfies typeof walletCoreBuildInfo;
+}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
index 991c03ee2..9a8ea8470 100644
--- a/packages/taler-wallet-core/src/wallet-api-types.ts
+++ b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -16,273 +16,1342 @@
/**
* Type declarations for the high-level interface to wallet-core.
+ *
+ * Documentation is auto-generated from this file.
*/
/**
* Imports.
*/
import {
- AbortPayWithRefundRequest,
+ AbortTransactionRequest,
AcceptBankIntegratedWithdrawalRequest,
AcceptExchangeTosRequest,
AcceptManualWithdrawalRequest,
AcceptManualWithdrawalResult,
- AcceptTipRequest,
AcceptWithdrawalResponse,
AddExchangeRequest,
- ApplyRefundRequest,
- ApplyRefundResponse,
+ AddGlobalCurrencyAuditorRequest,
+ AddGlobalCurrencyExchangeRequest,
+ AddKnownBankAccountsRequest,
+ AmountResponse,
+ ApplyDevExperimentRequest,
BackupRecovery,
BalancesResponse,
+ CanonicalizeBaseUrlRequest,
+ CanonicalizeBaseUrlResponse,
+ CheckPeerPullCreditRequest,
+ CheckPeerPullCreditResponse,
+ CheckPeerPushDebitRequest,
+ CheckPeerPushDebitResponse,
CoinDumpJson,
ConfirmPayRequest,
ConfirmPayResult,
+ ConfirmPeerPullDebitRequest,
+ ConfirmPeerPushCreditRequest,
+ ConfirmWithdrawalRequest,
+ ConvertAmountRequest,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
+ CreateStoredBackupResponse,
+ DeleteExchangeRequest,
+ DeleteStoredBackupRequest,
DeleteTransactionRequest,
- ExchangesListRespose,
+ ExchangeDetailedResponse,
+ ExchangesListResponse,
+ ExchangesShortListResponse,
+ FailTransactionRequest,
ForceRefreshRequest,
+ ForgetKnownBankAccountsRequest,
+ GetActiveTasksResponse,
+ GetAmountRequest,
+ GetBalanceDetailRequest,
+ GetContractTermsDetailsRequest,
+ GetCurrencySpecificationRequest,
+ GetCurrencySpecificationResponse,
+ GetExchangeEntryByUrlRequest,
+ GetExchangeEntryByUrlResponse,
+ GetExchangeResourcesRequest,
+ GetExchangeResourcesResponse,
GetExchangeTosRequest,
GetExchangeTosResult,
+ GetPlanForOperationRequest,
+ GetPlanForOperationResponse,
GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest,
+ ImportDbRequest,
+ InitRequest,
+ InitResponse,
+ InitiatePeerPullCreditRequest,
+ InitiatePeerPullCreditResponse,
+ InitiatePeerPushDebitRequest,
+ InitiatePeerPushDebitResponse,
IntegrationTestArgs,
- ManualWithdrawalDetails,
+ KnownBankAccounts,
+ ListAssociatedRefreshesRequest,
+ ListAssociatedRefreshesResponse,
+ ListExchangesForScopedCurrencyRequest,
+ ListGlobalCurrencyAuditorsResponse,
+ ListGlobalCurrencyExchangesResponse,
+ ListKnownBankAccountsRequest,
+ PrepareBankIntegratedWithdrawalRequest,
+ PrepareBankIntegratedWithdrawalResponse,
+ PrepareDepositRequest,
+ PrepareDepositResponse,
PreparePayRequest,
PreparePayResult,
- PrepareTipRequest,
- PrepareTipResult,
+ PreparePayTemplateRequest,
+ PreparePeerPullDebitRequest,
+ PreparePeerPullDebitResponse,
+ PreparePeerPushCreditRequest,
+ PreparePeerPushCreditResponse,
+ PrepareRefundRequest,
+ PrepareWithdrawExchangeRequest,
+ PrepareWithdrawExchangeResponse,
+ RecoverStoredBackupRequest,
RecoveryLoadRequest,
+ RemoveGlobalCurrencyAuditorRequest,
+ RemoveGlobalCurrencyExchangeRequest,
RetryTransactionRequest,
SetCoinSuspendedRequest,
SetWalletDeviceIdRequest,
+ SharePaymentRequest,
+ SharePaymentResult,
+ StartRefundQueryForUriResponse,
+ StartRefundQueryRequest,
+ StoredBackupList,
TestPayArgs,
- TrackDepositGroupRequest,
- TrackDepositGroupResponse,
+ TestPayResult,
+ TestingGetDenomStatsRequest,
+ TestingGetDenomStatsResponse,
+ TestingListTasksForTransactionRequest,
+ TestingListTasksForTransactionsResponse,
+ TestingSetTimetravelRequest,
+ TestingWaitTransactionRequest,
+ Transaction,
+ TransactionByIdRequest,
+ TransactionWithdrawal,
TransactionsRequest,
TransactionsResponse,
- WalletBackupContentV1,
- WalletCurrencyInfo,
- WithdrawFakebankRequest,
+ TxIdResponse,
+ UpdateExchangeEntryRequest,
+ UserAttentionByIdRequest,
+ UserAttentionsCountResponse,
+ UserAttentionsRequest,
+ UserAttentionsResponse,
+ ValidateIbanRequest,
+ ValidateIbanResponse,
+ WalletContractData,
+ WalletCoreVersion,
WithdrawTestBalanceRequest,
WithdrawUriInfoResponse,
+ WithdrawalDetailsForAmount,
+ WithdrawalTransactionByURIRequest,
} from "@gnu-taler/taler-util";
import {
AddBackupProviderRequest,
+ AddBackupProviderResponse,
BackupInfo,
-} from "./operations/backup/index.js";
-import { PendingOperationsResponse } from "./pending-types.js";
+ RemoveBackupProviderRequest,
+ RunBackupCycleRequest,
+} from "./backup/index.js";
+import { PaymentBalanceDetails } from "./balance.js";
export enum WalletApiOperation {
InitWallet = "initWallet",
+ SetWalletRunConfig = "setWalletRunConfig",
WithdrawTestkudos = "withdrawTestkudos",
WithdrawTestBalance = "withdrawTestBalance",
PreparePayForUri = "preparePayForUri",
+ SharePayment = "sharePayment",
+ PreparePayForTemplate = "preparePayForTemplate",
+ GetContractTermsDetails = "getContractTermsDetails",
RunIntegrationTest = "runIntegrationTest",
+ RunIntegrationTestV2 = "runIntegrationTestV2",
+ TestCrypto = "testCrypto",
TestPay = "testPay",
AddExchange = "addExchange",
GetTransactions = "getTransactions",
+ GetTransactionById = "getTransactionById",
+ GetWithdrawalTransactionByUri = "getWithdrawalTransactionByUri",
+ TestingGetSampleTransactions = "testingGetSampleTransactions",
ListExchanges = "listExchanges",
+ GetExchangeEntryByUrl = "getExchangeEntryByUrl",
+ ListKnownBankAccounts = "listKnownBankAccounts",
+ AddKnownBankAccounts = "addKnownBankAccounts",
+ ForgetKnownBankAccounts = "forgetKnownBankAccounts",
GetWithdrawalDetailsForUri = "getWithdrawalDetailsForUri",
GetWithdrawalDetailsForAmount = "getWithdrawalDetailsForAmount",
AcceptManualWithdrawal = "acceptManualWithdrawal",
GetBalances = "getBalances",
+ GetBalanceDetail = "getBalanceDetail",
+ GetPlanForOperation = "getPlanForOperation",
+ ConvertDepositAmount = "ConvertDepositAmount",
+ GetMaxDepositAmount = "GetMaxDepositAmount",
+ ConvertPeerPushAmount = "ConvertPeerPushAmount",
+ GetMaxPeerPushAmount = "GetMaxPeerPushAmount",
+ ConvertWithdrawalAmount = "ConvertWithdrawalAmount",
+ GetUserAttentionRequests = "getUserAttentionRequests",
+ GetUserAttentionUnreadCount = "getUserAttentionUnreadCount",
+ MarkAttentionRequestAsRead = "markAttentionRequestAsRead",
GetPendingOperations = "getPendingOperations",
+ GetActiveTasks = "getActiveTasks",
SetExchangeTosAccepted = "setExchangeTosAccepted",
- ApplyRefund = "applyRefund",
+ SetExchangeTosForgotten = "SetExchangeTosForgotten",
+ StartRefundQueryForUri = "startRefundQueryForUri",
+ StartRefundQuery = "startRefundQuery",
+ PrepareBankIntegratedWithdrawal = "prepareBankIntegratedWithdrawal",
+ ConfirmWithdrawal = "confirmWithdrawal",
AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal",
GetExchangeTos = "getExchangeTos",
+ GetExchangeDetailedInfo = "getExchangeDetailedInfo",
RetryPendingNow = "retryPendingNow",
- AbortFailedPayWithRefund = "abortFailedPayWithRefund",
+ AbortTransaction = "abortTransaction",
+ FailTransaction = "failTransaction",
+ SuspendTransaction = "suspendTransaction",
+ ResumeTransaction = "resumeTransaction",
+ DeleteTransaction = "deleteTransaction",
+ RetryTransaction = "retryTransaction",
ConfirmPay = "confirmPay",
DumpCoins = "dumpCoins",
SetCoinSuspended = "setCoinSuspended",
ForceRefresh = "forceRefresh",
- PrepareTip = "prepareTip",
- AcceptTip = "acceptTip",
ExportBackup = "exportBackup",
AddBackupProvider = "addBackupProvider",
+ RemoveBackupProvider = "removeBackupProvider",
RunBackupCycle = "runBackupCycle",
ExportBackupRecovery = "exportBackupRecovery",
ImportBackupRecovery = "importBackupRecovery",
GetBackupInfo = "getBackupInfo",
- TrackDepositGroup = "trackDepositGroup",
- DeleteTransaction = "deleteTransaction",
- RetryTransaction = "retryTransaction",
- GetCoins = "getCoins",
- ListCurrencies = "listCurrencies",
+ PrepareDeposit = "prepareDeposit",
+ GetVersion = "getVersion",
+ GenerateDepositGroupTxId = "generateDepositGroupTxId",
CreateDepositGroup = "createDepositGroup",
SetWalletDeviceId = "setWalletDeviceId",
- ExportBackupPlain = "exportBackupPlain",
- WithdrawFakebank = "withdrawFakebank",
+ ImportDb = "importDb",
+ ExportDb = "exportDb",
+ PreparePeerPushCredit = "preparePeerPushCredit",
+ CheckPeerPushDebit = "checkPeerPushDebit",
+ InitiatePeerPushDebit = "initiatePeerPushDebit",
+ ConfirmPeerPushCredit = "confirmPeerPushCredit",
+ CheckPeerPullCredit = "checkPeerPullCredit",
+ InitiatePeerPullCredit = "initiatePeerPullCredit",
+ PreparePeerPullDebit = "preparePeerPullDebit",
+ ConfirmPeerPullDebit = "confirmPeerPullDebit",
+ ClearDb = "clearDb",
+ Recycle = "recycle",
+ ApplyDevExperiment = "applyDevExperiment",
+ ValidateIban = "validateIban",
+ GetCurrencySpecification = "getCurrencySpecification",
+ ListStoredBackups = "listStoredBackups",
+ CreateStoredBackup = "createStoredBackup",
+ DeleteStoredBackup = "deleteStoredBackup",
+ RecoverStoredBackup = "recoverStoredBackup",
+ UpdateExchangeEntry = "updateExchangeEntry",
+ ListExchangesForScopedCurrency = "listExchangesForScopedCurrency",
+ PrepareWithdrawExchange = "prepareWithdrawExchange",
+ GetExchangeResources = "getExchangeResources",
+ DeleteExchange = "deleteExchange",
+ ListGlobalCurrencyExchanges = "listGlobalCurrencyExchanges",
+ ListGlobalCurrencyAuditors = "listGlobalCurrencyAuditors",
+ AddGlobalCurrencyExchange = "addGlobalCurrencyExchange",
+ RemoveGlobalCurrencyExchange = "removeGlobalCurrencyExchange",
+ AddGlobalCurrencyAuditor = "addGlobalCurrencyAuditor",
+ RemoveGlobalCurrencyAuditor = "removeGlobalCurrencyAuditor",
+ ListAssociatedRefreshes = "listAssociatedRefreshes",
+ Shutdown = "shutdown",
+ CanonicalizeBaseUrl = "canonicalizeBaseUrl",
+ TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
+ TestingWaitRefreshesFinal = "testingWaitRefreshesFinal",
+ TestingWaitTransactionState = "testingWaitTransactionState",
+ TestingWaitTasksDone = "testingWaitTasksDone",
+ TestingSetTimetravel = "testingSetTimetravel",
+ TestingInfiniteTransactionLoop = "testingInfiniteTransactionLoop",
+ TestingListTaskForTransaction = "testingListTasksForTransaction",
+ TestingGetDenomStats = "testingGetDenomStats",
+ TestingPing = "testingPing",
+ TestingGetReserveHistory = "testingGetReserveHistory",
}
+// group: Initialization
+
+type EmptyObject = Record<string, never>;
+
+/**
+ * Initialize wallet-core.
+ *
+ * Must be the first request made to wallet-core.
+ */
+export type InitWalletOp = {
+ op: WalletApiOperation.InitWallet;
+ request: InitRequest;
+ response: InitResponse;
+};
+
+export type ShutdownOp = {
+ op: WalletApiOperation.Shutdown;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Change the configuration of wallet-core.
+ *
+ * Currently an alias for the initWallet request.
+ */
+export type SetWalletRunConfigOp = {
+ op: WalletApiOperation.SetWalletRunConfig;
+ request: InitRequest;
+ response: InitResponse;
+};
+
+export type GetVersionOp = {
+ op: WalletApiOperation.GetVersion;
+ request: EmptyObject;
+ response: WalletCoreVersion;
+};
+
+// group: Basic Wallet Information
+
+/**
+ * Get current wallet balance.
+ */
+export type GetBalancesOp = {
+ op: WalletApiOperation.GetBalances;
+ request: EmptyObject;
+ response: BalancesResponse;
+};
+export type GetBalancesDetailOp = {
+ op: WalletApiOperation.GetBalanceDetail;
+ request: GetBalanceDetailRequest;
+ response: PaymentBalanceDetails;
+};
+
+export type GetPlanForOperationOp = {
+ op: WalletApiOperation.GetPlanForOperation;
+ request: GetPlanForOperationRequest;
+ response: GetPlanForOperationResponse;
+};
+
+export type ConvertDepositAmountOp = {
+ op: WalletApiOperation.ConvertDepositAmount;
+ request: ConvertAmountRequest;
+ response: AmountResponse;
+};
+export type GetMaxDepositAmountOp = {
+ op: WalletApiOperation.GetMaxDepositAmount;
+ request: GetAmountRequest;
+ response: AmountResponse;
+};
+export type ConvertPeerPushAmountOp = {
+ op: WalletApiOperation.ConvertPeerPushAmount;
+ request: ConvertAmountRequest;
+ response: AmountResponse;
+};
+export type GetMaxPeerPushAmountOp = {
+ op: WalletApiOperation.GetMaxPeerPushAmount;
+ request: GetAmountRequest;
+ response: AmountResponse;
+};
+export type ConvertWithdrawalAmountOp = {
+ op: WalletApiOperation.ConvertWithdrawalAmount;
+ request: ConvertAmountRequest;
+ response: AmountResponse;
+};
+
+// group: Managing Transactions
+
+/**
+ * Get transactions.
+ */
+export type GetTransactionsOp = {
+ op: WalletApiOperation.GetTransactions;
+ request: TransactionsRequest;
+ response: TransactionsResponse;
+};
+
+/**
+ * List refresh transactions associated with another transaction.
+ */
+export type ListAssociatedRefreshesOp = {
+ op: WalletApiOperation.ListAssociatedRefreshes;
+ request: ListAssociatedRefreshesRequest;
+ response: ListAssociatedRefreshesResponse;
+};
+
+/**
+ * Get sample transactions.
+ */
+export type TestingGetSampleTransactionsOp = {
+ op: WalletApiOperation.TestingGetSampleTransactions;
+ request: EmptyObject;
+ response: TransactionsResponse;
+};
+
+export type GetTransactionByIdOp = {
+ op: WalletApiOperation.GetTransactionById;
+ request: TransactionByIdRequest;
+ response: Transaction;
+};
+
+export type GetWithdrawalTransactionByUriOp = {
+ op: WalletApiOperation.GetWithdrawalTransactionByUri;
+ request: WithdrawalTransactionByURIRequest;
+ response: TransactionWithdrawal | undefined;
+};
+
+export type RetryPendingNowOp = {
+ op: WalletApiOperation.RetryPendingNow;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Delete a transaction locally in the wallet.
+ */
+export type DeleteTransactionOp = {
+ op: WalletApiOperation.DeleteTransaction;
+ request: DeleteTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Immediately retry a transaction.
+ */
+export type RetryTransactionOp = {
+ op: WalletApiOperation.RetryTransaction;
+ request: RetryTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Abort a transaction
+ *
+ * For payment transactions, it puts the payment into an "aborting" state.
+ */
+export type AbortTransactionOp = {
+ op: WalletApiOperation.AbortTransaction;
+ request: AbortTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Cancel aborting a transaction
+ *
+ * For payment transactions, it puts the payment into an "aborting" state.
+ */
+export type FailTransactionOp = {
+ op: WalletApiOperation.FailTransaction;
+ request: FailTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Suspend a transaction
+ */
+export type SuspendTransactionOp = {
+ op: WalletApiOperation.SuspendTransaction;
+ request: AbortTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Resume a transaction
+ */
+export type ResumeTransactionOp = {
+ op: WalletApiOperation.ResumeTransaction;
+ request: AbortTransactionRequest;
+ response: EmptyObject;
+};
+
+// group: Withdrawals
+
+/**
+ * Get details for withdrawing a particular amount (manual withdrawal).
+ */
+export type GetWithdrawalDetailsForAmountOp = {
+ op: WalletApiOperation.GetWithdrawalDetailsForAmount;
+ request: GetWithdrawalDetailsForAmountRequest;
+ response: WithdrawalDetailsForAmount;
+};
+
+/**
+ * Get details for withdrawing via a particular taler:// URI.
+ */
+export type GetWithdrawalDetailsForUriOp = {
+ op: WalletApiOperation.GetWithdrawalDetailsForUri;
+ request: GetWithdrawalDetailsForUriRequest;
+ response: WithdrawUriInfoResponse;
+};
+
+/**
+ * Prepare a bank-integrated withdrawal operation.
+ */
+export type PrepareBankIntegratedWithdrawalOp = {
+ op: WalletApiOperation.PrepareBankIntegratedWithdrawal;
+ request: PrepareBankIntegratedWithdrawalRequest;
+ response: PrepareBankIntegratedWithdrawalResponse;
+};
+
+/**
+ * Confirm a withdrawal transaction.
+ */
+export type ConfirmWithdrawalOp = {
+ op: WalletApiOperation.ConfirmWithdrawal;
+ request: ConfirmWithdrawalRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Accept a bank-integrated withdrawal.
+ *
+ * @deprecated in favor of prepare/confirm withdrawal.
+ */
+export type AcceptBankIntegratedWithdrawalOp = {
+ op: WalletApiOperation.AcceptBankIntegratedWithdrawal;
+ request: AcceptBankIntegratedWithdrawalRequest;
+ response: AcceptWithdrawalResponse;
+};
+
+/**
+ * Create a manual withdrawal.
+ */
+export type AcceptManualWithdrawalOp = {
+ op: WalletApiOperation.AcceptManualWithdrawal;
+ request: AcceptManualWithdrawalRequest;
+ response: AcceptManualWithdrawalResult;
+};
+
+// group: Merchant Payments
+
+/**
+ * Prepare to make a payment based on a taler://pay/ URI.
+ */
+export type PreparePayForUriOp = {
+ op: WalletApiOperation.PreparePayForUri;
+ request: PreparePayRequest;
+ response: PreparePayResult;
+};
+
+export type SharePaymentOp = {
+ op: WalletApiOperation.SharePayment;
+ request: SharePaymentRequest;
+ response: SharePaymentResult;
+};
+
+/**
+ * Prepare to make a payment based on a taler://pay-template/ URI.
+ */
+export type PreparePayForTemplateOp = {
+ op: WalletApiOperation.PreparePayForTemplate;
+ request: PreparePayTemplateRequest;
+ response: PreparePayResult;
+};
+
+export type GetContractTermsDetailsOp = {
+ op: WalletApiOperation.GetContractTermsDetails;
+ request: GetContractTermsDetailsRequest;
+ response: WalletContractData;
+};
+
+/**
+ * Confirm a payment that was previously prepared with
+ * {@link PreparePayForUriOp}
+ */
+export type ConfirmPayOp = {
+ op: WalletApiOperation.ConfirmPay;
+ request: ConfirmPayRequest;
+ response: ConfirmPayResult;
+};
+
+/**
+ * Check for a refund based on a taler://refund URI.
+ */
+export type StartRefundQueryForUriOp = {
+ op: WalletApiOperation.StartRefundQueryForUri;
+ request: PrepareRefundRequest;
+ response: StartRefundQueryForUriResponse;
+};
+
+export type StartRefundQueryOp = {
+ op: WalletApiOperation.StartRefundQuery;
+ request: StartRefundQueryRequest;
+ response: EmptyObject;
+};
+
+// group: Global Currency management
+
+export type ListGlobalCurrencyAuditorsOp = {
+ op: WalletApiOperation.ListGlobalCurrencyAuditors;
+ request: EmptyObject;
+ response: ListGlobalCurrencyAuditorsResponse;
+};
+
+export type ListGlobalCurrencyExchangesOp = {
+ op: WalletApiOperation.ListGlobalCurrencyExchanges;
+ request: EmptyObject;
+ response: ListGlobalCurrencyExchangesResponse;
+};
+
+export type AddGlobalCurrencyExchangeOp = {
+ op: WalletApiOperation.AddGlobalCurrencyExchange;
+ request: AddGlobalCurrencyExchangeRequest;
+ response: EmptyObject;
+};
+
+export type AddGlobalCurrencyAuditorOp = {
+ op: WalletApiOperation.AddGlobalCurrencyAuditor;
+ request: AddGlobalCurrencyAuditorRequest;
+ response: EmptyObject;
+};
+
+export type RemoveGlobalCurrencyExchangeOp = {
+ op: WalletApiOperation.RemoveGlobalCurrencyExchange;
+ request: RemoveGlobalCurrencyExchangeRequest;
+ response: EmptyObject;
+};
+
+export type RemoveGlobalCurrencyAuditorOp = {
+ op: WalletApiOperation.RemoveGlobalCurrencyAuditor;
+ request: RemoveGlobalCurrencyAuditorRequest;
+ response: EmptyObject;
+};
+
+// group: Exchange Management
+
+/**
+ * List exchanges known to the wallet.
+ */
+export type ListExchangesOp = {
+ op: WalletApiOperation.ListExchanges;
+ request: EmptyObject;
+ response: ExchangesListResponse;
+};
+
+/**
+ * List exchanges that are available for withdrawing a particular
+ * scoped currency.
+ */
+export type ListExchangesForScopedCurrencyOp = {
+ op: WalletApiOperation.ListExchangesForScopedCurrency;
+ request: ListExchangesForScopedCurrencyRequest;
+ response: ExchangesShortListResponse;
+};
+
+/**
+ * Prepare for withdrawing via a taler://withdraw-exchange URI.
+ */
+export type PrepareWithdrawExchangeOp = {
+ op: WalletApiOperation.PrepareWithdrawExchange;
+ request: PrepareWithdrawExchangeRequest;
+ response: PrepareWithdrawExchangeResponse;
+};
+
+/**
+ * Add / force-update an exchange.
+ */
+export type AddExchangeOp = {
+ op: WalletApiOperation.AddExchange;
+ request: AddExchangeRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Update an exchange entry.
+ */
+export type UpdateExchangeEntryOp = {
+ op: WalletApiOperation.UpdateExchangeEntry;
+ request: UpdateExchangeEntryRequest;
+ response: EmptyObject;
+};
+
+export type ListKnownBankAccountsOp = {
+ op: WalletApiOperation.ListKnownBankAccounts;
+ request: ListKnownBankAccountsRequest;
+ response: KnownBankAccounts;
+};
+
+export type AddKnownBankAccountsOp = {
+ op: WalletApiOperation.AddKnownBankAccounts;
+ request: AddKnownBankAccountsRequest;
+ response: EmptyObject;
+};
+
+export type ForgetKnownBankAccountsOp = {
+ op: WalletApiOperation.ForgetKnownBankAccounts;
+ request: ForgetKnownBankAccountsRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Accept a particular version of the exchange terms of service.
+ */
+export type SetExchangeTosAcceptedOp = {
+ op: WalletApiOperation.SetExchangeTosAccepted;
+ request: AcceptExchangeTosRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Accept a particular version of the exchange terms of service.
+ */
+export type SetExchangeTosForgottenOp = {
+ op: WalletApiOperation.SetExchangeTosForgotten;
+ request: AcceptExchangeTosRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Get the current terms of a service of an exchange.
+ */
+export type GetExchangeTosOp = {
+ op: WalletApiOperation.GetExchangeTos;
+ request: GetExchangeTosRequest;
+ response: GetExchangeTosResult;
+};
+
+/**
+ * Get the current terms of a service of an exchange.
+ */
+export type GetExchangeDetailedInfoOp = {
+ op: WalletApiOperation.GetExchangeDetailedInfo;
+ request: AddExchangeRequest;
+ response: ExchangeDetailedResponse;
+};
+
+/**
+ * Get the current terms of a service of an exchange.
+ */
+export type GetExchangeEntryByUrlOp = {
+ op: WalletApiOperation.GetExchangeEntryByUrl;
+ request: GetExchangeEntryByUrlRequest;
+ response: GetExchangeEntryByUrlResponse;
+};
+
+/**
+ * Get resources associated with an exchange.
+ */
+export type GetExchangeResourcesOp = {
+ op: WalletApiOperation.GetExchangeResources;
+ request: GetExchangeResourcesRequest;
+ response: GetExchangeResourcesResponse;
+};
+
+/**
+ * Get resources associated with an exchange.
+ */
+export type DeleteExchangeOp = {
+ op: WalletApiOperation.GetExchangeResources;
+ request: DeleteExchangeRequest;
+ response: EmptyObject;
+};
+
+export type GetCurrencySpecificationOp = {
+ op: WalletApiOperation.GetCurrencySpecification;
+ request: GetCurrencySpecificationRequest;
+ response: GetCurrencySpecificationResponse;
+};
+
+// group: Deposits
+
+/**
+ * Generate a fresh transaction ID for a deposit group.
+ *
+ * The resulting transaction ID can be specified when creating
+ * a deposit group, so that the client can already start waiting for notifications
+ * on that specific deposit group before the GreateDepositGroup request returns.
+ */
+export type GenerateDepositGroupTxIdOp = {
+ op: WalletApiOperation.GenerateDepositGroupTxId;
+ request: EmptyObject;
+ response: TxIdResponse;
+};
+
+/**
+ * Create a new deposit group.
+ *
+ * Deposit groups are used to deposit multiple coins to a bank
+ * account, usually the wallet user's own bank account.
+ */
+export type CreateDepositGroupOp = {
+ op: WalletApiOperation.CreateDepositGroup;
+ request: CreateDepositGroupRequest;
+ response: CreateDepositGroupResponse;
+};
+
+export type PrepareDepositOp = {
+ op: WalletApiOperation.PrepareDeposit;
+ request: PrepareDepositRequest;
+ response: PrepareDepositResponse;
+};
+
+// group: Backups
+
+/**
+ * Export the recovery information for the wallet.
+ */
+export type ExportBackupRecoveryOp = {
+ op: WalletApiOperation.ExportBackupRecovery;
+ request: EmptyObject;
+ response: BackupRecovery;
+};
+
+/**
+ * Import recovery information into the wallet.
+ */
+export type ImportBackupRecoveryOp = {
+ op: WalletApiOperation.ImportBackupRecovery;
+ request: RecoveryLoadRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Manually make and upload a backup.
+ */
+export type RunBackupCycleOp = {
+ op: WalletApiOperation.RunBackupCycle;
+ request: RunBackupCycleRequest;
+ response: EmptyObject;
+};
+
+export type ExportBackupOp = {
+ op: WalletApiOperation.ExportBackup;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Add a new backup provider.
+ */
+export type AddBackupProviderOp = {
+ op: WalletApiOperation.AddBackupProvider;
+ request: AddBackupProviderRequest;
+ response: AddBackupProviderResponse;
+};
+
+export type RemoveBackupProviderOp = {
+ op: WalletApiOperation.RemoveBackupProvider;
+ request: RemoveBackupProviderRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Get some useful stats about the backup state.
+ */
+export type GetBackupInfoOp = {
+ op: WalletApiOperation.GetBackupInfo;
+ request: EmptyObject;
+ response: BackupInfo;
+};
+
+/**
+ * Set the internal device ID of the wallet, used to
+ * identify whether a different/new wallet is accessing
+ * the backup of another wallet.
+ */
+export type SetWalletDeviceIdOp = {
+ op: WalletApiOperation.SetWalletDeviceId;
+ request: SetWalletDeviceIdRequest;
+ response: EmptyObject;
+};
+
+export type ListStoredBackupsOp = {
+ op: WalletApiOperation.ListStoredBackups;
+ request: EmptyObject;
+ response: StoredBackupList;
+};
+
+export type CreateStoredBackupsOp = {
+ op: WalletApiOperation.CreateStoredBackup;
+ request: EmptyObject;
+ response: CreateStoredBackupResponse;
+};
+
+export type RecoverStoredBackupsOp = {
+ op: WalletApiOperation.RecoverStoredBackup;
+ request: RecoverStoredBackupRequest;
+ response: EmptyObject;
+};
+
+export type DeleteStoredBackupOp = {
+ op: WalletApiOperation.DeleteStoredBackup;
+ request: DeleteStoredBackupRequest;
+ response: EmptyObject;
+};
+
+// group: Peer Payments
+
+/**
+ * Check if initiating a peer push payment is possible
+ * based on the funds in the wallet.
+ */
+export type CheckPeerPushDebitOp = {
+ op: WalletApiOperation.CheckPeerPushDebit;
+ request: CheckPeerPushDebitRequest;
+ response: CheckPeerPushDebitResponse;
+};
+
+/**
+ * Initiate an outgoing peer push payment.
+ */
+export type InitiatePeerPushDebitOp = {
+ op: WalletApiOperation.InitiatePeerPushDebit;
+ request: InitiatePeerPushDebitRequest;
+ response: InitiatePeerPushDebitResponse;
+};
+
+/**
+ * Check an incoming peer push payment.
+ */
+export type PreparePeerPushCreditOp = {
+ op: WalletApiOperation.PreparePeerPushCredit;
+ request: PreparePeerPushCreditRequest;
+ response: PreparePeerPushCreditResponse;
+};
+
+/**
+ * Accept an incoming peer push payment.
+ */
+export type ConfirmPeerPushCreditOp = {
+ op: WalletApiOperation.ConfirmPeerPushCredit;
+ request: ConfirmPeerPushCreditRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Check fees for an outgoing peer pull payment.
+ */
+export type CheckPeerPullCreditOp = {
+ op: WalletApiOperation.CheckPeerPullCredit;
+ request: CheckPeerPullCreditRequest;
+ response: CheckPeerPullCreditResponse;
+};
+
+/**
+ * Initiate an outgoing peer pull payment.
+ */
+export type InitiatePeerPullCreditOp = {
+ op: WalletApiOperation.InitiatePeerPullCredit;
+ request: InitiatePeerPullCreditRequest;
+ response: InitiatePeerPullCreditResponse;
+};
+
+/**
+ * Prepare for an incoming peer pull payment.
+ */
+export type PreparePeerPullDebitOp = {
+ op: WalletApiOperation.PreparePeerPullDebit;
+ request: PreparePeerPullDebitRequest;
+ response: PreparePeerPullDebitResponse;
+};
+
+/**
+ * Accept an incoming peer pull payment (i.e. pay the other party).
+ */
+export type ConfirmPeerPullDebitOp = {
+ op: WalletApiOperation.ConfirmPeerPullDebit;
+ request: ConfirmPeerPullDebitRequest;
+ response: EmptyObject;
+};
+
+// group: Data Validation
+
+export type ValidateIbanOp = {
+ op: WalletApiOperation.ValidateIban;
+ request: ValidateIbanRequest;
+ response: ValidateIbanResponse;
+};
+
+export type CanonicalizeBaseUrlOp = {
+ op: WalletApiOperation.CanonicalizeBaseUrl;
+ request: CanonicalizeBaseUrlRequest;
+ response: CanonicalizeBaseUrlResponse;
+};
+
+// group: Database Management
+
+/**
+ * Export the wallet database's contents to JSON.
+ */
+export type ExportDbOp = {
+ op: WalletApiOperation.ExportDb;
+ request: EmptyObject;
+ response: any;
+};
+
+export type ImportDbOp = {
+ op: WalletApiOperation.ImportDb;
+ request: ImportDbRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Dangerously clear the whole wallet database.
+ */
+export type ClearDbOp = {
+ op: WalletApiOperation.ClearDb;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Export a backup, clear the database and re-import it.
+ */
+export type RecycleOp = {
+ op: WalletApiOperation.Recycle;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+// group: Testing and Debugging
+
+/**
+ * Apply a developer experiment to the current wallet state.
+ *
+ * This allows UI developers / testers to play around without
+ * an elaborate test environment.
+ */
+export type ApplyDevExperimentOp = {
+ op: WalletApiOperation.ApplyDevExperiment;
+ request: ApplyDevExperimentRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Run a simple integration test on a test deployment
+ * of the exchange and merchant.
+ */
+export type RunIntegrationTestOp = {
+ op: WalletApiOperation.RunIntegrationTest;
+ request: IntegrationTestArgs;
+ response: EmptyObject;
+};
+
+/**
+ * Run a simple integration test on a test deployment
+ * of the exchange and merchant.
+ */
+export type RunIntegrationTestV2Op = {
+ op: WalletApiOperation.RunIntegrationTestV2;
+ request: IntegrationTestArgs;
+ response: EmptyObject;
+};
+
+/**
+ * Test crypto worker.
+ */
+export type TestCryptoOp = {
+ op: WalletApiOperation.TestCrypto;
+ request: EmptyObject;
+ response: any;
+};
+
+/**
+ * Make withdrawal on a test deployment of the exchange
+ * and merchant.
+ */
+export type WithdrawTestBalanceOp = {
+ op: WalletApiOperation.WithdrawTestBalance;
+ request: WithdrawTestBalanceRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Make a withdrawal of testkudos on test.taler.net.
+ */
+export type WithdrawTestkudosOp = {
+ op: WalletApiOperation.WithdrawTestkudos;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Make a test payment using a test deployment of
+ * the exchange and merchant.
+ */
+export type TestPayOp = {
+ op: WalletApiOperation.TestPay;
+ request: TestPayArgs;
+ response: TestPayResult;
+};
+
+/**
+ * Get wallet-internal pending tasks.
+ */
+export type GetUserAttentionRequests = {
+ op: WalletApiOperation.GetUserAttentionRequests;
+ request: UserAttentionsRequest;
+ response: UserAttentionsResponse;
+};
+
+/**
+ * Get wallet-internal pending tasks.
+ */
+export type MarkAttentionRequestAsRead = {
+ op: WalletApiOperation.MarkAttentionRequestAsRead;
+ request: UserAttentionByIdRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Get wallet-internal pending tasks.
+ */
+export type GetUserAttentionsUnreadCount = {
+ op: WalletApiOperation.GetUserAttentionUnreadCount;
+ request: UserAttentionsRequest;
+ response: UserAttentionsCountResponse;
+};
+
+/**
+ * Get wallet-internal pending tasks.
+ *
+ * @deprecated
+ */
+export type GetPendingTasksOp = {
+ op: WalletApiOperation.GetPendingOperations;
+ request: EmptyObject;
+ response: any;
+};
+
+export type GetActiveTasksOp = {
+ op: WalletApiOperation.GetActiveTasks;
+ request: EmptyObject;
+ response: GetActiveTasksResponse;
+};
+
+/**
+ * Dump all coins of the wallet in a simple JSON format.
+ */
+export type DumpCoinsOp = {
+ op: WalletApiOperation.DumpCoins;
+ request: EmptyObject;
+ response: CoinDumpJson;
+};
+
+/**
+ * Add an offset to the wallet's internal time.
+ */
+export type TestingSetTimetravelOp = {
+ op: WalletApiOperation.TestingSetTimetravel;
+ request: TestingSetTimetravelRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Add an offset to the wallet's internal time.
+ */
+export type TestingListTasksForTransactionOp = {
+ op: WalletApiOperation.TestingListTaskForTransaction;
+ request: TestingListTasksForTransactionRequest;
+ response: TestingListTasksForTransactionsResponse;
+};
+
+/**
+ * Wait until all transactions are in a final state.
+ */
+export type TestingWaitTransactionsFinalOp = {
+ op: WalletApiOperation.TestingWaitTransactionsFinal;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Wait until all transactions are in a final state.
+ */
+export type TestingWaitTasksDoneOp = {
+ op: WalletApiOperation.TestingWaitTasksDone;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Wait until all refresh transactions are in a final state.
+ */
+export type TestingWaitRefreshesFinalOp = {
+ op: WalletApiOperation.TestingWaitRefreshesFinal;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Wait until a transaction is in a particular state.
+ */
+export type TestingWaitTransactionStateOp = {
+ op: WalletApiOperation.TestingWaitTransactionState;
+ request: TestingWaitTransactionRequest;
+ response: EmptyObject;
+};
+
+export type TestingPingOp = {
+ op: WalletApiOperation.TestingPing;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+export type TestingGetReserveHistoryOp = {
+ op: WalletApiOperation.TestingGetReserveHistory;
+ request: EmptyObject;
+ response: any;
+};
+
+/**
+ * Get stats about an exchange denomination.
+ */
+export type TestingGetDenomStatsOp = {
+ op: WalletApiOperation.TestingGetDenomStats;
+ request: TestingGetDenomStatsRequest;
+ response: TestingGetDenomStatsResponse;
+};
+
+/**
+ * Set a coin as (un-)suspended.
+ * Suspended coins won't be used for payments.
+ */
+export type SetCoinSuspendedOp = {
+ op: WalletApiOperation.SetCoinSuspended;
+ request: SetCoinSuspendedRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Force a refresh on coins where it would not
+ * be necessary.
+ */
+export type ForceRefreshOp = {
+ op: WalletApiOperation.ForceRefresh;
+ request: ForceRefreshRequest;
+ response: EmptyObject;
+};
+
export type WalletOperations = {
- [WalletApiOperation.InitWallet]: {
- request: {};
- response: {};
- };
- [WalletApiOperation.WithdrawFakebank]: {
- request: WithdrawFakebankRequest;
- response: {};
- };
- [WalletApiOperation.PreparePayForUri]: {
- request: PreparePayRequest;
- response: PreparePayResult;
- };
- [WalletApiOperation.WithdrawTestkudos]: {
- request: {};
- response: {};
- };
- [WalletApiOperation.ConfirmPay]: {
- request: ConfirmPayRequest;
- response: ConfirmPayResult;
- };
- [WalletApiOperation.AbortFailedPayWithRefund]: {
- request: AbortPayWithRefundRequest;
- response: {};
- };
- [WalletApiOperation.GetBalances]: {
- request: {};
- response: BalancesResponse;
- };
- [WalletApiOperation.GetTransactions]: {
- request: TransactionsRequest;
- response: TransactionsResponse;
- };
- [WalletApiOperation.GetPendingOperations]: {
- request: {};
- response: PendingOperationsResponse;
- };
- [WalletApiOperation.DumpCoins]: {
- request: {};
- response: CoinDumpJson;
- };
- [WalletApiOperation.SetCoinSuspended]: {
- request: SetCoinSuspendedRequest;
- response: {};
- };
- [WalletApiOperation.ForceRefresh]: {
- request: ForceRefreshRequest;
- response: {};
- };
- [WalletApiOperation.DeleteTransaction]: {
- request: DeleteTransactionRequest;
- response: {};
- };
- [WalletApiOperation.RetryTransaction]: {
- request: RetryTransactionRequest;
- response: {};
- };
- [WalletApiOperation.PrepareTip]: {
- request: PrepareTipRequest;
- response: PrepareTipResult;
- };
- [WalletApiOperation.AcceptTip]: {
- request: AcceptTipRequest;
- response: {};
- };
- [WalletApiOperation.ApplyRefund]: {
- request: ApplyRefundRequest;
- response: ApplyRefundResponse;
- };
- [WalletApiOperation.ListCurrencies]: {
- request: {};
- response: WalletCurrencyInfo;
- };
- [WalletApiOperation.GetWithdrawalDetailsForAmount]: {
- request: GetWithdrawalDetailsForAmountRequest;
- response: ManualWithdrawalDetails;
- };
- [WalletApiOperation.GetWithdrawalDetailsForUri]: {
- request: GetWithdrawalDetailsForUriRequest;
- response: WithdrawUriInfoResponse;
- };
- [WalletApiOperation.AcceptBankIntegratedWithdrawal]: {
- request: AcceptBankIntegratedWithdrawalRequest;
- response: AcceptWithdrawalResponse;
- };
- [WalletApiOperation.AcceptManualWithdrawal]: {
- request: AcceptManualWithdrawalRequest;
- response: AcceptManualWithdrawalResult;
- };
- [WalletApiOperation.ListExchanges]: {
- request: {};
- response: ExchangesListRespose;
- };
- [WalletApiOperation.AddExchange]: {
- request: AddExchangeRequest;
- response: {};
- };
- [WalletApiOperation.SetExchangeTosAccepted]: {
- request: AcceptExchangeTosRequest;
- response: {};
- };
- [WalletApiOperation.GetExchangeTos]: {
- request: GetExchangeTosRequest;
- response: GetExchangeTosResult;
- };
- [WalletApiOperation.TrackDepositGroup]: {
- request: TrackDepositGroupRequest;
- response: TrackDepositGroupResponse;
- };
- [WalletApiOperation.CreateDepositGroup]: {
- request: CreateDepositGroupRequest;
- response: CreateDepositGroupResponse;
- };
- [WalletApiOperation.SetWalletDeviceId]: {
- request: SetWalletDeviceIdRequest;
- response: {};
- };
- [WalletApiOperation.ExportBackupPlain]: {
- request: {};
- response: WalletBackupContentV1;
- };
- [WalletApiOperation.ExportBackupRecovery]: {
- request: {};
- response: BackupRecovery;
- };
- [WalletApiOperation.ImportBackupRecovery]: {
- request: RecoveryLoadRequest;
- response: {};
- };
- [WalletApiOperation.RunBackupCycle]: {
- request: {};
- response: {};
- };
- [WalletApiOperation.AddBackupProvider]: {
- request: AddBackupProviderRequest;
- response: {};
- };
- [WalletApiOperation.GetBackupInfo]: {
- request: {};
- response: BackupInfo;
- };
- [WalletApiOperation.RunIntegrationTest]: {
- request: IntegrationTestArgs;
- response: {};
- };
- [WalletApiOperation.WithdrawTestBalance]: {
- request: WithdrawTestBalanceRequest;
- response: {};
- };
- [WalletApiOperation.TestPay]: {
- request: TestPayArgs;
- response: {};
- };
-};
-
-export type RequestType<
- Op extends WalletApiOperation & keyof WalletOperations
+ [WalletApiOperation.InitWallet]: InitWalletOp;
+ [WalletApiOperation.SetWalletRunConfig]: SetWalletRunConfigOp;
+ [WalletApiOperation.GetVersion]: GetVersionOp;
+ [WalletApiOperation.PreparePayForUri]: PreparePayForUriOp;
+ [WalletApiOperation.SharePayment]: SharePaymentOp;
+ [WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp;
+ [WalletApiOperation.GetContractTermsDetails]: GetContractTermsDetailsOp;
+ [WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;
+ [WalletApiOperation.ConfirmPay]: ConfirmPayOp;
+ [WalletApiOperation.AbortTransaction]: AbortTransactionOp;
+ [WalletApiOperation.FailTransaction]: FailTransactionOp;
+ [WalletApiOperation.SuspendTransaction]: SuspendTransactionOp;
+ [WalletApiOperation.ResumeTransaction]: ResumeTransactionOp;
+ [WalletApiOperation.GetBalances]: GetBalancesOp;
+ [WalletApiOperation.ConvertDepositAmount]: ConvertDepositAmountOp;
+ [WalletApiOperation.GetMaxDepositAmount]: GetMaxDepositAmountOp;
+ [WalletApiOperation.ConvertPeerPushAmount]: ConvertPeerPushAmountOp;
+ [WalletApiOperation.GetMaxPeerPushAmount]: GetMaxPeerPushAmountOp;
+ [WalletApiOperation.ConvertWithdrawalAmount]: ConvertWithdrawalAmountOp;
+ [WalletApiOperation.GetPlanForOperation]: GetPlanForOperationOp;
+ [WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp;
+ [WalletApiOperation.GetTransactions]: GetTransactionsOp;
+ [WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp;
+ [WalletApiOperation.GetTransactionById]: GetTransactionByIdOp;
+ [WalletApiOperation.GetWithdrawalTransactionByUri]: GetWithdrawalTransactionByUriOp;
+ [WalletApiOperation.RetryPendingNow]: RetryPendingNowOp;
+ [WalletApiOperation.GetPendingOperations]: GetPendingTasksOp;
+ [WalletApiOperation.GetActiveTasks]: GetActiveTasksOp;
+ [WalletApiOperation.GetUserAttentionRequests]: GetUserAttentionRequests;
+ [WalletApiOperation.GetUserAttentionUnreadCount]: GetUserAttentionsUnreadCount;
+ [WalletApiOperation.MarkAttentionRequestAsRead]: MarkAttentionRequestAsRead;
+ [WalletApiOperation.DumpCoins]: DumpCoinsOp;
+ [WalletApiOperation.SetCoinSuspended]: SetCoinSuspendedOp;
+ [WalletApiOperation.ForceRefresh]: ForceRefreshOp;
+ [WalletApiOperation.DeleteTransaction]: DeleteTransactionOp;
+ [WalletApiOperation.RetryTransaction]: RetryTransactionOp;
+ [WalletApiOperation.StartRefundQueryForUri]: StartRefundQueryForUriOp;
+ [WalletApiOperation.StartRefundQuery]: StartRefundQueryOp;
+ [WalletApiOperation.GetWithdrawalDetailsForAmount]: GetWithdrawalDetailsForAmountOp;
+ [WalletApiOperation.GetWithdrawalDetailsForUri]: GetWithdrawalDetailsForUriOp;
+ [WalletApiOperation.AcceptBankIntegratedWithdrawal]: AcceptBankIntegratedWithdrawalOp;
+ [WalletApiOperation.AcceptManualWithdrawal]: AcceptManualWithdrawalOp;
+ [WalletApiOperation.ListExchanges]: ListExchangesOp;
+ [WalletApiOperation.ListExchangesForScopedCurrency]: ListExchangesForScopedCurrencyOp;
+ [WalletApiOperation.AddExchange]: AddExchangeOp;
+ [WalletApiOperation.ListKnownBankAccounts]: ListKnownBankAccountsOp;
+ [WalletApiOperation.AddKnownBankAccounts]: AddKnownBankAccountsOp;
+ [WalletApiOperation.ForgetKnownBankAccounts]: ForgetKnownBankAccountsOp;
+ [WalletApiOperation.SetExchangeTosAccepted]: SetExchangeTosAcceptedOp;
+ [WalletApiOperation.SetExchangeTosForgotten]: SetExchangeTosForgottenOp;
+ [WalletApiOperation.GetExchangeTos]: GetExchangeTosOp;
+ [WalletApiOperation.GetExchangeDetailedInfo]: GetExchangeDetailedInfoOp;
+ [WalletApiOperation.GetExchangeEntryByUrl]: GetExchangeEntryByUrlOp;
+ [WalletApiOperation.PrepareDeposit]: PrepareDepositOp;
+ [WalletApiOperation.GenerateDepositGroupTxId]: GenerateDepositGroupTxIdOp;
+ [WalletApiOperation.CreateDepositGroup]: CreateDepositGroupOp;
+ [WalletApiOperation.SetWalletDeviceId]: SetWalletDeviceIdOp;
+ [WalletApiOperation.ExportBackupRecovery]: ExportBackupRecoveryOp;
+ [WalletApiOperation.ImportBackupRecovery]: ImportBackupRecoveryOp;
+ [WalletApiOperation.RunBackupCycle]: RunBackupCycleOp;
+ [WalletApiOperation.ExportBackup]: ExportBackupOp;
+ [WalletApiOperation.AddBackupProvider]: AddBackupProviderOp;
+ [WalletApiOperation.RemoveBackupProvider]: RemoveBackupProviderOp;
+ [WalletApiOperation.GetBackupInfo]: GetBackupInfoOp;
+ [WalletApiOperation.RunIntegrationTest]: RunIntegrationTestOp;
+ [WalletApiOperation.RunIntegrationTestV2]: RunIntegrationTestV2Op;
+ [WalletApiOperation.TestCrypto]: TestCryptoOp;
+ [WalletApiOperation.WithdrawTestBalance]: WithdrawTestBalanceOp;
+ [WalletApiOperation.TestPay]: TestPayOp;
+ [WalletApiOperation.ExportDb]: ExportDbOp;
+ [WalletApiOperation.ImportDb]: ImportDbOp;
+ [WalletApiOperation.CheckPeerPushDebit]: CheckPeerPushDebitOp;
+ [WalletApiOperation.InitiatePeerPushDebit]: InitiatePeerPushDebitOp;
+ [WalletApiOperation.PreparePeerPushCredit]: PreparePeerPushCreditOp;
+ [WalletApiOperation.ConfirmPeerPushCredit]: ConfirmPeerPushCreditOp;
+ [WalletApiOperation.CheckPeerPullCredit]: CheckPeerPullCreditOp;
+ [WalletApiOperation.InitiatePeerPullCredit]: InitiatePeerPullCreditOp;
+ [WalletApiOperation.PreparePeerPullDebit]: PreparePeerPullDebitOp;
+ [WalletApiOperation.ConfirmPeerPullDebit]: ConfirmPeerPullDebitOp;
+ [WalletApiOperation.ClearDb]: ClearDbOp;
+ [WalletApiOperation.Recycle]: RecycleOp;
+ [WalletApiOperation.ApplyDevExperiment]: ApplyDevExperimentOp;
+ [WalletApiOperation.ValidateIban]: ValidateIbanOp;
+ [WalletApiOperation.TestingWaitTransactionsFinal]: TestingWaitTransactionsFinalOp;
+ [WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinalOp;
+ [WalletApiOperation.TestingSetTimetravel]: TestingSetTimetravelOp;
+ [WalletApiOperation.TestingWaitTransactionState]: TestingWaitTransactionStateOp;
+ [WalletApiOperation.TestingWaitTasksDone]: TestingWaitTasksDoneOp;
+ [WalletApiOperation.GetCurrencySpecification]: GetCurrencySpecificationOp;
+ [WalletApiOperation.CreateStoredBackup]: CreateStoredBackupsOp;
+ [WalletApiOperation.ListStoredBackups]: ListStoredBackupsOp;
+ [WalletApiOperation.DeleteStoredBackup]: DeleteStoredBackupOp;
+ [WalletApiOperation.RecoverStoredBackup]: RecoverStoredBackupsOp;
+ [WalletApiOperation.UpdateExchangeEntry]: UpdateExchangeEntryOp;
+ [WalletApiOperation.PrepareWithdrawExchange]: PrepareWithdrawExchangeOp;
+ [WalletApiOperation.TestingInfiniteTransactionLoop]: any;
+ [WalletApiOperation.DeleteExchange]: DeleteExchangeOp;
+ [WalletApiOperation.GetExchangeResources]: GetExchangeResourcesOp;
+ [WalletApiOperation.ListGlobalCurrencyAuditors]: ListGlobalCurrencyAuditorsOp;
+ [WalletApiOperation.ListGlobalCurrencyExchanges]: ListGlobalCurrencyExchangesOp;
+ [WalletApiOperation.AddGlobalCurrencyAuditor]: AddGlobalCurrencyAuditorOp;
+ [WalletApiOperation.RemoveGlobalCurrencyAuditor]: RemoveGlobalCurrencyAuditorOp;
+ [WalletApiOperation.AddGlobalCurrencyExchange]: AddGlobalCurrencyExchangeOp;
+ [WalletApiOperation.RemoveGlobalCurrencyExchange]: RemoveGlobalCurrencyExchangeOp;
+ [WalletApiOperation.ListAssociatedRefreshes]: ListAssociatedRefreshesOp;
+ [WalletApiOperation.TestingListTaskForTransaction]: TestingListTasksForTransactionOp;
+ [WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp;
+ [WalletApiOperation.TestingPing]: TestingPingOp;
+ [WalletApiOperation.Shutdown]: ShutdownOp;
+ [WalletApiOperation.PrepareBankIntegratedWithdrawal]: PrepareBankIntegratedWithdrawalOp;
+ [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp;
+ [WalletApiOperation.CanonicalizeBaseUrl]: CanonicalizeBaseUrlOp;
+ [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp;
+};
+
+export type WalletCoreRequestType<
+ Op extends WalletApiOperation & keyof WalletOperations,
> = WalletOperations[Op] extends { request: infer T } ? T : never;
-export type ResponseType<
- Op extends WalletApiOperation & keyof WalletOperations
+export type WalletCoreResponseType<
+ Op extends WalletApiOperation & keyof WalletOperations,
> = WalletOperations[Op] extends { response: infer T } ? T : never;
+export type WalletCoreOpKeys = WalletApiOperation & keyof WalletOperations;
+
export interface WalletCoreApiClient {
- call<Op extends WalletApiOperation & keyof WalletOperations>(
+ call<Op extends keyof WalletOperations>(
operation: Op,
- payload: RequestType<Op>,
- ): Promise<ResponseType<Op>>;
+ payload: WalletCoreRequestType<Op>,
+ ): Promise<WalletCoreResponseType<Op>>;
}
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index 32e3945e8..b232080e9 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -15,34 +15,156 @@
*/
/**
- * High-level wallet operations that should be indepentent from the underlying
+ * High-level wallet operations that should be independent from the underlying
* browser extension interface.
*/
/**
* Imports.
*/
+import { IDBDatabase, IDBFactory } from "@gnu-taler/idb-bridge";
import {
- BalancesResponse,
+ AbsoluteTime,
+ ActiveTask,
+ AmountJson,
+ AmountString,
+ Amounts,
+ AsyncCondition,
+ CancellationToken,
+ CoinDumpJson,
+ CoinStatus,
+ CoreApiResponse,
+ CreateStoredBackupResponse,
+ DeleteStoredBackupRequest,
+ DenominationInfo,
+ Duration,
+ ExchangesShortListResponse,
+ GetCurrencySpecificationResponse,
+ InitResponse,
+ KnownBankAccounts,
+ KnownBankAccountsInfo,
+ ListGlobalCurrencyAuditorsResponse,
+ ListGlobalCurrencyExchangesResponse,
+ Logger,
+ NotificationType,
+ ObservabilityContext,
+ ObservabilityEventType,
+ ObservableHttpClientLibrary,
+ OpenedPromise,
+ PartialWalletRunConfig,
+ PrepareWithdrawExchangeRequest,
+ PrepareWithdrawExchangeResponse,
+ RecoverStoredBackupRequest,
+ StoredBackupList,
+ TalerError,
+ TalerErrorCode,
+ TalerProtocolTimestamp,
+ TalerUriAction,
+ TestingGetDenomStatsResponse,
+ TestingListTasksForTransactionsResponse,
+ TestingWaitTransactionRequest,
+ TimerAPI,
+ TimerGroup,
+ TransactionType,
+ ValidateIbanResponse,
+ WalletCoreVersion,
+ WalletNotification,
+ WalletRunConfig,
+ canonicalizeBaseUrl,
+ checkDbInvariant,
+ codecForAbortTransaction,
+ codecForAcceptBankIntegratedWithdrawalRequest,
+ codecForAcceptExchangeTosRequest,
+ codecForAcceptManualWithdrawalRequest,
+ codecForAcceptPeerPullPaymentRequest,
+ codecForAddExchangeRequest,
+ codecForAddGlobalCurrencyAuditorRequest,
+ codecForAddGlobalCurrencyExchangeRequest,
+ codecForAddKnownBankAccounts,
codecForAny,
+ codecForApplyDevExperiment,
+ codecForCanonicalizeBaseUrlRequest,
+ codecForCheckPeerPullPaymentRequest,
+ codecForCheckPeerPushDebitRequest,
+ codecForConfirmPayRequest,
+ codecForConfirmPeerPushPaymentRequest,
+ codecForConfirmWithdrawalRequestRequest,
+ codecForConvertAmountRequest,
+ codecForCreateDepositGroupRequest,
+ codecForDeleteExchangeRequest,
+ codecForDeleteStoredBackupRequest,
codecForDeleteTransactionRequest,
+ codecForFailTransactionRequest,
+ codecForForceRefreshRequest,
+ codecForForgetKnownBankAccounts,
+ codecForGetAmountRequest,
+ codecForGetBalanceDetailRequest,
+ codecForGetContractTermsDetails,
+ codecForGetCurrencyInfoRequest,
+ codecForGetExchangeEntryByUrlRequest,
+ codecForGetExchangeResourcesRequest,
+ codecForGetExchangeTosRequest,
+ codecForGetWithdrawalDetailsForAmountRequest,
+ codecForGetWithdrawalDetailsForUri,
+ codecForImportDbRequest,
+ codecForInitRequest,
+ codecForInitiatePeerPullPaymentRequest,
+ codecForInitiatePeerPushDebitRequest,
+ codecForIntegrationTestArgs,
+ codecForIntegrationTestV2Args,
+ codecForListExchangesForScopedCurrencyRequest,
+ codecForListKnownBankAccounts,
+ codecForPrepareBankIntegratedWithdrawalRequest,
+ codecForPrepareDepositRequest,
+ codecForPreparePayRequest,
+ codecForPreparePayTemplateRequest,
+ codecForPreparePeerPullPaymentRequest,
+ codecForPreparePeerPushCreditRequest,
+ codecForPrepareRefundRequest,
+ codecForPrepareWithdrawExchangeRequest,
+ codecForRecoverStoredBackupRequest,
+ codecForRemoveGlobalCurrencyAuditorRequest,
+ codecForRemoveGlobalCurrencyExchangeRequest,
+ codecForResumeTransaction,
codecForRetryTransactionRequest,
+ codecForSetCoinSuspendedRequest,
codecForSetWalletDeviceIdRequest,
- codecForGetExchangeWithdrawalInfo,
- durationFromSpec,
- durationMin,
- getDurationRemaining,
- isTimestampExpired,
+ codecForSharePaymentRequest,
+ codecForStartRefundQueryRequest,
+ codecForSuspendTransaction,
+ codecForTestPayArgs,
+ codecForTestingGetDenomStatsRequest,
+ codecForTestingGetReserveHistoryRequest,
+ codecForTestingListTasksForTransactionRequest,
+ codecForTestingSetTimetravelRequest,
+ codecForTransactionByIdRequest,
+ codecForTransactionsRequest,
+ codecForUpdateExchangeEntryRequest,
+ codecForUserAttentionByIdRequest,
+ codecForUserAttentionsRequest,
+ codecForValidateIbanRequest,
+ codecForWithdrawTestBalance,
+ getErrorDetailFromException,
j2s,
- TalerErrorCode,
- Timestamp,
- timestampMin,
- WalletNotification,
- codecForWithdrawFakebankRequest,
- URL,
+ openPromise,
parsePaytoUri,
+ parseTalerUri,
+ performanceNow,
+ safeStringifyException,
+ sampleWalletCoreTransactions,
+ setDangerousTimetravel,
+ validateIban,
} from "@gnu-taler/taler-util";
import {
+ readSuccessResponseJsonOrThrow,
+ type HttpRequestLibrary,
+} from "@gnu-taler/taler-util/http";
+import {
+ getUserAttentions,
+ getUserAttentionsUnreadCount,
+ markAttentionRequestAsRead,
+} from "./attention.js";
+import {
addBackupProvider,
codecForAddBackupProviderRequest,
codecForRemoveBackupProvider,
@@ -50,544 +172,342 @@ import {
getBackupInfo,
getBackupRecovery,
loadBackupRecovery,
- processBackupForProvider,
removeBackupProvider,
runBackupCycle,
-} from "./operations/backup/index.js";
-import { exportBackup } from "./operations/backup/export.js";
-import { getBalances } from "./operations/balance.js";
+ setWalletDeviceId,
+} from "./backup/index.js";
+import { getBalanceDetail, getBalances } from "./balance.js";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
- createDepositGroup,
- processDepositGroup,
- trackDepositGroup,
-} from "./operations/deposits.js";
+ CryptoDispatcher,
+ CryptoWorkerFactory,
+} from "./crypto/workers/crypto-dispatcher.js";
+import {
+ CoinSourceType,
+ ConfigRecordKey,
+ DenominationRecord,
+ WalletDbReadOnlyTransaction,
+ WalletStoresV1,
+ clearDatabase,
+ exportDb,
+ importDb,
+ openStoredBackupsDatabase,
+ openTalerDatabase,
+ timestampAbsoluteFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
import {
- makeErrorDetails,
- OperationFailedAndReportedError,
- OperationFailedError,
-} from "./errors.js";
+ checkDepositGroup,
+ createDepositGroup,
+ generateDepositGroupTxId,
+} from "./deposits.js";
+import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
import {
+ ReadyExchangeSummary,
acceptExchangeTermsOfService,
- getExchangeDetails,
- getExchangeTrust,
- updateExchangeFromUrl,
-} from "./operations/exchanges.js";
+ addPresetExchangeEntry,
+ deleteExchange,
+ fetchFreshExchange,
+ forgetExchangeTermsOfService,
+ getExchangeDetailedInfo,
+ getExchangeResources,
+ getExchangeTos,
+ listExchanges,
+ lookupExchangeByUri,
+} from "./exchanges.js";
+import {
+ convertDepositAmount,
+ convertPeerPushAmount,
+ convertWithdrawalAmount,
+ getMaxDepositAmount,
+ getMaxPeerPushAmount,
+} from "./instructedAmountConversion.js";
+import {
+ ObservableDbAccess,
+ ObservableTaskScheduler,
+ observeTalerCrypto,
+} from "./observable-wrappers.js";
import {
confirmPay,
+ getContractTermsDetails,
+ preparePayForTemplate,
preparePayForUri,
- processDownloadProposal,
- processPurchasePay,
-} from "./operations/pay.js";
-import { getPendingOperations } from "./operations/pending.js";
-import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
+ sharePayment,
+ startQueryRefund,
+ startRefundQueryForUri,
+} from "./pay-merchant.js";
import {
- autoRefresh,
- createRefreshGroup,
- processRefreshGroup,
-} from "./operations/refresh.js";
+ checkPeerPullPaymentInitiation,
+ initiatePeerPullPayment,
+} from "./pay-peer-pull-credit.js";
import {
- abortFailedPayWithRefund,
- applyRefund,
- processPurchaseQueryRefund,
-} from "./operations/refund.js";
+ confirmPeerPullDebit,
+ preparePeerPullDebit,
+} from "./pay-peer-pull-debit.js";
import {
- createReserve,
- createTalerWithdrawReserve,
- getFundingPaytoUris,
- processReserve,
-} from "./operations/reserves.js";
+ confirmPeerPushCredit,
+ preparePeerPushCredit,
+} from "./pay-peer-push-credit.js";
import {
- ExchangeOperations,
- InternalWalletState,
- NotificationListener,
- RecoupOperations,
-} from "./common.js";
+ checkPeerPushDebit,
+ initiatePeerPushDebit,
+} from "./pay-peer-push-debit.js";
+import {
+ AfterCommitInfo,
+ DbAccess,
+ DbAccessImpl,
+ TriggerSpec,
+} from "./query.js";
+import { forceRefresh } from "./refresh.js";
+import {
+ TaskScheduler,
+ TaskSchedulerImpl,
+ convertTaskToTransactionId,
+ listTaskForTransactionId,
+} from "./shepherd.js";
import {
runIntegrationTest,
+ runIntegrationTest2,
testPay,
+ waitTasksDone,
+ waitTransactionState,
+ waitUntilAllTransactionsFinal,
+ waitUntilRefreshesDone,
withdrawTestBalance,
-} from "./operations/testing.js";
-import { acceptTip, prepareTip, processTip } from "./operations/tip.js";
+} from "./testing.js";
import {
+ abortTransaction,
+ constructTransactionIdentifier,
deleteTransaction,
+ failTransaction,
+ getTransactionById,
getTransactions,
+ getWithdrawalTransactionByUri,
+ parseTransactionIdentifier,
+ resumeTransaction,
retryTransaction,
-} from "./operations/transactions.js";
-import {
- getExchangeWithdrawalInfo,
- getWithdrawalDetailsForUri,
- processWithdrawGroup,
-} from "./operations/withdraw.js";
+ suspendTransaction,
+} from "./transactions.js";
import {
- AuditorTrustRecord,
- CoinSourceType,
- ReserveRecordStatus,
- WalletStoresV1,
-} from "./db.js";
-import { NotificationType } from "@gnu-taler/taler-util";
+ WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_COREBANK_API_PROTOCOL_VERSION,
+ WALLET_CORE_API_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ WALLET_MERCHANT_PROTOCOL_VERSION,
+} from "./versions.js";
import {
- PendingTaskInfo,
- PendingOperationsResponse,
- PendingTaskType,
-} from "./pending-types.js";
-import { CoinDumpJson } from "@gnu-taler/taler-util";
-import { codecForTransactionsRequest } from "@gnu-taler/taler-util";
+ WalletApiOperation,
+ WalletCoreApiClient,
+ WalletCoreResponseType,
+} from "./wallet-api-types.js";
import {
- AcceptManualWithdrawalResult,
- AcceptWithdrawalResponse,
- codecForAbortPayWithRefundRequest,
- codecForAcceptBankIntegratedWithdrawalRequest,
- codecForAcceptExchangeTosRequest,
- codecForAcceptManualWithdrawalRequet,
- codecForAcceptTipRequest,
- codecForAddExchangeRequest,
- codecForApplyRefundRequest,
- codecForConfirmPayRequest,
- codecForCreateDepositGroupRequest,
- codecForForceRefreshRequest,
- codecForGetExchangeTosRequest,
- codecForGetWithdrawalDetailsForAmountRequest,
- codecForGetWithdrawalDetailsForUri,
- codecForIntegrationTestArgs,
- codecForPreparePayRequest,
- codecForPrepareTipRequest,
- codecForSetCoinSuspendedRequest,
- codecForTestPayArgs,
- codecForTrackDepositGroupRequest,
- codecForWithdrawTestBalance,
- CoreApiResponse,
- ExchangeListItem,
- ExchangesListRespose,
- GetExchangeTosResult,
- ManualWithdrawalDetails,
- RefreshReason,
-} from "@gnu-taler/taler-util";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { assertUnreachable } from "./util/assertUnreachable.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { setWalletDeviceId } from "./operations/backup/state.js";
-import { WalletCoreApiClient } from "./wallet-api-types.js";
-import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
-import { CryptoApi, CryptoWorkerFactory } from "./crypto/workers/cryptoApi.js";
-import { TimerGroup } from "./util/timer.js";
-import {
- AsyncCondition,
- OpenedPromise,
- openPromise,
-} from "./util/promiseUtils.js";
-import { DbAccess } from "./util/query.js";
-import {
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
-} from "./util/http.js";
-
-const builtinAuditors: AuditorTrustRecord[] = [
- {
- currency: "KUDOS",
- auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0",
- auditorBaseUrl: "https://auditor.demo.taler.net/",
- uids: ["5P25XF8TVQP9AW6VYGY2KV47WT5Y3ZXFSJAA570GJPX5SVJXKBVG"],
- },
-];
+ acceptWithdrawalFromUri,
+ confirmWithdrawal,
+ createManualWithdrawal,
+ getWithdrawalDetailsForAmount,
+ getWithdrawalDetailsForUri,
+ prepareBankIntegratedWithdrawal,
+} from "./withdraw.js";
const logger = new Logger("wallet.ts");
-async function getWithdrawalDetailsForAmount(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- amount: AmountJson,
-): Promise<ManualWithdrawalDetails> {
- const wi = await getExchangeWithdrawalInfo(ws, exchangeBaseUrl, amount);
- const paytoUris = wi.exchangeDetails.wireInfo.accounts.map(
- (x) => x.payto_uri,
- );
- if (!paytoUris) {
- throw Error("exchange is in invalid state");
- }
- return {
- amountRaw: Amounts.stringify(amount),
- amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
- paytoUris,
- tosAccepted: wi.termsOfServiceAccepted,
- };
-}
-
/**
- * Execute one operation based on the pending operation info record.
- */
-async function processOnePendingOperation(
- ws: InternalWalletState,
- pending: PendingTaskInfo,
- forceNow = false,
-): Promise<void> {
- logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`);
- switch (pending.type) {
- case PendingTaskType.ExchangeUpdate:
- await updateExchangeFromUrl(
- ws,
- pending.exchangeBaseUrl,
- undefined,
- forceNow,
- );
- break;
- case PendingTaskType.Refresh:
- await processRefreshGroup(ws, pending.refreshGroupId, forceNow);
- break;
- case PendingTaskType.Reserve:
- await processReserve(ws, pending.reservePub, forceNow);
- break;
- case PendingTaskType.Withdraw:
- await processWithdrawGroup(ws, pending.withdrawalGroupId, forceNow);
- break;
- case PendingTaskType.ProposalDownload:
- await processDownloadProposal(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.TipPickup:
- await processTip(ws, pending.tipId, forceNow);
- break;
- case PendingTaskType.Pay:
- await processPurchasePay(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.RefundQuery:
- await processPurchaseQueryRefund(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.Recoup:
- await processRecoupGroup(ws, pending.recoupGroupId, forceNow);
- break;
- case PendingTaskType.ExchangeCheckRefresh:
- await autoRefresh(ws, pending.exchangeBaseUrl);
- break;
- case PendingTaskType.Deposit:
- await processDepositGroup(ws, pending.depositGroupId);
- break;
- case PendingTaskType.Backup:
- await processBackupForProvider(ws, pending.backupProviderBaseUrl);
- break;
- default:
- assertUnreachable(pending);
- }
-}
-
-/**
- * Process pending operations.
- */
-export async function runPending(
- ws: InternalWalletState,
- forceNow = false,
-): Promise<void> {
- const pendingOpsResponse = await getPendingOperations(ws);
- for (const p of pendingOpsResponse.pendingOperations) {
- if (!forceNow && !isTimestampExpired(p.timestampDue)) {
- continue;
- }
- try {
- await processOnePendingOperation(ws, p, forceNow);
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- console.error(
- "Operation failed:",
- JSON.stringify(e.operationError, undefined, 2),
- );
- } else {
- console.error(e);
- }
- }
- }
-}
-
-export interface RetryLoopOpts {
- /**
- * Stop when the number of retries is exceeded for any pending
- * operation.
- */
- maxRetries?: number;
-
- /**
- * Stop the retry loop when all lifeness-giving pending operations
- * are done.
- *
- * Defaults to false.
- */
- stopWhenDone?: boolean;
-}
-
-/**
- * Main retry loop of the wallet.
+ * Execution context for code that is run in the wallet.
*
- * Looks up pending operations from the wallet, runs them, repeat.
+ * Typically the execution context is either for a wallet-core
+ * request handler or for a shepherded task.
*/
-async function runTaskLoop(
- ws: InternalWalletState,
- opts: RetryLoopOpts = {},
-): Promise<void> {
- for (let iteration = 0; !ws.stopped; iteration++) {
- const pending = await getPendingOperations(ws);
- logger.trace(`pending operations: ${j2s(pending)}`);
- let numGivingLiveness = 0;
- let numDue = 0;
- let minDue: Timestamp = { t_ms: "never" };
- for (const p of pending.pendingOperations) {
- minDue = timestampMin(minDue, p.timestampDue);
- if (isTimestampExpired(p.timestampDue)) {
- numDue++;
- }
- if (p.givesLifeness) {
- numGivingLiveness++;
- }
-
- const maxRetries = opts.maxRetries;
-
- if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) {
- logger.warn(
- `stopping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`,
- );
- return;
- }
- }
+export interface WalletExecutionContext {
+ readonly ws: InternalWalletState;
+ readonly cryptoApi: TalerCryptoInterface;
+ readonly cancellationToken: CancellationToken;
+ readonly http: HttpRequestLibrary;
+ readonly db: DbAccess<typeof WalletStoresV1>;
+ readonly oc: ObservabilityContext;
+ readonly taskScheduler: TaskScheduler;
+}
- if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) {
- logger.warn(`stopping, as no pending operations have lifeness`);
- return;
- }
+export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
+export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";
- // Make sure that we run tasks that don't give lifeness at least
- // one time.
- if (iteration !== 0 && numDue === 0) {
- // We've executed pending, due operations at least one.
- // Now we don't have any more operations available,
- // and need to wait.
+export type NotificationListener = (n: WalletNotification) => void;
- // Wait for at most 5 seconds to the next check.
- const dt = durationMin(
- durationFromSpec({
- seconds: 5,
- }),
- getDurationRemaining(minDue),
- );
- logger.trace(`waiting for at most ${dt.d_ms} ms`);
- const timeout = ws.timerGroup.resolveAfter(dt);
- ws.notify({
- type: NotificationType.WaitingForRetry,
- numGivingLiveness,
- numPending: pending.pendingOperations.length,
- });
- // Wait until either the timeout, or we are notified (via the latch)
- // that more work might be available.
- await Promise.race([timeout, ws.latch.wait()]);
- } else {
- logger.trace(
- `running ${pending.pendingOperations.length} pending operations`,
- );
- for (const p of pending.pendingOperations) {
- if (!isTimestampExpired(p.timestampDue)) {
- continue;
- }
- try {
- await processOnePendingOperation(ws, p);
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- logger.warn("operation processed resulted in reported error");
- } else {
- logger.error("Uncaught exception", e);
- ws.notify({
- type: NotificationType.InternalError,
- message: "uncaught exception",
- exception: e,
- });
- }
- }
- ws.notify({
- type: NotificationType.PendingOperationProcessed,
- });
- }
- }
- }
- logger.trace("exiting wallet retry loop");
-}
+type CancelFn = () => void;
/**
* Insert the hard-coded defaults for exchanges, coins and
* auditors into the database, unless these defaults have
* already been applied.
*/
-async function fillDefaults(ws: InternalWalletState): Promise<void> {
- await ws.db
- .mktx((x) => ({ config: x.config, auditorTrustStore: x.auditorTrust }))
- .runReadWrite(async (tx) => {
- let applied = false;
- await tx.config.iter().forEach((x) => {
- if (x.key == "currencyDefaultsApplied" && x.value == true) {
- applied = true;
- }
- });
- if (!applied) {
- for (const c of builtinAuditors) {
- await tx.auditorTrustStore.put(c);
+async function fillDefaults(wex: WalletExecutionContext): Promise<void> {
+ const notifications: WalletNotification[] = [];
+ await wex.db.runReadWriteTx(
+ { storeNames: ["config", "exchanges"] },
+ async (tx) => {
+ const appliedRec = await tx.config.get("currencyDefaultsApplied");
+ let alreadyApplied = appliedRec ? !!appliedRec.value : false;
+ if (alreadyApplied) {
+ logger.trace("defaults already applied");
+ return;
+ }
+ for (const exch of wex.ws.config.builtin.exchanges) {
+ const resp = await addPresetExchangeEntry(
+ tx,
+ exch.exchangeBaseUrl,
+ exch.currencyHint,
+ );
+ if (resp.notification) {
+ notifications.push(resp.notification);
}
}
- });
-}
-
-/**
- * Create a reserve for a manual withdrawal.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- */
-async function acceptManualWithdrawal(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- amount: AmountJson,
-): Promise<AcceptManualWithdrawalResult> {
- try {
- const resp = await createReserve(ws, {
- amount,
- exchange: exchangeBaseUrl,
- });
- const exchangePaytoUris = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- reserves: x.reserves,
- }))
- .runReadWrite((tx) => getFundingPaytoUris(tx, resp.reservePub));
- return {
- reservePub: resp.reservePub,
- exchangePaytoUris,
- };
- } finally {
- ws.latch.trigger();
+ await tx.config.put({
+ key: ConfigRecordKey.CurrencyDefaultsApplied,
+ value: true,
+ });
+ },
+ );
+ for (const notif of notifications) {
+ wex.ws.notify(notif);
}
}
-async function getExchangeTos(
- ws: InternalWalletState,
+export async function getDenomInfo(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
exchangeBaseUrl: string,
- acceptedFormat?: string[],
-): Promise<GetExchangeTosResult> {
- const { exchangeDetails } = await updateExchangeFromUrl(
- ws,
- exchangeBaseUrl,
- acceptedFormat,
- );
- const content = exchangeDetails.termsOfServiceText;
- const currentEtag = exchangeDetails.termsOfServiceLastEtag;
- const contentType = exchangeDetails.termsOfServiceContentType;
- if (
- content === undefined ||
- currentEtag === undefined ||
- contentType === undefined
- ) {
- throw Error("exchange is in invalid state");
+ denomPubHash: string,
+): Promise<DenominationInfo | undefined> {
+ const cacheKey = `${exchangeBaseUrl}:${denomPubHash}`;
+ const cached = wex.ws.denomInfoCache.get(cacheKey);
+ if (cached) {
+ return cached;
}
- return {
- acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag,
- currentEtag,
- content,
- contentType,
- };
+ const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
+ if (d) {
+ const denomInfo = DenominationRecord.toDenomInfo(d);
+ wex.ws.denomInfoCache.put(cacheKey, denomInfo);
+ return denomInfo;
+ }
+ return undefined;
}
-async function getExchanges(
- ws: InternalWalletState,
-): Promise<ExchangesListRespose> {
- const exchanges: ExchangeListItem[] = [];
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const dp = r.detailsPointer;
- if (!dp) {
- continue;
- }
- const { currency } = dp;
- const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
- if (!exchangeDetails) {
- continue;
- }
- exchanges.push({
- exchangeBaseUrl: r.baseUrl,
- currency,
- paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+/**
+ * List bank accounts known to the wallet from
+ * previous withdrawals.
+ */
+async function listKnownBankAccounts(
+ wex: WalletExecutionContext,
+ currency?: string,
+): Promise<KnownBankAccounts> {
+ const accounts: KnownBankAccountsInfo[] = [];
+ await wex.db.runReadOnlyTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ const knownAccounts = await tx.bankAccounts.iter().toArray();
+ for (const r of knownAccounts) {
+ if (currency && currency !== r.currency) {
+ continue;
+ }
+ const payto = parsePaytoUri(r.uri);
+ if (payto) {
+ accounts.push({
+ uri: payto,
+ alias: r.alias,
+ kyc_completed: r.kycCompleted,
+ currency: r.currency,
});
}
- });
- return { exchanges };
+ }
+ });
+ return { accounts };
}
-async function acceptWithdrawal(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- selectedExchange: string,
-): Promise<AcceptWithdrawalResponse> {
- try {
- return createTalerWithdrawReserve(ws, talerWithdrawUri, selectedExchange);
- } finally {
- ws.latch.trigger();
- }
+/**
+ */
+async function addKnownBankAccounts(
+ wex: WalletExecutionContext,
+ payto: string,
+ alias: string,
+ currency: string,
+): Promise<void> {
+ await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ tx.bankAccounts.put({
+ uri: payto,
+ alias: alias,
+ currency: currency,
+ kycCompleted: false,
+ });
+ });
+ return;
}
/**
- * Inform the wallet that the status of a reserve has changed (e.g. due to a
- * confirmation from the bank.).
*/
-export async function handleNotifyReserve(
- ws: InternalWalletState,
+async function forgetKnownBankAccounts(
+ wex: WalletExecutionContext,
+ payto: string,
): Promise<void> {
- const reserves = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.iter().toArray();
- });
- for (const r of reserves) {
- if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) {
- try {
- processReserve(ws, r.reservePub);
- } catch (e) {
- console.error(e);
- }
+ await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ const account = await tx.bankAccounts.get(payto);
+ if (!account) {
+ throw Error(`account not found: ${payto}`);
}
- }
+ tx.bankAccounts.delete(account.uri);
+ });
+ return;
}
async function setCoinSuspended(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
coinPub: string,
suspended: boolean,
): Promise<void> {
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "coinAvailability"] },
+ async (tx) => {
const c = await tx.coins.get(coinPub);
if (!c) {
logger.warn(`coin ${coinPub} not found, won't suspend`);
return;
}
- c.suspended = suspended;
+ const coinAvailability = await tx.coinAvailability.get([
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ c.maxAge,
+ ]);
+ checkDbInvariant(!!coinAvailability);
+ if (suspended) {
+ if (c.status !== CoinStatus.Fresh) {
+ return;
+ }
+ if (coinAvailability.freshCoinCount === 0) {
+ throw Error(
+ `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+ );
+ }
+ coinAvailability.freshCoinCount--;
+ c.status = CoinStatus.FreshSuspended;
+ } else {
+ if (c.status == CoinStatus.Dormant) {
+ return;
+ }
+ coinAvailability.freshCoinCount++;
+ c.status = CoinStatus.Fresh;
+ }
await tx.coins.put(c);
- });
+ await tx.coinAvailability.put(coinAvailability);
+ },
+ );
}
/**
* Dump the public information of coins we have in an easy-to-process format.
*/
-async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
+async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> {
const coinsJson: CoinDumpJson = { coins: [] };
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadOnly(async (tx) => {
+ logger.info("dumping coins");
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
const coins = await tx.coins.iter().toArray();
for (const c of coins) {
const denom = await tx.denominations.get([
@@ -595,7 +515,7 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
c.denomPubHash,
]);
if (!denom) {
- console.error("no denom session found for coin");
+ logger.warn("no denom found for coin");
continue;
}
const cs = c.coinSource;
@@ -605,42 +525,55 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
}
let withdrawalReservePub: string | undefined;
if (cs.type == CoinSourceType.Withdraw) {
- const ws = await tx.withdrawalGroups.get(cs.withdrawalGroupId);
- if (!ws) {
- console.error("no withdrawal session found for coin");
- continue;
- }
- withdrawalReservePub = ws.reservePub;
+ withdrawalReservePub = cs.reservePub;
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ );
+ if (!denomInfo) {
+ logger.warn("no denomination found for coin");
+ continue;
}
coinsJson.coins.push({
coin_pub: c.coinPub,
- denom_pub: c.denomPub,
+ denom_pub: denomInfo.denomPub,
denom_pub_hash: c.denomPubHash,
- denom_value: Amounts.stringify(denom.value),
+ denom_value: denom.value,
exchange_base_url: c.exchangeBaseUrl,
refresh_parent_coin_pub: refreshParentCoinPub,
- remaining_value: Amounts.stringify(c.currentAmount),
withdrawal_reserve_pub: withdrawalReservePub,
- coin_suspended: c.suspended,
+ coin_status: c.status,
+ ageCommitmentProof: c.ageCommitmentProof,
+ spend_allocation: c.spendAllocation
+ ? {
+ amount: c.spendAllocation.amount,
+ id: c.spendAllocation.id,
+ }
+ : undefined,
});
}
- });
+ },
+ );
return coinsJson;
}
/**
* Get an API client from an internal wallet state object.
*/
-export async function getClientFromWalletState(
+let id = 0;
+async function getClientFromWalletState(
ws: InternalWalletState,
): Promise<WalletCoreApiClient> {
- let id = 0;
const client: WalletCoreApiClient = {
async call(op, payload): Promise<any> {
- const res = await handleCoreApiRequest(ws, op, `${id++}`, payload);
+ id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100);
+ const res = await handleCoreApiRequest(ws, op, String(id), payload);
switch (res.type) {
case "error":
- throw new OperationFailedError(res.error);
+ throw TalerError.fromUncheckedDetail(res.error);
case "response":
return res.result;
}
@@ -649,320 +582,1099 @@ export async function getClientFromWalletState(
return client;
}
+async function createStoredBackup(
+ wex: WalletExecutionContext,
+): Promise<CreateStoredBackupResponse> {
+ const backup = await exportDb(wex.ws.idb);
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ const name = `backup-${new Date().getTime()}`;
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupMeta.add({
+ name,
+ });
+ await tx.backupData.add(backup, name);
+ });
+ return {
+ name,
+ };
+}
+
+async function listStoredBackups(
+ wex: WalletExecutionContext,
+): Promise<StoredBackupList> {
+ const storedBackups: StoredBackupList = {
+ storedBackups: [],
+ };
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupMeta.iter().forEach((x) => {
+ storedBackups.storedBackups.push({
+ name: x.name,
+ });
+ });
+ });
+ return storedBackups;
+}
+
+async function deleteStoredBackup(
+ wex: WalletExecutionContext,
+ req: DeleteStoredBackupRequest,
+): Promise<void> {
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupData.delete(req.name);
+ await tx.backupMeta.delete(req.name);
+ });
+}
+
+async function recoverStoredBackup(
+ wex: WalletExecutionContext,
+ req: RecoverStoredBackupRequest,
+): Promise<void> {
+ logger.info(`Recovering stored backup ${req.name}`);
+ const { name } = req;
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ const bd = await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ const backupMeta = tx.backupMeta.get(name);
+ if (!backupMeta) {
+ throw Error("backup not found");
+ }
+ const backupData = await tx.backupData.get(name);
+ if (!backupData) {
+ throw Error("no backup data (DB corrupt)");
+ }
+ return backupData;
+ });
+ logger.info(`backup found, now importing`);
+ await importDb(wex.db.idbHandle(), bd);
+ logger.info(`import done`);
+}
+
+async function handlePrepareWithdrawExchange(
+ wex: WalletExecutionContext,
+ req: PrepareWithdrawExchangeRequest,
+): Promise<PrepareWithdrawExchangeResponse> {
+ const parsedUri = parseTalerUri(req.talerUri);
+ if (parsedUri?.type !== TalerUriAction.WithdrawExchange) {
+ throw Error("expected a taler://withdraw-exchange URI");
+ }
+ const exchangeBaseUrl = parsedUri.exchangeBaseUrl;
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
+ if (parsedUri.amount) {
+ const amt = Amounts.parseOrThrow(parsedUri.amount);
+ if (amt.currency !== exchange.currency) {
+ throw Error("mismatch of currency (URI vs exchange)");
+ }
+ }
+ return {
+ exchangeBaseUrl,
+ amount: parsedUri.amount,
+ };
+}
+
+/**
+ * Response returned from the pending operations API.
+ *
+ * @deprecated this is a placeholder for the response type of a deprecated wallet-core request.
+ */
+export interface PendingOperationsResponse {
+ /**
+ * List of pending operations.
+ */
+ pendingOperations: any[];
+}
+
/**
* Implementation of the "wallet-core" API.
*/
async function dispatchRequestInternal(
- ws: InternalWalletState,
- operation: string,
+ wex: WalletExecutionContext,
+ cts: CancellationToken.Source,
+ operation: WalletApiOperation,
payload: unknown,
-): Promise<Record<string, any>> {
- if (!ws.initCalled && operation !== "initWallet") {
+): Promise<WalletCoreResponseType<typeof operation>> {
+ if (!wex.ws.initCalled && operation !== WalletApiOperation.InitWallet) {
throw Error(
`wallet must be initialized before running operation ${operation}`,
);
}
+ // FIXME: Can we make this more type-safe by using the request/response type
+ // definitions we already have?
switch (operation) {
- case "initWallet": {
- ws.initCalled = true;
- await fillDefaults(ws);
+ case WalletApiOperation.CreateStoredBackup:
+ return createStoredBackup(wex);
+ case WalletApiOperation.DeleteStoredBackup: {
+ const req = codecForDeleteStoredBackupRequest().decode(payload);
+ await deleteStoredBackup(wex, req);
return {};
}
- case "withdrawTestkudos": {
- await withdrawTestBalance(
- ws,
- "TESTKUDOS:10",
- "https://bank.test.taler.net/",
- "https://exchange.test.taler.net/",
- );
+ case WalletApiOperation.ListStoredBackups:
+ return listStoredBackups(wex);
+ case WalletApiOperation.RecoverStoredBackup: {
+ const req = codecForRecoverStoredBackupRequest().decode(payload);
+ await recoverStoredBackup(wex, req);
return {};
}
- case "withdrawTestBalance": {
+ case WalletApiOperation.SetWalletRunConfig:
+ case WalletApiOperation.InitWallet: {
+ const req = codecForInitRequest().decode(payload);
+
+ logger.info(`init request: ${j2s(req)}`);
+
+ if (wex.ws.initCalled) {
+ logger.info("initializing wallet (repeat initialization)");
+ } else {
+ logger.info("initializing wallet (first initialization)");
+ }
+
+ // Write to the DB to make sure that we're failing early in
+ // case the DB is not writeable.
+ try {
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ tx.config.put({
+ key: ConfigRecordKey.LastInitInfo,
+ value: timestampProtocolToDb(TalerProtocolTimestamp.now()),
+ });
+ });
+ } catch (e) {
+ logger.error("error writing to database during initialization");
+ throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
+ innerError: getErrorDetailFromException(e),
+ });
+ }
+ wex.ws.initWithConfig(applyRunConfigDefaults(req.config));
+
+ if (wex.ws.config.testing.skipDefaults) {
+ logger.trace("skipping defaults");
+ } else {
+ logger.trace("filling defaults");
+ await fillDefaults(wex);
+ }
+ const resp: InitResponse = {
+ versionInfo: getVersion(wex),
+ };
+
+ if (req.config?.features?.lazyTaskLoop) {
+ logger.trace("lazily starting task loop");
+ } else {
+ await wex.taskScheduler.ensureRunning();
+ }
+
+ wex.ws.initCalled = true;
+ return resp;
+ }
+ case WalletApiOperation.WithdrawTestkudos: {
+ await withdrawTestBalance(wex, {
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: "https://bank.test.taler.net/",
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ });
+ return {
+ versionInfo: getVersion(wex),
+ };
+ }
+ case WalletApiOperation.WithdrawTestBalance: {
const req = codecForWithdrawTestBalance().decode(payload);
- await withdrawTestBalance(
- ws,
- req.amount,
- req.bankBaseUrl,
- req.exchangeBaseUrl,
- );
+ await withdrawTestBalance(wex, req);
return {};
}
- case "runIntegrationTest": {
+ case WalletApiOperation.TestingListTaskForTransaction: {
+ const req =
+ codecForTestingListTasksForTransactionRequest().decode(payload);
+ return {
+ taskIdList: listTaskForTransactionId(req.transactionId),
+ } satisfies TestingListTasksForTransactionsResponse;
+ }
+ case WalletApiOperation.RunIntegrationTest: {
const req = codecForIntegrationTestArgs().decode(payload);
- await runIntegrationTest(ws, req);
+ await runIntegrationTest(wex, req);
return {};
}
- case "testPay": {
- const req = codecForTestPayArgs().decode(payload);
- await testPay(ws, req);
+ case WalletApiOperation.RunIntegrationTestV2: {
+ const req = codecForIntegrationTestV2Args().decode(payload);
+ await runIntegrationTest2(wex, req);
return {};
}
- case "getTransactions": {
+ case WalletApiOperation.ValidateIban: {
+ const req = codecForValidateIbanRequest().decode(payload);
+ const valRes = validateIban(req.iban);
+ const resp: ValidateIbanResponse = {
+ valid: valRes.type === "valid",
+ };
+ return resp;
+ }
+ case WalletApiOperation.TestPay: {
+ const req = codecForTestPayArgs().decode(payload);
+ return await testPay(wex, req);
+ }
+ case WalletApiOperation.GetTransactions: {
const req = codecForTransactionsRequest().decode(payload);
- return await getTransactions(ws, req);
+ return await getTransactions(wex, req);
}
- case "addExchange": {
+ case WalletApiOperation.GetTransactionById: {
+ const req = codecForTransactionByIdRequest().decode(payload);
+ return await getTransactionById(wex, req);
+ }
+ case WalletApiOperation.GetWithdrawalTransactionByUri: {
+ const req = codecForGetWithdrawalDetailsForUri().decode(payload);
+ return await getWithdrawalTransactionByUri(wex, req);
+ }
+ case WalletApiOperation.AddExchange: {
const req = codecForAddExchangeRequest().decode(payload);
- await updateExchangeFromUrl(
- ws,
- req.exchangeBaseUrl,
- undefined,
- req.forceUpdate,
+ await fetchFreshExchange(wex, req.exchangeBaseUrl, {});
+ return {};
+ }
+ case WalletApiOperation.TestingPing: {
+ return {};
+ }
+ case WalletApiOperation.UpdateExchangeEntry: {
+ const req = codecForUpdateExchangeEntryRequest().decode(payload);
+ await fetchFreshExchange(wex, req.exchangeBaseUrl, {
+ forceUpdate: !!req.force,
+ });
+ return {};
+ }
+ case WalletApiOperation.TestingGetDenomStats: {
+ const req = codecForTestingGetDenomStatsRequest().decode(payload);
+ const denomStats: TestingGetDenomStatsResponse = {
+ numKnown: 0,
+ numLost: 0,
+ numOffered: 0,
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ const denoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ req.exchangeBaseUrl,
+ );
+ for (const d of denoms) {
+ denomStats.numKnown++;
+ if (d.isOffered) {
+ denomStats.numOffered++;
+ }
+ if (d.isLost) {
+ denomStats.numLost++;
+ }
+ }
+ },
);
+ return denomStats;
+ }
+ case WalletApiOperation.ListExchanges: {
+ return await listExchanges(wex);
+ }
+ case WalletApiOperation.GetExchangeEntryByUrl: {
+ const req = codecForGetExchangeEntryByUrlRequest().decode(payload);
+ return lookupExchangeByUri(wex, req);
+ }
+ case WalletApiOperation.ListExchangesForScopedCurrency: {
+ const req =
+ codecForListExchangesForScopedCurrencyRequest().decode(payload);
+ const exchangesResp = await listExchanges(wex);
+ const result: ExchangesShortListResponse = {
+ exchanges: [],
+ };
+ // Right now we only filter on the currency, as wallet-core doesn't
+ // fully support scoped currencies yet.
+ for (const exch of exchangesResp.exchanges) {
+ if (exch.currency === req.scope.currency) {
+ result.exchanges.push({
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ });
+ }
+ }
+ return result;
+ }
+ case WalletApiOperation.GetExchangeDetailedInfo: {
+ const req = codecForAddExchangeRequest().decode(payload);
+ return await getExchangeDetailedInfo(wex, req.exchangeBaseUrl);
+ }
+ case WalletApiOperation.ListKnownBankAccounts: {
+ const req = codecForListKnownBankAccounts().decode(payload);
+ return await listKnownBankAccounts(wex, req.currency);
+ }
+ case WalletApiOperation.AddKnownBankAccounts: {
+ const req = codecForAddKnownBankAccounts().decode(payload);
+ await addKnownBankAccounts(wex, req.payto, req.alias, req.currency);
return {};
}
- case "listExchanges": {
- return await getExchanges(ws);
+ case WalletApiOperation.ForgetKnownBankAccounts: {
+ const req = codecForForgetKnownBankAccounts().decode(payload);
+ await forgetKnownBankAccounts(wex, req.payto);
+ return {};
}
- case "getWithdrawalDetailsForUri": {
+ case WalletApiOperation.GetWithdrawalDetailsForUri: {
const req = codecForGetWithdrawalDetailsForUri().decode(payload);
- return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri);
+ return await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri, {
+ restrictAge: req.restrictAge,
+ });
}
- case "getExchangeWithdrawalInfo": {
- const req = codecForGetExchangeWithdrawalInfo().decode(payload);
- return await getExchangeWithdrawalInfo(
- ws,
- req.exchangeBaseUrl,
- req.amount,
+ case WalletApiOperation.TestingGetReserveHistory: {
+ const req = codecForTestingGetReserveHistoryRequest().decode(payload);
+ const reserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
+ return tx.reserves.indexes.byReservePub.get(req.reservePub);
+ },
);
- }
- case "acceptManualWithdrawal": {
- const req = codecForAcceptManualWithdrawalRequet().decode(payload);
- const res = await acceptManualWithdrawal(
- ws,
- req.exchangeBaseUrl,
- Amounts.parseOrThrow(req.amount),
+ if (!reserve) {
+ throw Error("no reserve pub found");
+ }
+ const sigResp = await wex.cryptoApi.signReserveHistoryReq({
+ reservePriv: reserve.reservePriv,
+ startOffset: 0,
+ });
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ const url = new URL(
+ `reserves/${req.reservePub}/history`,
+ exchangeBaseUrl,
);
+ const resp = await wex.http.fetch(url.href, {
+ headers: { ["Taler-Reserve-History-Signature"]: sigResp.sig },
+ });
+ const historyJson = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForAny(),
+ );
+ return historyJson;
+ }
+ case WalletApiOperation.AcceptManualWithdrawal: {
+ const req = codecForAcceptManualWithdrawalRequest().decode(payload);
+ const res = await createManualWithdrawal(wex, {
+ amount: Amounts.parseOrThrow(req.amount),
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ restrictAge: req.restrictAge,
+ forceReservePriv: req.forceReservePriv,
+ });
return res;
}
- case "getWithdrawalDetailsForAmount": {
- const req = codecForGetWithdrawalDetailsForAmountRequest().decode(
- payload,
- );
- return await getWithdrawalDetailsForAmount(
- ws,
- req.exchangeBaseUrl,
- Amounts.parseOrThrow(req.amount),
- );
+ case WalletApiOperation.GetWithdrawalDetailsForAmount: {
+ const req =
+ codecForGetWithdrawalDetailsForAmountRequest().decode(payload);
+ const resp = await getWithdrawalDetailsForAmount(wex, cts, req);
+ return resp;
+ }
+ case WalletApiOperation.GetBalances: {
+ return await getBalances(wex);
+ }
+ case WalletApiOperation.GetBalanceDetail: {
+ const req = codecForGetBalanceDetailRequest().decode(payload);
+ return await getBalanceDetail(wex, req);
}
- case "getBalances": {
- return await getBalances(ws);
+ case WalletApiOperation.GetUserAttentionRequests: {
+ const req = codecForUserAttentionsRequest().decode(payload);
+ return await getUserAttentions(wex, req);
}
- case "getPendingOperations": {
- return await getPendingOperations(ws);
+ case WalletApiOperation.MarkAttentionRequestAsRead: {
+ const req = codecForUserAttentionByIdRequest().decode(payload);
+ return await markAttentionRequestAsRead(wex, req);
}
- case "setExchangeTosAccepted": {
+ case WalletApiOperation.GetUserAttentionUnreadCount: {
+ const req = codecForUserAttentionsRequest().decode(payload);
+ return await getUserAttentionsUnreadCount(wex, req);
+ }
+ case WalletApiOperation.GetPendingOperations: {
+ // FIXME: Eventually remove the handler after deprecation period.
+ return {
+ pendingOperations: [],
+ } satisfies PendingOperationsResponse;
+ }
+ case WalletApiOperation.SetExchangeTosAccepted: {
const req = codecForAcceptExchangeTosRequest().decode(payload);
- await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag);
+ await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl);
return {};
}
- case "applyRefund": {
- const req = codecForApplyRefundRequest().decode(payload);
- return await applyRefund(ws, req.talerRefundUri);
+ case WalletApiOperation.SetExchangeTosForgotten: {
+ const req = codecForAcceptExchangeTosRequest().decode(payload);
+ await forgetExchangeTermsOfService(wex, req.exchangeBaseUrl);
+ return {};
}
- case "acceptBankIntegratedWithdrawal": {
- const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(
- payload,
- );
- return await acceptWithdrawal(
- ws,
- req.talerWithdrawUri,
+ case WalletApiOperation.AcceptBankIntegratedWithdrawal: {
+ const req =
+ codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
+ return await acceptWithdrawalFromUri(wex, {
+ selectedExchange: req.exchangeBaseUrl,
+ talerWithdrawUri: req.talerWithdrawUri,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ });
+ }
+ case WalletApiOperation.ConfirmWithdrawal: {
+ const req = codecForConfirmWithdrawalRequestRequest().decode(payload);
+ return confirmWithdrawal(wex, req.transactionId);
+ }
+ case WalletApiOperation.PrepareBankIntegratedWithdrawal: {
+ const req =
+ codecForPrepareBankIntegratedWithdrawalRequest().decode(payload);
+ return prepareBankIntegratedWithdrawal(wex, {
+ selectedExchange: req.exchangeBaseUrl,
+ talerWithdrawUri: req.talerWithdrawUri,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ });
+ }
+ case WalletApiOperation.GetExchangeTos: {
+ const req = codecForGetExchangeTosRequest().decode(payload);
+ return getExchangeTos(
+ wex,
req.exchangeBaseUrl,
+ req.acceptedFormat,
+ req.acceptLanguage,
);
}
- case "getExchangeTos": {
- const req = codecForGetExchangeTosRequest().decode(payload);
- return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat);
+ case WalletApiOperation.GetContractTermsDetails: {
+ const req = codecForGetContractTermsDetails().decode(payload);
+ if (req.proposalId) {
+ // FIXME: deprecated path
+ return getContractTermsDetails(wex, req.proposalId);
+ }
+ if (req.transactionId) {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (parsedTx?.tag === TransactionType.Payment) {
+ return getContractTermsDetails(wex, parsedTx.proposalId);
+ }
+ throw Error("transactionId is not a payment transaction");
+ }
+ throw Error("transactionId missing");
}
- case "retryPendingNow": {
- await runPending(ws, true);
+ case WalletApiOperation.RetryPendingNow: {
+ logger.error("retryPendingNow currently not implemented");
return {};
}
- // FIXME: Deprecate one of the aliases!
- case "preparePayForUri":
- case "preparePay": {
+ case WalletApiOperation.SharePayment: {
+ const req = codecForSharePaymentRequest().decode(payload);
+ return await sharePayment(wex, req.merchantBaseUrl, req.orderId);
+ }
+ case WalletApiOperation.PrepareWithdrawExchange: {
+ const req = codecForPrepareWithdrawExchangeRequest().decode(payload);
+ return handlePrepareWithdrawExchange(wex, req);
+ }
+ case WalletApiOperation.PreparePayForUri: {
const req = codecForPreparePayRequest().decode(payload);
- return await preparePayForUri(ws, req.talerPayUri);
+ return await preparePayForUri(wex, req.talerPayUri);
}
- case "confirmPay": {
+ case WalletApiOperation.PreparePayForTemplate: {
+ const req = codecForPreparePayTemplateRequest().decode(payload);
+ return preparePayForTemplate(wex, req);
+ }
+ case WalletApiOperation.ConfirmPay: {
const req = codecForConfirmPayRequest().decode(payload);
- return await confirmPay(ws, req.proposalId, req.sessionId);
+ let transactionId;
+ if (req.proposalId) {
+ // legacy client support
+ transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: req.proposalId,
+ });
+ } else if (req.transactionId) {
+ transactionId = req.transactionId;
+ } else {
+ throw Error("transactionId or (deprecated) proposalId required");
+ }
+ return await confirmPay(wex, transactionId, req.sessionId);
}
- case "abortFailedPayWithRefund": {
- const req = codecForAbortPayWithRefundRequest().decode(payload);
- await abortFailedPayWithRefund(ws, req.proposalId);
+ case WalletApiOperation.AbortTransaction: {
+ const req = codecForAbortTransaction().decode(payload);
+ await abortTransaction(wex, req.transactionId);
return {};
}
- case "dumpCoins": {
- return await dumpCoins(ws);
- }
- case "setCoinSuspended": {
- const req = codecForSetCoinSuspendedRequest().decode(payload);
- await setCoinSuspended(ws, req.coinPub, req.suspended);
+ case WalletApiOperation.SuspendTransaction: {
+ const req = codecForSuspendTransaction().decode(payload);
+ await suspendTransaction(wex, req.transactionId);
return {};
}
- case "forceRefresh": {
- const req = codecForForceRefreshRequest().decode(payload);
- const coinPubs = req.coinPubList.map((x) => ({ coinPub: x }));
- const refreshGroupId = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- denominations: x.denominations,
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
- return await createRefreshGroup(
- ws,
- tx,
- coinPubs,
- RefreshReason.Manual,
+ case WalletApiOperation.GetActiveTasks: {
+ const allTasksId = wex.taskScheduler.getActiveTasks();
+
+ const tasksInfo = await Promise.all(
+ allTasksId.map(async (id) => {
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ return tx.operationRetries.get(id);
+ },
);
- });
- processRefreshGroup(ws, refreshGroupId.refreshGroupId, true).catch(
- (x) => {
- logger.error(x);
- },
+ }),
);
- return {
- refreshGroupId,
- };
+
+ const tasks = allTasksId.map((taskId, i): ActiveTask => {
+ const transaction = convertTaskToTransactionId(taskId);
+ const d = tasksInfo[i];
+
+ const firstTry = !d
+ ? undefined
+ : timestampAbsoluteFromDb(d.retryInfo.firstTry);
+ const nextTry = !d
+ ? undefined
+ : timestampAbsoluteFromDb(d.retryInfo.nextRetry);
+ const counter = d?.retryInfo.retryCounter;
+ const lastError = d?.lastError;
+
+ return {
+ taskId: taskId,
+ retryCounter: counter,
+ firstTry,
+ nextTry,
+ lastError,
+ transaction,
+ };
+ });
+ return { tasks };
+ }
+ case WalletApiOperation.FailTransaction: {
+ const req = codecForFailTransactionRequest().decode(payload);
+ await failTransaction(wex, req.transactionId);
+ return {};
+ }
+ case WalletApiOperation.ResumeTransaction: {
+ const req = codecForResumeTransaction().decode(payload);
+ await resumeTransaction(wex, req.transactionId);
+ return {};
}
- case "prepareTip": {
- const req = codecForPrepareTipRequest().decode(payload);
- return await prepareTip(ws, req.talerTipUri);
+ case WalletApiOperation.DumpCoins: {
+ return await dumpCoins(wex);
}
- case "acceptTip": {
- const req = codecForAcceptTipRequest().decode(payload);
- await acceptTip(ws, req.walletTipId);
+ case WalletApiOperation.SetCoinSuspended: {
+ const req = codecForSetCoinSuspendedRequest().decode(payload);
+ await setCoinSuspended(wex, req.coinPub, req.suspended);
return {};
}
- case "exportBackupPlain": {
- return exportBackup(ws);
+ case WalletApiOperation.TestingGetSampleTransactions:
+ return { transactions: sampleWalletCoreTransactions };
+ case WalletApiOperation.ForceRefresh: {
+ const req = codecForForceRefreshRequest().decode(payload);
+ return await forceRefresh(wex, req);
}
- case "addBackupProvider": {
- const req = codecForAddBackupProviderRequest().decode(payload);
- await addBackupProvider(ws, req);
+ case WalletApiOperation.StartRefundQueryForUri: {
+ const req = codecForPrepareRefundRequest().decode(payload);
+ return await startRefundQueryForUri(wex, req.talerRefundUri);
+ }
+ case WalletApiOperation.StartRefundQuery: {
+ const req = codecForStartRefundQueryRequest().decode(payload);
+ const txIdParsed = parseTransactionIdentifier(req.transactionId);
+ if (!txIdParsed) {
+ throw Error("invalid transaction ID");
+ }
+ if (txIdParsed.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ await startQueryRefund(wex, txIdParsed.proposalId);
return {};
}
- case "runBackupCycle": {
+ case WalletApiOperation.AddBackupProvider: {
+ const req = codecForAddBackupProviderRequest().decode(payload);
+ return await addBackupProvider(wex, req);
+ }
+ case WalletApiOperation.RunBackupCycle: {
const req = codecForRunBackupCycle().decode(payload);
- await runBackupCycle(ws, req);
+ await runBackupCycle(wex, req);
return {};
}
- case "removeBackupProvider": {
+ case WalletApiOperation.RemoveBackupProvider: {
const req = codecForRemoveBackupProvider().decode(payload);
- await removeBackupProvider(ws, req);
+ await removeBackupProvider(wex, req);
return {};
}
- case "exportBackupRecovery": {
- const resp = await getBackupRecovery(ws);
+ case WalletApiOperation.ExportBackupRecovery: {
+ const resp = await getBackupRecovery(wex);
return resp;
}
- case "importBackupRecovery": {
+ case WalletApiOperation.TestingWaitTransactionState: {
+ const req = payload as TestingWaitTransactionRequest;
+ await waitTransactionState(wex, req.transactionId, req.txState);
+ return {};
+ }
+ case WalletApiOperation.GetCurrencySpecification: {
+ // Ignore result, just validate in this mock implementation
+ const req = codecForGetCurrencyInfoRequest().decode(payload);
+ // Hard-coded mock for KUDOS and TESTKUDOS
+ if (req.scope.currency === "KUDOS") {
+ const kudosResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: "Kudos (Taler Demonstrator)",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: {
+ "0": "ク",
+ },
+ },
+ };
+ return kudosResp;
+ } else if (req.scope.currency === "TESTKUDOS") {
+ const testkudosResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: "Test (Taler Unstable Demonstrator)",
+ num_fractional_input_digits: 0,
+ num_fractional_normal_digits: 0,
+ num_fractional_trailing_zero_digits: 0,
+ alt_unit_names: {
+ "0": "テ",
+ },
+ },
+ };
+ return testkudosResp;
+ }
+ const defaultResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: req.scope.currency,
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: {
+ "0": req.scope.currency,
+ },
+ },
+ };
+ return defaultResp;
+ }
+ case WalletApiOperation.ImportBackupRecovery: {
const req = codecForAny().decode(payload);
- await loadBackupRecovery(ws, req);
+ await loadBackupRecovery(wex, req);
return {};
}
- case "getBackupInfo": {
- const resp = await getBackupInfo(ws);
+ // case WalletApiOperation.GetPlanForOperation: {
+ // const req = codecForGetPlanForOperationRequest().decode(payload);
+ // return await getPlanForOperation(ws, req);
+ // }
+ case WalletApiOperation.ConvertDepositAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertDepositAmount(wex, req);
+ }
+ case WalletApiOperation.GetMaxDepositAmount: {
+ const req = codecForGetAmountRequest.decode(payload);
+ return await getMaxDepositAmount(wex, req);
+ }
+ case WalletApiOperation.ConvertPeerPushAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertPeerPushAmount(wex, req);
+ }
+ case WalletApiOperation.GetMaxPeerPushAmount: {
+ const req = codecForGetAmountRequest.decode(payload);
+ return await getMaxPeerPushAmount(wex, req);
+ }
+ case WalletApiOperation.ConvertWithdrawalAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertWithdrawalAmount(wex, req);
+ }
+ case WalletApiOperation.GetBackupInfo: {
+ const resp = await getBackupInfo(wex);
return resp;
}
- case "createDepositGroup": {
- const req = codecForCreateDepositGroupRequest().decode(payload);
- return await createDepositGroup(ws, req);
+ case WalletApiOperation.PrepareDeposit: {
+ const req = codecForPrepareDepositRequest().decode(payload);
+ return await checkDepositGroup(wex, req);
}
- case "trackDepositGroup": {
- const req = codecForTrackDepositGroupRequest().decode(payload);
- return trackDepositGroup(ws, req);
+ case WalletApiOperation.GenerateDepositGroupTxId:
+ return {
+ transactionId: generateDepositGroupTxId(),
+ };
+ case WalletApiOperation.CreateDepositGroup: {
+ const req = codecForCreateDepositGroupRequest().decode(payload);
+ return await createDepositGroup(wex, req);
}
- case "deleteTransaction": {
+ case WalletApiOperation.DeleteTransaction: {
const req = codecForDeleteTransactionRequest().decode(payload);
- await deleteTransaction(ws, req.transactionId);
+ await deleteTransaction(wex, req.transactionId);
return {};
}
- case "retryTransaction": {
+ case WalletApiOperation.RetryTransaction: {
const req = codecForRetryTransactionRequest().decode(payload);
- await retryTransaction(ws, req.transactionId);
+ await retryTransaction(wex, req.transactionId);
return {};
}
- case "setWalletDeviceId": {
+ case WalletApiOperation.SetWalletDeviceId: {
const req = codecForSetWalletDeviceIdRequest().decode(payload);
- await setWalletDeviceId(ws, req.walletDeviceId);
+ await setWalletDeviceId(wex, req.walletDeviceId);
return {};
}
- case "listCurrencies": {
- return await ws.db
- .mktx((x) => ({
- auditorTrust: x.auditorTrust,
- exchangeTrust: x.exchangeTrust,
- }))
- .runReadOnly(async (tx) => {
- const trustedAuditors = await tx.auditorTrust.iter().toArray();
- const trustedExchanges = await tx.exchangeTrust.iter().toArray();
- return {
- trustedAuditors: trustedAuditors.map((x) => ({
- currency: x.currency,
- auditorBaseUrl: x.auditorBaseUrl,
- auditorPub: x.auditorPub,
- })),
- trustedExchanges: trustedExchanges.map((x) => ({
- currency: x.currency,
- exchangeBaseUrl: x.exchangeBaseUrl,
- exchangeMasterPub: x.exchangeMasterPub,
- })),
- };
- });
+ case WalletApiOperation.TestCrypto: {
+ return await wex.cryptoApi.hashString({ str: "hello world" });
}
- case "withdrawFakebank": {
- const req = codecForWithdrawFakebankRequest().decode(payload);
- const amount = Amounts.parseOrThrow(req.amount);
- const details = await getWithdrawalDetailsForAmount(
- ws,
- req.exchange,
- amount,
+ case WalletApiOperation.ClearDb: {
+ wex.ws.clearAllCaches();
+ await clearDatabase(wex.db.idbHandle());
+ return {};
+ }
+ case WalletApiOperation.Recycle: {
+ throw Error("not implemented");
+ return {};
+ }
+ case WalletApiOperation.ExportDb: {
+ const dbDump = await exportDb(wex.ws.idb);
+ return dbDump;
+ }
+ case WalletApiOperation.ListGlobalCurrencyExchanges: {
+ const resp: ListGlobalCurrencyExchangesResponse = {
+ exchanges: [],
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const gceList = await tx.globalCurrencyExchanges.iter().toArray();
+ for (const gce of gceList) {
+ resp.exchanges.push({
+ currency: gce.currency,
+ exchangeBaseUrl: gce.exchangeBaseUrl,
+ exchangeMasterPub: gce.exchangeMasterPub,
+ });
+ }
+ },
);
- const wres = await acceptManualWithdrawal(ws, req.exchange, amount);
- const paytoUri = details.paytoUris[0];
- const pt = parsePaytoUri(paytoUri);
- if (!pt) {
- throw Error("failed to parse payto URI");
- }
- const components = pt.targetPath.split("/");
- const creditorAcct = components[components.length - 1];
- logger.info(`making testbank transfer to '${creditorAcct}''`)
- const fbReq = await ws.http.postJson(
- new URL(`${creditorAcct}/admin/add-incoming`, req.bank).href,
- {
- amount: Amounts.stringify(amount),
- reserve_pub: wres.reservePub,
- debit_account: "payto://x-taler-bank/localhost/testdebtor",
+ return resp;
+ }
+ case WalletApiOperation.ListGlobalCurrencyAuditors: {
+ const resp: ListGlobalCurrencyAuditorsResponse = {
+ auditors: [],
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const gcaList = await tx.globalCurrencyAuditors.iter().toArray();
+ for (const gca of gcaList) {
+ resp.auditors.push({
+ currency: gca.currency,
+ auditorBaseUrl: gca.auditorBaseUrl,
+ auditorPub: gca.auditorPub,
+ });
+ }
+ },
+ );
+ return resp;
+ }
+ case WalletApiOperation.AddGlobalCurrencyExchange: {
+ const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const key = [
+ req.currency,
+ req.exchangeBaseUrl,
+ req.exchangeMasterPub,
+ ];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.globalCurrencyExchanges.add({
+ currency: req.currency,
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ exchangeMasterPub: req.exchangeMasterPub,
+ });
},
);
- const fbResp = await readSuccessResponseJsonOrThrow(fbReq, codecForAny());
- logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`);
return {};
}
+ case WalletApiOperation.RemoveGlobalCurrencyExchange: {
+ const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const key = [
+ req.currency,
+ req.exchangeBaseUrl,
+ req.exchangeMasterPub,
+ ];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyExchanges.delete(existingRec.id);
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.AddGlobalCurrencyAuditor: {
+ const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ await tx.globalCurrencyAuditors.add({
+ currency: req.currency,
+ auditorBaseUrl: req.auditorBaseUrl,
+ auditorPub: req.auditorPub,
+ });
+ wex.ws.exchangeCache.clear();
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.TestingWaitTasksDone: {
+ await waitTasksDone(wex);
+ return {};
+ }
+ case WalletApiOperation.RemoveGlobalCurrencyAuditor: {
+ const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyAuditors.delete(existingRec.id);
+ wex.ws.exchangeCache.clear();
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.ImportDb: {
+ const req = codecForImportDbRequest().decode(payload);
+ await importDb(wex.db.idbHandle(), req.dump);
+ return [];
+ }
+ case WalletApiOperation.CheckPeerPushDebit: {
+ const req = codecForCheckPeerPushDebitRequest().decode(payload);
+ return await checkPeerPushDebit(wex, req);
+ }
+ case WalletApiOperation.InitiatePeerPushDebit: {
+ const req = codecForInitiatePeerPushDebitRequest().decode(payload);
+ return await initiatePeerPushDebit(wex, req);
+ }
+ case WalletApiOperation.PreparePeerPushCredit: {
+ const req = codecForPreparePeerPushCreditRequest().decode(payload);
+ return await preparePeerPushCredit(wex, req);
+ }
+ case WalletApiOperation.ConfirmPeerPushCredit: {
+ const req = codecForConfirmPeerPushPaymentRequest().decode(payload);
+ return await confirmPeerPushCredit(wex, req);
+ }
+ case WalletApiOperation.CheckPeerPullCredit: {
+ const req = codecForPreparePeerPullPaymentRequest().decode(payload);
+ return await checkPeerPullPaymentInitiation(wex, req);
+ }
+ case WalletApiOperation.InitiatePeerPullCredit: {
+ const req = codecForInitiatePeerPullPaymentRequest().decode(payload);
+ return await initiatePeerPullPayment(wex, req);
+ }
+ case WalletApiOperation.PreparePeerPullDebit: {
+ const req = codecForCheckPeerPullPaymentRequest().decode(payload);
+ return await preparePeerPullDebit(wex, req);
+ }
+ case WalletApiOperation.ConfirmPeerPullDebit: {
+ const req = codecForAcceptPeerPullPaymentRequest().decode(payload);
+ return await confirmPeerPullDebit(wex, req);
+ }
+ case WalletApiOperation.ApplyDevExperiment: {
+ const req = codecForApplyDevExperiment().decode(payload);
+ await applyDevExperiment(wex, req.devExperimentUri);
+ return {};
+ }
+ case WalletApiOperation.Shutdown: {
+ wex.ws.stop();
+ return {};
+ }
+ case WalletApiOperation.GetVersion: {
+ return getVersion(wex);
+ }
+ case WalletApiOperation.TestingWaitTransactionsFinal:
+ return await waitUntilAllTransactionsFinal(wex);
+ case WalletApiOperation.TestingWaitRefreshesFinal:
+ return await waitUntilRefreshesDone(wex);
+ case WalletApiOperation.TestingSetTimetravel: {
+ const req = codecForTestingSetTimetravelRequest().decode(payload);
+ setDangerousTimetravel(req.offsetMs);
+ await wex.taskScheduler.reload();
+ return {};
+ }
+ case WalletApiOperation.DeleteExchange: {
+ const req = codecForDeleteExchangeRequest().decode(payload);
+ await deleteExchange(wex, req);
+ return {};
+ }
+ case WalletApiOperation.GetExchangeResources: {
+ const req = codecForGetExchangeResourcesRequest().decode(payload);
+ return await getExchangeResources(wex, req.exchangeBaseUrl);
+ }
+ case WalletApiOperation.CanonicalizeBaseUrl: {
+ const req = codecForCanonicalizeBaseUrlRequest().decode(payload);
+ return {
+ url: canonicalizeBaseUrl(req.url),
+ };
+ }
+ case WalletApiOperation.TestingInfiniteTransactionLoop: {
+ const myDelayMs = (payload as any).delayMs ?? 5;
+ const shouldFetch = !!(payload as any).shouldFetch;
+ const doFetch = async () => {
+ while (1) {
+ const url =
+ "https://exchange.demo.taler.net/reserves/01PMMB9PJN0QBWAFBXV6R0KNJJMAKXCV4D6FDG0GJFDJQXGYP32G?timeout_ms=30000";
+ logger.info(`fetching ${url}`);
+ const res = await wex.http.fetch(url);
+ logger.info(`fetch result ${res.status}`);
+ }
+ };
+ if (shouldFetch) {
+ // In the background!
+ doFetch();
+ }
+ let loopCount = 0;
+ while (true) {
+ logger.info(`looping test write tx, iteration ${loopCount}`);
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ await tx.config.put({
+ key: ConfigRecordKey.TestLoopTx,
+ value: loopCount,
+ });
+ });
+ if (myDelayMs != 0) {
+ await new Promise<void>((resolve, reject) => {
+ setTimeout(() => resolve(), myDelayMs);
+ });
+ }
+ loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1);
+ }
+ }
+ // default:
+ // assertUnreachable(operation);
}
- throw OperationFailedError.fromCode(
+ throw TalerError.fromDetail(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
- "unknown operation",
{
operation,
},
+ "unknown operation",
);
}
+export function getVersion(wex: WalletExecutionContext): WalletCoreVersion {
+ const result: WalletCoreVersion = {
+ implementationSemver: walletCoreBuildInfo.implementationSemver,
+ implementationGitHash: walletCoreBuildInfo.implementationGitHash,
+ hash: undefined,
+ version: WALLET_CORE_API_PROTOCOL_VERSION,
+ exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
+ bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
+ bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION,
+ bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ devMode: wex.ws.config.testing.devModeActive,
+ };
+ return result;
+}
+
+export function getObservedWalletExecutionContext(
+ ws: InternalWalletState,
+ cancellationToken: CancellationToken,
+ oc: ObservabilityContext,
+): WalletExecutionContext {
+ const wex: WalletExecutionContext = {
+ ws,
+ cancellationToken,
+ cryptoApi: observeTalerCrypto(ws.cryptoApi, oc),
+ db: new ObservableDbAccess(ws.db, oc),
+ http: new ObservableHttpClientLibrary(ws.http, oc),
+ taskScheduler: new ObservableTaskScheduler(ws.taskScheduler, oc),
+ oc,
+ };
+ return wex;
+}
+
+export function getNormalWalletExecutionContext(
+ ws: InternalWalletState,
+ cancellationToken: CancellationToken,
+ oc: ObservabilityContext,
+): WalletExecutionContext {
+ const wex: WalletExecutionContext = {
+ ws,
+ cancellationToken,
+ cryptoApi: ws.cryptoApi,
+ db: ws.db,
+ get http() {
+ if (ws.initCalled) {
+ return ws.http;
+ }
+ throw Error("wallet not initialized");
+ },
+ taskScheduler: ws.taskScheduler,
+ oc,
+ };
+ return wex;
+}
+
/**
* Handle a request to the wallet-core API.
*/
-export async function handleCoreApiRequest(
+async function handleCoreApiRequest(
ws: InternalWalletState,
operation: string,
id: string,
payload: unknown,
): Promise<CoreApiResponse> {
+ if (operation !== WalletApiOperation.InitWallet) {
+ if (!ws.initCalled) {
+ throw Error("init must be called first");
+ }
+ // Might be lazily initialized!
+ await ws.taskScheduler.ensureRunning();
+ }
+
+ let wex: WalletExecutionContext;
+ let oc: ObservabilityContext;
+
+ const cts = CancellationToken.create();
+
+ if (ws.initCalled && ws.config.testing.emitObservabilityEvents) {
+ oc = {
+ observe(evt) {
+ ws.notify({
+ type: NotificationType.RequestObservabilityEvent,
+ operation,
+ requestId: id,
+ event: evt,
+ });
+ },
+ };
+
+ wex = getObservedWalletExecutionContext(ws, cts.token, oc);
+ } else {
+ oc = {
+ observe(evt) {},
+ };
+ wex = getNormalWalletExecutionContext(ws, cts.token, oc);
+ }
+
try {
- const result = await dispatchRequestInternal(ws, operation, payload);
+ const start = performanceNow();
+ await ws.ensureWalletDbOpen();
+ oc.observe({
+ type: ObservabilityEventType.RequestStart,
+ });
+ const result = await dispatchRequestInternal(
+ wex,
+ cts,
+ operation as any,
+ payload,
+ );
+ const end = performanceNow();
+ oc.observe({
+ type: ObservabilityEventType.RequestFinishSuccess,
+ durationMs: Number((end - start) / 1000n / 1000n),
+ });
return {
type: "response",
operation,
@@ -970,86 +1682,171 @@ export async function handleCoreApiRequest(
result,
};
} catch (e: any) {
- if (
- e instanceof OperationFailedError ||
- e instanceof OperationFailedAndReportedError
- ) {
- return {
- type: "error",
- operation,
- id,
- error: e.operationError,
- };
- } else {
- try {
- logger.error("Caught unexpected exception:");
- logger.error(e.stack);
- } catch (e) {}
- return {
- type: "error",
- operation,
- id,
- error: makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception: ${e}`,
- {},
- ),
- };
- }
+ const err = getErrorDetailFromException(e);
+ logger.info(
+ `finished wallet core request ${operation} with error: ${j2s(err)}`,
+ );
+ oc.observe({
+ type: ObservabilityEventType.RequestFinishError,
+ });
+ return {
+ type: "error",
+ operation,
+ id,
+ error: err,
+ };
}
}
+export function applyRunConfigDefaults(
+ wcp?: PartialWalletRunConfig,
+): WalletRunConfig {
+ return {
+ builtin: {
+ exchanges: wcp?.builtin?.exchanges ?? [
+ {
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ currencyHint: "KUDOS",
+ },
+ ],
+ },
+ features: {
+ allowHttp: wcp?.features?.allowHttp ?? false,
+ lazyTaskLoop: false,
+ },
+ testing: {
+ denomselAllowLate: wcp?.testing?.denomselAllowLate ?? false,
+ devModeActive: wcp?.testing?.devModeActive ?? false,
+ insecureTrustExchange: wcp?.testing?.insecureTrustExchange ?? false,
+ preventThrottling: wcp?.testing?.preventThrottling ?? false,
+ skipDefaults: wcp?.testing?.skipDefaults ?? false,
+ emitObservabilityEvents: wcp?.testing?.emitObservabilityEvents ?? false,
+ },
+ };
+}
+
+export type HttpFactory = (config: WalletRunConfig) => HttpRequestLibrary;
+
/**
* Public handle to a running wallet.
*/
export class Wallet {
private ws: InternalWalletState;
- private _client: WalletCoreApiClient;
+ private _client: WalletCoreApiClient | undefined;
private constructor(
- db: DbAccess<typeof WalletStoresV1>,
- http: HttpRequestLibrary,
+ idb: IDBFactory,
+ httpFactory: HttpFactory,
+ timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
) {
- this.ws = new InternalWalletStateImpl(db, http, cryptoWorkerFactory);
+ this.ws = new InternalWalletState(
+ idb,
+ httpFactory,
+ timer,
+ cryptoWorkerFactory,
+ );
}
- get client() {
+ get client(): WalletCoreApiClient {
+ if (!this._client) {
+ throw Error();
+ }
return this._client;
}
static async create(
- db: DbAccess<typeof WalletStoresV1>,
- http: HttpRequestLibrary,
+ idb: IDBFactory,
+ httpFactory: HttpFactory,
+ timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
): Promise<Wallet> {
- const w = new Wallet(db, http, cryptoWorkerFactory);
+ const w = new Wallet(idb, httpFactory, timer, cryptoWorkerFactory);
w._client = await getClientFromWalletState(w.ws);
return w;
}
- addNotificationListener(f: (n: WalletNotification) => void): void {
+ addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
return this.ws.addNotificationListener(f);
}
- stop(): void {
- this.ws.stop();
+ async handleCoreApiRequest(
+ operation: string,
+ id: string,
+ payload: unknown,
+ ): Promise<CoreApiResponse> {
+ await this.ws.ensureWalletDbOpen();
+ return handleCoreApiRequest(this.ws, operation, id, payload);
+ }
+}
+
+export interface DevExperimentState {
+ blockRefreshes?: boolean;
+}
+
+export class Cache<T> {
+ private map: Map<string, [AbsoluteTime, T]> = new Map();
+
+ constructor(
+ private maxCapacity: number,
+ private cacheDuration: Duration,
+ ) {}
+
+ get(key: string): T | undefined {
+ const r = this.map.get(key);
+ if (!r) {
+ return undefined;
+ }
+
+ if (AbsoluteTime.isExpired(r[0])) {
+ this.map.delete(key);
+ return undefined;
+ }
+
+ return r[1];
}
- runPending(forceNow: boolean = false) {
- return runPending(this.ws, forceNow);
+ clear(): void {
+ this.map.clear();
}
- runTaskLoop(opts?: RetryLoopOpts) {
- return runTaskLoop(this.ws, opts);
+ put(key: string, value: T): void {
+ if (this.map.size > this.maxCapacity) {
+ this.map.clear();
+ }
+ const expiry = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ this.cacheDuration,
+ );
+ this.map.set(key, [expiry, value]);
}
+}
- handleCoreApiRequest(
- operation: string,
- id: string,
- payload: unknown,
- ): Promise<CoreApiResponse> {
- return handleCoreApiRequest(this.ws, operation, id, payload);
+/**
+ * Implementation of triggers for the wallet DB.
+ */
+class WalletDbTriggerSpec implements TriggerSpec {
+ constructor(public ws: InternalWalletState) {}
+
+ afterCommit(info: AfterCommitInfo): void {
+ if (info.mode !== "readwrite") {
+ return;
+ }
+ logger.trace(
+ `in after commit callback for readwrite, modified ${j2s([
+ ...info.modifiedStores,
+ ])}`,
+ );
+ const modified = info.accessedStores;
+ if (
+ modified.has(WalletStoresV1.exchanges.storeName) ||
+ modified.has(WalletStoresV1.exchangeDetails.storeName) ||
+ modified.has(WalletStoresV1.denominations.storeName) ||
+ modified.has(WalletStoresV1.globalCurrencyAuditors.storeName) ||
+ modified.has(WalletStoresV1.globalCurrencyExchanges.storeName)
+ ) {
+ this.ws.clearAllCaches();
+ }
}
}
@@ -1058,34 +1855,32 @@ export class Wallet {
*
* This ties together all the operation implementations.
*/
-class InternalWalletStateImpl implements InternalWalletState {
- memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoGetPending: AsyncOpMemoSingle<PendingOperationsResponse> = new AsyncOpMemoSingle();
- memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle();
- memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoProcessDeposit: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- cryptoApi: CryptoApi;
-
- timerGroup: TimerGroup = new TimerGroup();
- latch = new AsyncCondition();
+export class InternalWalletState {
+ cryptoApi: TalerCryptoInterface;
+ cryptoDispatcher: CryptoDispatcher;
+
+ readonly timerGroup: TimerGroup;
+ workAvailable = new AsyncCondition();
stopped = false;
- listeners: NotificationListener[] = [];
+ private listeners: NotificationListener[] = [];
- initCalled: boolean = false;
+ initCalled = false;
- exchangeOps: ExchangeOperations = {
- getExchangeDetails,
- getExchangeTrust,
- updateExchangeFromUrl,
- };
+ refreshCostCache: Cache<AmountJson> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
- recoupOps: RecoupOperations = {
- createRecoupGroup: createRecoupGroup,
- processRecoupGroup: processRecoupGroup,
- };
+ denomInfoCache: Cache<DenominationInfo> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
+
+ exchangeCache: Cache<ReadyExchangeSummary> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
/**
* Promises that are waiting for a particular resource.
@@ -1097,20 +1892,106 @@ class InternalWalletStateImpl implements InternalWalletState {
*/
private resourceLocks: Set<string> = new Set();
+ taskScheduler: TaskScheduler = new TaskSchedulerImpl(this);
+
+ private _config: Readonly<WalletRunConfig> | undefined;
+
+ private _indexedDbHandle: IDBDatabase | undefined = undefined;
+
+ private _dbAccessHandle: DbAccess<typeof WalletStoresV1> | undefined;
+
+ private _http: HttpRequestLibrary | undefined = undefined;
+
+ get db(): DbAccess<typeof WalletStoresV1> {
+ if (!this._dbAccessHandle) {
+ this._dbAccessHandle = this.createDbAccessHandle(
+ CancellationToken.CONTINUE,
+ );
+ }
+ return this._dbAccessHandle;
+ }
+
+ devExperimentState: DevExperimentState = {};
+
+ clientCancellationMap: Map<string, CancellationToken.Source> = new Map();
+
+ clearAllCaches(): void {
+ this.exchangeCache.clear();
+ this.denomInfoCache.clear();
+ this.refreshCostCache.clear();
+ }
+
+ initWithConfig(newConfig: WalletRunConfig): void {
+ this._config = newConfig;
+
+ logger.info(`setting new config to ${j2s(newConfig)}`);
+
+ this._http = this.httpFactory(newConfig);
+
+ if (this.config.testing.devModeActive) {
+ this._http = new DevExperimentHttpLib(this.http);
+ }
+ }
+
+ createDbAccessHandle(
+ cancellationToken: CancellationToken,
+ ): DbAccess<typeof WalletStoresV1> {
+ if (!this._indexedDbHandle) {
+ throw Error("db not initialized");
+ }
+ return new DbAccessImpl(
+ this._indexedDbHandle,
+ WalletStoresV1,
+ new WalletDbTriggerSpec(this),
+ cancellationToken,
+ );
+ }
+
+ get config(): WalletRunConfig {
+ if (!this._config) {
+ throw Error("config not initialized");
+ }
+ return this._config;
+ }
+
+ get http(): HttpRequestLibrary {
+ if (!this._http) {
+ throw Error("wallet not initialized");
+ }
+ return this._http;
+ }
+
constructor(
- // FIXME: Make this a getter and make
- // the actual value nullable.
- // Check if we are in a DB migration / garbage collection
- // and throw an error in that case.
- public db: DbAccess<typeof WalletStoresV1>,
- public http: HttpRequestLibrary,
+ public idb: IDBFactory,
+ private httpFactory: HttpFactory,
+ public timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
) {
- this.cryptoApi = new CryptoApi(cryptoWorkerFactory);
+ this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory);
+ this.cryptoApi = this.cryptoDispatcher.cryptoApi;
+ this.timerGroup = new TimerGroup(timer);
+ }
+
+ async ensureWalletDbOpen(): Promise<void> {
+ if (this._indexedDbHandle) {
+ return;
+ }
+ const myVersionChange = async (): Promise<void> => {
+ logger.info("version change requested for Taler DB");
+ };
+ try {
+ const myDb = await openTalerDatabase(this.idb, myVersionChange);
+ this._indexedDbHandle = myDb;
+ } catch (e) {
+ logger.error("error writing to database during initialization");
+ throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
+ innerError: getErrorDetailFromException(e),
+ });
+ }
}
notify(n: WalletNotification): void {
- logger.trace("Notification", n);
+ logger.trace(`Notification: ${j2s(n)}`);
for (const l of this.listeners) {
const nc = JSON.parse(JSON.stringify(n));
setTimeout(() => {
@@ -1119,32 +2000,37 @@ class InternalWalletStateImpl implements InternalWalletState {
}
}
- addNotificationListener(f: (n: WalletNotification) => void): void {
+ addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
this.listeners.push(f);
+ return () => {
+ const idx = this.listeners.indexOf(f);
+ if (idx >= 0) {
+ this.listeners.splice(idx, 1);
+ }
+ };
}
/**
* Stop ongoing processing.
*/
stop(): void {
+ logger.trace("stopping (at internal wallet state)");
this.stopped = true;
this.timerGroup.stopCurrentAndFutureTimers();
- this.cryptoApi.stop();
- }
-
- async runUntilDone(
- req: {
- maxRetries?: number;
- } = {},
- ): Promise<void> {
- await runTaskLoop(this, { ...req, stopWhenDone: true });
+ this.cryptoDispatcher.stop();
+ this.taskScheduler.shutdown().catch((e) => {
+ logger.warn(`shutdown failed: ${safeStringifyException(e)}`);
+ });
}
/**
* Run an async function after acquiring a list of locks, identified
* by string tokens.
*/
- async runSequentialized<T>(tokens: string[], f: () => Promise<T>) {
+ async runSequentialized<T>(
+ tokens: string[],
+ f: () => Promise<T>,
+ ): Promise<T> {
// Make sure locks are always acquired in the same order
tokens = [...tokens].sort();
@@ -1169,7 +2055,7 @@ class InternalWalletStateImpl implements InternalWalletState {
} finally {
for (const token of tokens) {
this.resourceLocks.delete(token);
- let waiter = (this.resourceWaiters[token] ?? []).shift();
+ const waiter = (this.resourceWaiters[token] ?? []).shift();
if (waiter) {
waiter.resolve();
}
diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts
new file mode 100644
index 000000000..2a081b481
--- /dev/null
+++ b/packages/taler-wallet-core/src/withdraw.test.ts
@@ -0,0 +1,364 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ 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 { AmountString, Amounts, DenomKeyType } from "@gnu-taler/taler-util";
+import test from "ava";
+import {
+ DenominationRecord,
+ DenominationVerificationStatus,
+ timestampProtocolToDb,
+} from "./db.js";
+import { selectWithdrawalDenominations } from "./denomSelection.js";
+
+test("withdrawal selection bug repro", (t) => {
+ const amount = {
+ currency: "KUDOS",
+ fraction: 43000000,
+ value: 23,
+ };
+
+ const denoms: DenominationRecord[] = [
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ currency: "KUDOS",
+ value: "KUDOS:1000" as AmountString,
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
+ age_mask: 0,
+ },
+
+ denomPubHash:
+ "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:10" as AmountString,
+ currency: "KUDOS",
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:5" as AmountString,
+ currency: "KUDOS",
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
+ age_mask: 0,
+ },
+
+ denomPubHash:
+ "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:1" as AmountString,
+ currency: "KUDOS",
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 10000000,
+ value: 0,
+ }),
+ currency: "KUDOS",
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:2" as AmountString,
+ currency: "KUDOS",
+ },
+ ];
+
+ const res = selectWithdrawalDenominations(amount, denoms);
+
+ t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
+ t.pass();
+});
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
new file mode 100644
index 000000000..0eb9a3dfe
--- /dev/null
+++ b/packages/taler-wallet-core/src/withdraw.ts
@@ -0,0 +1,3483 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2024 Taler Systems SA
+
+ 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/>
+ */
+
+/**
+ * @fileoverview Implementation of Taler withdrawals, both
+ * bank-integrated and manual.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AcceptManualWithdrawalResult,
+ AcceptWithdrawalResponse,
+ AgeRestriction,
+ Amount,
+ AmountJson,
+ AmountLike,
+ AmountString,
+ Amounts,
+ AsyncFlag,
+ BankWithdrawDetails,
+ CancellationToken,
+ CoinStatus,
+ CurrencySpecification,
+ DenomKeyType,
+ DenomSelItem,
+ DenomSelectionState,
+ Duration,
+ EddsaPrivateKeyString,
+ ExchangeBatchWithdrawRequest,
+ ExchangeUpdateStatus,
+ ExchangeWireAccount,
+ ExchangeWithdrawBatchResponse,
+ ExchangeWithdrawRequest,
+ ExchangeWithdrawResponse,
+ ExchangeWithdrawalDetails,
+ ForcedDenomSel,
+ GetWithdrawalDetailsForAmountRequest,
+ HttpStatusCode,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ ObservabilityEventType,
+ PrepareBankIntegratedWithdrawalResponse,
+ TalerBankIntegrationHttpClient,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ Transaction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ UnblindedSignature,
+ WalletNotification,
+ WithdrawUriInfoResponse,
+ WithdrawalDetailsForAmount,
+ WithdrawalExchangeAccountDetails,
+ WithdrawalType,
+ addPaytoQueryParams,
+ assertUnreachable,
+ checkDbInvariant,
+ checkLogicInvariant,
+ codeForBankWithdrawalOperationPostResponse,
+ codecForBankWithdrawalOperationStatus,
+ codecForCashinConversionResponse,
+ codecForConversionBankConfig,
+ codecForExchangeWithdrawBatchResponse,
+ codecForReserveStatus,
+ codecForWalletKycUuid,
+ codecForWithdrawOperationStatusResponse,
+ encodeCrock,
+ getErrorDetailFromException,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parseWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpResponse,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResult,
+ TransitionResultType,
+ constructTaskIdentifier,
+ makeCoinAvailable,
+ makeCoinsVisible,
+} from "./common.js";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ KycPendingInfo,
+ PlanchetRecord,
+ PlanchetStatus,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletDbStoresArr,
+ WalletStoresV1,
+ WgInfo,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampAbsoluteFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+} from "./db.js";
+import {
+ selectForcedWithdrawalDenominations,
+ selectWithdrawalDenominations,
+} from "./denomSelection.js";
+import { isWithdrawableDenom } from "./denominations.js";
+import {
+ ReadyExchangeSummary,
+ fetchFreshExchange,
+ getExchangePaytoUri,
+ getExchangeWireDetailsInTx,
+ listExchanges,
+ markExchangeUsed,
+} from "./exchanges.js";
+import { DbAccess } from "./query.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ isUnsuccessfulTransaction,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+} from "./versions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+/**
+ * Logger for this file.
+ */
+const logger = new Logger("withdraw.ts");
+
+/**
+ * Update the materialized withdrawal transaction based
+ * on the withdrawal group record.
+ */
+async function updateWithdrawalTransaction(
+ ctx: WithdrawTransactionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "withdrawalGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+): Promise<void> {
+ const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId);
+ if (!wgRecord) {
+ await tx.transactions.delete(ctx.transactionId);
+ return;
+ }
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+
+ let transactionItem: Transaction;
+
+ if (wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) {
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+ transactionItem = {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed
+ ? true
+ : false,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reservePub: wgRecord.reservePub,
+ bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: ctx.transactionId,
+ };
+ } else if (
+ wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual
+ ) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ wgRecord.exchangeBaseUrl,
+ );
+ const plainPaytoUris =
+ exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+
+ const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ wgRecord.reservePub,
+ wgRecord.instructedAmount,
+ );
+
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+
+ transactionItem = {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ reservePub: wgRecord.reservePub,
+ exchangePaytoUris,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: ctx.transactionId,
+ };
+ } else {
+ // FIXME: If this is an orphaned withdrawal for a p2p transaction, we
+ // still might want to report the withdrawal.
+ return;
+ }
+
+ if (retryRecord?.lastError) {
+ transactionItem.error = retryRecord.lastError;
+ }
+
+ await tx.transactions.put({
+ currency: Amounts.currencyOf(wgRecord.instructedAmount),
+ transactionItem,
+ exchanges: [wgRecord.exchangeBaseUrl],
+ });
+
+ // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted?
+}
+
+export class WithdrawTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public withdrawalGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId,
+ });
+ }
+
+ /**
+ * Transition a withdrawal transaction.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transition<StoreNameArray extends WalletDbStoresArr = []>(
+ opts: { extraStores?: StoreNameArray; transactionLabel?: string },
+ f: (
+ rec: WithdrawalGroupRecord | undefined,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "withdrawalGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ...StoreNameArray,
+ ]
+ >,
+ ) => Promise<TransitionResult<WithdrawalGroupRecord>>,
+ ): Promise<TransitionInfo | undefined> {
+ const baseStores = [
+ "withdrawalGroups" as const,
+ "transactions" as const,
+ "operationRetries" as const,
+ "exchanges" as const,
+ "exchangeDetails" as const,
+ ];
+ let stores = opts.extraStores
+ ? [...baseStores, ...opts.extraStores]
+ : baseStores;
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ { storeNames: stores },
+ async (tx) => {
+ const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId);
+ let oldTxState: TransactionState;
+ if (wgRec) {
+ oldTxState = computeWithdrawalTransactionStatus(wgRec);
+ } else {
+ oldTxState = {
+ major: TransactionMajorState.None,
+ };
+ }
+ const res = await f(wgRec, tx);
+ switch (res.type) {
+ case TransitionResultType.Transition: {
+ await tx.withdrawalGroups.put(res.rec);
+ await updateWithdrawalTransaction(this, tx);
+ const newTxState = computeWithdrawalTransactionStatus(res.rec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ case TransitionResultType.Delete:
+ await tx.withdrawalGroups.delete(this.withdrawalGroupId);
+ await updateWithdrawalTransaction(this, tx);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.None,
+ },
+ };
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ return transitionInfo;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ await this.transition(
+ {
+ extraStores: ["tombstones"],
+ transactionLabel: "delete-transaction-withdraw",
+ },
+ async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ if (rec) {
+ await tx.tombstones.put({
+ id:
+ TombstoneTag.DeleteWithdrawalGroup + ":" + rec.withdrawalGroupId,
+ });
+ }
+ return TransitionResult.delete();
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "suspend-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.PendingReady:
+ newStatus = WithdrawalGroupStatus.SuspendedReady;
+ break;
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
+ break;
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
+ break;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
+ break;
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
+ break;
+ case WithdrawalGroupStatus.PendingKyc:
+ newStatus = WithdrawalGroupStatus.SuspendedKyc;
+ break;
+ case WithdrawalGroupStatus.PendingAml:
+ newStatus = WithdrawalGroupStatus.SuspendedAml;
+ break;
+ default:
+ logger.warn(
+ `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
+ );
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "abort-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ newStatus = WithdrawalGroupStatus.AbortingBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedAml:
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ newStatus = WithdrawalGroupStatus.AbortedExchange;
+ break;
+ case WithdrawalGroupStatus.PendingReady:
+ newStatus = WithdrawalGroupStatus.SuspendedReady;
+ break;
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ // No transition needed, but not an error
+ return TransitionResult.stay();
+ case WithdrawalGroupStatus.DialogProposed:
+ newStatus = WithdrawalGroupStatus.AbortedUserRefused;
+ break;
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ // Not allowed
+ throw Error("abort not allowed in current state");
+ default:
+ assertUnreachable(wg.status);
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "resume-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedReady:
+ newStatus = WithdrawalGroupStatus.PendingReady;
+ break;
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ newStatus = WithdrawalGroupStatus.AbortingBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
+ break;
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedAml:
+ newStatus = WithdrawalGroupStatus.PendingAml;
+ break;
+ case WithdrawalGroupStatus.SuspendedKyc:
+ newStatus = WithdrawalGroupStatus.PendingKyc;
+ break;
+ default:
+ logger.warn(
+ `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
+ );
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async failTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "fail-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.FailedAbortingBank;
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+}
+
+/**
+ * Compute the DD37 transaction state of a withdrawal transaction
+ * from the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionStatus(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionState {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case WithdrawalGroupStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankRegisterReserve,
+ };
+ case WithdrawalGroupStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.WithdrawCoins,
+ };
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ };
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ };
+ case WithdrawalGroupStatus.AbortingBank:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ };
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BankRegisterReserve,
+ };
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ };
+ case WithdrawalGroupStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.WithdrawCoins,
+ };
+ case WithdrawalGroupStatus.PendingAml:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AmlRequired,
+ };
+ case WithdrawalGroupStatus.PendingKyc:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case WithdrawalGroupStatus.SuspendedAml:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AmlRequired,
+ };
+ case WithdrawalGroupStatus.SuspendedKyc:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.AbortingBank,
+ };
+ case WithdrawalGroupStatus.AbortedExchange:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Exchange,
+ };
+ case WithdrawalGroupStatus.AbortedBank:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Refused,
+ };
+ case WithdrawalGroupStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.CompletedByOtherWallet,
+ };
+ }
+}
+
+/**
+ * Compute DD37 transaction actions for a withdrawal transaction
+ * based on the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionActions(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionAction[] {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.Done:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingReady:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.AbortingBank:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedReady:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingAml:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedAml:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.DialogProposed:
+ return [TransactionAction.Abort];
+ }
+}
+
+async function processWithdrawalGroupDialogProposed(
+ ctx: WithdrawTransactionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ if (
+ withdrawalGroup.wgInfo.withdrawalType !==
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ throw new Error(
+ "processWithdrawalGroupDialogProposed called in unexpected state",
+ );
+ }
+
+ const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;
+
+ const parsedUri = parseWithdrawUri(talerWithdrawUri);
+
+ checkLogicInvariant(!!parsedUri);
+
+ const wopid = parsedUri.withdrawalOperationId;
+
+ const url = new URL(
+ `withdrawal-operation/${wopid}`,
+ parsedUri.bankIntegrationApiBaseUrl,
+ );
+
+ url.searchParams.set("old_state", "pending");
+ url.searchParams.set("long_poll_ms", "30000");
+
+ const resp = await ctx.wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: ctx.wex.cancellationToken,
+ });
+
+ // If the bank claims that the withdrawal operation is already
+ // pending, but we're still in DialogProposed, some other wallet
+ // must've completed the withdrawal, we're giving up.
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ const body = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForBankWithdrawalOperationStatus(),
+ );
+ if (body.status !== "pending") {
+ await ctx.transition({}, async (rec) => {
+ switch (rec?.status) {
+ case WithdrawalGroupStatus.DialogProposed: {
+ rec.status = WithdrawalGroupStatus.AbortedOtherWallet;
+ return TransitionResult.transition(rec);
+ }
+ }
+ return TransitionResult.stay();
+ });
+ }
+ break;
+ }
+ }
+
+ return TaskRunResult.longpollReturnedPending();
+}
+
+/**
+ * Get information about a withdrawal from
+ * a taler://withdraw URI by asking the bank.
+ *
+ * FIXME: Move into bank client.
+ */
+export async function getBankWithdrawalInfo(
+ http: HttpRequestLibrary,
+ talerWithdrawUri: string,
+): Promise<BankWithdrawDetails> {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse URL ${talerWithdrawUri}`);
+ }
+
+ const bankApi = new TalerBankIntegrationHttpClient(
+ uriResult.bankIntegrationApiBaseUrl,
+ http,
+ );
+
+ const { body: config } = await bankApi.getConfig();
+
+ if (!bankApi.isCompatible(config.version)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ bankProtocolVersion: config.version,
+ walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ },
+ "bank integration protocol version not compatible with wallet",
+ );
+ }
+
+ const resp = await bankApi.getWithdrawalOperationById(
+ uriResult.withdrawalOperationId,
+ );
+
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ const { body: status } = resp;
+
+ return {
+ operationId: uriResult.withdrawalOperationId,
+ apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
+ amount: Amounts.parseOrThrow(status.amount),
+ confirmTransferUrl: status.confirm_transfer_url,
+ senderWire: status.sender_wire,
+ suggestedExchange: status.suggested_exchange,
+ wireTypes: status.wire_types,
+ status: status.status,
+ };
+}
+
+/**
+ * Return denominations that can potentially used for a withdrawal.
+ */
+async function getCandidateWithdrawalDenoms(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency);
+ },
+ );
+}
+
+export async function getCandidateWithdrawalDenomsTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ // FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations!
+ const allDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ return allDenoms
+ .filter((d) => d.currency === currency)
+ .filter((d) =>
+ isWithdrawableDenom(d, wex.ws.config.testing.denomselAllowLate),
+ );
+}
+
+/**
+ * Generate a planchet for a coin index in a withdrawal group.
+ * Does not actually withdraw the coin yet.
+ *
+ * Split up so that we can parallelize the crypto, but serialize
+ * the exchange requests per reserve.
+ */
+async function processPlanchetGenerate(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ let planchet = await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets"] },
+ async (tx) => {
+ return tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ },
+ );
+ if (planchet) {
+ return;
+ }
+ let ci = 0;
+ let isSkipped = false;
+ let maybeDenomPubHash: string | undefined;
+ for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
+ const d = withdrawalGroup.denomsSel.selectedDenoms[di];
+ if (coinIdx >= ci && coinIdx < ci + d.count) {
+ maybeDenomPubHash = d.denomPubHash;
+ if (coinIdx >= ci + d.count - (d.skip ?? 0)) {
+ isSkipped = true;
+ }
+ break;
+ }
+ ci += d.count;
+ }
+ if (isSkipped) {
+ return;
+ }
+ if (!maybeDenomPubHash) {
+ throw Error("invariant violated");
+ }
+ const denomPubHash = maybeDenomPubHash;
+
+ const denom = await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ return getDenomInfo(
+ wex,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ denomPubHash,
+ );
+ },
+ );
+ checkDbInvariant(!!denom);
+ const r = await wex.cryptoApi.createPlanchet({
+ denomPub: denom.denomPub,
+ feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
+ reservePriv: withdrawalGroup.reservePriv,
+ reservePub: withdrawalGroup.reservePub,
+ value: Amounts.parseOrThrow(denom.value),
+ coinIndex: coinIdx,
+ secretSeed: withdrawalGroup.secretSeed,
+ restrictAge: withdrawalGroup.restrictAge,
+ });
+ const newPlanchet: PlanchetRecord = {
+ blindingKey: r.blindingKey,
+ coinEv: r.coinEv,
+ coinEvHash: r.coinEvHash,
+ coinIdx,
+ coinPriv: r.coinPriv,
+ coinPub: r.coinPub,
+ denomPubHash: r.denomPubHash,
+ planchetStatus: PlanchetStatus.Pending,
+ withdrawSig: r.withdrawSig,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ ageCommitmentProof: r.ageCommitmentProof,
+ lastError: undefined,
+ };
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
+ const p = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (p) {
+ planchet = p;
+ return;
+ }
+ await tx.planchets.put(newPlanchet);
+ planchet = newPlanchet;
+ });
+}
+
+interface WithdrawalRequestBatchArgs {
+ coinStartIndex: number;
+
+ batchSize: number;
+}
+
+interface WithdrawalBatchResult {
+ coinIdxs: number[];
+ batchResp: ExchangeWithdrawBatchResponse;
+}
+
+// FIXME: Move to exchange API types
+enum ExchangeAmlStatus {
+ Normal = 0,
+ Pending = 1,
+ Frozen = 2,
+}
+
+async function handleKycRequired(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+ resp: HttpResponse,
+ startIdx: number,
+ requestCoinIdxs: number[],
+): Promise<void> {
+ logger.info("withdrawal requires KYC");
+ const respJson = await resp.json();
+ const uuidResp = codecForWalletKycUuid().decode(respJson);
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
+ const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
+ const userType = "individual";
+ const kycInfo: KycPendingInfo = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ let kycUrl: string;
+ let amlStatus: ExchangeAmlStatus | undefined;
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return;
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ kycUrl = kycStatus.kyc_url;
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ amlStatus = kycStatus.aml_status;
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+
+ await ctx.transition(
+ {
+ extraStores: ["planchets"],
+ },
+ async (wg2, tx) => {
+ if (!wg2) {
+ return TransitionResult.stay();
+ }
+ for (let i = startIdx; i < requestCoinIdxs.length; i++) {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ requestCoinIdxs[i],
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ planchet.planchetStatus = PlanchetStatus.KycRequired;
+ await tx.planchets.put(planchet);
+ }
+ if (wg2.status !== WithdrawalGroupStatus.PendingReady) {
+ return TransitionResult.stay();
+ }
+ wg2.kycPending = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ wg2.kycUrl = kycUrl;
+ wg2.status =
+ amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined
+ ? WithdrawalGroupStatus.PendingKyc
+ : amlStatus === ExchangeAmlStatus.Pending
+ ? WithdrawalGroupStatus.PendingAml
+ : amlStatus === ExchangeAmlStatus.Frozen
+ ? WithdrawalGroupStatus.SuspendedAml
+ : assertUnreachable(amlStatus);
+ return TransitionResult.transition(wg2);
+ },
+ );
+}
+
+/**
+ * Send the withdrawal request for a generated planchet to the exchange.
+ *
+ * The verification of the response is done asynchronously to enable parallelism.
+ */
+async function processPlanchetExchangeBatchRequest(
+ wex: WalletExecutionContext,
+ wgContext: WithdrawalGroupStatusInfo,
+ args: WithdrawalRequestBatchArgs,
+): Promise<WithdrawalBatchResult> {
+ const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
+ logger.info(
+ `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
+ );
+
+ const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
+ // Indices of coins that are included in the batch request
+ const requestCoinIdxs: number[] = [];
+
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets", "denominations"] },
+ async (tx) => {
+ for (
+ let coinIdx = args.coinStartIndex;
+ coinIdx < args.coinStartIndex + args.batchSize &&
+ coinIdx < wgContext.numPlanchets;
+ coinIdx++
+ ) {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
+
+ if (!denom) {
+ logger.error("db inconsistent: denom for planchet not found");
+ continue;
+ }
+
+ const planchetReq: ExchangeWithdrawRequest = {
+ denom_pub_hash: planchet.denomPubHash,
+ reserve_sig: planchet.withdrawSig,
+ coin_ev: planchet.coinEv,
+ };
+ batchReq.planchets.push(planchetReq);
+ requestCoinIdxs.push(coinIdx);
+ }
+ },
+ );
+
+ if (batchReq.planchets.length == 0) {
+ logger.warn("empty withdrawal batch");
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+
+ async function storeCoinError(
+ errDetail: TalerErrorDetail,
+ coinIdx: number,
+ ): Promise<void> {
+ logger.trace(`withdrawal request failed: ${j2s(errDetail)}`);
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ planchet.lastError = errDetail;
+ await tx.planchets.put(planchet);
+ });
+ }
+
+ // FIXME: handle individual error codes better!
+
+ const reqUrl = new URL(
+ `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
+ withdrawalGroup.exchangeBaseUrl,
+ ).href;
+
+ try {
+ const resp = await wex.http.fetch(reqUrl, {
+ method: "POST",
+ body: batchReq,
+ cancellationToken: wex.cancellationToken,
+ timeout: Duration.fromSpec({ seconds: 40 }),
+ });
+ if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ if (resp.status === HttpStatusCode.Gone) {
+ const e = await readTalerErrorResponse(resp);
+ // FIXME: Store in place of the planchet that is actually affected!
+ await storeCoinError(e, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ const r = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWithdrawBatchResponse(),
+ );
+ return {
+ coinIdxs: requestCoinIdxs,
+ batchResp: r,
+ };
+ } catch (e) {
+ const errDetail = getErrorDetailFromException(e);
+ // We don't know which coin is affected, so we store the error
+ // with the first coin of the batch.
+ await storeCoinError(errDetail, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+}
+
+async function processPlanchetVerifyAndStoreCoin(
+ wex: WalletExecutionContext,
+ wgContext: WithdrawalGroupStatusInfo,
+ coinIdx: number,
+ resp: ExchangeWithdrawResponse,
+): Promise<void> {
+ const withdrawalGroup = wgContext.wgRecord;
+ logger.trace(`checking and storing planchet idx=${coinIdx}`);
+ const d = await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets", "denominations"] },
+ async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ return;
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
+ if (!denomInfo) {
+ return;
+ }
+ return {
+ planchet,
+ denomInfo,
+ exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
+ };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
+ });
+
+ const { planchet, denomInfo } = d;
+
+ const planchetDenomPub = denomInfo.denomPub;
+ if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
+ }
+
+ let evSig = resp.ev_sig;
+ if (!(evSig.cipher === DenomKeyType.Rsa)) {
+ throw Error("unsupported cipher");
+ }
+
+ const denomSigRsa = await wex.cryptoApi.rsaUnblind({
+ bk: planchet.blindingKey,
+ blindedSig: evSig.blinded_rsa_signature,
+ pk: planchetDenomPub.rsa_public_key,
+ });
+
+ const isValid = await wex.cryptoApi.rsaVerify({
+ hm: planchet.coinPub,
+ pk: planchetDenomPub.rsa_public_key,
+ sig: denomSigRsa.sig,
+ });
+
+ if (!isValid) {
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ planchet.lastError = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
+ {},
+ "invalid signature from the exchange after unblinding",
+ );
+ await tx.planchets.put(planchet);
+ });
+ return;
+ }
+
+ let denomSig: UnblindedSignature;
+ if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
+ denomSig = {
+ cipher: planchetDenomPub.cipher,
+ rsa_signature: denomSigRsa.sig,
+ };
+ } else {
+ throw Error("unsupported cipher");
+ }
+
+ const coin: CoinRecord = {
+ blindingKey: planchet.blindingKey,
+ coinPriv: planchet.coinPriv,
+ coinPub: planchet.coinPub,
+ denomPubHash: planchet.denomPubHash,
+ denomSig,
+ coinEvHash: planchet.coinEvHash,
+ exchangeBaseUrl: d.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Withdraw,
+ coinIndex: coinIdx,
+ reservePub: withdrawalGroup.reservePub,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ },
+ sourceTransactionId: transactionId,
+ maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
+ ageCommitmentProof: planchet.ageCommitmentProof,
+ spendAllocation: undefined,
+ };
+
+ const planchetCoinPub = planchet.coinPub;
+
+ wgContext.planchetsFinished.add(planchet.coinPub);
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["planchets", "coins", "coinAvailability", "denominations"] },
+ async (tx) => {
+ const p = await tx.planchets.get(planchetCoinPub);
+ if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ return;
+ }
+ p.planchetStatus = PlanchetStatus.WithdrawalDone;
+ p.lastError = undefined;
+ await tx.planchets.put(p);
+ await makeCoinAvailable(wex, tx, coin);
+ },
+ );
+}
+
+/**
+ * Make sure that denominations that currently can be used for withdrawal
+ * are validated, and the result of validation is stored in the database.
+ */
+export async function updateWithdrawalDenoms(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ logger.trace(
+ `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
+ );
+ const exchangeDetails = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
+ },
+ );
+ if (!exchangeDetails) {
+ logger.error("exchange details not available");
+ throw Error(`exchange ${exchangeBaseUrl} details not available`);
+ }
+ // First do a pass where the validity of candidate denominations
+ // is checked and the result is stored in the database.
+ logger.trace("getting candidate denominations");
+ const denominations = await getCandidateWithdrawalDenoms(
+ wex,
+ exchangeBaseUrl,
+ exchangeDetails.currency,
+ );
+ logger.trace(`got ${denominations.length} candidate denominations`);
+ const batchSize = 500;
+ let current = 0;
+
+ while (current < denominations.length) {
+ const updatedDenominations: DenominationRecord[] = [];
+ // Do a batch of batchSize
+ for (
+ let batchIdx = 0;
+ batchIdx < batchSize && current < denominations.length;
+ batchIdx++, current++
+ ) {
+ const denom = denominations[current];
+ if (
+ denom.verificationStatus === DenominationVerificationStatus.Unverified
+ ) {
+ logger.trace(
+ `Validating denomination (${current + 1}/${
+ denominations.length
+ }) signature of ${denom.denomPubHash}`,
+ );
+ let valid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ valid = true;
+ } else {
+ const res = await wex.cryptoApi.isValidDenom({
+ denom,
+ masterPub: exchangeDetails.masterPublicKey,
+ });
+ valid = res.valid;
+ }
+ logger.trace(`Done validating ${denom.denomPubHash}`);
+ if (!valid) {
+ logger.warn(
+ `Signature check for denomination h=${denom.denomPubHash} failed`,
+ );
+ denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
+ } else {
+ denom.verificationStatus =
+ DenominationVerificationStatus.VerifiedGood;
+ }
+ updatedDenominations.push(denom);
+ }
+ }
+ if (updatedDenominations.length > 0) {
+ logger.trace("writing denomination batch to db");
+ await wex.db.runReadWriteTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ for (let i = 0; i < updatedDenominations.length; i++) {
+ const denom = updatedDenominations[i];
+ await tx.denominations.put(denom);
+ }
+ },
+ );
+ wex.ws.denomInfoCache.clear();
+ logger.trace("done with DB write");
+ }
+ }
+}
+
+/**
+ * Update the information about a reserve that is stored in the wallet
+ * by querying the reserve's exchange.
+ *
+ * If the reserve have funds that are not allocated in a withdrawal group yet
+ * and are big enough to withdraw with available denominations,
+ * create a new withdrawal group for the remaining amount.
+ */
+async function processQueryReserve(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+ if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
+ return TaskRunResult.backoff();
+ }
+ const reservePub = withdrawalGroup.reservePub;
+
+ const reserveUrl = new URL(
+ `reserves/${reservePub}`,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+
+ logger.trace(`querying reserve status via ${reserveUrl.href}`);
+
+ const resp = await wex.http.fetch(reserveUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+
+ logger.trace(`reserve status code: HTTP ${resp.status}`);
+
+ const result = await readSuccessResponseJsonOrErrorCode(
+ resp,
+ codecForReserveStatus(),
+ );
+
+ if (result.isError) {
+ logger.trace(
+ `got reserve status error, EC=${result.talerErrorResponse.code}`,
+ );
+ if (resp.status === HttpStatusCode.NotFound) {
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throwUnexpectedRequestError(resp, result.talerErrorResponse);
+ }
+ }
+
+ logger.trace(`got reserve status ${j2s(result.response)}`);
+
+ let amountChanged = false;
+ if (
+ Amounts.cmp(
+ result.response.balance,
+ withdrawalGroup.denomsSel.totalWithdrawCost,
+ ) != 0
+ ) {
+ amountChanged = true;
+ }
+
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+ const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount);
+
+ const transitionResult = await ctx.transition(
+ {
+ extraStores: ["denominations"],
+ },
+ async (wg, tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ if (wg.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
+ return TransitionResult.stay();
+ }
+ if (amountChanged) {
+ const candidates = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+ wg.denomsSel = selectWithdrawalDenominations(
+ Amounts.parseOrThrow(result.response.balance),
+ candidates,
+ );
+ }
+ wg.status = WithdrawalGroupStatus.PendingReady;
+ wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
+ return TransitionResult.transition(wg);
+ },
+ );
+
+ if (transitionResult) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+/**
+ * Withdrawal context that is kept in-memory.
+ *
+ * Used to store some cached info during a withdrawal operation.
+ */
+interface WithdrawalGroupStatusInfo {
+ numPlanchets: number;
+ planchetsFinished: Set<string>;
+
+ /**
+ * Cached withdrawal group record from the database.
+ */
+ wgRecord: WithdrawalGroupRecord;
+}
+
+async function processWithdrawalGroupAbortingBank(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const wgInfo = withdrawalGroup.wgInfo;
+ if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
+ throw Error("invalid state (aborting(bank) without bank info");
+ }
+ const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
+ logger.info(`aborting withdrawal at ${abortUrl}`);
+ const abortResp = await wex.http.fetch(abortUrl, {
+ method: "POST",
+ body: {},
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`abort response status: ${abortResp.status}`);
+
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.AbortedBank;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.finished();
+}
+
+async function processWithdrawalGroupPendingKyc(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+ const userType = "individual";
+ const kycInfo = withdrawalGroup.kycPending;
+ if (!kycInfo) {
+ throw Error("no kyc info available in pending(kyc)");
+ }
+ const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "30000");
+ logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`);
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ await ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.PendingKyc: {
+ delete rec.kycPending;
+ delete rec.kycUrl;
+ rec.status = WithdrawalGroupStatus.PendingReady;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ return TransitionResult.stay();
+ }
+ });
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const kycUrl = kycStatus.kyc_url;
+ if (typeof kycUrl === "string") {
+ await ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.PendingReady: {
+ rec.kycUrl = kycUrl;
+ return TransitionResult.transition(rec);
+ }
+ }
+ return TransitionResult.stay();
+ });
+ }
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Select new denominations for a withdrawal group.
+ * Necessary when denominations expired or got revoked
+ * before the withdrawal could complete.
+ */
+async function redenominateWithdrawal(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["withdrawalGroups", "planchets", "denominations"] },
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return;
+ }
+ const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost);
+ const exchangeBaseUrl = wg.exchangeBaseUrl;
+
+ const candidates = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+
+ const oldSel = wg.denomsSel;
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`old denom sel: ${j2s(oldSel)}`);
+ }
+
+ let zero = Amount.zeroOfCurrency(currency);
+ let amountRemaining = zero;
+ let prevTotalCoinValue = zero;
+ let prevTotalWithdrawalCost = zero;
+ let prevHasDenomWithAgeRestriction = false;
+ let prevEarliestDepositExpiration = AbsoluteTime.never();
+ let prevDenoms: DenomSelItem[] = [];
+ let coinIndex = 0;
+ for (let i = 0; i < oldSel.selectedDenoms.length; i++) {
+ const sel = wg.denomsSel.selectedDenoms[i];
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ sel.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error("denom in use but not not found");
+ }
+ // FIXME: Also check planchet if there was a different error or planchet already withdrawn
+ let denomOkay = isWithdrawableDenom(
+ denom,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ const numCoins = sel.count - (sel.skip ?? 0);
+ const denomValue = Amount.from(denom.value).mult(numCoins);
+ const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult(
+ numCoins,
+ );
+ if (denomOkay) {
+ prevTotalCoinValue = prevTotalCoinValue.add(denomValue);
+ prevTotalWithdrawalCost = prevTotalWithdrawalCost.add(
+ denomValue,
+ denomFeeWithdraw,
+ );
+ prevDenoms.push({
+ count: sel.count,
+ denomPubHash: sel.denomPubHash,
+ skip: sel.skip,
+ });
+ prevHasDenomWithAgeRestriction =
+ prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
+ prevEarliestDepositExpiration = AbsoluteTime.min(
+ prevEarliestDepositExpiration,
+ timestampAbsoluteFromDb(denom.stampExpireDeposit),
+ );
+ } else {
+ amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw);
+ prevDenoms.push({
+ count: sel.count,
+ denomPubHash: sel.denomPubHash,
+ skip: (sel.skip ?? 0) + numCoins,
+ });
+
+ for (let j = 0; j < sel.count; j++) {
+ const ci = coinIndex + j;
+ const p = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroupId,
+ ci,
+ ]);
+ if (!p) {
+ // Maybe planchet wasn't yet generated.
+ // No problem!
+ logger.info(
+ `not aborting planchet #${coinIndex}, planchet not found`,
+ );
+ continue;
+ }
+ logger.info(`aborting planchet #${coinIndex}`);
+ p.planchetStatus = PlanchetStatus.AbortedReplaced;
+ await tx.planchets.put(p);
+ }
+ }
+
+ coinIndex += sel.count;
+ }
+
+ const newSel = selectWithdrawalDenominations(
+ amountRemaining.toJson(),
+ candidates,
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`new denom sel: ${j2s(newSel)}`);
+ }
+
+ const mergedSel: DenomSelectionState = {
+ selectedDenoms: [...prevDenoms, ...newSel.selectedDenoms],
+ totalCoinValue: zero
+ .add(prevTotalCoinValue, newSel.totalCoinValue)
+ .toString(),
+ totalWithdrawCost: zero
+ .add(prevTotalWithdrawalCost, newSel.totalWithdrawCost)
+ .toString(),
+ hasDenomWithAgeRestriction:
+ prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction,
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.min(
+ prevEarliestDepositExpiration,
+ AbsoluteTime.fromProtocolTimestamp(
+ newSel.earliestDepositExpiration,
+ ),
+ ),
+ ),
+ };
+ wg.denomsSel = mergedSel;
+ if (logger.shouldLogTrace()) {
+ logger.trace(`merged denom sel: ${j2s(mergedSel)}`);
+ }
+ await tx.withdrawalGroups.put(wg);
+ },
+ );
+}
+
+async function processWithdrawalGroupPendingReady(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+
+ await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl);
+
+ if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
+ logger.warn("Finishing empty withdrawal group (no denoms)");
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.Done;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.finished();
+ }
+
+ const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
+ .map((x) => x.count)
+ .reduce((a, b) => a + b);
+
+ const wgContext: WithdrawalGroupStatusInfo = {
+ numPlanchets: numTotalCoins,
+ planchetsFinished: new Set<string>(),
+ wgRecord: withdrawalGroup,
+ };
+
+ await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
+ const planchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const p of planchets) {
+ if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ wgContext.planchetsFinished.add(p.coinPub);
+ }
+ }
+ });
+
+ // We sequentially generate planchets, so that
+ // large withdrawal groups don't make the wallet unresponsive.
+ for (let i = 0; i < numTotalCoins; i++) {
+ await processPlanchetGenerate(wex, withdrawalGroup, i);
+ }
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
+ const resp = await processPlanchetExchangeBatchRequest(wex, wgContext, {
+ batchSize: maxBatchSize,
+ coinStartIndex: i,
+ });
+ let work: Promise<void>[] = [];
+ work = [];
+ for (let j = 0; j < resp.coinIdxs.length; j++) {
+ if (!resp.batchResp.ev_sigs[j]) {
+ // response may not be available when there is kyc needed
+ continue;
+ }
+ work.push(
+ processPlanchetVerifyAndStoreCoin(
+ wex,
+ wgContext,
+ resp.coinIdxs[j],
+ resp.batchResp.ev_sigs[j],
+ ),
+ );
+ }
+ await Promise.all(work);
+ }
+
+ let redenomRequired = false;
+
+ await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
+ const planchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const p of planchets) {
+ if (p.planchetStatus !== PlanchetStatus.Pending) {
+ continue;
+ }
+ if (!p.lastError) {
+ continue;
+ }
+ switch (p.lastError.code) {
+ case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED:
+ case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED:
+ redenomRequired = true;
+ return;
+ }
+ }
+ });
+
+ if (redenomRequired) {
+ logger.warn(`withdrawal ${withdrawalGroupId} requires redenomination`);
+ await fetchFreshExchange(wex, exchangeBaseUrl, {
+ forceUpdate: true,
+ });
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+ await redenominateWithdrawal(wex, withdrawalGroupId);
+ return TaskRunResult.backoff();
+ }
+
+ const errorsPerCoin: Record<number, TalerErrorDetail> = {};
+ let numPlanchetErrors = 0;
+ let numActive = 0;
+ let numDone = 0;
+ const maxReportedErrors = 5;
+
+ const res = await ctx.transition(
+ {
+ extraStores: ["coins", "coinAvailability", "planchets"],
+ },
+ async (wg, tx) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+
+ const groupPlanchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const x of groupPlanchets) {
+ switch (x.planchetStatus) {
+ case PlanchetStatus.KycRequired:
+ case PlanchetStatus.Pending:
+ numActive++;
+ break;
+ case PlanchetStatus.WithdrawalDone:
+ numDone++;
+ break;
+ }
+ if (x.lastError) {
+ numPlanchetErrors++;
+ if (numPlanchetErrors < maxReportedErrors) {
+ errorsPerCoin[x.coinIdx] = x.lastError;
+ }
+ }
+ }
+
+ if (wg.timestampFinish === undefined && numActive === 0) {
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ wg.status = WithdrawalGroupStatus.Done;
+ await makeCoinsVisible(wex, tx, ctx.transactionId);
+ }
+ return TransitionResult.transition(wg);
+ },
+ );
+
+ if (!res) {
+ throw Error("withdrawal group does not exist anymore");
+ }
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ if (numPlanchetErrors > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
+ {
+ errorsPerCoin,
+ numErrors: numPlanchetErrors,
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+export async function processWithdrawalGroup(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ logger.trace("processing withdrawal group", withdrawalGroupId);
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error(`withdrawal group ${withdrawalGroupId} not found`);
+ }
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ switch (withdrawalGroup.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return await processBankRegisterReserve(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return processQueryReserve(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return await processReserveBankStatus(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingAml:
+ // FIXME: Handle this case, withdrawal doesn't support AML yet.
+ return TaskRunResult.backoff();
+ case WithdrawalGroupStatus.PendingKyc:
+ return processWithdrawalGroupPendingKyc(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.PendingReady:
+ // Continue with the actual withdrawal!
+ return await processWithdrawalGroupPendingReady(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortingBank:
+ return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.DialogProposed:
+ return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedAml:
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ // Nothing to do.
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(withdrawalGroup.status);
+ }
+}
+
+const AGE_MASK_GROUPS = "8:10:12:14:16:18"
+ .split(":")
+ .map((n) => parseInt(n, 10));
+
+export async function getExchangeWithdrawalInfo(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ instructedAmount: AmountJson,
+ ageRestricted: number | undefined,
+): Promise<ExchangeWithdrawalDetails> {
+ logger.trace("updating exchange");
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, {});
+
+ wex.cancellationToken.throwIfCancelled();
+
+ if (exchange.currency != instructedAmount.currency) {
+ // Specifying the amount in the conversion input currency is not yet supported.
+ // We might add support for it later.
+ throw new Error(
+ `withdrawal only supported when specifying target currency ${exchange.currency}`,
+ );
+ }
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount,
+ },
+ wex.cancellationToken,
+ );
+
+ logger.trace("updating withdrawal denoms");
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+
+ wex.cancellationToken.throwIfCancelled();
+
+ logger.trace("getting candidate denoms");
+ const candidateDenoms = await getCandidateWithdrawalDenoms(
+ wex,
+ exchangeBaseUrl,
+ instructedAmount.currency,
+ );
+
+ wex.cancellationToken.throwIfCancelled();
+
+ logger.trace("selecting withdrawal denoms");
+ // FIXME: Why not in a transaction?
+ const selectedDenoms = selectWithdrawalDenominations(
+ instructedAmount,
+ candidateDenoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+
+ logger.trace("selection done");
+
+ if (selectedDenoms.selectedDenoms.length === 0) {
+ throw Error(
+ `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
+ instructedAmount,
+ )}`,
+ );
+ }
+
+ const exchangeWireAccounts: string[] = [];
+
+ for (const account of exchange.wireInfo.accounts) {
+ exchangeWireAccounts.push(account.payto_uri);
+ }
+
+ let versionMatch;
+ if (exchange.protocolVersionRange) {
+ versionMatch = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ exchange.protocolVersionRange,
+ );
+
+ if (
+ versionMatch &&
+ !versionMatch.compatible &&
+ versionMatch.currentCmp === -1
+ ) {
+ logger.warn(
+ `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
+ `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
+ );
+ }
+ }
+
+ let tosAccepted = false;
+ if (exchange.tosAcceptedTimestamp) {
+ if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
+ tosAccepted = true;
+ }
+ }
+
+ const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
+ if (!paytoUris) {
+ throw Error("exchange is in invalid state");
+ }
+
+ const ret: ExchangeWithdrawalDetails = {
+ earliestDepositExpiration: selectedDenoms.earliestDepositExpiration,
+ exchangePaytoUris: paytoUris,
+ exchangeWireAccounts,
+ exchangeCreditAccountDetails: withdrawalAccountsList,
+ exchangeVersion: exchange.protocolVersionRange || "unknown",
+ selectedDenoms,
+ versionMatch,
+ walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ termsOfServiceAccepted: tosAccepted,
+ withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
+ withdrawalAmountRaw: Amounts.stringify(instructedAmount),
+ // TODO: remove hardcoding, this should be calculated from the denominations info
+ // force enabled for testing
+ ageRestrictionOptions: selectedDenoms.hasDenomWithAgeRestriction
+ ? AGE_MASK_GROUPS
+ : undefined,
+ scopeInfo: exchange.scopeInfo,
+ };
+ return ret;
+}
+
+export interface GetWithdrawalDetailsForUriOpts {
+ restrictAge?: number;
+ notifyChangeFromPendingTimeoutMs?: number;
+}
+
+/**
+ * Get more information about a taler://withdraw URI.
+ *
+ * As side effects, the bank (via the bank integration API) is queried
+ * and the exchange suggested by the bank is ephemerally added
+ * to the wallet's list of known exchanges.
+ */
+export async function getWithdrawalDetailsForUri(
+ wex: WalletExecutionContext,
+ talerWithdrawUri: string,
+ opts: GetWithdrawalDetailsForUriOpts = {},
+): Promise<WithdrawUriInfoResponse> {
+ logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
+ const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri);
+ logger.trace(`got bank info`);
+ if (info.suggestedExchange) {
+ try {
+ // If the exchange entry doesn't exist yet,
+ // it'll be created as an ephemeral entry.
+ await fetchFreshExchange(wex, info.suggestedExchange);
+ } catch (e) {
+ // We still continued if it failed, as other exchanges might be available.
+ // We don't want to fail if the bank-suggested exchange is broken/offline.
+ logger.trace(
+ `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
+ );
+ }
+ }
+
+ const currency = Amounts.currencyOf(info.amount);
+
+ const listExchangesResp = await listExchanges(wex);
+ const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
+ return (
+ x.currency === currency &&
+ (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
+ x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
+ );
+ });
+
+ return {
+ operationId: info.operationId,
+ confirmTransferUrl: info.confirmTransferUrl,
+ status: info.status,
+ amount: Amounts.stringify(info.amount),
+ defaultExchangeBaseUrl: info.suggestedExchange,
+ possibleExchanges,
+ };
+}
+
+export function augmentPaytoUrisForWithdrawal(
+ plainPaytoUris: string[],
+ reservePub: string,
+ instructedAmount: AmountLike,
+): string[] {
+ return plainPaytoUris.map((x) =>
+ addPaytoQueryParams(x, {
+ amount: Amounts.stringify(instructedAmount),
+ message: `Taler ${reservePub}`,
+ }),
+ );
+}
+
+/**
+ * Get payto URIs that can be used to fund a withdrawal operation.
+ */
+export async function getFundingPaytoUris(
+ tx: WalletDbReadOnlyTransaction<
+ ["withdrawalGroups", "exchanges", "exchangeDetails"]
+ >,
+ withdrawalGroupId: string,
+): Promise<string[]> {
+ const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
+ checkDbInvariant(!!withdrawalGroup);
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
+ return [];
+ }
+ const plainPaytoUris =
+ exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+ if (!plainPaytoUris) {
+ logger.error(
+ `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
+ );
+ return [];
+ }
+ return augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ withdrawalGroup.reservePub,
+ withdrawalGroup.instructedAmount,
+ );
+}
+
+async function getWithdrawalGroupRecordTx(
+ db: DbAccess<typeof WalletStoresV1>,
+ req: {
+ withdrawalGroupId: string;
+ },
+): Promise<WithdrawalGroupRecord | undefined> {
+ return await db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return tx.withdrawalGroups.get(req.withdrawalGroupId);
+ },
+ );
+}
+
+export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
+ return { d_ms: 60000 };
+}
+
+export function getBankStatusUrl(talerWithdrawUri: string): string {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ return url.href;
+}
+
+export function getBankAbortUrl(talerWithdrawUri: string): string {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ return url.href;
+}
+
+async function registerReserveWithBank(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ switch (withdrawalGroup?.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default:
+ return;
+ }
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("expecting withdrawal type = bank integrated");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ return;
+ }
+ const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
+ const reqBody = {
+ reserve_pub: withdrawalGroup.reservePub,
+ selected_exchange: bankInfo.exchangePaytoUri,
+ };
+ logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
+ const httpResp = await wex.http.fetch(bankStatusUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ const status = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codeForBankWithdrawalOperationPostResponse(),
+ );
+
+ await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
+ );
+ r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
+ return TransitionResult.transition(r);
+ });
+}
+
+async function transitionBankAborted(
+ ctx: WithdrawTransactionContext,
+): Promise<TaskRunResult> {
+ logger.info("bank aborted the withdrawal");
+ await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.FailedBankAborted;
+ return TransitionResult.transition(r);
+ });
+ return TaskRunResult.progress();
+}
+
+async function processBankRegisterReserve(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ throw Error("no bank info in bank-integrated withdrawal");
+ }
+
+ const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+
+ const statusResp = await wex.http.fetch(url.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.aborted) {
+ return transitionBankAborted(ctx);
+ }
+
+ // FIXME: Put confirm transfer URL in the DB!
+
+ await registerReserveWithBank(wex, withdrawalGroupId);
+ return TaskRunResult.progress();
+}
+
+async function processReserveBankStatus(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ throw Error("no bank info in bank-integrated withdrawal");
+ }
+
+ const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
+ }
+ const bankStatusUrl = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ bankStatusUrl.searchParams.set("long_poll_ms", "30000");
+
+ logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`);
+ const statusResp = await wex.http.fetch(bankStatusUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(
+ `long-polling for withdrawal operation returned status ${statusResp.status}`,
+ );
+
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`response body: ${j2s(status)}`);
+ }
+
+ if (status.aborted) {
+ return transitionBankAborted(ctx);
+ }
+
+ if (!status.transfer_done) {
+ return TaskRunResult.longpollReturnedPending();
+ }
+
+ const transitionInfo = await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ // Re-check reserve status within transaction
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ if (status.transfer_done) {
+ logger.info("withdrawal: transfer confirmed by bank.");
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.PendingQueryingStatus;
+ return TransitionResult.transition(r);
+ } else {
+ return TransitionResult.stay();
+ }
+ });
+
+ if (transitionInfo) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+export interface PrepareCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transactionId: string;
+ creationInfo?: {
+ amount: AmountJson;
+ canonExchange: string;
+ };
+}
+
+export async function internalPrepareCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<PrepareCreateWithdrawalGroupResult> {
+ const reserveKeyPair =
+ args.reserveKeyPair ?? (await wex.cryptoApi.createEddsaKeypair({}));
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const secretSeed = encodeCrock(getRandomBytes(32));
+ const exchangeBaseUrl = args.exchangeBaseUrl;
+ const amount = args.amount;
+ const currency = Amounts.currencyOf(amount);
+
+ let withdrawalGroupId;
+
+ if (args.forcedWithdrawalGroupId) {
+ withdrawalGroupId = args.forcedWithdrawalGroupId;
+ const wgId = withdrawalGroupId;
+ const existingWg = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return tx.withdrawalGroups.get(wgId);
+ },
+ );
+
+ if (existingWg) {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWg.withdrawalGroupId,
+ });
+ return { withdrawalGroup: existingWg, transactionId };
+ }
+ } else {
+ withdrawalGroupId = encodeCrock(getRandomBytes(32));
+ }
+
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+ const denoms = await getCandidateWithdrawalDenoms(
+ wex,
+ exchangeBaseUrl,
+ currency,
+ );
+
+ let initialDenomSel: DenomSelectionState;
+ const denomSelUid = encodeCrock(getRandomBytes(16));
+ if (args.forcedDenomSel) {
+ logger.warn("using forced denom selection");
+ initialDenomSel = selectForcedWithdrawalDenominations(
+ amount,
+ denoms,
+ args.forcedDenomSel,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ } else {
+ initialDenomSel = selectWithdrawalDenominations(
+ amount,
+ denoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ }
+
+ const withdrawalGroup: WithdrawalGroupRecord = {
+ denomSelUid,
+ denomsSel: initialDenomSel,
+ exchangeBaseUrl: exchangeBaseUrl,
+ instructedAmount: Amounts.stringify(amount),
+ timestampStart: timestampPreciseToDb(now),
+ rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
+ effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
+ secretSeed,
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ status: args.reserveStatus,
+ withdrawalGroupId,
+ restrictAge: args.restrictAge,
+ senderWire: undefined,
+ timestampFinish: undefined,
+ wgInfo: args.wgInfo,
+ };
+
+ await fetchFreshExchange(wex, exchangeBaseUrl);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ });
+
+ return {
+ withdrawalGroup,
+ transactionId,
+ creationInfo: {
+ canonExchange: exchangeBaseUrl,
+ amount,
+ },
+ };
+}
+
+export interface PerformCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transitionInfo: TransitionInfo | undefined;
+
+ /**
+ * Notification for the exchange state transition.
+ *
+ * Should be emitted after the transaction has succeeded.
+ */
+ exchangeNotif: WalletNotification | undefined;
+}
+
+export async function internalPerformCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["withdrawalGroups", "reserves", "exchanges"]
+ >,
+ prep: PrepareCreateWithdrawalGroupResult,
+): Promise<PerformCreateWithdrawalGroupResult> {
+ const { withdrawalGroup } = prep;
+ if (!prep.creationInfo) {
+ return {
+ withdrawalGroup,
+ transitionInfo: undefined,
+ exchangeNotif: undefined,
+ };
+ }
+ const existingWg = await tx.withdrawalGroups.get(
+ withdrawalGroup.withdrawalGroupId,
+ );
+ if (existingWg) {
+ return {
+ withdrawalGroup: existingWg,
+ exchangeNotif: undefined,
+ transitionInfo: undefined,
+ };
+ }
+ await tx.withdrawalGroups.add(withdrawalGroup);
+ await tx.reserves.put({
+ reservePub: withdrawalGroup.reservePub,
+ reservePriv: withdrawalGroup.reservePriv,
+ });
+
+ const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
+ if (exchange) {
+ exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ await tx.exchanges.put(exchange);
+ }
+
+ const oldTxState = {
+ major: TransactionMajorState.None,
+ minor: undefined,
+ };
+ const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
+ const transitionInfo = {
+ oldTxState,
+ newTxState,
+ };
+
+ const exchangeUsedRes = await markExchangeUsed(
+ wex,
+ tx,
+ prep.withdrawalGroup.exchangeBaseUrl,
+ );
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ withdrawalGroup,
+ transitionInfo,
+ exchangeNotif: exchangeUsedRes.notif,
+ };
+}
+
+/**
+ * Create a withdrawal group.
+ *
+ * If a forcedWithdrawalGroupId is given and a
+ * withdrawal group with this ID already exists,
+ * the existing one is returned. No conflict checking
+ * of the other arguments is done in that case.
+ */
+export async function internalCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<WithdrawalGroupRecord> {
+ const prep = await internalPrepareCreateWithdrawalGroup(wex, args);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
+ });
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ prep.withdrawalGroup.withdrawalGroupId,
+ );
+ const res = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "operationRetries",
+ ],
+ },
+ async (tx) => {
+ const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep);
+ await updateWithdrawalTransaction(ctx, tx);
+ return res;
+ },
+ );
+ if (res.exchangeNotif) {
+ wex.ws.notify(res.exchangeNotif);
+ }
+ notifyTransition(wex, transactionId, res.transitionInfo);
+ return res.withdrawalGroup;
+}
+
+export async function prepareBankIntegratedWithdrawal(
+ wex: WalletExecutionContext,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+ },
+): Promise<PrepareBankIntegratedWithdrawalResponse> {
+ const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ req.talerWithdrawUri,
+ );
+ },
+ );
+
+ if (existingWithdrawalGroup) {
+ let url: string | undefined;
+ if (
+ existingWithdrawalGroup.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
+ }
+ return {
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
+ }),
+ };
+ }
+
+ const selectedExchange = req.selectedExchange;
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
+
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ wex,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ },
+ wex.cancellationToken,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ amount: withdrawInfo.amount,
+ exchangeBaseUrl: req.selectedExchange,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ exchangeCreditAccounts: withdrawalAccountList,
+ bankInfo: {
+ exchangePaytoUri,
+ talerWithdrawUri: req.talerWithdrawUri,
+ confirmUrl: withdrawInfo.confirmTransferUrl,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ },
+ },
+ restrictAge: req.restrictAge,
+ forcedDenomSel: req.forcedDenomSel,
+ reserveStatus: WithdrawalGroupStatus.DialogProposed,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId: ctx.transactionId,
+ };
+}
+
+export async function confirmWithdrawal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+ if (parsedTx?.tag !== TransactionType.Withdrawal) {
+ throw Error("invalid withdrawal transaction ID");
+ }
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.get(parsedTx.withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error("withdrawal group not found");
+ }
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+ ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.DialogProposed: {
+ rec.status = WithdrawalGroupStatus.PendingRegisteringBank;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ throw Error("unable to confirm withdrawal in current state");
+ }
+ });
+
+ await wex.taskScheduler.resetTaskRetries(ctx.taskId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+/**
+ * Accept a bank-integrated withdrawal.
+ *
+ * Before returning, the wallet tries to register the reserve with the bank.
+ *
+ * Thus after this call returns, the withdrawal operation can be confirmed
+ * with the bank.
+ *
+ * @deprecated in favor of prepare/accept
+ */
+export async function acceptWithdrawalFromUri(
+ wex: WalletExecutionContext,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+ },
+): Promise<AcceptWithdrawalResponse> {
+ const selectedExchange = req.selectedExchange;
+ logger.info(
+ `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
+ );
+ const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ req.talerWithdrawUri,
+ );
+ },
+ );
+
+ if (existingWithdrawalGroup) {
+ let url: string | undefined;
+ if (
+ existingWithdrawalGroup.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
+ }
+ return {
+ reservePub: existingWithdrawalGroup.reservePub,
+ confirmTransferUrl: url,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
+ }),
+ };
+ }
+
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ wex,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ },
+ CancellationToken.CONTINUE,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ amount: withdrawInfo.amount,
+ exchangeBaseUrl: req.selectedExchange,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ exchangeCreditAccounts: withdrawalAccountList,
+ bankInfo: {
+ exchangePaytoUri,
+ talerWithdrawUri: req.talerWithdrawUri,
+ confirmUrl: withdrawInfo.confirmTransferUrl,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ },
+ },
+ restrictAge: req.restrictAge,
+ forcedDenomSel: req.forcedDenomSel,
+ reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ await waitWithdrawalRegistered(wex, ctx);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ transactionId: ctx.transactionId,
+ };
+}
+
+async function internalWaitWithdrawalRegistered(
+ wex: WalletExecutionContext,
+ ctx: WithdrawTransactionContext,
+ withdrawalNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "operationRetries"] },
+ async (tx) => {
+ return {
+ withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ retryRec: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+
+ if (!withdrawalRec) {
+ throw Error("withdrawal not found anymore");
+ }
+
+ switch (withdrawalRec.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+ {},
+ );
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.PendingReady:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default: {
+ if (retryRec) {
+ if (retryRec.lastError) {
+ throw TalerError.fromUncheckedDetail(retryRec.lastError);
+ } else {
+ throw Error("withdrawal unexpectedly pending");
+ }
+ }
+ }
+ }
+
+ await withdrawalNotifFlag.wait();
+ withdrawalNotifFlag.reset();
+ }
+}
+
+async function waitWithdrawalRegistered(
+ wex: WalletExecutionContext,
+ ctx: WithdrawTransactionContext,
+): Promise<void> {
+ // FIXME: Doesn't support cancellation yet
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const withdrawalNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ withdrawalNotifFlag.raise();
+ }
+ });
+
+ try {
+ const res = await internalWaitWithdrawalRegistered(
+ wex,
+ ctx,
+ withdrawalNotifFlag,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function fetchAccount(
+ wex: WalletExecutionContext,
+ instructedAmount: AmountJson,
+ acct: ExchangeWireAccount,
+ reservePub: string | undefined,
+ cancellationToken: CancellationToken,
+): Promise<WithdrawalExchangeAccountDetails> {
+ let paytoUri: string;
+ let transferAmount: AmountString | undefined = undefined;
+ let currencySpecification: CurrencySpecification | undefined = undefined;
+ if (acct.conversion_url != null) {
+ const reqUrl = new URL("cashin-rate", acct.conversion_url);
+ reqUrl.searchParams.set(
+ "amount_credit",
+ Amounts.stringify(instructedAmount),
+ );
+ const httpResp = await wex.http.fetch(reqUrl.href, {
+ cancellationToken,
+ });
+ const respOrErr = await readSuccessResponseJsonOrErrorCode(
+ httpResp,
+ codecForCashinConversionResponse(),
+ );
+ if (respOrErr.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: respOrErr.talerErrorResponse,
+ };
+ }
+ const resp = respOrErr.response;
+ paytoUri = acct.payto_uri;
+ transferAmount = resp.amount_debit;
+ const configUrl = new URL("config", acct.conversion_url);
+ const configResp = await wex.http.fetch(configUrl.href, {
+ cancellationToken,
+ });
+ const configRespOrError = await readSuccessResponseJsonOrErrorCode(
+ configResp,
+ codecForConversionBankConfig(),
+ );
+ if (configRespOrError.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: configRespOrError.talerErrorResponse,
+ };
+ }
+ const configParsed = configRespOrError.response;
+ currencySpecification = configParsed.fiat_currency_specification;
+ } else {
+ paytoUri = acct.payto_uri;
+ transferAmount = Amounts.stringify(instructedAmount);
+ }
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ amount: Amounts.stringify(transferAmount),
+ });
+ if (reservePub != null) {
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ message: `Taler ${reservePub}`,
+ });
+ }
+ const acctInfo: WithdrawalExchangeAccountDetails = {
+ status: "ok",
+ paytoUri,
+ transferAmount,
+ bankLabel: acct.bank_label,
+ priority: acct.priority,
+ currencySpecification,
+ creditRestrictions: acct.credit_restrictions,
+ };
+ if (transferAmount != null) {
+ acctInfo.transferAmount = transferAmount;
+ }
+ return acctInfo;
+}
+
+/**
+ * Gather information about bank accounts that can be used for
+ * withdrawals. This includes accounts that are in a different
+ * currency and require conversion.
+ */
+async function fetchWithdrawalAccountInfo(
+ wex: WalletExecutionContext,
+ req: {
+ exchange: ReadyExchangeSummary;
+ instructedAmount: AmountJson;
+ reservePub?: string;
+ },
+ cancellationToken: CancellationToken,
+): Promise<WithdrawalExchangeAccountDetails[]> {
+ const { exchange } = req;
+ const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
+ for (let acct of exchange.wireInfo.accounts) {
+ const acctInfo = await fetchAccount(
+ wex,
+ req.instructedAmount,
+ acct,
+ req.reservePub,
+ cancellationToken,
+ );
+ withdrawalAccounts.push(acctInfo);
+ }
+ withdrawalAccounts.sort((x1, x2) => {
+ // Accounts without explicit priority have prio 0.
+ const n1 = x1.priority ?? 0;
+ const n2 = x2.priority ?? 0;
+ return Math.sign(n2 - n1);
+ });
+ return withdrawalAccounts;
+}
+
+/**
+ * Create a manual withdrawal operation.
+ *
+ * Adds the corresponding exchange as a trusted exchange if it is neither
+ * audited nor trusted already.
+ *
+ * Asynchronously starts the withdrawal.
+ */
+export async function createManualWithdrawal(
+ wex: WalletExecutionContext,
+ req: {
+ exchangeBaseUrl: string;
+ amount: AmountLike;
+ restrictAge?: number;
+ forcedDenomSel?: ForcedDenomSel;
+ forceReservePriv?: EddsaPrivateKeyString;
+ },
+): Promise<AcceptManualWithdrawalResult> {
+ const { exchangeBaseUrl } = req;
+ const amount = Amounts.parseOrThrow(req.amount);
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ if (exchange.currency != amount.currency) {
+ throw Error(
+ "manual withdrawal with conversion from foreign currency is not yet supported",
+ );
+ }
+
+ let reserveKeyPair: EddsaKeypair;
+ if (req.forceReservePriv) {
+ const pubResp = await wex.cryptoApi.eddsaGetPublic({
+ priv: req.forceReservePriv,
+ });
+
+ reserveKeyPair = {
+ priv: req.forceReservePriv,
+ pub: pubResp.pub,
+ };
+ } else {
+ reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+ }
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: amount,
+ reservePub: reserveKeyPair.pub,
+ },
+ CancellationToken.CONTINUE,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ amount: Amounts.jsonifyAmount(req.amount),
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankManual,
+ exchangeCreditAccounts: withdrawalAccountsList,
+ },
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair,
+ });
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ const exchangePaytoUris = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] },
+ async (tx) => {
+ return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris: exchangePaytoUris,
+ withdrawalAccountsList: withdrawalAccountsList,
+ transactionId: ctx.transactionId,
+ };
+}
+
+/**
+ * Wait until a refresh operation is final.
+ */
+export async function waitWithdrawalFinal(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const withdrawalNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ withdrawalNotifFlag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ withdrawalNotifFlag.raise();
+ });
+
+ try {
+ await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag);
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+async function internalWaitWithdrawalFinal(
+ ctx: WithdrawTransactionContext,
+ flag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ if (ctx.wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+
+ // Check if refresh is final
+ const res = await ctx.wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups", "operationRetries"] },
+ async (tx) => {
+ return {
+ wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ };
+ },
+ );
+ const { wg } = res;
+ if (!wg) {
+ // Must've been deleted, we consider that final.
+ return;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ // Transaction is final
+ return;
+ }
+
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+}
+
+export async function getWithdrawalDetailsForAmount(
+ wex: WalletExecutionContext,
+ cts: CancellationToken.Source,
+ req: GetWithdrawalDetailsForAmountRequest,
+): Promise<WithdrawalDetailsForAmount> {
+ const clientCancelKey = req.clientCancellationId
+ ? `ccid:getWithdrawalDetailsForAmount:${req.clientCancellationId}`
+ : undefined;
+ if (clientCancelKey) {
+ const prevCts = wex.ws.clientCancellationMap.get(clientCancelKey);
+ if (prevCts) {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Cancelling previous key ${clientCancelKey}`,
+ });
+ prevCts.cancel();
+ } else {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `No previous key ${clientCancelKey}`,
+ });
+ }
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Setting clientCancelKey ${clientCancelKey} to ${cts}`,
+ });
+ wex.ws.clientCancellationMap.set(clientCancelKey, cts);
+ }
+ try {
+ return await internalGetWithdrawalDetailsForAmount(wex, req);
+ } finally {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Deleting clientCancelKey ${clientCancelKey} to ${cts}`,
+ });
+ if (clientCancelKey && !cts.token.isCancelled) {
+ wex.ws.clientCancellationMap.delete(clientCancelKey);
+ }
+ }
+}
+
+async function internalGetWithdrawalDetailsForAmount(
+ wex: WalletExecutionContext,
+ req: GetWithdrawalDetailsForAmountRequest,
+): Promise<WithdrawalDetailsForAmount> {
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ req.exchangeBaseUrl,
+ Amounts.parseOrThrow(req.amount),
+ req.restrictAge,
+ );
+ let numCoins = 0;
+ for (const x of wi.selectedDenoms.selectedDenoms) {
+ numCoins += x.count;
+ }
+ const resp: WithdrawalDetailsForAmount = {
+ amountRaw: req.amount,
+ amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
+ paytoUris: wi.exchangePaytoUris,
+ tosAccepted: wi.termsOfServiceAccepted,
+ ageRestrictionOptions: wi.ageRestrictionOptions,
+ withdrawalAccountsList: wi.exchangeCreditAccountDetails,
+ numCoins,
+ scopeInfo: wi.scopeInfo,
+ };
+ return resp;
+}
diff --git a/packages/taler-wallet-core/tsconfig.json b/packages/taler-wallet-core/tsconfig.json
index 3da332364..7a1a0fcce 100644
--- a/packages/taler-wallet-core/tsconfig.json
+++ b/packages/taler-wallet-core/tsconfig.json
@@ -4,16 +4,18 @@
"composite": true,
"declaration": true,
"declarationMap": false,
- "target": "ES2017",
- "module": "ESNext",
- "moduleResolution": "node",
+ "target": "ES2020",
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "resolveJsonModule": true,
"sourceMap": true,
- "lib": ["es6"],
+ "lib": ["ES2020"],
+ "resolvePackageJsonImports": true,
"types": ["node"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
- "strictPropertyInitialization": false,
+ "strictPropertyInitialization": true,
"outDir": "lib",
"noImplicitAny": true,
"noImplicitThis": true,
@@ -21,7 +23,7 @@
"esModuleInterop": true,
"importHelpers": true,
"rootDir": "./src",
- "typeRoots": ["./node_modules/@types"],
+ "typeRoots": ["./node_modules/@types"]
},
"references": [
{
@@ -31,5 +33,5 @@
"path": "../taler-util/"
}
],
- "include": ["src/**/*"]
+ "include": ["src/**/*", "src/*.json"]
}
diff --git a/packages/taler-wallet-core/watch_test.sh b/packages/taler-wallet-core/watch_test.sh
new file mode 100755
index 000000000..124e18e21
--- /dev/null
+++ b/packages/taler-wallet-core/watch_test.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+./node_modules/typescript/bin/tsc --watch &
+./node_modules/ava/entrypoints/cli.mjs -w "$@"