diff options
Diffstat (limited to 'packages/taler-wallet-core')
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 "$@" |