taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit e951075d2ef52fa8e9e7489c62031777c3a7e66b
parent e975740ac4e9ba4bc531226784d640a018c00833
Author: Florian Dold <florian@dold.me>
Date:   Mon, 19 Feb 2024 18:05:48 +0100

wallet-core: flatten directory structure

Diffstat:
Apackages/taler-wallet-core/src/attention.ts | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/backup/index.ts | 1059+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpackages/taler-wallet-core/src/operations/backup/state.ts -> packages/taler-wallet-core/src/backup/state.ts | 0
Apackages/taler-wallet-core/src/balance.ts | 730+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/common.ts | 693+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db.ts | 4++--
Mpackages/taler-wallet-core/src/dbless.ts | 4++--
Apackages/taler-wallet-core/src/deposits.ts | 1598+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/exchanges.ts | 2007+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/index.ts | 18+++++++++---------
Mpackages/taler-wallet-core/src/internal-wallet-state.ts | 2+-
Apackages/taler-wallet-core/src/merchants.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/taler-wallet-core/src/operations/attention.ts | 133-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/backup/index.ts | 1059-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/balance.ts | 730-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/common.ts | 693-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/deposits.ts | 1598-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/exchanges.ts | 2007-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/merchants.ts | 66------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/pay-merchant.ts | 3232-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/pay-peer-common.ts | 172-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts | 1204-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts | 883-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/pay-peer-push-credit.ts | 1037-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/pay-peer-push-debit.ts | 1150-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/recoup.ts | 535-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/refresh.ts | 1430-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/reward.ts | 321-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/testing.ts | 913-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/transactions.ts | 2007-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/withdraw.test.ts | 370-------------------------------------------------------------------------------
Dpackages/taler-wallet-core/src/operations/withdraw.ts | 2754-------------------------------------------------------------------------------
Apackages/taler-wallet-core/src/pay-merchant.ts | 3232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/pay-peer-common.ts | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 1204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 883+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/pay-peer-push-credit.ts | 1037+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/pay-peer-push-debit.ts | 1150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pending-types.ts | 2+-
Rpackages/taler-wallet-core/src/util/query.ts -> packages/taler-wallet-core/src/query.ts | 0
Apackages/taler-wallet-core/src/recoup.ts | 535+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/refresh.ts | 1430+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/reward.ts | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/shepherd.ts | 26+++++++++++++-------------
Apackages/taler-wallet-core/src/testing.ts | 913+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/transactions.ts | 2007+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/util/coinSelection.ts | 4++--
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 4++--
Mpackages/taler-wallet-core/src/wallet.ts | 34+++++++++++++++++-----------------
Apackages/taler-wallet-core/src/withdraw.test.ts | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/withdraw.ts | 2754+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/tsconfig.json | 2+-
52 files changed, 22344 insertions(+), 22344 deletions(-)

diff --git a/packages/taler-wallet-core/src/attention.ts b/packages/taler-wallet-core/src/attention.ts @@ -0,0 +1,133 @@ +/* + 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 "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; + +const logger = new Logger("operations/attention.ts"); + +export async function getUserAttentionsUnreadCount( + ws: InternalWalletState, + req: UserAttentionsRequest, +): Promise<UserAttentionsCountResponse> { + const total = await ws.db.runReadOnlyTx(["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( + ws: InternalWalletState, + req: UserAttentionsRequest, +): Promise<UserAttentionsResponse> { + return await ws.db.runReadOnlyTx(["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( + ws: InternalWalletState, + req: UserAttentionByIdRequest, +): Promise<void> { + await ws.db.runReadWriteTx(["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 ws + * @param info + */ +export async function addAttentionRequest( + ws: InternalWalletState, + info: AttentionInfo, + entityId: string, +): Promise<void> { + await ws.db.runReadWriteTx(["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 ws + * @param created + */ +export async function removeAttentionRequest( + ws: InternalWalletState, + req: UserAttentionByIdRequest, +): Promise<void> { + await ws.db.runReadWriteTx(["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 @@ -0,0 +1,1059 @@ +/* + 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, + AmountString, + AttentionType, + BackupRecovery, + Codec, + EddsaKeyPair, + HttpStatusCode, + Logger, + PreparePayResult, + RecoveryLoadRequest, + RecoveryMergeStrategy, + TalerError, + TalerErrorCode, + TalerErrorDetail, + TalerPreciseTimestamp, + URL, + buildCodecForObject, + buildCodecForUnion, + bytesToString, + canonicalJson, + canonicalizeBaseUrl, + codecForAmountString, + codecForBoolean, + codecForConstString, + codecForList, + codecForNumber, + codecForString, + codecOptional, + decodeCrock, + durationFromSpec, + 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 { + BackupProviderRecord, + BackupProviderState, + BackupProviderStateTag, + BackupProviderTerms, + ConfigRecord, + ConfigRecordKey, + WalletBackupConfState, + WalletDbReadOnlyTransaction, + timestampOptionalPreciseFromDb, + timestampPreciseToDb, +} from "../../db.js"; +import { InternalWalletState } from "../../internal-wallet-state.js"; +import { + checkDbInvariant, + checkLogicInvariant, +} from "../../util/invariants.js"; +import { addAttentionRequest, removeAttentionRequest } from "../../attention.js"; +import { + TaskIdentifiers, + TaskRunResult, + TaskRunResultType, +} from "../../common.js"; +import { preparePayForUri } from "../../pay-merchant.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(), + durationFromSpec({ minutes: 5 }), + ), + ); +} + +async function runBackupCycleForProvider( + ws: InternalWalletState, + args: BackupForProviderArgs, +): Promise<TaskRunResult> { + const provider = await ws.db.runReadOnlyTx( + ["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(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 syncSigResp = await ws.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 ws.http.fetch(accountBackupUrl.href, { + method: "POST", + body: encBackup, + headers: { + "content-type": "application/octet-stream", + "sync-signature": syncSigResp.sig, + "if-none-match": newHash, + ...(provider.lastBackupHash + ? { + "if-match": provider.lastBackupHash, + } + : {}), + }, + }); + + logger.trace(`sync response status: ${resp.status}`); + + if (resp.status === HttpStatusCode.NotModified) { + await ws.db.runReadWriteTx(["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(ws, { + 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(ws, talerUri); + } catch (e) { + const error = TalerError.fromException(e); + if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) { + throw error; + } + } + + if (res === undefined) { + //claimed + + await ws.db.runReadWriteTx(["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 ws.db.runReadWriteTx(["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( + ws, + { + 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 ws.db.runReadWriteTx(["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(ws, { + 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(ws); + // const blob = await decryptBackup(backupConfig, backupEnc); + // FIXME: Re-implement backup import with merging + // await importBackup(ws, blob, cryptoData); + await ws.db.runReadWriteTx(["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(ws, 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( + ws: InternalWalletState, + backupProviderBaseUrl: string, +): Promise<TaskRunResult> { + const provider = await ws.db.runReadOnlyTx( + ["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(ws, { + backupProviderBaseUrl: provider.baseUrl, + }); +} + +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.runReadWriteTx(["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( + ws: InternalWalletState, + req: RunBackupCycleRequest, +): Promise<void> { + const providers = await ws.db.runReadOnlyTx( + ["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(ws, { + backupProviderBaseUrl: provider.baseUrl, + }); + } +} + +export 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; +} + +export 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 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( + ws: InternalWalletState, + req: AddBackupProviderRequest, +): Promise<AddBackupProviderResponse> { + logger.info(`adding backup provider ${j2s(req)}`); + await provideBackupState(ws); + const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); + await ws.db.runReadWriteTx(["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 ws.http.fetch(termsUrl.href); + const terms = await readSuccessResponseJsonOrThrow( + resp, + codecForSyncTermsOfServiceResponse(), + ); + await ws.db.runReadWriteTx(["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(ws, { + backupProviderBaseUrl: canonUrl, + }); +} + +async function runFirstBackupCycleForProvider( + ws: InternalWalletState, + 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; +} + +/** + * 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?: TalerErrorDetail; + lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp; + lastAttemptedBackupTimestamp?: TalerPreciseTimestamp; + 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: AbsoluteTime; +} + +export type ProviderPaymentStatus = + | ProviderPaymentTermsChanged + | ProviderPaymentPaid + | ProviderPaymentInsufficientBalance + | ProviderPaymentUnpaid + | ProviderPaymentPending; + +export interface BackupInfo { + walletRootPub: string; + deviceId: string; + providers: ProviderInfo[]; +} + +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; + amount: AmountString; +} + +export interface ProviderPaymentPending { + type: ProviderPaymentType.Pending; + talerUri?: string; +} + +export interface ProviderPaymentPaid { + type: ProviderPaymentType.Paid; + paidUntil: AbsoluteTime; +} + +export interface ProviderPaymentTermsChanged { + type: ProviderPaymentType.TermsChanged; + paidUntil: AbsoluteTime; + oldTerms: BackupProviderTerms; + newTerms: BackupProviderTerms; +} + +async function getProviderPaymentInfo( + ws: InternalWalletState, + 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( + ws: InternalWalletState, +): Promise<BackupInfo> { + const backupConfig = await provideBackupState(ws); + const providerRecords = await ws.db.runReadOnlyTx( + ["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(ws, 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( + ws: InternalWalletState, +): Promise<BackupRecovery> { + const bs = await provideBackupState(ws); + const providers = await ws.db.runReadOnlyTx( + ["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( + ws: InternalWalletState, + br: BackupRecovery, +) { + await ws.db.runReadWriteTx(["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(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.runReadOnlyTx( + ["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(ws, br.recovery); + } else { + return backupRecoveryOurs(ws, 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( + ws: InternalWalletState, +): Promise<WalletBackupConfState> { + const bs: ConfigRecord | undefined = await ws.db.runReadOnlyTx( + ["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 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.runReadWriteTx(["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( + ws: InternalWalletState, + deviceId: string, +): Promise<void> { + await provideBackupState(ws); + await ws.db.runReadWriteTx(["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( + ws: InternalWalletState, +): Promise<string> { + const bs = await provideBackupState(ws); + return bs.deviceId; +} diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts b/packages/taler-wallet-core/src/backup/state.ts diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -0,0 +1,730 @@ +/* + 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 { + AllowedAuditorInfo, + AllowedExchangeInfo, + AmountJson, + AmountLike, + Amounts, + BalanceFlag, + BalancesResponse, + canonicalizeBaseUrl, + GetBalanceDetailRequest, + Logger, + parsePaytoUri, + ScopeInfo, + ScopeType, +} from "@gnu-taler/taler-util"; +import { + DepositOperationStatus, + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + RefreshGroupRecord, + RefreshOperationStatus, + WalletDbReadOnlyTransaction, + WithdrawalGroupStatus, +} from "./db.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkLogicInvariant } from "./util/invariants.js"; +import { + getExchangeScopeInfo, + getExchangeWireDetailsInTx, +} from "./exchanges.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 ws: InternalWalletState, + 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 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.available, 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( + ws: InternalWalletState, + tx: WalletDbReadOnlyTransaction< + [ + "exchanges", + "exchangeDetails", + "coinAvailability", + "refreshGroups", + "depositGroups", + "withdrawalGroups", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ] + >, +): Promise<BalancesResponse> { + const balanceStore: BalancesStore = new BalancesStore(ws, tx); + + const keyRangeActive = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + + await tx.coinAvailability.iter().forEachAsync(async (ca) => { + const count = ca.visibleCoinCount ?? 0; + 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.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.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); + } + } + }); + + return balanceStore.toBalancesResponse(); +} + +/** + * 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.runReadWriteTx( + [ + "coinAvailability", + "coins", + "depositGroups", + "exchangeDetails", + "exchanges", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + "purchases", + "refreshGroups", + "withdrawalGroups", + ], + async (tx) => { + return getBalancesInsideTransaction(ws, tx); + }, + ); + + logger.trace("finished computing wallet balance"); + + return wbal; +} + +/** + * Information about the balance for a particular payment to a particular + * merchant. + */ +export interface MerchantPaymentBalanceDetails { + balanceAvailable: AmountJson; +} + +export interface MerchantPaymentRestrictionsForBalance { + currency: string; + minAge: number; + acceptedExchanges: AllowedExchangeInfo[]; + acceptedAuditors: AllowedAuditorInfo[]; + acceptedWireMethods: string[]; +} + +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[]; +} + +/** + * Get all exchanges that are acceptable for a particular payment. + */ +export async function getAcceptableExchangeBaseUrls( + ws: InternalWalletState, + req: MerchantPaymentRestrictionsForBalance, +): Promise<AcceptableExchanges> { + const acceptableExchangeUrls = new Set<string>(); + const depositableExchangeUrls = new Set<string>(); + await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => { + // FIXME: We should have a DB index to look up all exchanges + // for a particular auditor ... + + const canonExchanges = new Set<string>(); + const canonAuditors = new Set<string>(); + + for (const exchangeHandle of req.acceptedExchanges) { + const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl); + canonExchanges.add(normUrl); + } + + for (const auditorHandle of req.acceptedAuditors) { + const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl); + canonAuditors.add(normUrl); + } + + await tx.exchanges.iter().forEachAsync(async (exchange) => { + const dp = exchange.detailsPointer; + if (!dp) { + return; + } + const { currency, masterPublicKey } = dp; + const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([ + exchange.baseUrl, + currency, + masterPublicKey, + ]); + if (!exchangeDetails) { + return; + } + + let acceptable = false; + + if (canonExchanges.has(exchange.baseUrl)) { + acceptableExchangeUrls.add(exchange.baseUrl); + acceptable = true; + } + for (const exchangeAuditor of exchangeDetails.auditors) { + if (canonAuditors.has(exchangeAuditor.auditor_url)) { + acceptableExchangeUrls.add(exchange.baseUrl); + acceptable = true; + break; + } + } + + if (!acceptable) { + return; + } + // FIXME: Also consider exchange and auditor public key + // instead of just base URLs? + + let wireMethodSupported = false; + for (const acc of exchangeDetails.wireInfo.accounts) { + const pp = parsePaytoUri(acc.payto_uri); + checkLogicInvariant(!!pp); + for (const wm of req.acceptedWireMethods) { + if (pp.targetType === wm) { + wireMethodSupported = true; + break; + } + if (wireMethodSupported) { + break; + } + } + } + + acceptableExchangeUrls.add(exchange.baseUrl); + if (wireMethodSupported) { + depositableExchangeUrls.add(exchange.baseUrl); + } + }); + }); + return { + acceptableExchanges: [...acceptableExchangeUrls], + depositableExchanges: [...depositableExchangeUrls], + }; +} + +export interface MerchantPaymentBalanceDetails { + /** + * 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). + */ + balanceMerchantAcceptable: AmountJson; + + /** + * Balance of type "merchant-depositable" (see balance.ts for definition). + */ + balanceMerchantDepositable: AmountJson; +} + +export async function getMerchantPaymentBalanceDetails( + ws: InternalWalletState, + req: MerchantPaymentRestrictionsForBalance, +): Promise<MerchantPaymentBalanceDetails> { + const acceptability = await getAcceptableExchangeBaseUrls(ws, req); + + const d: MerchantPaymentBalanceDetails = { + balanceAvailable: Amounts.zeroOfCurrency(req.currency), + balanceMaterial: Amounts.zeroOfCurrency(req.currency), + balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency), + balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency), + balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency), + }; + + await ws.db.runReadOnlyTx( + ["coinAvailability", "refreshGroups"], + async (tx) => { + await tx.coinAvailability.iter().forEach((ca) => { + if (ca.currency != req.currency) { + return; + } + const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); + const coinAmount: AmountJson = Amounts.mult( + singleCoinAmount, + ca.freshCoinCount, + ).amount; + d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount; + d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount; + if (ca.maxAge === 0 || ca.maxAge > req.minAge) { + d.balanceAgeAcceptable = Amounts.add( + d.balanceAgeAcceptable, + coinAmount, + ).amount; + if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) { + d.balanceMerchantAcceptable = Amounts.add( + d.balanceMerchantAcceptable, + coinAmount, + ).amount; + if ( + acceptability.depositableExchanges.includes(ca.exchangeBaseUrl) + ) { + d.balanceMerchantDepositable = Amounts.add( + d.balanceMerchantDepositable, + coinAmount, + ).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( + ws: InternalWalletState, + req: GetBalanceDetailRequest, +): Promise<MerchantPaymentBalanceDetails> { + const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = []; + const wires = new Array<string>(); + await ws.db.runReadOnlyTx(["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 getMerchantPaymentBalanceDetails(ws, { + currency: req.currency, + acceptedAuditors: [], + acceptedExchanges: exchanges, + acceptedWireMethods: wires, + minAge: 0, + }); +} + +export interface PeerPaymentRestrictionsForBalance { + currency: string; + restrictExchangeTo?: string; +} + +export interface PeerPaymentBalanceDetails { + /** + * Balance of type "available" (see balance.ts for definition). + */ + balanceAvailable: AmountJson; + + /** + * Balance of type "material" (see balance.ts for definition). + */ + balanceMaterial: AmountJson; +} + +export async function getPeerPaymentBalanceDetailsInTx( + ws: InternalWalletState, + tx: WalletDbReadOnlyTransaction<["coinAvailability", "refreshGroups"]>, + req: PeerPaymentRestrictionsForBalance, +): Promise<PeerPaymentBalanceDetails> { + let balanceAvailable = Amounts.zeroOfCurrency(req.currency); + let balanceMaterial = Amounts.zeroOfCurrency(req.currency); + + await tx.coinAvailability.iter().forEach((ca) => { + if (ca.currency != req.currency) { + return; + } + if ( + req.restrictExchangeTo && + req.restrictExchangeTo !== ca.exchangeBaseUrl + ) { + return; + } + const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); + const coinAmount: AmountJson = Amounts.mult( + singleCoinAmount, + ca.freshCoinCount, + ).amount; + balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount; + balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount; + }); + + await tx.refreshGroups.iter().forEach((r) => { + if (r.currency != req.currency) { + return; + } + balanceAvailable = Amounts.add( + balanceAvailable, + computeRefreshGroupAvailableAmount(r), + ).amount; + }); + + return { + balanceAvailable, + balanceMaterial, + }; +} diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -0,0 +1,693 @@ +/* + 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 { + AbsoluteTime, + AmountJson, + Amounts, + CoinRefreshRequest, + CoinStatus, + Duration, + ExchangeEntryState, + ExchangeEntryStatus, + ExchangeTosStatus, + ExchangeUpdateStatus, + Logger, + RefreshReason, + TalerErrorDetail, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TombstoneIdStr, + TransactionIdStr, + 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 { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType, TaskId } from "./pending-types.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; +import { createRefreshGroup } from "./refresh.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( + ws: InternalWalletState, + 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 async function makeCoinAvailable( + ws: InternalWalletState, + 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( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction< + ["coins", "coinAvailability", "refreshGroups", "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 ws.getDenomInfo( + ws, + 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( + ws, + tx, + Amounts.currencyOf(csi.contributions[0]), + refreshCoinPubs, + csi.refreshReason, + csi.allocationId, + ); +} + +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; + } +} + +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; + } +} + +/** + * Compute the state of an exchange entry from the DB + * record. + */ +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); + } +} + +/** + * Uniform interface for a particular wallet transaction. + */ +export interface TransactionManager { + get taskId(): TaskId; + 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", + ScheduleLater = "schedule-later", +} + +export type TaskRunResult = + | TaskRunFinishedResult + | TaskRunErrorResult + | TaskRunBackoffResult + | TaskRunProgressResult + | TaskRunScheduleLaterResult; + +export namespace TaskRunResult { + /** + * Task is finished and does not need to be processed again. + */ + 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, + }; + } +} + +export interface TaskRunFinishedResult { + type: TaskRunResultType.Finished; +} + +export interface TaskRunBackoffResult { + type: TaskRunResultType.Backoff; +} + +export interface TaskRunProgressResult { + type: TaskRunResultType.Progress; +} + +export interface TaskRunScheduleLaterResult { + type: TaskRunResultType.ScheduleLater; + runAt: AbsoluteTime; +} + +export interface TaskRunErrorResult { + type: TaskRunResultType.Error; + errorDetail: TalerErrorDetail; +} + +export interface DbRetryInfo { + firstTry: DbPreciseTimestamp; + nextRetry: DbPreciseTimestamp; + retryCounter: number; +} + +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 namespace DbRetryInfo { + export function getDuration( + r: DbRetryInfo | 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: + p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t), + }; + } + + 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): TaskId { + switch (p.tag) { + case PendingTaskType.Backup: + return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId; + case PendingTaskType.Deposit: + return `${p.tag}:${p.depositGroupId}` as TaskId; + case PendingTaskType.ExchangeUpdate: + return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskId; + case PendingTaskType.PeerPullDebit: + return `${p.tag}:${p.peerPullDebitId}` as TaskId; + case PendingTaskType.PeerPushCredit: + return `${p.tag}:${p.peerPushCreditId}` as TaskId; + case PendingTaskType.PeerPullCredit: + return `${p.tag}:${p.pursePub}` as TaskId; + case PendingTaskType.PeerPushDebit: + return `${p.tag}:${p.pursePub}` as TaskId; + case PendingTaskType.Purchase: + return `${p.tag}:${p.proposalId}` as TaskId; + case PendingTaskType.Recoup: + return `${p.tag}:${p.recoupGroupId}` as TaskId; + case PendingTaskType.Refresh: + return `${p.tag}:${p.refreshGroupId}` as TaskId; + case PendingTaskType.RewardPickup: + return `${p.tag}:${p.walletRewardId}` as TaskId; + case PendingTaskType.Withdraw: + return `${p.tag}:${p.withdrawalGroupId}` as TaskId; + default: + assertUnreachable(p); + } +} + +export namespace TaskIdentifiers { + export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId { + return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId; + } + export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId { + return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( + exch.baseUrl, + )}` as TaskId; + } + export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId { + return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( + exchBaseUrl, + )}` as TaskId; + } + export function forTipPickup(tipRecord: RewardRecord): TaskId { + return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId; + } + export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId { + return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId; + } + export function forPay(purchaseRecord: PurchaseRecord): TaskId { + return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId; + } + export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId { + return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId; + } + export function forDeposit(depositRecord: DepositGroupRecord): TaskId { + return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId; + } + export function forBackup(backupRecord: BackupProviderRecord): TaskId { + return `${PendingTaskType.Backup}:${encodeURIComponent( + backupRecord.baseUrl, + )}` as TaskId; + } + export function forPeerPushPaymentInitiation( + ppi: PeerPushDebitRecord, + ): TaskId { + return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId; + } + export function forPeerPullPaymentInitiation( + ppi: PeerPullCreditRecord, + ): TaskId { + return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId; + } + export function forPeerPullPaymentDebit( + ppi: PeerPullPaymentIncomingRecord, + ): TaskId { + return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskId; + } + export function forPeerPushCredit( + ppi: PeerPushPaymentIncomingRecord, + ): TaskId { + return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId; + } +} + +/** + * Result of a transaction transition. + */ +export enum TransitionResult { + Transition = 1, + Stay = 2, +} + +/** + * Transaction context. + * Uniform interface to all transactions. + */ +export interface TransactionContext { + abortTransaction(): Promise<void>; + suspendTransaction(): Promise<void>; + resumeTransaction(): Promise<void>; + failTransaction(): Promise<void>; + deleteTransaction(): Promise<void>; +} diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -60,7 +60,7 @@ import { WithdrawalExchangeAccountDetails, codecForAny, } from "@gnu-taler/taler-util"; -import { DbRetryInfo, TaskIdentifiers } from "./operations/common.js"; +import { DbRetryInfo, TaskIdentifiers } from "./common.js"; import { DbAccess, DbReadOnlyTransaction, @@ -74,7 +74,7 @@ import { describeStore, describeStoreV2, openDatabase, -} from "./util/query.js"; +} from "./query.js"; /** * This file contains the database schema of the Taler wallet together diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts @@ -64,11 +64,11 @@ import { ExchangeKeysDownloadResult, isWithdrawableDenom, } from "./index.js"; -import { assembleRefreshRevealRequest } from "./operations/refresh.js"; +import { assembleRefreshRevealRequest } from "./refresh.js"; import { getBankStatusUrl, getBankWithdrawalInfo, -} from "./operations/withdraw.js"; +} from "./withdraw.js"; const logger = new Logger("dbless.ts"); diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -0,0 +1,1598 @@ +/* + 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, + ExchangeRefundRequest, + HttpStatusCode, + Logger, + MerchantContractTerms, + NotificationType, + PayCoinSelection, + PrepareDepositRequest, + PrepareDepositResponse, + RefreshReason, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TrackTransaction, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + URL, + WireFee, + canonicalJson, + codecForBatchDepositSuccess, + codecForTackTransactionAccepted, + codecForTackTransactionWired, + durationFromSpec, + encodeCrock, + getRandomBytes, + hashTruncate32, + hashWire, + j2s, + parsePaytoUri, + stringToBytes, +} from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { DepositElementStatus, DepositGroupRecord } from "./db.js"; +import { + DepositOperationStatus, + DepositTrackingInfo, + KycPendingInfo, + PendingTaskType, + RefreshOperationStatus, + TaskId, + createRefreshGroup, + getCandidateWithdrawalDenomsTx, + getTotalRefreshCost, + timestampPreciseToDb, + timestampProtocolToDb, +} from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { selectPayCoinsNew } from "./util/coinSelection.js"; +import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, + spendCoins, +} from "./common.js"; +import { getExchangeWireDetailsInTx } from "./exchanges.js"; +import { + extractContractData, + generateDepositPermissions, + getTotalPaymentCost, +} from "./pay-merchant.js"; +import { + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; + +/** + * Logger. + */ +const logger = new Logger("deposits.ts"); + +export class DepositTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskId; + constructor( + public ws: InternalWalletState, + 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.ws; + // FIXME: We should check first if we are in a final state + // where deletion is allowed. + await ws.db.runReadWriteTx(["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 { ws, depositGroupId, transactionId, taskId: retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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), + }; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { ws, depositGroupId, transactionId, taskId: retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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: { + dg.operationStatus = DepositOperationStatus.Aborting; + await tx.depositGroups.put(dg); + return { + oldTxState: oldState, + newTxState: computeDepositTransactionStatus(dg), + }; + } + case DepositOperationStatus.SuspendedDeposit: + // FIXME: Can we abort a suspended transaction?! + return undefined; + } + return undefined; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async resumeTransaction(): Promise<void> { + const { ws, depositGroupId, transactionId, taskId: retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async failTransaction(): Promise<void> { + const { ws, depositGroupId, transactionId, taskId: retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + // FIXME: Also cancel ongoing work (via cancellation token, once implemented) + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + } +} + +/** + * 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); + } +} + +/** + * 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( + ws: InternalWalletState, + depositGroup: DepositGroupRecord, +): Promise<TaskRunResult> { + const abortRefreshGroupId = depositGroup.abortRefreshGroupId; + checkLogicInvariant(!!abortRefreshGroupId); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Deposit, + depositGroupId: depositGroup.depositGroupId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.backoff(); +} + +async function refundDepositGroup( + ws: InternalWalletState, + depositGroup: DepositGroupRecord, +): Promise<TaskRunResult> { + const newTxPerCoin = [...depositGroup.statusPerCoin]; + logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`); + for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { + const st = depositGroup.statusPerCoin[i]; + switch (st) { + case DepositElementStatus.RefundFailed: + case DepositElementStatus.RefundSuccess: + break; + default: { + const coinPub = depositGroup.payCoinSelection.coinPubs[i]; + const coinExchange = await ws.db.runReadOnlyTx( + ["coins"], + async (tx) => { + const coinRecord = await tx.coins.get(coinPub); + checkDbInvariant(!!coinRecord); + return coinRecord.exchangeBaseUrl; + }, + ); + const refundAmount = depositGroup.payCoinSelection.coinContributions[i]; + // We use a constant refund transaction ID, since there can + // only be one refund. + const rtid = 1; + const sig = await ws.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 ws.http.fetch(refundUrl.href, { + method: "POST", + body: refundReq, + }); + 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); + + await ws.db.runReadWriteTx( + [ + "depositGroups", + "refreshGroups", + "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: depositGroup.payCoinSelection.coinContributions[i], + coinPub: depositGroup.payCoinSelection.coinPubs[i], + }); + } + if (isDone) { + const rgid = await createRefreshGroup( + ws, + tx, + currency, + refreshCoins, + RefreshReason.AbortDeposit, + constructTransactionIdentifier({ + tag: TransactionType.Deposit, + depositGroupId: newDg.depositGroupId, + }), + ); + newDg.abortRefreshGroupId = rgid.refreshGroupId; + } + await tx.depositGroups.put(newDg); + }, + ); + + return TaskRunResult.backoff(); +} + +async function processDepositGroupAborting( + ws: InternalWalletState, + depositGroup: DepositGroupRecord, +): Promise<TaskRunResult> { + logger.info("processing deposit tx in 'aborting'"); + const abortRefreshGroupId = depositGroup.abortRefreshGroupId; + if (!abortRefreshGroupId) { + logger.info("refunding deposit group"); + return refundDepositGroup(ws, depositGroup); + } + logger.info("waiting for refresh"); + return waitForRefreshOnDepositGroup(ws, depositGroup); +} + +async function processDepositGroupPendingKyc( + ws: InternalWalletState, + depositGroup: DepositGroupRecord, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + const { depositGroupId } = depositGroup; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Deposit, + depositGroupId, + }); + const retryTag = constructTaskIdentifier({ + tag: PendingTaskType.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 ws.http.fetch(url.href, { + method: "GET", + 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 ws.db.runReadWriteTx( + ["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(ws, 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( + ws: InternalWalletState, + 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 ws.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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.finished(); + } else { + throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`); + } +} + +async function processDepositGroupPendingTrack( + ws: InternalWalletState, + depositGroup: DepositGroupRecord, + cancellationToken?: CancellationToken, +): Promise<TaskRunResult> { + const { depositGroupId } = depositGroup; + for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { + const coinPub = depositGroup.payCoinSelection.coinPubs[i]; + // FIXME: Make the URL part of the coin selection? + const exchangeBaseUrl = await ws.db.runReadWriteTx( + ["coins"], + async (tx) => { + const coinRecord = await tx.coins.get(coinPub); + checkDbInvariant(!!coinRecord); + return coinRecord.exchangeBaseUrl; + }, + ); + + let updatedTxStatus: DepositElementStatus | undefined = undefined; + let newWiredCoin: + | { + id: string; + value: DepositTrackingInfo; + } + | undefined; + + if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) { + const track = await trackDeposit( + ws, + 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( + ws, + 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( + ws, + 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 ws.db.runReadWriteTx(["depositGroups"], async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + 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 ws.db.runReadWriteTx( + ["depositGroups"], + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + return undefined; + } + const oldTxState = computeDepositTransactionStatus(dg); + for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { + if (depositGroup.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(ws, transactionId, transitionInfo); + if (allWired) { + return TaskRunResult.finished(); + } else { + // FIXME: Use long-polling. + return TaskRunResult.backoff(); + } +} + +async function processDepositGroupPendingDeposit( + ws: InternalWalletState, + depositGroup: DepositGroupRecord, + cancellationToken?: CancellationToken, +): Promise<TaskRunResult> { + logger.info("processing deposit group in pending(deposit)"); + const depositGroupId = depositGroup.depositGroupId; + const contractTermsRec = await ws.db.runReadOnlyTx( + ["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(); + + // FIXME: Cache these! + const depositPermissions = await generateDepositPermissions( + ws, + 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 ws.http.fetch(url.href, { + method: "POST", + body: batchReq, + cancellationToken: cancellationToken, + }); + await readSuccessResponseJsonOrThrow( + httpResp, + codecForBatchDepositSuccess(), + ); + + await ws.db.runReadWriteTx(["depositGroups"], async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.progress(); +} + +/** + * Process a deposit group that is not in its final state yet. + */ +export async function processDepositGroup( + ws: InternalWalletState, + depositGroupId: string, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + const depositGroup = await ws.db.runReadOnlyTx( + ["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( + ws, + depositGroup, + cancellationToken, + ); + case DepositOperationStatus.PendingKyc: + return processDepositGroupPendingKyc(ws, depositGroup, cancellationToken); + case DepositOperationStatus.PendingDeposit: + return processDepositGroupPendingDeposit( + ws, + depositGroup, + cancellationToken, + ); + case DepositOperationStatus.Aborting: + return processDepositGroupAborting(ws, depositGroup); + } + + return TaskRunResult.finished(); +} + +/** + * FIXME: Consider moving this to exchanges.ts. + */ +async function getExchangeWireFee( + ws: InternalWalletState, + wireType: string, + baseUrl: string, + time: TalerProtocolTimestamp, +): Promise<WireFee> { + const exchangeDetails = await ws.db.runReadOnlyTx( + ["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( + ws: InternalWalletState, + 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 ws.cryptoApi.signTrackTransaction({ + coinPub, + contractTermsHash: depositGroup.contractTermsHash, + merchantPriv: depositGroup.merchantPriv, + merchantPub: depositGroup.merchantPub, + wireHash, + }); + url.searchParams.set("merchant_sig", sigResp.sig); + const httpResp = await ws.http.fetch(url.href, { method: "GET" }); + 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. + * + * FIXME: This should be renamed to checkDepositGroup, + * as it doesn't prepare anything + */ +export async function prepareDepositGroup( + ws: InternalWalletState, + req: PrepareDepositRequest, +): Promise<PrepareDepositResponse> { + 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.runReadOnlyTx(["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), + max_wire_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, durationFromSpec({ hours: 1 })), + ), + merchant: { + name: "(wallet)", + }, + merchant_pub: "", + refund_deadline: TalerProtocolTimestamp.zero(), + }; + + const { h: contractTermsHash } = await ws.cryptoApi.hashString({ + str: canonicalJson(contractTerms), + }); + + const contractData = extractContractData( + contractTerms, + contractTermsHash, + "", + ); + + const payCoinSel = await selectPayCoinsNew(ws, { + auditors: [], + exchanges: contractData.allowedExchanges, + wireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), + prevPayCoins: [], + }); + + if (payCoinSel.type !== "success") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + } + + const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel); + + const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount( + ws, + p.targetType, + payCoinSel.coinSel, + ); + + const fees = await getTotalFeesForDepositAmount( + ws, + p.targetType, + amount, + payCoinSel.coinSel, + ); + + 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( + 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.runReadOnlyTx(["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 ws.cryptoApi.createEddsaKeypair({}); + const merchantPair = await ws.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), + max_wire_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, durationFromSpec({ hours: 1 })), + ), + merchant: { + name: "(wallet)", + }, + merchant_pub: merchantPair.pub, + refund_deadline: TalerProtocolTimestamp.zero(), + }; + + const { h: contractTermsHash } = await ws.cryptoApi.hashString({ + str: canonicalJson(contractTerms), + }); + + const contractData = extractContractData( + contractTerms, + contractTermsHash, + "", + ); + + const payCoinSel = await selectPayCoinsNew(ws, { + auditors: [], + exchanges: contractData.allowedExchanges, + wireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), + prevPayCoins: [], + }); + + if (payCoinSel.type !== "success") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + } + + const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel); + + 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 counterpartyEffectiveDepositAmount = + await getCounterpartyEffectiveDepositAmount( + ws, + p.targetType, + payCoinSel.coinSel, + ); + + 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: payCoinSel.coinSel.coinPubs.map( + () => DepositElementStatus.DepositPending, + ), + payCoinSelection: payCoinSel.coinSel, + payCoinSelectionUid: encodeCrock(getRandomBytes(32)), + 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, + }; + + const ctx = new DepositTransactionContext(ws, depositGroupId); + const transactionId = ctx.transactionId; + + const newTxState = await ws.db.runReadWriteTx( + [ + "depositGroups", + "coins", + "recoupGroups", + "denominations", + "refreshGroups", + "coinAvailability", + "contractTerms", + ], + async (tx) => { + await spendCoins(ws, tx, { + allocationId: transactionId, + coinPubs: payCoinSel.coinSel.coinPubs, + contributions: payCoinSel.coinSel.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); + }, + ); + + ws.notify({ + type: NotificationType.TransactionStateTransition, + transactionId, + oldTxState: { + major: TransactionMajorState.None, + }, + newTxState, + }); + + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + ws.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( + ws: InternalWalletState, + wireType: string, + pcs: PayCoinSelection, +): Promise<AmountJson> { + const amt: AmountJson[] = []; + const fees: AmountJson[] = []; + const exchangeSet: Set<string> = new Set(); + + await ws.db.runReadOnlyTx( + ["coins", "denominations", "exchangeDetails", "exchanges"], + 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 amount, coin not found"); + } + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denom) { + throw Error("can't find denomination to calculate deposit amount"); + } + amt.push(Amounts.parseOrThrow(pcs.coinContributions[i])); + fees.push(Amounts.parseOrThrow(denom.feeDeposit)); + exchangeSet.add(coin.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( + ws: InternalWalletState, + wireType: string, + total: AmountJson, + pcs: PayCoinSelection, +): Promise<DepositGroupFees> { + const wireFee: AmountJson[] = []; + const coinFee: AmountJson[] = []; + const refreshFee: AmountJson[] = []; + const exchangeSet: Set<string> = new Set(); + const currency = Amounts.currencyOf(total); + + await ws.db.runReadOnlyTx( + ["coins", "denominations", "exchanges", "exchangeDetails"], + 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 amount, coin not found"); + } + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denom) { + throw Error("can't find denomination to calculate deposit amount"); + } + coinFee.push(Amounts.parseOrThrow(denom.feeDeposit)); + exchangeSet.add(coin.exchangeBaseUrl); + + const allDenoms = await getCandidateWithdrawalDenomsTx( + ws, + tx, + coin.exchangeBaseUrl, + currency, + ); + const amountLeft = Amounts.sub( + denom.value, + pcs.coinContributions[i], + ).amount; + const refreshCost = getTotalRefreshCost( + allDenoms, + denom, + amountLeft, + ws.config.testing.denomselAllowLate, + ); + 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/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -0,0 +1,2007 @@ +/* + 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, + Amounts, + AsyncFlag, + CancellationToken, + CoinRefreshRequest, + CoinStatus, + DeleteExchangeRequest, + DenomKeyType, + 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, + URL, + WalletNotification, + WireFee, + WireFeeMap, + WireFeesJson, + WireInfo, + assertUnreachable, + canonicalizeBaseUrl, + codecForExchangeKeysJson, + durationFromSpec, + durationMul, + encodeCrock, + hashDenomPub, + j2s, + makeErrorDetail, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + HttpRequestLibrary, + getExpiry, + readSuccessResponseJsonOrThrow, + readSuccessResponseTextOrThrow, +} from "@gnu-taler/taler-util/http"; +import { + DenominationRecord, + DenominationVerificationStatus, + ExchangeDetailsRecord, + ExchangeEntryRecord, + WalletStoresV1, +} from "./db.js"; +import { + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + PendingTaskType, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, + createRefreshGroup, + createTimeline, + isWithdrawableDenom, + selectBestForOverlappingDenominations, + selectMinimumFee, + timestampAbsoluteFromDb, + timestampOptionalPreciseFromDb, + timestampPreciseFromDb, + timestampPreciseToDb, + timestampProtocolFromDb, + timestampProtocolToDb, +} from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { DbReadOnlyTransaction } from "./query.js"; +import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; +import { + TaskIdentifiers, + TaskRunResult, + TaskRunResultType, + constructTaskIdentifier, + getAutoRefreshExecuteThreshold, + getExchangeEntryStatusFromRecord, + getExchangeState, + getExchangeTosStatusFromRecord, + getExchangeUpdateStatusFromRecord, +} from "./common.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( + 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, + }); + 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) { + return; + } + const dp = r.detailsPointer; + if (!dp) { + return; + } + const { currency, masterPublicKey } = dp; + return await tx.exchangeDetails.indexes.byPointer.get([ + r.baseUrl, + currency, + masterPublicKey, + ]); +} + +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, + currency: exchangeDetails?.currency ?? r.presetCurrencyHint, + 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) ?? [], + lastUpdateErrorInfo, + scopeInfo, + }; +} + +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( + ws: InternalWalletState, + req: GetExchangeEntryByUrlRequest, +): Promise<ExchangeListItem> { + return await ws.db.runReadOnlyTx( + [ + "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( + ws: InternalWalletState, + exchangeBaseUrl: string, +): Promise<void> { + const notif = await ws.db.runReadWriteTx( + ["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); + return { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl, + newExchangeState: newExchangeState, + oldExchangeState: oldExchangeState, + } satisfies WalletNotification; + } + return undefined; + }, + ); + if (notif) { + ws.notify(notif); + } +} + +/** + * Validate wire fees and wire accounts. + * + * Throw an exception if they are invalid. + */ +async function validateWireInfo( + ws: InternalWalletState, + versionCurrent: number, + wireInfo: ExchangeKeysDownloadResult, + masterPublicKey: string, +): Promise<WireInfo> { + for (const a of wireInfo.accounts) { + logger.trace("validating exchange acct"); + let isValid = false; + if (ws.config.testing.insecureTrustExchange) { + isValid = true; + } else { + const { valid: v } = await 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 (ws.config.testing.insecureTrustExchange) { + isValid = true; + } else { + const { valid: v } = await 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( + ws: InternalWalletState, + fees: GlobalFees[], + masterPub: string, +): Promise<ExchangeGlobalFees[]> { + const egf: ExchangeGlobalFees[] = []; + for (const gf of fees) { + logger.trace("validating exchange global fees"); + let isValid = false; + if (ws.config.testing.insecureTrustExchange) { + isValid = true; + } else { + const { valid: v } = await ws.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()), + ), + 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, +): Promise<ExchangeKeysDownloadResult> { + const keysUrl = new URL("keys", baseUrl); + + const resp = await http.fetch(keysUrl.href, { + timeout, + cancellationToken, + }); + + 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, + 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, + listIssueDate: timestampProtocolToDb( + exchangeKeysJsonUnchecked.list_issue_date, + ), + 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( + ws: InternalWalletState, + 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( + baseUrl, + ws.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( + baseUrl, + ws.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( + ws: InternalWalletState, + exchangeBaseUrl: string, + options: { forceUpdate?: boolean } = {}, +): Promise<void> { + const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + + logger.info( + `starting update of exchange entry ${canonBaseUrl}, forced=${ + options.forceUpdate ?? false + }`, + ); + + const { notification } = await ws.db.runReadWriteTx( + ["exchanges", "exchangeDetails"], + async (tx) => { + return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl); + }, + ); + + if (notification) { + ws.notify(notification); + } + + const { oldExchangeState, newExchangeState, taskId } = + await ws.db.runReadWriteTx( + ["exchanges", "operationRetries"], + async (tx) => { + const r = await tx.exchanges.get(canonBaseUrl); + if (!r) { + throw Error("exchange not found"); + } + const oldExchangeState = getExchangeState(r); + switch (r.updateStatus) { + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + break; + case ExchangeEntryDbUpdateStatus.Suspended: + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + 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; + } + break; + } + case ExchangeEntryDbUpdateStatus.Initial: + r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate; + break; + } + 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 }; + }, + ); + ws.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: canonBaseUrl, + newExchangeState: newExchangeState, + oldExchangeState: oldExchangeState, + }); + await ws.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( + ws: InternalWalletState, + canonUrl: string, + exchangeNotifFlag: AsyncFlag, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + expectedMasterPub?: string; + } = {}, +): Promise<ReadyExchangeSummary> { + const operationId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: canonUrl, + }); + while (true) { + logger.info(`waiting for ready exchange ${canonUrl}`); + const { exchange, exchangeDetails, retryInfo, scopeInfo } = + await ws.db.runReadOnlyTx( + [ + "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; + 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( + ws: InternalWalletState, + baseUrl: string, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + expectedMasterPub?: string; + } = {}, +): Promise<ReadyExchangeSummary> { + const canonUrl = canonicalizeBaseUrl(baseUrl); + + ws.ensureTaskLoopRunning(); + + await startUpdateExchangeEntry(ws, canonUrl, { + forceUpdate: options.forceUpdate, + }); + + return waitReadyExchange(ws, canonUrl, options); +} + +async function waitReadyExchange( + ws: InternalWalletState, + canonUrl: string, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + expectedMasterPub?: string; + } = {}, +): Promise<ReadyExchangeSummary> { + // 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 = ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === canonUrl + ) { + logger.info(`raising update notification: ${j2s(notif)}`); + exchangeNotifFlag.raise(); + } + }); + + try { + const res = await internalWaitReadyExchange( + ws, + canonUrl, + exchangeNotifFlag, + options, + ); + logger.info("done waiting for ready exchange"); + return res; + } finally { + cancelNotif(); + } +} + +/** + * 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( + ws: InternalWalletState, + exchangeBaseUrl: string, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + logger.trace(`updating exchange info for ${exchangeBaseUrl}`); + exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + + const oldExchangeRec = await ws.db.runReadOnlyTx( + ["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: + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + updateRequestedExplicitly = true; + 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)) { + 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, + ws.http, + timeout, + cancellationToken, + ); + + 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( + ws, + version.current, + keysInfo, + keysInfo.masterPublicKey, + ); + + const globalFees = await validateGlobalFees( + ws, + 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( + ws, + exchangeBaseUrl, + timeout, + ["text/plain"], + ); + + let recoupGroupId: string | undefined; + + logger.trace("updating exchange info in database"); + + let detailsPointerChanged = false; + + let ageMask = 0; + for (const x of keysInfo.currentDenominations) { + if ( + isWithdrawableDenom(x, ws.config.testing.denomselAllowLate) && + x.denomPub.age_mask != 0 + ) { + ageMask = x.denomPub.age_mask; + break; + } + } + + const updated = await ws.db.runReadWriteTx( + [ + "exchanges", + "exchangeDetails", + "exchangeSignKeys", + "denominations", + "coins", + "refreshGroups", + "recoupGroups", + ], + async (tx) => { + const r = await tx.exchanges.get(exchangeBaseUrl); + if (!r) { + logger.warn(`exchange ${exchangeBaseUrl} no longer present`); + return; + } + const oldExchangeState = getExchangeState(r); + const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl); + if (!existingDetails) { + detailsPointerChanged = true; + } + if (existingDetails) { + if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) { + detailsPointerChanged = true; + } + if (existingDetails.currency !== keysInfo.currency) { + detailsPointerChanged = true; + } + // FIXME: We need to do some consistency checks! + } + 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.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; + 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), + }); + } + + 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([ + exchangeBaseUrl, + 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 = timestampProtocolToDb(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.info("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.info("recouping coins", newlyRevokedCoinPubs); + recoupGroupId = await ws.recoupOps.createRecoupGroup( + ws, + tx, + exchangeBaseUrl, + newlyRevokedCoinPubs, + ); + } + + const newExchangeState = getExchangeState(r); + + return { + exchange: r, + exchangeDetails: newDetails, + oldExchangeState, + newExchangeState, + }; + }, + ); + + if (recoupGroupId) { + const recoupTaskId = constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId, + }); + // Asynchronously start recoup. This doesn't need to finish + // for the exchange update to be considered finished. + ws.taskScheduler.startShepherdTask(recoupTaskId); + } + + if (!updated) { + throw Error("something went wrong with updating the exchange"); + } + + logger.trace("done updating exchange info in database"); + + logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`); + + let minCheckThreshold = AbsoluteTime.addDuration( + AbsoluteTime.now(), + durationFromSpec({ days: 1 }), + ); + + if (refreshCheckNecessary) { + // Do auto-refresh. + await ws.db.runReadWriteTx( + [ + "coins", + "denominations", + "coinAvailability", + "refreshGroups", + "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( + ws, + 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), + ); + await tx.exchanges.put(exchange); + }, + ); + } + + 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(); +} + +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( + 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 ws.db.runReadOnlyTx( + ["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( + ws: InternalWalletState, + exchangeBaseUrl: string, + acceptedFormat?: string[], + acceptLanguage?: string, +): Promise<GetExchangeTosResult> { + const exch = await fetchFreshExchange(ws, exchangeBaseUrl); + + const tosDownload = await downloadTosFromAcceptedFormat( + ws, + exchangeBaseUrl, + getExchangeRequestTimeout(), + acceptedFormat, + acceptLanguage, + ); + + await ws.db.runReadWriteTx(["exchanges"], async (tx) => { + const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl); + if (updateExchangeEntry) { + updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag; + 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, + ); + return { + keys: keysInfo, + }; +} + +/** + * List all exchange entries known to the wallet. + */ +export async function listExchanges( + ws: InternalWalletState, +): Promise<ExchangesListResponse> { + const exchanges: ExchangeListItem[] = []; + await ws.db.runReadOnlyTx( + [ + "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( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction<["exchanges"]>, + exchangeBaseUrl: string, +): Promise<{ notif: WalletNotification | undefined }> { + exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + 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( + ws: InternalWalletState, + exchangeBaseurl: string, +): Promise<ExchangeDetailedResponse> { + const exchange = await ws.db.runReadOnlyTx( + ["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( + ws: InternalWalletState, + 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, + }; +} + +export async function deleteExchange( + ws: InternalWalletState, + req: DeleteExchangeRequest, +): Promise<void> { + let inUse: boolean = false; + const exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl); + await ws.db.runReadWriteTx( + ["exchanges", "coins", "withdrawalGroups", "exchangeDetails"], + 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(ws, tx, exchangeBaseUrl); + if (res.hasResources) { + if (req.purge) { + 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); + } + // FIXME: Also remove records related to transactions? + } else { + inUse = true; + return; + } + } + await tx.exchanges.delete(exchangeBaseUrl); + }, + ); + + if (inUse) { + throw TalerError.fromUncheckedDetail({ + code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED, + hint: "Exchange in use.", + }); + } +} + +export async function getExchangeResources( + ws: InternalWalletState, + exchangeBaseUrl: string, +): Promise<GetExchangeResourcesResponse> { + // Withdrawals include internal withdrawals from peer transactions + const res = await ws.db.runReadOnlyTx( + ["exchanges", "withdrawalGroups", "coins"], + async (tx) => { + const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl); + if (!exchangeRecord) { + return undefined; + } + return internalGetExchangeResources(ws, tx, exchangeBaseUrl); + }, + ); + if (!res) { + throw Error("exchange not found"); + } + return res; +} diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts @@ -19,7 +19,7 @@ */ // Util functionality -export * from "./util/query.js"; +export * from "./query.js"; export * from "./versions.js"; @@ -27,11 +27,11 @@ export * from "./db.js"; // Crypto and crypto workers // export * from "./crypto/workers/nodeThreadWorker.js"; -export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js"; export { - CryptoWorkerFactory, CryptoDispatcher, + CryptoWorkerFactory, } from "./crypto/workers/crypto-dispatcher.js"; +export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js"; export * from "./pending-types.js"; @@ -39,20 +39,20 @@ export { InternalWalletState } from "./internal-wallet-state.js"; export * from "./wallet-api-types.js"; export * from "./wallet.js"; -export * from "./operations/backup/index.js"; +export * from "./backup/index.js"; -export * from "./operations/exchanges.js"; +export * from "./exchanges.js"; -export * from "./operations/withdraw.js"; -export * from "./operations/refresh.js"; +export * from "./refresh.js"; +export * from "./withdraw.js"; export * from "./dbless.js"; -export * from "./crypto/cryptoTypes.js"; export * from "./crypto/cryptoImplementation.js"; +export * from "./crypto/cryptoTypes.js"; -export * from "./util/timer.js"; export * from "./util/denominations.js"; +export * from "./util/timer.js"; export { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js"; export * from "./host-common.js"; diff --git a/packages/taler-wallet-core/src/internal-wallet-state.ts b/packages/taler-wallet-core/src/internal-wallet-state.ts @@ -44,7 +44,7 @@ import { WalletStoresV1, } from "./db.js"; import { TaskScheduler } from "./shepherd.js"; -import { DbAccess } from "./util/query.js"; +import { DbAccess } from "./query.js"; import { TimerGroup } from "./util/timer.js"; import { WalletConfig } from "./wallet-api-types.js"; diff --git a/packages/taler-wallet-core/src/merchants.ts b/packages/taler-wallet-core/src/merchants.ts @@ -0,0 +1,66 @@ +/* + 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 { + canonicalizeBaseUrl, + Logger, + URL, + codecForMerchantConfigResponse, + LibtoolVersion, +} from "@gnu-taler/taler-util"; +import { InternalWalletState, MerchantInfo } from "./internal-wallet-state.js"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; + +const logger = new Logger("taler-wallet-core:merchants.ts"); + +export async function getMerchantInfo( + ws: InternalWalletState, + merchantBaseUrl: string, +): Promise<MerchantInfo> { + const canonBaseUrl = canonicalizeBaseUrl(merchantBaseUrl); + + const existingInfo = ws.merchantInfoCache[canonBaseUrl]; + if (existingInfo) { + return existingInfo; + } + + const configUrl = new URL("config", canonBaseUrl); + const resp = await ws.http.fetch(configUrl.href); + + const configResp = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantConfigResponse(), + ); + + logger.info( + `merchant "${canonBaseUrl}" reports protocol ${configResp.version}"`, + ); + + const parsedVersion = LibtoolVersion.parseVersion(configResp.version); + if (!parsedVersion) { + throw Error("invalid merchant version"); + } + + const merchantInfo: MerchantInfo = { + protocolVersionCurrent: parsedVersion.current, + }; + + ws.merchantInfoCache[canonBaseUrl] = merchantInfo; + return merchantInfo; +} diff --git a/packages/taler-wallet-core/src/operations/attention.ts b/packages/taler-wallet-core/src/operations/attention.ts @@ -1,133 +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 { - AttentionInfo, - Logger, - TalerPreciseTimestamp, - UserAttentionByIdRequest, - UserAttentionPriority, - UserAttentionUnreadList, - UserAttentionsCountResponse, - UserAttentionsRequest, - UserAttentionsResponse, -} from "@gnu-taler/taler-util"; -import { timestampPreciseFromDb, timestampPreciseToDb } from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; - -const logger = new Logger("operations/attention.ts"); - -export async function getUserAttentionsUnreadCount( - ws: InternalWalletState, - req: UserAttentionsRequest, -): Promise<UserAttentionsCountResponse> { - const total = await ws.db.runReadOnlyTx(["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( - ws: InternalWalletState, - req: UserAttentionsRequest, -): Promise<UserAttentionsResponse> { - return await ws.db.runReadOnlyTx(["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( - ws: InternalWalletState, - req: UserAttentionByIdRequest, -): Promise<void> { - await ws.db.runReadWriteTx(["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 ws - * @param info - */ -export async function addAttentionRequest( - ws: InternalWalletState, - info: AttentionInfo, - entityId: string, -): Promise<void> { - await ws.db.runReadWriteTx(["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 ws - * @param created - */ -export async function removeAttentionRequest( - ws: InternalWalletState, - req: UserAttentionByIdRequest, -): Promise<void> { - await ws.db.runReadWriteTx(["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/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -1,1059 +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 { - AbsoluteTime, - AmountString, - AttentionType, - BackupRecovery, - Codec, - EddsaKeyPair, - HttpStatusCode, - Logger, - PreparePayResult, - RecoveryLoadRequest, - RecoveryMergeStrategy, - TalerError, - TalerErrorCode, - TalerErrorDetail, - TalerPreciseTimestamp, - URL, - buildCodecForObject, - buildCodecForUnion, - bytesToString, - canonicalJson, - canonicalizeBaseUrl, - codecForAmountString, - codecForBoolean, - codecForConstString, - codecForList, - codecForNumber, - codecForString, - codecOptional, - decodeCrock, - durationFromSpec, - 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 { - BackupProviderRecord, - BackupProviderState, - BackupProviderStateTag, - BackupProviderTerms, - ConfigRecord, - ConfigRecordKey, - WalletBackupConfState, - WalletDbReadOnlyTransaction, - timestampOptionalPreciseFromDb, - timestampPreciseToDb, -} from "../../db.js"; -import { InternalWalletState } from "../../internal-wallet-state.js"; -import { - checkDbInvariant, - checkLogicInvariant, -} from "../../util/invariants.js"; -import { addAttentionRequest, removeAttentionRequest } from "../attention.js"; -import { - TaskIdentifiers, - TaskRunResult, - TaskRunResultType, -} from "../common.js"; -import { preparePayForUri } from "../pay-merchant.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(), - durationFromSpec({ minutes: 5 }), - ), - ); -} - -async function runBackupCycleForProvider( - ws: InternalWalletState, - args: BackupForProviderArgs, -): Promise<TaskRunResult> { - const provider = await ws.db.runReadOnlyTx( - ["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(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 syncSigResp = await ws.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 ws.http.fetch(accountBackupUrl.href, { - method: "POST", - body: encBackup, - headers: { - "content-type": "application/octet-stream", - "sync-signature": syncSigResp.sig, - "if-none-match": newHash, - ...(provider.lastBackupHash - ? { - "if-match": provider.lastBackupHash, - } - : {}), - }, - }); - - logger.trace(`sync response status: ${resp.status}`); - - if (resp.status === HttpStatusCode.NotModified) { - await ws.db.runReadWriteTx(["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(ws, { - 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(ws, talerUri); - } catch (e) { - const error = TalerError.fromException(e); - if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) { - throw error; - } - } - - if (res === undefined) { - //claimed - - await ws.db.runReadWriteTx(["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 ws.db.runReadWriteTx(["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( - ws, - { - 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 ws.db.runReadWriteTx(["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(ws, { - 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(ws); - // const blob = await decryptBackup(backupConfig, backupEnc); - // FIXME: Re-implement backup import with merging - // await importBackup(ws, blob, cryptoData); - await ws.db.runReadWriteTx(["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(ws, 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( - ws: InternalWalletState, - backupProviderBaseUrl: string, -): Promise<TaskRunResult> { - const provider = await ws.db.runReadOnlyTx( - ["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(ws, { - backupProviderBaseUrl: provider.baseUrl, - }); -} - -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.runReadWriteTx(["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( - ws: InternalWalletState, - req: RunBackupCycleRequest, -): Promise<void> { - const providers = await ws.db.runReadOnlyTx( - ["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(ws, { - backupProviderBaseUrl: provider.baseUrl, - }); - } -} - -export 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; -} - -export 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 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( - ws: InternalWalletState, - req: AddBackupProviderRequest, -): Promise<AddBackupProviderResponse> { - logger.info(`adding backup provider ${j2s(req)}`); - await provideBackupState(ws); - const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); - await ws.db.runReadWriteTx(["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 ws.http.fetch(termsUrl.href); - const terms = await readSuccessResponseJsonOrThrow( - resp, - codecForSyncTermsOfServiceResponse(), - ); - await ws.db.runReadWriteTx(["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(ws, { - backupProviderBaseUrl: canonUrl, - }); -} - -async function runFirstBackupCycleForProvider( - ws: InternalWalletState, - 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; -} - -/** - * 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?: TalerErrorDetail; - lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp; - lastAttemptedBackupTimestamp?: TalerPreciseTimestamp; - 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: AbsoluteTime; -} - -export type ProviderPaymentStatus = - | ProviderPaymentTermsChanged - | ProviderPaymentPaid - | ProviderPaymentInsufficientBalance - | ProviderPaymentUnpaid - | ProviderPaymentPending; - -export interface BackupInfo { - walletRootPub: string; - deviceId: string; - providers: ProviderInfo[]; -} - -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; - amount: AmountString; -} - -export interface ProviderPaymentPending { - type: ProviderPaymentType.Pending; - talerUri?: string; -} - -export interface ProviderPaymentPaid { - type: ProviderPaymentType.Paid; - paidUntil: AbsoluteTime; -} - -export interface ProviderPaymentTermsChanged { - type: ProviderPaymentType.TermsChanged; - paidUntil: AbsoluteTime; - oldTerms: BackupProviderTerms; - newTerms: BackupProviderTerms; -} - -async function getProviderPaymentInfo( - ws: InternalWalletState, - 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( - ws: InternalWalletState, -): Promise<BackupInfo> { - const backupConfig = await provideBackupState(ws); - const providerRecords = await ws.db.runReadOnlyTx( - ["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(ws, 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( - ws: InternalWalletState, -): Promise<BackupRecovery> { - const bs = await provideBackupState(ws); - const providers = await ws.db.runReadOnlyTx( - ["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( - ws: InternalWalletState, - br: BackupRecovery, -) { - await ws.db.runReadWriteTx(["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(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.runReadOnlyTx( - ["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(ws, br.recovery); - } else { - return backupRecoveryOurs(ws, 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( - ws: InternalWalletState, -): Promise<WalletBackupConfState> { - const bs: ConfigRecord | undefined = await ws.db.runReadOnlyTx( - ["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 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.runReadWriteTx(["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( - ws: InternalWalletState, - deviceId: string, -): Promise<void> { - await provideBackupState(ws); - await ws.db.runReadWriteTx(["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( - 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 @@ -1,730 +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/> - */ - -/** - * 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 { - AllowedAuditorInfo, - AllowedExchangeInfo, - AmountJson, - AmountLike, - Amounts, - BalanceFlag, - BalancesResponse, - canonicalizeBaseUrl, - GetBalanceDetailRequest, - Logger, - parsePaytoUri, - ScopeInfo, - ScopeType, -} from "@gnu-taler/taler-util"; -import { - DepositOperationStatus, - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, - RefreshGroupRecord, - RefreshOperationStatus, - WalletDbReadOnlyTransaction, - WithdrawalGroupStatus, -} from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkLogicInvariant } from "../util/invariants.js"; -import { - getExchangeScopeInfo, - getExchangeWireDetailsInTx, -} from "./exchanges.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 ws: InternalWalletState, - 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 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.available, 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( - ws: InternalWalletState, - tx: WalletDbReadOnlyTransaction< - [ - "exchanges", - "exchangeDetails", - "coinAvailability", - "refreshGroups", - "depositGroups", - "withdrawalGroups", - "globalCurrencyAuditors", - "globalCurrencyExchanges", - ] - >, -): Promise<BalancesResponse> { - const balanceStore: BalancesStore = new BalancesStore(ws, tx); - - const keyRangeActive = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, - ); - - await tx.coinAvailability.iter().forEachAsync(async (ca) => { - const count = ca.visibleCoinCount ?? 0; - 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.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.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); - } - } - }); - - return balanceStore.toBalancesResponse(); -} - -/** - * 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.runReadWriteTx( - [ - "coinAvailability", - "coins", - "depositGroups", - "exchangeDetails", - "exchanges", - "globalCurrencyAuditors", - "globalCurrencyExchanges", - "purchases", - "refreshGroups", - "withdrawalGroups", - ], - async (tx) => { - return getBalancesInsideTransaction(ws, tx); - }, - ); - - logger.trace("finished computing wallet balance"); - - return wbal; -} - -/** - * Information about the balance for a particular payment to a particular - * merchant. - */ -export interface MerchantPaymentBalanceDetails { - balanceAvailable: AmountJson; -} - -export interface MerchantPaymentRestrictionsForBalance { - currency: string; - minAge: number; - acceptedExchanges: AllowedExchangeInfo[]; - acceptedAuditors: AllowedAuditorInfo[]; - acceptedWireMethods: string[]; -} - -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[]; -} - -/** - * Get all exchanges that are acceptable for a particular payment. - */ -export async function getAcceptableExchangeBaseUrls( - ws: InternalWalletState, - req: MerchantPaymentRestrictionsForBalance, -): Promise<AcceptableExchanges> { - const acceptableExchangeUrls = new Set<string>(); - const depositableExchangeUrls = new Set<string>(); - await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => { - // FIXME: We should have a DB index to look up all exchanges - // for a particular auditor ... - - const canonExchanges = new Set<string>(); - const canonAuditors = new Set<string>(); - - for (const exchangeHandle of req.acceptedExchanges) { - const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl); - canonExchanges.add(normUrl); - } - - for (const auditorHandle of req.acceptedAuditors) { - const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl); - canonAuditors.add(normUrl); - } - - await tx.exchanges.iter().forEachAsync(async (exchange) => { - const dp = exchange.detailsPointer; - if (!dp) { - return; - } - const { currency, masterPublicKey } = dp; - const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([ - exchange.baseUrl, - currency, - masterPublicKey, - ]); - if (!exchangeDetails) { - return; - } - - let acceptable = false; - - if (canonExchanges.has(exchange.baseUrl)) { - acceptableExchangeUrls.add(exchange.baseUrl); - acceptable = true; - } - for (const exchangeAuditor of exchangeDetails.auditors) { - if (canonAuditors.has(exchangeAuditor.auditor_url)) { - acceptableExchangeUrls.add(exchange.baseUrl); - acceptable = true; - break; - } - } - - if (!acceptable) { - return; - } - // FIXME: Also consider exchange and auditor public key - // instead of just base URLs? - - let wireMethodSupported = false; - for (const acc of exchangeDetails.wireInfo.accounts) { - const pp = parsePaytoUri(acc.payto_uri); - checkLogicInvariant(!!pp); - for (const wm of req.acceptedWireMethods) { - if (pp.targetType === wm) { - wireMethodSupported = true; - break; - } - if (wireMethodSupported) { - break; - } - } - } - - acceptableExchangeUrls.add(exchange.baseUrl); - if (wireMethodSupported) { - depositableExchangeUrls.add(exchange.baseUrl); - } - }); - }); - return { - acceptableExchanges: [...acceptableExchangeUrls], - depositableExchanges: [...depositableExchangeUrls], - }; -} - -export interface MerchantPaymentBalanceDetails { - /** - * 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). - */ - balanceMerchantAcceptable: AmountJson; - - /** - * Balance of type "merchant-depositable" (see balance.ts for definition). - */ - balanceMerchantDepositable: AmountJson; -} - -export async function getMerchantPaymentBalanceDetails( - ws: InternalWalletState, - req: MerchantPaymentRestrictionsForBalance, -): Promise<MerchantPaymentBalanceDetails> { - const acceptability = await getAcceptableExchangeBaseUrls(ws, req); - - const d: MerchantPaymentBalanceDetails = { - balanceAvailable: Amounts.zeroOfCurrency(req.currency), - balanceMaterial: Amounts.zeroOfCurrency(req.currency), - balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency), - balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency), - balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency), - }; - - await ws.db.runReadOnlyTx( - ["coinAvailability", "refreshGroups"], - async (tx) => { - await tx.coinAvailability.iter().forEach((ca) => { - if (ca.currency != req.currency) { - return; - } - const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); - const coinAmount: AmountJson = Amounts.mult( - singleCoinAmount, - ca.freshCoinCount, - ).amount; - d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount; - d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount; - if (ca.maxAge === 0 || ca.maxAge > req.minAge) { - d.balanceAgeAcceptable = Amounts.add( - d.balanceAgeAcceptable, - coinAmount, - ).amount; - if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) { - d.balanceMerchantAcceptable = Amounts.add( - d.balanceMerchantAcceptable, - coinAmount, - ).amount; - if ( - acceptability.depositableExchanges.includes(ca.exchangeBaseUrl) - ) { - d.balanceMerchantDepositable = Amounts.add( - d.balanceMerchantDepositable, - coinAmount, - ).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( - ws: InternalWalletState, - req: GetBalanceDetailRequest, -): Promise<MerchantPaymentBalanceDetails> { - const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = []; - const wires = new Array<string>(); - await ws.db.runReadOnlyTx(["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 getMerchantPaymentBalanceDetails(ws, { - currency: req.currency, - acceptedAuditors: [], - acceptedExchanges: exchanges, - acceptedWireMethods: wires, - minAge: 0, - }); -} - -export interface PeerPaymentRestrictionsForBalance { - currency: string; - restrictExchangeTo?: string; -} - -export interface PeerPaymentBalanceDetails { - /** - * Balance of type "available" (see balance.ts for definition). - */ - balanceAvailable: AmountJson; - - /** - * Balance of type "material" (see balance.ts for definition). - */ - balanceMaterial: AmountJson; -} - -export async function getPeerPaymentBalanceDetailsInTx( - ws: InternalWalletState, - tx: WalletDbReadOnlyTransaction<["coinAvailability", "refreshGroups"]>, - req: PeerPaymentRestrictionsForBalance, -): Promise<PeerPaymentBalanceDetails> { - let balanceAvailable = Amounts.zeroOfCurrency(req.currency); - let balanceMaterial = Amounts.zeroOfCurrency(req.currency); - - await tx.coinAvailability.iter().forEach((ca) => { - if (ca.currency != req.currency) { - return; - } - if ( - req.restrictExchangeTo && - req.restrictExchangeTo !== ca.exchangeBaseUrl - ) { - return; - } - const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); - const coinAmount: AmountJson = Amounts.mult( - singleCoinAmount, - ca.freshCoinCount, - ).amount; - balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount; - balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount; - }); - - await tx.refreshGroups.iter().forEach((r) => { - if (r.currency != req.currency) { - return; - } - balanceAvailable = Amounts.add( - balanceAvailable, - computeRefreshGroupAvailableAmount(r), - ).amount; - }); - - return { - balanceAvailable, - balanceMaterial, - }; -} diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts @@ -1,693 +0,0 @@ -/* - 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 { - AbsoluteTime, - AmountJson, - Amounts, - CoinRefreshRequest, - CoinStatus, - Duration, - ExchangeEntryState, - ExchangeEntryStatus, - ExchangeTosStatus, - ExchangeUpdateStatus, - Logger, - RefreshReason, - TalerErrorDetail, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TombstoneIdStr, - TransactionIdStr, - 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 { InternalWalletState } from "../internal-wallet-state.js"; -import { PendingTaskType, TaskId } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { createRefreshGroup } from "./refresh.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( - ws: InternalWalletState, - 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 async function makeCoinAvailable( - ws: InternalWalletState, - 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( - ws: InternalWalletState, - tx: WalletDbReadWriteTransaction< - ["coins", "coinAvailability", "refreshGroups", "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 ws.getDenomInfo( - ws, - 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( - ws, - tx, - Amounts.currencyOf(csi.contributions[0]), - refreshCoinPubs, - csi.refreshReason, - csi.allocationId, - ); -} - -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; - } -} - -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; - } -} - -/** - * Compute the state of an exchange entry from the DB - * record. - */ -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); - } -} - -/** - * Uniform interface for a particular wallet transaction. - */ -export interface TransactionManager { - get taskId(): TaskId; - 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", - ScheduleLater = "schedule-later", -} - -export type TaskRunResult = - | TaskRunFinishedResult - | TaskRunErrorResult - | TaskRunBackoffResult - | TaskRunProgressResult - | TaskRunScheduleLaterResult; - -export namespace TaskRunResult { - /** - * Task is finished and does not need to be processed again. - */ - 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, - }; - } -} - -export interface TaskRunFinishedResult { - type: TaskRunResultType.Finished; -} - -export interface TaskRunBackoffResult { - type: TaskRunResultType.Backoff; -} - -export interface TaskRunProgressResult { - type: TaskRunResultType.Progress; -} - -export interface TaskRunScheduleLaterResult { - type: TaskRunResultType.ScheduleLater; - runAt: AbsoluteTime; -} - -export interface TaskRunErrorResult { - type: TaskRunResultType.Error; - errorDetail: TalerErrorDetail; -} - -export interface DbRetryInfo { - firstTry: DbPreciseTimestamp; - nextRetry: DbPreciseTimestamp; - retryCounter: number; -} - -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 namespace DbRetryInfo { - export function getDuration( - r: DbRetryInfo | 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: - p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t), - }; - } - - 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): TaskId { - switch (p.tag) { - case PendingTaskType.Backup: - return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId; - case PendingTaskType.Deposit: - return `${p.tag}:${p.depositGroupId}` as TaskId; - case PendingTaskType.ExchangeUpdate: - return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskId; - case PendingTaskType.PeerPullDebit: - return `${p.tag}:${p.peerPullDebitId}` as TaskId; - case PendingTaskType.PeerPushCredit: - return `${p.tag}:${p.peerPushCreditId}` as TaskId; - case PendingTaskType.PeerPullCredit: - return `${p.tag}:${p.pursePub}` as TaskId; - case PendingTaskType.PeerPushDebit: - return `${p.tag}:${p.pursePub}` as TaskId; - case PendingTaskType.Purchase: - return `${p.tag}:${p.proposalId}` as TaskId; - case PendingTaskType.Recoup: - return `${p.tag}:${p.recoupGroupId}` as TaskId; - case PendingTaskType.Refresh: - return `${p.tag}:${p.refreshGroupId}` as TaskId; - case PendingTaskType.RewardPickup: - return `${p.tag}:${p.walletRewardId}` as TaskId; - case PendingTaskType.Withdraw: - return `${p.tag}:${p.withdrawalGroupId}` as TaskId; - default: - assertUnreachable(p); - } -} - -export namespace TaskIdentifiers { - export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId { - return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId; - } - export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId { - return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( - exch.baseUrl, - )}` as TaskId; - } - export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId { - return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( - exchBaseUrl, - )}` as TaskId; - } - export function forTipPickup(tipRecord: RewardRecord): TaskId { - return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId; - } - export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId { - return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId; - } - export function forPay(purchaseRecord: PurchaseRecord): TaskId { - return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId; - } - export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId { - return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId; - } - export function forDeposit(depositRecord: DepositGroupRecord): TaskId { - return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId; - } - export function forBackup(backupRecord: BackupProviderRecord): TaskId { - return `${PendingTaskType.Backup}:${encodeURIComponent( - backupRecord.baseUrl, - )}` as TaskId; - } - export function forPeerPushPaymentInitiation( - ppi: PeerPushDebitRecord, - ): TaskId { - return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId; - } - export function forPeerPullPaymentInitiation( - ppi: PeerPullCreditRecord, - ): TaskId { - return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId; - } - export function forPeerPullPaymentDebit( - ppi: PeerPullPaymentIncomingRecord, - ): TaskId { - return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskId; - } - export function forPeerPushCredit( - ppi: PeerPushPaymentIncomingRecord, - ): TaskId { - return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId; - } -} - -/** - * Result of a transaction transition. - */ -export enum TransitionResult { - Transition = 1, - Stay = 2, -} - -/** - * Transaction context. - * Uniform interface to all transactions. - */ -export interface TransactionContext { - abortTransaction(): Promise<void>; - suspendTransaction(): Promise<void>; - resumeTransaction(): Promise<void>; - failTransaction(): Promise<void>; - deleteTransaction(): Promise<void>; -} diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts @@ -1,1598 +0,0 @@ -/* - 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, - ExchangeRefundRequest, - HttpStatusCode, - Logger, - MerchantContractTerms, - NotificationType, - PayCoinSelection, - PrepareDepositRequest, - PrepareDepositResponse, - RefreshReason, - TalerError, - TalerErrorCode, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TrackTransaction, - TransactionAction, - TransactionIdStr, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, - URL, - WireFee, - canonicalJson, - codecForBatchDepositSuccess, - codecForTackTransactionAccepted, - codecForTackTransactionWired, - durationFromSpec, - encodeCrock, - getRandomBytes, - hashTruncate32, - hashWire, - j2s, - parsePaytoUri, - stringToBytes, -} from "@gnu-taler/taler-util"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { DepositElementStatus, DepositGroupRecord } from "../db.js"; -import { - DepositOperationStatus, - DepositTrackingInfo, - KycPendingInfo, - PendingTaskType, - RefreshOperationStatus, - TaskId, - createRefreshGroup, - getCandidateWithdrawalDenomsTx, - getTotalRefreshCost, - timestampPreciseToDb, - timestampProtocolToDb, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { selectPayCoinsNew } from "../util/coinSelection.js"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { - TaskRunResult, - TombstoneTag, - TransactionContext, - constructTaskIdentifier, - spendCoins, -} from "./common.js"; -import { getExchangeWireDetailsInTx } from "./exchanges.js"; -import { - extractContractData, - generateDepositPermissions, - getTotalPaymentCost, -} from "./pay-merchant.js"; -import { - constructTransactionIdentifier, - notifyTransition, - parseTransactionIdentifier, -} from "./transactions.js"; - -/** - * Logger. - */ -const logger = new Logger("deposits.ts"); - -export class DepositTransactionContext implements TransactionContext { - readonly transactionId: TransactionIdStr; - readonly taskId: TaskId; - constructor( - public ws: InternalWalletState, - 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.ws; - // FIXME: We should check first if we are in a final state - // where deletion is allowed. - await ws.db.runReadWriteTx(["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 { ws, depositGroupId, transactionId, taskId: retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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), - }; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - } - - async abortTransaction(): Promise<void> { - const { ws, depositGroupId, transactionId, taskId: retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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: { - dg.operationStatus = DepositOperationStatus.Aborting; - await tx.depositGroups.put(dg); - return { - oldTxState: oldState, - newTxState: computeDepositTransactionStatus(dg), - }; - } - case DepositOperationStatus.SuspendedDeposit: - // FIXME: Can we abort a suspended transaction?! - return undefined; - } - return undefined; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } - - async resumeTransaction(): Promise<void> { - const { ws, depositGroupId, transactionId, taskId: retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } - - async failTransaction(): Promise<void> { - const { ws, depositGroupId, transactionId, taskId: retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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; - }, - ); - // FIXME: Also cancel ongoing work (via cancellation token, once implemented) - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - } -} - -/** - * 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); - } -} - -/** - * 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( - ws: InternalWalletState, - depositGroup: DepositGroupRecord, -): Promise<TaskRunResult> { - const abortRefreshGroupId = depositGroup.abortRefreshGroupId; - checkLogicInvariant(!!abortRefreshGroupId); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId: depositGroup.depositGroupId, - }); - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return TaskRunResult.backoff(); -} - -async function refundDepositGroup( - ws: InternalWalletState, - depositGroup: DepositGroupRecord, -): Promise<TaskRunResult> { - const newTxPerCoin = [...depositGroup.statusPerCoin]; - logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`); - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - const st = depositGroup.statusPerCoin[i]; - switch (st) { - case DepositElementStatus.RefundFailed: - case DepositElementStatus.RefundSuccess: - break; - default: { - const coinPub = depositGroup.payCoinSelection.coinPubs[i]; - const coinExchange = await ws.db.runReadOnlyTx( - ["coins"], - async (tx) => { - const coinRecord = await tx.coins.get(coinPub); - checkDbInvariant(!!coinRecord); - return coinRecord.exchangeBaseUrl; - }, - ); - const refundAmount = depositGroup.payCoinSelection.coinContributions[i]; - // We use a constant refund transaction ID, since there can - // only be one refund. - const rtid = 1; - const sig = await ws.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 ws.http.fetch(refundUrl.href, { - method: "POST", - body: refundReq, - }); - 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); - - await ws.db.runReadWriteTx( - [ - "depositGroups", - "refreshGroups", - "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: depositGroup.payCoinSelection.coinContributions[i], - coinPub: depositGroup.payCoinSelection.coinPubs[i], - }); - } - if (isDone) { - const rgid = await createRefreshGroup( - ws, - tx, - currency, - refreshCoins, - RefreshReason.AbortDeposit, - constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId: newDg.depositGroupId, - }), - ); - newDg.abortRefreshGroupId = rgid.refreshGroupId; - } - await tx.depositGroups.put(newDg); - }, - ); - - return TaskRunResult.backoff(); -} - -async function processDepositGroupAborting( - ws: InternalWalletState, - depositGroup: DepositGroupRecord, -): Promise<TaskRunResult> { - logger.info("processing deposit tx in 'aborting'"); - const abortRefreshGroupId = depositGroup.abortRefreshGroupId; - if (!abortRefreshGroupId) { - logger.info("refunding deposit group"); - return refundDepositGroup(ws, depositGroup); - } - logger.info("waiting for refresh"); - return waitForRefreshOnDepositGroup(ws, depositGroup); -} - -async function processDepositGroupPendingKyc( - ws: InternalWalletState, - depositGroup: DepositGroupRecord, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - const { depositGroupId } = depositGroup; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId, - }); - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.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 ws.http.fetch(url.href, { - method: "GET", - 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 ws.db.runReadWriteTx( - ["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(ws, 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( - ws: InternalWalletState, - 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 ws.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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return TaskRunResult.finished(); - } else { - throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`); - } -} - -async function processDepositGroupPendingTrack( - ws: InternalWalletState, - depositGroup: DepositGroupRecord, - cancellationToken?: CancellationToken, -): Promise<TaskRunResult> { - const { depositGroupId } = depositGroup; - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - const coinPub = depositGroup.payCoinSelection.coinPubs[i]; - // FIXME: Make the URL part of the coin selection? - const exchangeBaseUrl = await ws.db.runReadWriteTx( - ["coins"], - async (tx) => { - const coinRecord = await tx.coins.get(coinPub); - checkDbInvariant(!!coinRecord); - return coinRecord.exchangeBaseUrl; - }, - ); - - let updatedTxStatus: DepositElementStatus | undefined = undefined; - let newWiredCoin: - | { - id: string; - value: DepositTrackingInfo; - } - | undefined; - - if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) { - const track = await trackDeposit( - ws, - 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( - ws, - 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( - ws, - 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 ws.db.runReadWriteTx(["depositGroups"], async (tx) => { - const dg = await tx.depositGroups.get(depositGroupId); - if (!dg) { - 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 ws.db.runReadWriteTx( - ["depositGroups"], - async (tx) => { - const dg = await tx.depositGroups.get(depositGroupId); - if (!dg) { - return undefined; - } - const oldTxState = computeDepositTransactionStatus(dg); - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - if (depositGroup.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(ws, transactionId, transitionInfo); - if (allWired) { - return TaskRunResult.finished(); - } else { - // FIXME: Use long-polling. - return TaskRunResult.backoff(); - } -} - -async function processDepositGroupPendingDeposit( - ws: InternalWalletState, - depositGroup: DepositGroupRecord, - cancellationToken?: CancellationToken, -): Promise<TaskRunResult> { - logger.info("processing deposit group in pending(deposit)"); - const depositGroupId = depositGroup.depositGroupId; - const contractTermsRec = await ws.db.runReadOnlyTx( - ["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(); - - // FIXME: Cache these! - const depositPermissions = await generateDepositPermissions( - ws, - 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 ws.http.fetch(url.href, { - method: "POST", - body: batchReq, - cancellationToken: cancellationToken, - }); - await readSuccessResponseJsonOrThrow( - httpResp, - codecForBatchDepositSuccess(), - ); - - await ws.db.runReadWriteTx(["depositGroups"], async (tx) => { - const dg = await tx.depositGroups.get(depositGroupId); - if (!dg) { - 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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return TaskRunResult.progress(); -} - -/** - * Process a deposit group that is not in its final state yet. - */ -export async function processDepositGroup( - ws: InternalWalletState, - depositGroupId: string, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - const depositGroup = await ws.db.runReadOnlyTx( - ["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( - ws, - depositGroup, - cancellationToken, - ); - case DepositOperationStatus.PendingKyc: - return processDepositGroupPendingKyc(ws, depositGroup, cancellationToken); - case DepositOperationStatus.PendingDeposit: - return processDepositGroupPendingDeposit( - ws, - depositGroup, - cancellationToken, - ); - case DepositOperationStatus.Aborting: - return processDepositGroupAborting(ws, depositGroup); - } - - return TaskRunResult.finished(); -} - -/** - * FIXME: Consider moving this to exchanges.ts. - */ -async function getExchangeWireFee( - ws: InternalWalletState, - wireType: string, - baseUrl: string, - time: TalerProtocolTimestamp, -): Promise<WireFee> { - const exchangeDetails = await ws.db.runReadOnlyTx( - ["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( - ws: InternalWalletState, - 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 ws.cryptoApi.signTrackTransaction({ - coinPub, - contractTermsHash: depositGroup.contractTermsHash, - merchantPriv: depositGroup.merchantPriv, - merchantPub: depositGroup.merchantPub, - wireHash, - }); - url.searchParams.set("merchant_sig", sigResp.sig); - const httpResp = await ws.http.fetch(url.href, { method: "GET" }); - 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. - * - * FIXME: This should be renamed to checkDepositGroup, - * as it doesn't prepare anything - */ -export async function prepareDepositGroup( - ws: InternalWalletState, - req: PrepareDepositRequest, -): Promise<PrepareDepositResponse> { - 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.runReadOnlyTx(["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), - max_wire_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, durationFromSpec({ hours: 1 })), - ), - merchant: { - name: "(wallet)", - }, - merchant_pub: "", - refund_deadline: TalerProtocolTimestamp.zero(), - }; - - const { h: contractTermsHash } = await ws.cryptoApi.hashString({ - str: canonicalJson(contractTerms), - }); - - const contractData = extractContractData( - contractTerms, - contractTermsHash, - "", - ); - - const payCoinSel = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), - prevPayCoins: [], - }); - - if (payCoinSel.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, - }, - ); - } - - const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel); - - const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount( - ws, - p.targetType, - payCoinSel.coinSel, - ); - - const fees = await getTotalFeesForDepositAmount( - ws, - p.targetType, - amount, - payCoinSel.coinSel, - ); - - 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( - 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.runReadOnlyTx(["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 ws.cryptoApi.createEddsaKeypair({}); - const merchantPair = await ws.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), - max_wire_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, durationFromSpec({ hours: 1 })), - ), - merchant: { - name: "(wallet)", - }, - merchant_pub: merchantPair.pub, - refund_deadline: TalerProtocolTimestamp.zero(), - }; - - const { h: contractTermsHash } = await ws.cryptoApi.hashString({ - str: canonicalJson(contractTerms), - }); - - const contractData = extractContractData( - contractTerms, - contractTermsHash, - "", - ); - - const payCoinSel = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), - prevPayCoins: [], - }); - - if (payCoinSel.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, - }, - ); - } - - const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel); - - 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 counterpartyEffectiveDepositAmount = - await getCounterpartyEffectiveDepositAmount( - ws, - p.targetType, - payCoinSel.coinSel, - ); - - 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: payCoinSel.coinSel.coinPubs.map( - () => DepositElementStatus.DepositPending, - ), - payCoinSelection: payCoinSel.coinSel, - payCoinSelectionUid: encodeCrock(getRandomBytes(32)), - 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, - }; - - const ctx = new DepositTransactionContext(ws, depositGroupId); - const transactionId = ctx.transactionId; - - const newTxState = await ws.db.runReadWriteTx( - [ - "depositGroups", - "coins", - "recoupGroups", - "denominations", - "refreshGroups", - "coinAvailability", - "contractTerms", - ], - async (tx) => { - await spendCoins(ws, tx, { - allocationId: transactionId, - coinPubs: payCoinSel.coinSel.coinPubs, - contributions: payCoinSel.coinSel.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); - }, - ); - - ws.notify({ - type: NotificationType.TransactionStateTransition, - transactionId, - oldTxState: { - major: TransactionMajorState.None, - }, - newTxState, - }); - - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - - ws.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( - ws: InternalWalletState, - wireType: string, - pcs: PayCoinSelection, -): Promise<AmountJson> { - const amt: AmountJson[] = []; - const fees: AmountJson[] = []; - const exchangeSet: Set<string> = new Set(); - - await ws.db.runReadOnlyTx( - ["coins", "denominations", "exchangeDetails", "exchanges"], - 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 amount, coin not found"); - } - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - if (!denom) { - throw Error("can't find denomination to calculate deposit amount"); - } - amt.push(Amounts.parseOrThrow(pcs.coinContributions[i])); - fees.push(Amounts.parseOrThrow(denom.feeDeposit)); - exchangeSet.add(coin.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( - ws: InternalWalletState, - wireType: string, - total: AmountJson, - pcs: PayCoinSelection, -): Promise<DepositGroupFees> { - const wireFee: AmountJson[] = []; - const coinFee: AmountJson[] = []; - const refreshFee: AmountJson[] = []; - const exchangeSet: Set<string> = new Set(); - const currency = Amounts.currencyOf(total); - - await ws.db.runReadOnlyTx( - ["coins", "denominations", "exchanges", "exchangeDetails"], - 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 amount, coin not found"); - } - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - if (!denom) { - throw Error("can't find denomination to calculate deposit amount"); - } - coinFee.push(Amounts.parseOrThrow(denom.feeDeposit)); - exchangeSet.add(coin.exchangeBaseUrl); - - const allDenoms = await getCandidateWithdrawalDenomsTx( - ws, - tx, - coin.exchangeBaseUrl, - currency, - ); - const amountLeft = Amounts.sub( - denom.value, - pcs.coinContributions[i], - ).amount; - const refreshCost = getTotalRefreshCost( - allDenoms, - denom, - amountLeft, - ws.config.testing.denomselAllowLate, - ); - 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/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -1,2007 +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/> - */ - -/** - * @fileoverview - * Implementation of exchange entry management in wallet-core. - * The details of exchange entry management are specified in DD48. - */ - -/** - * Imports. - */ -import { - AbsoluteTime, - AgeRestriction, - Amounts, - AsyncFlag, - CancellationToken, - CoinRefreshRequest, - CoinStatus, - DeleteExchangeRequest, - DenomKeyType, - 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, - URL, - WalletNotification, - WireFee, - WireFeeMap, - WireFeesJson, - WireInfo, - assertUnreachable, - canonicalizeBaseUrl, - codecForExchangeKeysJson, - durationFromSpec, - durationMul, - encodeCrock, - hashDenomPub, - j2s, - makeErrorDetail, - parsePaytoUri, -} from "@gnu-taler/taler-util"; -import { - HttpRequestLibrary, - getExpiry, - readSuccessResponseJsonOrThrow, - readSuccessResponseTextOrThrow, -} from "@gnu-taler/taler-util/http"; -import { - DenominationRecord, - DenominationVerificationStatus, - ExchangeDetailsRecord, - ExchangeEntryRecord, - WalletStoresV1, -} from "../db.js"; -import { - ExchangeEntryDbRecordStatus, - ExchangeEntryDbUpdateStatus, - PendingTaskType, - WalletDbReadOnlyTransaction, - WalletDbReadWriteTransaction, - createRefreshGroup, - createTimeline, - isWithdrawableDenom, - selectBestForOverlappingDenominations, - selectMinimumFee, - timestampAbsoluteFromDb, - timestampOptionalPreciseFromDb, - timestampPreciseFromDb, - timestampPreciseToDb, - timestampProtocolFromDb, - timestampProtocolToDb, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { DbReadOnlyTransaction } from "../util/query.js"; -import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js"; -import { - TaskIdentifiers, - TaskRunResult, - TaskRunResultType, - constructTaskIdentifier, - getAutoRefreshExecuteThreshold, - getExchangeEntryStatusFromRecord, - getExchangeState, - getExchangeTosStatusFromRecord, - getExchangeUpdateStatusFromRecord, -} from "./common.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( - 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, - }); - 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) { - return; - } - const dp = r.detailsPointer; - if (!dp) { - return; - } - const { currency, masterPublicKey } = dp; - return await tx.exchangeDetails.indexes.byPointer.get([ - r.baseUrl, - currency, - masterPublicKey, - ]); -} - -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, - currency: exchangeDetails?.currency ?? r.presetCurrencyHint, - 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) ?? [], - lastUpdateErrorInfo, - scopeInfo, - }; -} - -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( - ws: InternalWalletState, - req: GetExchangeEntryByUrlRequest, -): Promise<ExchangeListItem> { - return await ws.db.runReadOnlyTx( - [ - "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( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise<void> { - const notif = await ws.db.runReadWriteTx( - ["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); - return { - type: NotificationType.ExchangeStateTransition, - exchangeBaseUrl, - newExchangeState: newExchangeState, - oldExchangeState: oldExchangeState, - } satisfies WalletNotification; - } - return undefined; - }, - ); - if (notif) { - ws.notify(notif); - } -} - -/** - * Validate wire fees and wire accounts. - * - * Throw an exception if they are invalid. - */ -async function validateWireInfo( - ws: InternalWalletState, - versionCurrent: number, - wireInfo: ExchangeKeysDownloadResult, - masterPublicKey: string, -): Promise<WireInfo> { - for (const a of wireInfo.accounts) { - logger.trace("validating exchange acct"); - let isValid = false; - if (ws.config.testing.insecureTrustExchange) { - isValid = true; - } else { - const { valid: v } = await 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 (ws.config.testing.insecureTrustExchange) { - isValid = true; - } else { - const { valid: v } = await 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( - ws: InternalWalletState, - fees: GlobalFees[], - masterPub: string, -): Promise<ExchangeGlobalFees[]> { - const egf: ExchangeGlobalFees[] = []; - for (const gf of fees) { - logger.trace("validating exchange global fees"); - let isValid = false; - if (ws.config.testing.insecureTrustExchange) { - isValid = true; - } else { - const { valid: v } = await ws.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()), - ), - 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, -): Promise<ExchangeKeysDownloadResult> { - const keysUrl = new URL("keys", baseUrl); - - const resp = await http.fetch(keysUrl.href, { - timeout, - cancellationToken, - }); - - 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, - 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, - listIssueDate: timestampProtocolToDb( - exchangeKeysJsonUnchecked.list_issue_date, - ), - 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( - ws: InternalWalletState, - 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( - baseUrl, - ws.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( - baseUrl, - ws.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( - ws: InternalWalletState, - exchangeBaseUrl: string, - options: { forceUpdate?: boolean } = {}, -): Promise<void> { - const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - - logger.info( - `starting update of exchange entry ${canonBaseUrl}, forced=${ - options.forceUpdate ?? false - }`, - ); - - const { notification } = await ws.db.runReadWriteTx( - ["exchanges", "exchangeDetails"], - async (tx) => { - return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl); - }, - ); - - if (notification) { - ws.notify(notification); - } - - const { oldExchangeState, newExchangeState, taskId } = - await ws.db.runReadWriteTx( - ["exchanges", "operationRetries"], - async (tx) => { - const r = await tx.exchanges.get(canonBaseUrl); - if (!r) { - throw Error("exchange not found"); - } - const oldExchangeState = getExchangeState(r); - switch (r.updateStatus) { - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - break; - case ExchangeEntryDbUpdateStatus.Suspended: - break; - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - 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; - } - break; - } - case ExchangeEntryDbUpdateStatus.Initial: - r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate; - break; - } - 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 }; - }, - ); - ws.notify({ - type: NotificationType.ExchangeStateTransition, - exchangeBaseUrl: canonBaseUrl, - newExchangeState: newExchangeState, - oldExchangeState: oldExchangeState, - }); - await ws.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( - ws: InternalWalletState, - canonUrl: string, - exchangeNotifFlag: AsyncFlag, - options: { - cancellationToken?: CancellationToken; - forceUpdate?: boolean; - expectedMasterPub?: string; - } = {}, -): Promise<ReadyExchangeSummary> { - const operationId = constructTaskIdentifier({ - tag: PendingTaskType.ExchangeUpdate, - exchangeBaseUrl: canonUrl, - }); - while (true) { - logger.info(`waiting for ready exchange ${canonUrl}`); - const { exchange, exchangeDetails, retryInfo, scopeInfo } = - await ws.db.runReadOnlyTx( - [ - "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; - 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( - ws: InternalWalletState, - baseUrl: string, - options: { - cancellationToken?: CancellationToken; - forceUpdate?: boolean; - expectedMasterPub?: string; - } = {}, -): Promise<ReadyExchangeSummary> { - const canonUrl = canonicalizeBaseUrl(baseUrl); - - ws.ensureTaskLoopRunning(); - - await startUpdateExchangeEntry(ws, canonUrl, { - forceUpdate: options.forceUpdate, - }); - - return waitReadyExchange(ws, canonUrl, options); -} - -async function waitReadyExchange( - ws: InternalWalletState, - canonUrl: string, - options: { - cancellationToken?: CancellationToken; - forceUpdate?: boolean; - expectedMasterPub?: string; - } = {}, -): Promise<ReadyExchangeSummary> { - // 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 = ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.ExchangeStateTransition && - notif.exchangeBaseUrl === canonUrl - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - exchangeNotifFlag.raise(); - } - }); - - try { - const res = await internalWaitReadyExchange( - ws, - canonUrl, - exchangeNotifFlag, - options, - ); - logger.info("done waiting for ready exchange"); - return res; - } finally { - cancelNotif(); - } -} - -/** - * 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( - ws: InternalWalletState, - exchangeBaseUrl: string, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - logger.trace(`updating exchange info for ${exchangeBaseUrl}`); - exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - - const oldExchangeRec = await ws.db.runReadOnlyTx( - ["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: - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - updateRequestedExplicitly = true; - 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)) { - 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, - ws.http, - timeout, - cancellationToken, - ); - - 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( - ws, - version.current, - keysInfo, - keysInfo.masterPublicKey, - ); - - const globalFees = await validateGlobalFees( - ws, - 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( - ws, - exchangeBaseUrl, - timeout, - ["text/plain"], - ); - - let recoupGroupId: string | undefined; - - logger.trace("updating exchange info in database"); - - let detailsPointerChanged = false; - - let ageMask = 0; - for (const x of keysInfo.currentDenominations) { - if ( - isWithdrawableDenom(x, ws.config.testing.denomselAllowLate) && - x.denomPub.age_mask != 0 - ) { - ageMask = x.denomPub.age_mask; - break; - } - } - - const updated = await ws.db.runReadWriteTx( - [ - "exchanges", - "exchangeDetails", - "exchangeSignKeys", - "denominations", - "coins", - "refreshGroups", - "recoupGroups", - ], - async (tx) => { - const r = await tx.exchanges.get(exchangeBaseUrl); - if (!r) { - logger.warn(`exchange ${exchangeBaseUrl} no longer present`); - return; - } - const oldExchangeState = getExchangeState(r); - const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl); - if (!existingDetails) { - detailsPointerChanged = true; - } - if (existingDetails) { - if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) { - detailsPointerChanged = true; - } - if (existingDetails.currency !== keysInfo.currency) { - detailsPointerChanged = true; - } - // FIXME: We need to do some consistency checks! - } - 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.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; - 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), - }); - } - - 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([ - exchangeBaseUrl, - 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 = timestampProtocolToDb(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.info("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.info("recouping coins", newlyRevokedCoinPubs); - recoupGroupId = await ws.recoupOps.createRecoupGroup( - ws, - tx, - exchangeBaseUrl, - newlyRevokedCoinPubs, - ); - } - - const newExchangeState = getExchangeState(r); - - return { - exchange: r, - exchangeDetails: newDetails, - oldExchangeState, - newExchangeState, - }; - }, - ); - - if (recoupGroupId) { - const recoupTaskId = constructTaskIdentifier({ - tag: PendingTaskType.Recoup, - recoupGroupId, - }); - // Asynchronously start recoup. This doesn't need to finish - // for the exchange update to be considered finished. - ws.taskScheduler.startShepherdTask(recoupTaskId); - } - - if (!updated) { - throw Error("something went wrong with updating the exchange"); - } - - logger.trace("done updating exchange info in database"); - - logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`); - - let minCheckThreshold = AbsoluteTime.addDuration( - AbsoluteTime.now(), - durationFromSpec({ days: 1 }), - ); - - if (refreshCheckNecessary) { - // Do auto-refresh. - await ws.db.runReadWriteTx( - [ - "coins", - "denominations", - "coinAvailability", - "refreshGroups", - "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( - ws, - 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), - ); - await tx.exchanges.put(exchange); - }, - ); - } - - 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(); -} - -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( - 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 ws.db.runReadOnlyTx( - ["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( - ws: InternalWalletState, - exchangeBaseUrl: string, - acceptedFormat?: string[], - acceptLanguage?: string, -): Promise<GetExchangeTosResult> { - const exch = await fetchFreshExchange(ws, exchangeBaseUrl); - - const tosDownload = await downloadTosFromAcceptedFormat( - ws, - exchangeBaseUrl, - getExchangeRequestTimeout(), - acceptedFormat, - acceptLanguage, - ); - - await ws.db.runReadWriteTx(["exchanges"], async (tx) => { - const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl); - if (updateExchangeEntry) { - updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag; - 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, - ); - return { - keys: keysInfo, - }; -} - -/** - * List all exchange entries known to the wallet. - */ -export async function listExchanges( - ws: InternalWalletState, -): Promise<ExchangesListResponse> { - const exchanges: ExchangeListItem[] = []; - await ws.db.runReadOnlyTx( - [ - "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( - ws: InternalWalletState, - tx: WalletDbReadWriteTransaction<["exchanges"]>, - exchangeBaseUrl: string, -): Promise<{ notif: WalletNotification | undefined }> { - exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - 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( - ws: InternalWalletState, - exchangeBaseurl: string, -): Promise<ExchangeDetailedResponse> { - const exchange = await ws.db.runReadOnlyTx( - ["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( - ws: InternalWalletState, - 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, - }; -} - -export async function deleteExchange( - ws: InternalWalletState, - req: DeleteExchangeRequest, -): Promise<void> { - let inUse: boolean = false; - const exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl); - await ws.db.runReadWriteTx( - ["exchanges", "coins", "withdrawalGroups", "exchangeDetails"], - 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(ws, tx, exchangeBaseUrl); - if (res.hasResources) { - if (req.purge) { - 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); - } - // FIXME: Also remove records related to transactions? - } else { - inUse = true; - return; - } - } - await tx.exchanges.delete(exchangeBaseUrl); - }, - ); - - if (inUse) { - throw TalerError.fromUncheckedDetail({ - code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED, - hint: "Exchange in use.", - }); - } -} - -export async function getExchangeResources( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise<GetExchangeResourcesResponse> { - // Withdrawals include internal withdrawals from peer transactions - const res = await ws.db.runReadOnlyTx( - ["exchanges", "withdrawalGroups", "coins"], - async (tx) => { - const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl); - if (!exchangeRecord) { - return undefined; - } - return internalGetExchangeResources(ws, tx, exchangeBaseUrl); - }, - ); - if (!res) { - throw Error("exchange not found"); - } - return res; -} diff --git a/packages/taler-wallet-core/src/operations/merchants.ts b/packages/taler-wallet-core/src/operations/merchants.ts @@ -1,66 +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 { - canonicalizeBaseUrl, - Logger, - URL, - codecForMerchantConfigResponse, - LibtoolVersion, -} from "@gnu-taler/taler-util"; -import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; - -const logger = new Logger("taler-wallet-core:merchants.ts"); - -export async function getMerchantInfo( - ws: InternalWalletState, - merchantBaseUrl: string, -): Promise<MerchantInfo> { - const canonBaseUrl = canonicalizeBaseUrl(merchantBaseUrl); - - const existingInfo = ws.merchantInfoCache[canonBaseUrl]; - if (existingInfo) { - return existingInfo; - } - - const configUrl = new URL("config", canonBaseUrl); - const resp = await ws.http.fetch(configUrl.href); - - const configResp = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantConfigResponse(), - ); - - logger.info( - `merchant "${canonBaseUrl}" reports protocol ${configResp.version}"`, - ); - - const parsedVersion = LibtoolVersion.parseVersion(configResp.version); - if (!parsedVersion) { - throw Error("invalid merchant version"); - } - - const merchantInfo: MerchantInfo = { - protocolVersionCurrent: parsedVersion.current, - }; - - ws.merchantInfoCache[canonBaseUrl] = merchantInfo; - return merchantInfo; -} diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -1,3232 +0,0 @@ -/* - 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, - AsyncFlag, - codecForAbortResponse, - codecForMerchantContractTerms, - codecForMerchantOrderRefundPickupResponse, - codecForMerchantOrderStatusPaid, - codecForMerchantPayResponse, - codecForMerchantPostOrderResponse, - codecForProposal, - CoinDepositPermission, - CoinRefreshRequest, - ConfirmPayResult, - ConfirmPayResultType, - ContractTermsUtil, - Duration, - encodeCrock, - ForcedCoinSel, - getRandomBytes, - HttpStatusCode, - j2s, - Logger, - makeErrorDetail, - makePendingOperationFailedError, - MerchantCoinRefundStatus, - MerchantContractTerms, - MerchantPayResponse, - MerchantUsingTemplateDetails, - NotificationType, - parsePayTemplateUri, - parsePayUri, - parseTalerUri, - PayCoinSelection, - PreparePayResult, - PreparePayResultType, - PreparePayTemplateRequest, - randomBytes, - RefreshReason, - 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 { EddsaKeypair } from "../crypto/cryptoImplementation.js"; -import { - CoinRecord, - DenominationRecord, - PurchaseRecord, - PurchaseStatus, - RefundReason, - WalletStoresV1, -} from "../db.js"; -import { - getCandidateWithdrawalDenomsTx, - PendingTaskType, - RefundGroupRecord, - RefundGroupStatus, - RefundItemRecord, - RefundItemStatus, - TaskId, - timestampPreciseToDb, - timestampProtocolFromDb, - timestampProtocolToDb, - WalletDbReadOnlyTransaction, - WalletDbReadWriteTransaction, -} from "../index.js"; -import { - EXCHANGE_COINS_LOCK, - InternalWalletState, -} from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { DbReadWriteTransaction, StoreNames } from "../util/query.js"; -import { - constructTaskIdentifier, - DbRetryInfo, - spendCoins, - TaskIdentifiers, - TaskRunResult, - TaskRunResultType, - TombstoneTag, - TransactionContext, - TransitionResult, -} from "./common.js"; -import { - calculateRefreshOutput, - createRefreshGroup, - getTotalRefreshCost, -} from "./refresh.js"; -import { - constructTransactionIdentifier, - notifyTransition, - parseTransactionIdentifier, -} from "./transactions.js"; - -/** - * Logger. - */ -const logger = new Logger("pay-merchant.ts"); - -export class PayMerchantTransactionContext implements TransactionContext { - readonly transactionId: TransactionIdStr; - readonly taskId: TaskId; - - constructor( - public ws: InternalWalletState, - 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<TransitionResult>, - ): 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<TransitionResult>, - ): Promise<void> { - const ws = this.ws; - const extraStores = opts.extraStores ?? []; - const transitionInfo = await ws.db.runReadWriteTx( - ["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 TransitionResult.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 { ws, proposalId } = this; - await ws.db.runReadWriteTx(["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 { ws, proposalId, transactionId } = this; - ws.taskScheduler.stopShepherdTask(this.taskId); - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - } - - async abortTransaction(): Promise<void> { - const { ws, proposalId, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - [ - "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); - const oldStatus = purchase.purchaseStatus; - if (purchase.timestampFirstSuccessfulPay) { - // No point in aborting it. We don't even report an error. - logger.warn(`tried to abort successful payment`); - return; - } - switch (oldStatus) { - case PurchaseStatus.Done: - return; - case PurchaseStatus.PendingPaying: - case PurchaseStatus.SuspendedPaying: { - purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; - if (purchase.payInfo) { - 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( - ws, - tx, - currency, - refreshCoins, - RefreshReason.AbortPay, - this.transactionId, - ); - } - break; - } - case PurchaseStatus.DialogProposed: - purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused; - break; - } - await tx.purchases.put(purchase); - await tx.operationRetries.delete(this.taskId); - const newTxState = computePayMerchantTransactionState(purchase); - return { oldTxState, newTxState }; - }, - ); - ws.taskScheduler.stopShepherdTask(this.taskId); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(this.taskId); - } - - async resumeTransaction(): Promise<void> { - const { ws, proposalId, transactionId, taskId: retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(this.taskId); - } - - async failTransaction(): Promise<void> { - const { ws, proposalId, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - [ - "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(ws, transactionId, transitionInfo); - ws.taskScheduler.stopShepherdTask(this.taskId); - } -} - -export class RefundTransactionContext implements TransactionContext { - public transactionId: string; - constructor( - public ws: InternalWalletState, - public refundGroupId: string, - ) { - this.transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refund, - refundGroupId, - }); - } - - async deleteTransaction(): Promise<void> { - const { ws, refundGroupId, transactionId } = this; - await ws.db.runReadWriteTx(["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( - ws: InternalWalletState, - pcs: PayCoinSelection, -): Promise<AmountJson> { - const currency = Amounts.currencyOf(pcs.paymentAmount); - return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { - const costs: AmountJson[] = []; - 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 getCandidateWithdrawalDenomsTx( - ws, - tx, - coin.exchangeBaseUrl, - currency, - ); - const amountLeft = Amounts.sub( - denom.value, - pcs.coinContributions[i], - ).amount; - const refreshCost = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ws.config.testing.denomselAllowLate, - ); - costs.push(Amounts.parseOrThrow(pcs.coinContributions[i])); - costs.push(refreshCost); - } - const zero = Amounts.zeroOfAmount(pcs.paymentAmount); - return Amounts.sum([zero, ...costs]).amount; - }); -} - -async function failProposalPermanently( - ws: InternalWalletState, - proposalId: string, - err: TalerErrorDetail, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); -} - -function getProposalRequestTimeout(retryInfo?: DbRetryInfo): Duration { - return Duration.clamp({ - lower: Duration.fromSpec({ seconds: 1 }), - upper: Duration.fromSpec({ seconds: 60 }), - value: retryInfo - ? DbRetryInfo.getDuration(retryInfo) - : Duration.fromSpec({}), - }); -} - -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( - ws: InternalWalletState, - 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 ws.db.runReadOnlyTx(["contractTerms"], getFromTransaction); -} - -export function extractContractData( - parsedContractTerms: MerchantContractTerms, - 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.zeroOfCurrency(amount.currency); - } - 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, - maxWireFee: Amounts.stringify(maxWireFee), - payDeadline: parsedContractTerms.pay_deadline, - refundDeadline: parsedContractTerms.refund_deadline, - wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, - 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( - ws: InternalWalletState, - proposalId: string, -): Promise<TaskRunResult> { - const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => { - return await tx.purchases.get(proposalId); - }); - - if (!proposal) { - return TaskRunResult.finished(); - } - - const ctx = new PayMerchantTransactionContext(ws, 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 opId = TaskIdentifiers.forPay(proposal); - const retryRecord = await ws.db.runReadOnlyTx( - ["operationRetries"], - async (tx) => { - return tx.operationRetries.get(opId); - }, - ); - - const httpResponse = await ws.http.fetch(orderClaimUrl, { - method: "POST", - body: requestBody, - timeout: getProposalRequestTimeout(retryRecord?.retryInfo), - }); - 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(ws, 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(ws, proposalId, err); - throw makePendingOperationFailedError( - err, - TransactionType.Payment, - proposalId, - ); - } - - const sigValid = await ws.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(ws, 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(ws, 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 ws.db.runReadWriteTx( - ["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.refundAmountAwaiting === undefined) { - 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(ws, 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( - ws: InternalWalletState, - merchantBaseUrl: string, - orderId: string, - sessionId: string | undefined, - claimToken: string | undefined, - noncePriv: string | undefined, -): Promise<string> { - const oldProposals = await ws.db.runReadOnlyTx(["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(ws, oldProposal); - const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); - 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 ws.db.runReadWriteTx( - ["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.FailedClaim; - const newTxState = computePayMerchantTransactionState(p); - await tx.purchases.put(p); - return { oldTxState, newTxState }; - }, - ); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: oldProposal.proposalId, - }); - notifyTransition(ws, transactionId, transitionInfo); - } - } - return oldProposal.proposalId; - } - - let noncePair: EddsaKeypair; - let shared = false; - if (noncePriv) { - shared = true; - noncePair = { - priv: noncePriv, - pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, - }; - } else { - noncePair = await ws.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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return proposalId; -} - -async function storeFirstPaySuccess( - ws: InternalWalletState, - 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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); -} - -async function storePayReplaySuccess( - ws: InternalWalletState, - proposalId: string, - sessionId: string | undefined, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, 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( - ws: InternalWalletState, - proposalId: string, - err: TalerErrorDetail, -): Promise<void> { - logger.trace("handling insufficient funds, trying to re-select coins"); - - const proposal = await ws.db.runReadOnlyTx(["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(ws, proposal); - - const prevPayCoins: PreviousPayCoins = []; - - const payInfo = proposal.payInfo; - if (!payInfo) { - return; - } - - const payCoinSelection = payInfo.payCoinSelection; - - await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { - for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { - const coinPub = payCoinSelection.coinPubs[i]; - if (coinPub === brokenCoinPub) { - continue; - } - const contrib = 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: Amounts.parseOrThrow(contrib), - exchangeBaseUrl: coin.exchangeBaseUrl, - feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit), - }); - } - }); - - const res = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), - prevPayCoins, - requiredMinimumAge: contractData.minimumAge, - }); - - if (res.type !== "success") { - logger.trace("insufficient funds for coin re-selection"); - return; - } - - logger.trace("re-selected coins"); - - await ws.db.runReadWriteTx( - [ - "purchases", - "coins", - "coinAvailability", - "denominations", - "refreshGroups", - ], - async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - return; - } - const payInfo = p.payInfo; - if (!payInfo) { - return; - } - payInfo.payCoinSelection = res.coinSel; - payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); - await tx.purchases.put(p); - await spendCoins(ws, 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, - }); - }, - ); - - 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( - ws: InternalWalletState, - proposalId: string, - sessionId?: string, -): Promise<PreparePayResult> { - let proposal = await ws.db.runReadOnlyTx(["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 ws.db.runReadOnlyTx( - ["purchases"], - async (tx) => { - return tx.purchases.get(existingProposalId); - }, - ); - if (oldProposal) { - proposal = oldProposal; - } - } - } - const d = await expectProposalDownload(ws, proposal); - const contractData = d.contractData; - const merchantSig = d.contractData.merchantSig; - if (!merchantSig) { - throw Error("BUG: proposal is in invalid state"); - } - - proposalId = proposal.proposalId; - - const ctx = new PayMerchantTransactionContext(ws, 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 ws.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); - - if ( - !purchase || - purchase.purchaseStatus === PurchaseStatus.DialogProposed || - purchase.purchaseStatus === PurchaseStatus.DialogShared - ) { - // If not already paid, check if we could pay for it. - const res = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), - prevPayCoins: [], - requiredMinimumAge: contractData.minimumAge, - wireMethod: contractData.wireMethod, - }); - - if (res.type !== "success") { - 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, - }; - } - - const totalCost = await getTotalPaymentCost(ws, res.coinSel); - 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(res.coinSel.paymentAmount), - 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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(ctx.taskId); - - // FIXME: Consider changing the API here so that we don't have to - // wait inline for the repurchase. - - await waitPaymentResult(ws, proposalId, sessionId); - const download = await expectProposalDownload(ws, 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(ws, purchase); - return { - status: PreparePayResultType.AlreadyConfirmed, - contractTerms: download.contractTermsRaw, - contractTermsHash: download.contractData.contractTermsHash, - paid: false, - 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(ws, 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( - ws: InternalWalletState, - proposalId: string, -): Promise<WalletContractData> { - const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); - - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - const d = await expectProposalDownload(ws, 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( - ws: InternalWalletState, - 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( - ws, - uriResult.merchantBaseUrl, - uriResult.orderId, - uriResult.sessionId, - uriResult.claimToken, - uriResult.noncePriv, - ); - - await waitProposalDownloaded(ws, proposalId); - - return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId); -} - -/** - * Wait until a proposal is at least downloaded. - */ -async function waitProposalDownloaded( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const ctx = new PayMerchantTransactionContext(ws, proposalId); - - logger.info(`waiting for ${ctx.transactionId} to be downloaded`); - - ws.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 = 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.ws.db.runReadOnlyTx( - ["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( - ws: InternalWalletState, - 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 ws.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(ws, payUri); -} - -/** - * 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.runReadOnlyTx(["coins", "denominations"], 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]; - let wireInfoHash: string; - wireInfoHash = contractData.wireInfoHash; - logger.trace( - `signing deposit permission for coin with ageRestriction=${j2s( - coin.ageCommitmentProof, - )}`, - ); - const dp = await ws.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.ws.db.runReadOnlyTx( - ["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.ws, 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( - ws: InternalWalletState, - proposalId: string, - waitSessionId?: string, -): Promise<ConfirmPayResult> { - const ctx = new PayMerchantTransactionContext(ws, proposalId); - - ws.ensureTaskLoopRunning(); - - ws.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 = 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( - ws: InternalWalletState, - 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 ws.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(proposalId); - }); - - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - const d = await expectProposalDownload(ws, proposal); - if (!d) { - throw Error("proposal is in invalid state"); - } - - const existingPurchase = await ws.db.runReadWriteTx( - ["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( - ws, - existingPurchase.proposalId, - ); - await ws.taskScheduler.resetTaskRetries(ctx.taskId); - return waitPaymentResult(ws, proposalId); - } - - logger.trace("confirmPay: purchase record does not exist yet"); - - const contractData = d.contractData; - - const selectCoinsResult = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), - prevPayCoins: [], - requiredMinimumAge: contractData.minimumAge, - forcedSelection: forcedCoinSel, - }); - - logger.trace("coin selection result", selectCoinsResult); - - if (selectCoinsResult.type === "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"); - } - - const coinSelection = selectCoinsResult.coinSel; - const payCostInfo = await getTotalPaymentCost(ws, coinSelection); - - 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 ws.db.runReadWriteTx( - [ - "purchases", - "coins", - "refreshGroups", - "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 = { - payCoinSelection: coinSelection, - payCoinSelectionUid: encodeCrock(getRandomBytes(16)), - totalPayCost: Amounts.stringify(payCostInfo), - }; - p.lastSessionId = sessionId; - p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now()); - p.purchaseStatus = PurchaseStatus.PendingPaying; - await tx.purchases.put(p); - await spendCoins(ws, tx, { - //`txn:proposal:${p.proposalId}` - allocationId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: proposalId, - }), - coinPubs: coinSelection.coinPubs, - contributions: coinSelection.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(ws, transactionId, transitionInfo); - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - - // Wait until we have completed the first attempt to pay. - return waitPaymentResult(ws, proposalId); -} - -export async function processPurchase( - ws: InternalWalletState, - proposalId: string, -): Promise<TaskRunResult> { - const purchase = await ws.db.runReadOnlyTx(["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(ws, proposalId); - case PurchaseStatus.PendingPaying: - case PurchaseStatus.PendingPayingReplay: - return processPurchasePay(ws, proposalId); - case PurchaseStatus.PendingQueryingRefund: - return processPurchaseQueryRefund(ws, purchase); - case PurchaseStatus.PendingQueryingAutoRefund: - return processPurchaseAutoRefund(ws, purchase); - case PurchaseStatus.AbortingWithRefund: - return processPurchaseAbortingRefund(ws, purchase); - case PurchaseStatus.PendingAcceptRefund: - return processPurchaseAcceptRefund(ws, purchase); - case PurchaseStatus.DialogShared: - return processPurchaseDialogShared(ws, 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: - return TaskRunResult.finished(); - default: - assertUnreachable(purchase.purchaseStatus); - // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`); - } -} - -async function processPurchasePay( - ws: InternalWalletState, - proposalId: string, -): Promise<TaskRunResult> { - const purchase = await ws.db.runReadOnlyTx(["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 sessionId = purchase.lastSessionId; - - logger.trace(`paying with session ID ${sessionId}`); - const payInfo = purchase.payInfo; - checkDbInvariant(!!payInfo, "payInfo"); - - const download = await expectProposalDownload(ws, purchase); - - if (purchase.shared) { - const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); - - if (paid) { - const transitionInfo = await ws.db.runReadWriteTx( - ["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.FailedClaim; - const newTxState = computePayMerchantTransactionState(p); - await tx.purchases.put(p); - return { oldTxState, newTxState }; - }, - ); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - - notifyTransition(ws, transactionId, transitionInfo); - - return { - type: TaskRunResultType.Error, - errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, { - orderId: purchase.orderId, - fulfillmentUrl: download.contractData.fulfillmentUrl, - }), - }; - } - } - - if (!purchase.merchantPaySig) { - const payUrl = new URL( - `orders/${download.contractData.orderId}/pay`, - download.contractData.merchantBaseUrl, - ).href; - - let depositPermissions: CoinDepositPermission[]; - // FIXME: Cache! - depositPermissions = await generateDepositPermissions( - ws, - payInfo.payCoinSelection, - 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.fetch(payUrl, { - method: "POST", - body: reqBody, - timeout: getPayRequestTimeout(purchase), - }), - ); - - 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 - handleInsufficientFunds(ws, 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); - 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 ws.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(ws, 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 ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - ws.http.fetch(payAgainUrl, { method: "POST", body: reqBody }), - ); - 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(ws, proposalId, sessionId); - } - - return TaskRunResult.progress(); -} - -export async function refuseProposal( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, 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, - }; - } -} - -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]; - } -} - -export async function sharePayment( - ws: InternalWalletState, - merchantBaseUrl: string, - orderId: string, -): Promise<SharePaymentResult> { - const result = await ws.db.runReadWriteTx(["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; - } - if (p.purchaseStatus === PurchaseStatus.DialogProposed) { - p.purchaseStatus = PurchaseStatus.DialogShared; - p.shared = true; - tx.purchases.put(p); - } - - return { - nonce: p.noncePriv, - session: p.lastSessionId ?? p.downloadSessionId, - token: p.claimToken, - }; - }); - - if (result === undefined) { - throw Error("This purchase can't be shared"); - } - const privatePayUri = stringifyPayUri({ - merchantBaseUrl, - orderId, - sessionId: result.session ?? "", - noncePriv: result.nonce, - claimToken: result.token, - }); - return { privatePayUri }; -} - -async function checkIfOrderIsAlreadyPaid( - ws: InternalWalletState, - contract: WalletContractData, -) { - const requestUrl = new URL( - `orders/${contract.orderId}`, - contract.merchantBaseUrl, - ); - requestUrl.searchParams.set("h_contract", contract.contractTermsHash); - - requestUrl.searchParams.set("timeout_ms", "1000"); - - const resp = await ws.http.fetch(requestUrl.href); - 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( - ws: InternalWalletState, - purchase: PurchaseRecord, -): Promise<TaskRunResult> { - const proposalId = purchase.proposalId; - logger.trace(`processing dialog-shared for proposal ${proposalId}`); - const download = await expectProposalDownload(ws, purchase); - - if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) { - return TaskRunResult.finished(); - } - - const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); - if (paid) { - const transitionInfo = await ws.db.runReadWriteTx( - ["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.FailedClaim; - const newTxState = computePayMerchantTransactionState(p); - await tx.purchases.put(p); - return { oldTxState, newTxState }; - }, - ); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - - notifyTransition(ws, transactionId, transitionInfo); - } - - return TaskRunResult.backoff(); -} - -async function processPurchaseAutoRefund( - ws: InternalWalletState, - purchase: PurchaseRecord, -): Promise<TaskRunResult> { - const proposalId = purchase.proposalId; - logger.trace(`processing auto-refund for proposal ${proposalId}`); - - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - - const download = await expectProposalDownload(ws, purchase); - - if ( - !purchase.autoRefundDeadline || - AbsoluteTime.isExpired( - AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(purchase.autoRefundDeadline), - ), - ) - ) { - const transitionInfo = await ws.db.runReadWriteTx( - ["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.purchaseStatus = PurchaseStatus.Done; - p.refundAmountAwaiting = undefined; - const newTxState = computePayMerchantTransactionState(p); - await tx.purchases.put(p); - return { oldTxState, newTxState }; - }, - ); - notifyTransition(ws, 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", "1000"); - requestUrl.searchParams.set("await_refund_obtained", "yes"); - - const resp = await ws.http.fetch(requestUrl.href); - - // FIXME: Check other status codes! - - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), - ); - - if (orderStatus.refund_pending) { - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - } - - return TaskRunResult.backoff(); -} - -async function processPurchaseAbortingRefund( - ws: InternalWalletState, - purchase: PurchaseRecord, -): Promise<TaskRunResult> { - const proposalId = purchase.proposalId; - const download = await expectProposalDownload(ws, 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 ws.db.runReadOnlyTx(["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 ws.http.fetch(requestUrl.href, { - method: "POST", - body: abortReq, - }); - - 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(ws, proposalId); - await ctx.transition(async (rec) => { - if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) { - rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; - return TransitionResult.Transition; - } - return TransitionResult.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(ws, purchase, refunds, RefundReason.AbortRefund); -} - -async function processPurchaseQueryRefund( - ws: InternalWalletState, - purchase: PurchaseRecord, -): Promise<TaskRunResult> { - const proposalId = purchase.proposalId; - logger.trace(`processing query-refund for proposal ${proposalId}`); - - const download = await expectProposalDownload(ws, purchase); - - const requestUrl = new URL( - `orders/${download.contractData.orderId}`, - download.contractData.merchantBaseUrl, - ); - requestUrl.searchParams.set( - "h_contract", - download.contractData.contractTermsHash, - ); - - const resp = await ws.http.fetch(requestUrl.href); - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), - ); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - - if (!orderStatus.refund_pending) { - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return TaskRunResult.progress(); - } else { - const refundAwaiting = Amounts.sub( - Amounts.parseOrThrow(orderStatus.refund_amount), - Amounts.parseOrThrow(orderStatus.refund_taken), - ).amount; - - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return TaskRunResult.progress(); - } -} - -async function processPurchaseAcceptRefund( - ws: InternalWalletState, - purchase: PurchaseRecord, -): Promise<TaskRunResult> { - const download = await expectProposalDownload(ws, purchase); - - const requestUrl = new URL( - `orders/${download.contractData.orderId}/refund`, - download.contractData.merchantBaseUrl, - ); - - logger.trace(`making refund request to ${requestUrl.href}`); - - const request = await ws.http.fetch(requestUrl.href, { - method: "POST", - body: { - h_contract: download.contractData.contractTermsHash, - }, - }); - - const refundResponse = await readSuccessResponseJsonOrThrow( - request, - codecForMerchantOrderRefundPickupResponse(), - ); - return await storeRefunds( - ws, - purchase, - refundResponse.refunds, - RefundReason.AbortRefund, - ); -} - -export async function startRefundQueryForUri( - ws: InternalWalletState, - 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 ws.db.runReadOnlyTx( - ["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(ws, proposalId); - return { - transactionId, - }; -} - -export async function startQueryRefund( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const ctx = new PayMerchantTransactionContext(ws, proposalId); - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, ctx.transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(ctx.taskId); -} - -async function computeRefreshRequest( - ws: InternalWalletState, - 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 ws.getDenomInfo( - ws, - 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( - ws: InternalWalletState, - 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(ws, purchase); - const currency = Amounts.currencyOf(download.contractData.amount); - - const result = await ws.db.runReadWriteTx( - [ - "coins", - "denominations", - "purchases", - "refundItems", - "refundGroups", - "denominations", - "coins", - "coinAvailability", - "refreshGroups", - ], - 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( - ws, - tx, - newGroupRefunds, - ); - const outInfo = await calculateRefreshOutput( - ws, - 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(ws, tx, items); - await createRefreshGroup( - ws, - 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); - if (numPendingItemsTotal === 0) { - if (isAborting) { - myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; - } 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(ws, 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/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -1,172 +0,0 @@ -/* - 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, - Logger, - TalerProtocolTimestamp, - buildCodecForObject, - codecForAmountString, - codecForTimestamp, - codecOptional, -} from "@gnu-taler/taler-util"; -import { SpendCoinDetails } from "../crypto/cryptoImplementation.js"; -import { PeerPushPaymentCoinSelection, ReserveRecord } from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import type { SelectedPeerCoin } from "../util/coinSelection.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { getTotalRefreshCost } from "./refresh.js"; -import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; - -const logger = new Logger("operations/peer-to-peer.ts"); - -/** - * Get information about the coin selected for signatures. - */ -export async function queryCoinInfosForSelection( - ws: InternalWalletState, - csel: PeerPushPaymentCoinSelection, -): Promise<SpendCoinDetails[]> { - let infos: SpendCoinDetails[] = []; - await ws.db.runReadOnlyTx(["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 ws.getDenomInfo( - ws, - 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( - ws: InternalWalletState, - pcs: SelectedPeerCoin[], -): Promise<AmountJson> { - const currency = Amounts.currencyOf(pcs[0].contribution); - return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { - const costs: AmountJson[] = []; - for (let i = 0; i < pcs.length; i++) { - const coin = await tx.coins.get(pcs[i].coinPub); - if (!coin) { - throw Error("can't calculate payment cost, coin not found"); - } - const denomInfo = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - if (!denomInfo) { - throw Error( - "can't calculate payment cost, denomination for coin not found", - ); - } - const allDenoms = await getCandidateWithdrawalDenomsTx( - ws, - tx, - coin.exchangeBaseUrl, - currency, - ); - const amountLeft = Amounts.sub( - denomInfo.value, - pcs[i].contribution, - ).amount; - const refreshCost = getTotalRefreshCost( - allDenoms, - denomInfo, - amountLeft, - ws.config.testing.denomselAllowLate, - ); - 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( - ws: InternalWalletState, - 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 ws.cryptoApi.createEddsaKeypair({}); - - const mergeReserveRecord: ReserveRecord = await ws.db.runReadWriteTx( - ["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/operations/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts @@ -1,1204 +0,0 @@ -/* - 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, - CancellationToken, - CheckPeerPullCreditRequest, - CheckPeerPullCreditResponse, - ContractTermsUtil, - ExchangeReservePurseRequest, - HttpStatusCode, - InitiatePeerPullCreditRequest, - InitiatePeerPullCreditResponse, - Logger, - NotificationType, - PeerContractTerms, - TalerErrorCode, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TalerUriAction, - TransactionAction, - TransactionIdStr, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, - WalletAccountMergeFlags, - WalletKycUuid, - codecForAny, - codecForWalletKycUuid, - encodeCrock, - getRandomBytes, - j2s, - makeErrorDetail, - stringifyTalerUri, - talerPaytoFromExchangeReserve, -} from "@gnu-taler/taler-util"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { - KycPendingInfo, - KycUserType, - PeerPullCreditRecord, - PeerPullPaymentCreditStatus, - WithdrawalGroupStatus, - WithdrawalRecordType, - fetchFreshExchange, - timestampOptionalPreciseFromDb, - timestampPreciseFromDb, - timestampPreciseToDb, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { PendingTaskType, TaskId } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { - TaskRunResult, - TaskRunResultType, - TombstoneTag, - TransactionContext, - constructTaskIdentifier, -} from "./common.js"; -import { - codecForExchangePurseStatus, - getMergeReserveInfo, -} from "./pay-peer-common.js"; -import { - constructTransactionIdentifier, - notifyTransition, -} from "./transactions.js"; -import { - getExchangeWithdrawalInfo, - internalCreateWithdrawalGroup, -} from "./withdraw.js"; - -const logger = new Logger("pay-peer-pull-credit.ts"); - -export class PeerPullCreditTransactionContext implements TransactionContext { - readonly transactionId: TransactionIdStr; - readonly retryTag: TaskId; - - constructor( - public ws: InternalWalletState, - public pursePub: string, - ) { - this.retryTag = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullCredit, - pursePub, - }); - this.transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub, - }); - } - - async deleteTransaction(): Promise<void> { - const { ws, pursePub } = this; - await ws.db.runReadWriteTx( - ["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 { ws, pursePub, retryTag, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - } - - async failTransaction(): Promise<void> { - const { ws, pursePub, retryTag, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.taskScheduler.stopShepherdTask(retryTag); - } - - async resumeTransaction(): Promise<void> { - const { ws, pursePub, retryTag, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } - - async abortTransaction(): Promise<void> { - const { ws, pursePub, retryTag, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } -} - -async function queryPurseForPeerPullCredit( - ws: InternalWalletState, - pullIni: PeerPullCreditRecord, - cancellationToken: CancellationToken, -): 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 ws.http.fetch(purseDepositUrl.href, { - timeout: { d_ms: 60000 }, - 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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return TaskRunResult.backoff(); - } - case HttpStatusCode.NotFound: - return TaskRunResult.backoff(); - } - - 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 ws.db.runReadOnlyTx(["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(ws, { - 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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return TaskRunResult.backoff(); -} - -async function longpollKycStatus( - ws: InternalWalletState, - pursePub: string, - exchangeUrl: string, - kycInfo: KycPendingInfo, - userType: KycUserType, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub, - }); - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.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 ws.http.fetch(url.href, { - method: "GET", - 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 ws.db.runReadWriteTx( - ["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(ws, 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(); -} - -async function processPeerPullCreditAbortingDeletePurse( - ws: InternalWalletState, - peerPullIni: PeerPullCreditRecord, -): Promise<TaskRunResult> { - const { pursePub, pursePriv } = peerPullIni; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub, - }); - - const sigResp = await ws.cryptoApi.signDeletePurse({ - pursePriv, - }); - const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl); - const resp = await ws.http.fetch(purseUrl.href, { - method: "DELETE", - headers: { - "taler-purse-signature": sigResp.sig, - }, - }); - logger.info(`deleted purse with response status ${resp.status}`); - - const transitionInfo = await ws.db.runReadWriteTx( - [ - "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(ws, transactionId, transitionInfo); - - return TaskRunResult.backoff(); -} - -async function handlePeerPullCreditWithdrawing( - ws: InternalWalletState, - pullIni: PeerPullCreditRecord, -): Promise<TaskRunResult> { - if (!pullIni.withdrawalGroupId) { - throw Error("invalid db state (withdrawing, but no withdrawal group ID"); - } - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub: pullIni.pursePub, - }); - const wgId = pullIni.withdrawalGroupId; - let finished: boolean = false; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - if (finished) { - return TaskRunResult.finished(); - } else { - // FIXME: Return indicator that we depend on the other operation! - return TaskRunResult.backoff(); - } -} - -async function handlePeerPullCreditCreatePurse( - ws: InternalWalletState, - pullIni: PeerPullCreditRecord, -): Promise<TaskRunResult> { - const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount)); - const pursePub = pullIni.pursePub; - const mergeReserve = await ws.db.runReadOnlyTx(["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 ws.db.runReadOnlyTx( - ["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 ws.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 ws.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 ws.http.fetch(reservePurseMergeUrl.href, { - method: "POST", - body: reservePurseReqBody, - }); - - if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) { - const respJson = await httpResp.json(); - const kycPending = codecForWalletKycUuid().decode(respJson); - logger.info(`kyc uuid response: ${j2s(kycPending)}`); - return processPeerPullCreditKycRequired(ws, 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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return TaskRunResult.backoff(); -} - -export async function processPeerPullCredit( - ws: InternalWalletState, - pursePub: string, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - const pullIni = await ws.db.runReadOnlyTx(["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(ws, pullIni, cancellationToken); - case PeerPullPaymentCreditStatus.PendingMergeKycRequired: { - if (!pullIni.kycInfo) { - throw Error("invalid state, kycInfo required"); - } - return await longpollKycStatus( - ws, - pursePub, - pullIni.exchangeBaseUrl, - pullIni.kycInfo, - "individual", - cancellationToken, - ); - } - case PeerPullPaymentCreditStatus.PendingCreatePurse: - return handlePeerPullCreditCreatePurse(ws, pullIni); - case PeerPullPaymentCreditStatus.AbortingDeletePurse: - return await processPeerPullCreditAbortingDeletePurse(ws, pullIni); - case PeerPullPaymentCreditStatus.PendingWithdrawing: - return handlePeerPullCreditWithdrawing(ws, 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( - ws: InternalWalletState, - 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 ws.http.fetch(url.href, { - method: "GET", - }); - - 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 ws.db.runReadWriteTx( - ["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(ws, 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( - ws: InternalWalletState, - 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(ws, 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( - ws, - 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( - ws: InternalWalletState, - currency: string, -): Promise<string | undefined> { - // Find an exchange with the matching currency. - // Prefer exchanges with the most recent withdrawal. - const url = await ws.db.runReadOnlyTx(["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( - ws: InternalWalletState, - 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(ws, currency); - } - - if (!maybeExchangeBaseUrl) { - throw Error("no exchange found for initiating a peer pull payment"); - } - - const exchangeBaseUrl = maybeExchangeBaseUrl; - - await fetchFreshExchange(ws, exchangeBaseUrl); - - const mergeReserveInfo = await getMergeReserveInfo(ws, { - exchangeBaseUrl: exchangeBaseUrl, - }); - - const pursePair = await ws.cryptoApi.createEddsaKeypair({}); - const mergePair = await ws.cryptoApi.createEddsaKeypair({}); - - const contractTerms = req.partialContractTerms; - - const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); - - const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({}); - - const withdrawalGroupId = encodeCrock(getRandomBytes(32)); - - const mergeReserveRowId = mergeReserveInfo.rowId; - checkDbInvariant(!!mergeReserveRowId); - - const contractEncNonce = encodeCrock(getRandomBytes(24)); - - const wi = await getExchangeWithdrawalInfo( - ws, - exchangeBaseUrl, - Amounts.parseOrThrow(req.partialContractTerms.amount), - undefined, - ); - - const mergeTimestamp = TalerPreciseTimestamp.now(); - - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, pursePair.pub); - - // The pending-incoming balance has changed. - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); - - notifyTransition(ws, ctx.transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(ctx.retryTag); - - 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/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts @@ -1,883 +0,0 @@ -/* - 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, - PeerContractTerms, - PreparePeerPullDebitRequest, - PreparePeerPullDebitResponse, - RefreshReason, - TalerError, - TalerErrorCode, - TalerPreciseTimestamp, - TalerProtocolViolationError, - TransactionAction, - TransactionIdStr, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, - 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 { - DbReadWriteTransaction, - InternalWalletState, - PeerPullDebitRecordStatus, - PeerPullPaymentIncomingRecord, - PendingTaskType, - RefreshOperationStatus, - StoreNames, - TaskId, - WalletStoresV1, - createRefreshGroup, - timestampPreciseToDb, -} from "../index.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js"; -import { checkLogicInvariant } from "../util/invariants.js"; -import { - TaskRunResult, - TaskRunResultType, - TransactionContext, - TransitionResult, - constructTaskIdentifier, - spendCoins, -} from "./common.js"; -import { - codecForExchangePurseStatus, - getTotalPeerPaymentCost, - queryCoinInfosForSelection, -} from "./pay-peer-common.js"; -import { - constructTransactionIdentifier, - notifyTransition, - parseTransactionIdentifier, -} from "./transactions.js"; - -const logger = new Logger("pay-peer-pull-debit.ts"); - -/** - * Common context for a peer-pull-debit transaction. - */ -export class PeerPullDebitTransactionContext implements TransactionContext { - ws: InternalWalletState; - readonly transactionId: TransactionIdStr; - readonly taskId: TaskId; - peerPullDebitId: string; - - constructor(ws: InternalWalletState, peerPullDebitId: string) { - this.ws = ws; - 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.ws; - const peerPullDebitId = this.peerPullDebitId; - await ws.db.runReadWriteTx(["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 ws = this.ws; - const peerPullDebitId = this.peerPullDebitId; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.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 TransitionResult.Transition; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - pi.status = PeerPullDebitRecordStatus.AbortingRefresh; - return TransitionResult.Transition; - case PeerPullDebitRecordStatus.Aborted: - case PeerPullDebitRecordStatus.AbortingRefresh: - case PeerPullDebitRecordStatus.Failed: - case PeerPullDebitRecordStatus.DialogProposed: - case PeerPullDebitRecordStatus.Done: - case PeerPullDebitRecordStatus.PendingDeposit: - return TransitionResult.Stay; - } - }); - this.ws.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 TransitionResult.Transition; - default: - return TransitionResult.Stay; - } - }); - this.ws.taskScheduler.stopShepherdTask(this.taskId); - } - - async abortTransaction(): Promise<void> { - const ctx = this; - await ctx.transitionExtra( - { - extraStores: [ - "coinAvailability", - "denominations", - "refreshGroups", - "coins", - "coinAvailability", - ], - }, - async (pi, tx) => { - switch (pi.status) { - case PeerPullDebitRecordStatus.SuspendedDeposit: - case PeerPullDebitRecordStatus.PendingDeposit: - break; - default: - return TransitionResult.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.ws, - tx, - currency, - coinPubs, - RefreshReason.AbortPeerPullDebit, - this.transactionId, - ); - - pi.status = PeerPullDebitRecordStatus.AbortingRefresh; - pi.abortRefreshGroupId = refresh.refreshGroupId; - return TransitionResult.Transition; - }, - ); - } - - async transition( - f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResult>, - ): 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<TransitionResult>, - ): Promise<void> { - const ws = this.ws; - const extraStores = opts.extraStores ?? []; - const transitionInfo = await ws.db.runReadWriteTx( - ["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 TransitionResult.Transition: { - await tx.peerPullDebit.put(pi); - const newTxState = computePeerPullDebitTransactionState(pi); - return { - oldTxState, - newTxState, - }; - } - default: - return undefined; - } - }, - ); - ws.taskScheduler.stopShepherdTask(this.taskId); - notifyTransition(ws, this.transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(this.taskId); - } -} - -async function handlePurseCreationConflict( - ctx: PeerPullDebitTransactionContext, - peerPullInc: PeerPullPaymentIncomingRecord, - resp: HttpResponse, -): Promise<TaskRunResult> { - const ws = ctx.ws; - 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: PeerCoinRepair = { - coinPubs: [], - contribs: [], - exchangeBaseUrl: peerPullInc.exchangeBaseUrl, - }; - - for (let i = 0; i < sel.coinPubs.length; i++) { - if (sel.coinPubs[i] != brokenCoinPub) { - repair.coinPubs.push(sel.coinPubs[i]); - repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i])); - } - } - - const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); - - if (coinSelRes.type == "failure") { - // FIXME: Details! - throw Error( - "insufficient balance to re-select coins to repair double spending", - ); - } - - const totalAmount = await getTotalPeerPaymentCost( - ws, - coinSelRes.result.coins, - ); - - await ws.db.runReadWriteTx(["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( - ws: InternalWalletState, - peerPullInc: PeerPullPaymentIncomingRecord, -): Promise<TaskRunResult> { - const pursePub = peerPullInc.pursePub; - - const coinSel = peerPullInc.coinSel; - if (!coinSel) { - throw Error("invalid state, no coins selected"); - } - - const coins = await queryCoinInfosForSelection(ws, coinSel); - - const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ - exchangeBaseUrl: peerPullInc.exchangeBaseUrl, - pursePub: peerPullInc.pursePub, - coins, - }); - - const purseDepositUrl = new URL( - `purses/${pursePub}/deposit`, - peerPullInc.exchangeBaseUrl, - ); - - const depositPayload: ExchangePurseDeposits = { - deposits: depositSigsResp.deposits, - }; - - if (logger.shouldLogTrace()) { - logger.trace(`purse deposit payload: ${j2s(depositPayload)}`); - } - - const httpResp = await ws.http.fetch(purseDepositUrl.href, { - method: "POST", - body: depositPayload, - }); - - const ctx = new PeerPullDebitTransactionContext( - ws, - peerPullInc.peerPullDebitId, - ); - - switch (httpResp.status) { - case HttpStatusCode.Ok: { - const resp = await readSuccessResponseJsonOrThrow( - httpResp, - codecForAny(), - ); - logger.trace(`purse deposit response: ${j2s(resp)}`); - - await ctx.transition(async (r) => { - if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) { - return TransitionResult.Stay; - } - r.status = PeerPullDebitRecordStatus.Done; - return TransitionResult.Transition; - }); - return TaskRunResult.finished(); - } - 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, - }; - } - } -} - -async function processPeerPullDebitAbortingRefresh( - ws: InternalWalletState, - peerPullInc: PeerPullPaymentIncomingRecord, -): Promise<TaskRunResult> { - const peerPullDebitId = peerPullInc.peerPullDebitId; - const abortRefreshGroupId = peerPullInc.abortRefreshGroupId; - checkLogicInvariant(!!abortRefreshGroupId); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - // FIXME: Shouldn't this be finished in some cases?! - return TaskRunResult.backoff(); -} - -export async function processPeerPullDebit( - ws: InternalWalletState, - peerPullDebitId: string, -): Promise<TaskRunResult> { - const peerPullInc = await ws.db.runReadOnlyTx( - ["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(ws, peerPullInc); - case PeerPullDebitRecordStatus.AbortingRefresh: - return await processPeerPullDebitAbortingRefresh(ws, peerPullInc); - } - return TaskRunResult.finished(); -} - -export async function confirmPeerPullDebit( - ws: InternalWalletState, - req: ConfirmPeerPullDebitRequest, -): Promise<AcceptPeerPullPaymentResponse> { - let peerPullDebitId: string; - - if (req.transactionId) { - const parsedTx = parseTransactionIdentifier(req.transactionId); - if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) { - throw Error("invalid peer-pull-debit transaction identifier"); - } - peerPullDebitId = parsedTx.peerPullDebitId; - } else if (req.peerPullDebitId) { - peerPullDebitId = req.peerPullDebitId; - } else { - throw Error("invalid request, transactionId or peerPullDebitId required"); - } - - const peerPullInc = await ws.db.runReadOnlyTx( - ["peerPullDebit"], - async (tx) => { - return tx.peerPullDebit.get(peerPullDebitId); - }, - ); - - if (!peerPullInc) { - throw Error( - `can't accept unknown incoming p2p pull payment (${req.peerPullDebitId})`, - ); - } - - const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); - - const coinSelRes = await selectPeerCoins(ws, { instructedAmount }); - if (logger.shouldLogTrace()) { - logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); - } - - if (coinSelRes.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); - } - - const sel = coinSelRes.result; - - const totalAmount = await getTotalPeerPaymentCost( - ws, - coinSelRes.result.coins, - ); - - await ws.db.runReadWriteTx( - [ - "exchanges", - "coins", - "denominations", - "refreshGroups", - "peerPullDebit", - "coinAvailability", - ], - async (tx) => { - await spendCoins(ws, tx, { - // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, - allocationId: constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }), - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ), - refreshReason: RefreshReason.PayPeerPull, - }); - - const pi = await tx.peerPullDebit.get(peerPullDebitId); - if (!pi) { - throw Error(); - } - if (pi.status === PeerPullDebitRecordStatus.DialogProposed) { - pi.status = PeerPullDebitRecordStatus.PendingDeposit; - pi.coinSel = { - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => x.contribution), - totalCost: Amounts.stringify(totalAmount), - }; - } - await tx.peerPullDebit.put(pi); - }, - ); - - const ctx = new PeerPullDebitTransactionContext(ws, peerPullDebitId); - - const transactionId = ctx.transactionId; - - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - - ws.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( - ws: InternalWalletState, - req: PreparePeerPullDebitRequest, -): Promise<PreparePeerPullDebitResponse> { - const uri = parsePayPullUri(req.talerUri); - - if (!uri) { - throw Error("got invalid taler://pay-pull URI"); - } - - const existing = await ws.db.runReadOnlyTx( - ["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 ws.http.fetch(getContractUrl.href); - - const contractResp = await readSuccessResponseJsonOrThrow( - contractHttpResp, - codecForExchangeGetContractResponse(), - ); - - const pursePub = contractResp.purse_pub; - - const dec = await ws.cryptoApi.decryptContractForDeposit({ - ciphertext: contractResp.econtract, - contractPriv: contractPriv, - pursePub: pursePub, - }); - - const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl); - - const purseHttpResp = await ws.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(ws, { instructedAmount }); - if (logger.shouldLogTrace()) { - logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); - } - - if (coinSelRes.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); - } - - const totalAmount = await getTotalPeerPaymentCost( - ws, - coinSelRes.result.coins, - ); - - await ws.db.runReadWriteTx(["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/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts @@ -1,1037 +0,0 @@ -/* - 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, - CancellationToken, - ConfirmPeerPushCreditRequest, - ContractTermsUtil, - ExchangePurseMergeRequest, - HttpStatusCode, - Logger, - NotificationType, - PeerContractTerms, - PreparePeerPushCreditRequest, - PreparePeerPushCreditResponse, - TalerErrorCode, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TransactionAction, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, - WalletAccountMergeFlags, - WalletKycUuid, - 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 { - InternalWalletState, - KycPendingInfo, - KycUserType, - PeerPushCreditStatus, - PeerPushPaymentIncomingRecord, - PendingTaskType, - TaskId, - WithdrawalGroupStatus, - WithdrawalRecordType, - timestampPreciseToDb, -} from "../index.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { - TaskRunResult, - TaskRunResultType, - TombstoneTag, - TransactionContext, - constructTaskIdentifier, -} from "./common.js"; -import { fetchFreshExchange } from "./exchanges.js"; -import { - codecForExchangePurseStatus, - getMergeReserveInfo, -} from "./pay-peer-common.js"; -import { - TransitionInfo, - constructTransactionIdentifier, - notifyTransition, - parseTransactionIdentifier, -} from "./transactions.js"; -import { - PerformCreateWithdrawalGroupResult, - getExchangeWithdrawalInfo, - internalPerformCreateWithdrawalGroup, - internalPrepareCreateWithdrawalGroup, -} from "./withdraw.js"; - -const logger = new Logger("pay-peer-push-credit.ts"); - -export class PeerPushCreditTransactionContext implements TransactionContext { - readonly transactionId: string; - readonly retryTag: TaskId; - - constructor( - public ws: InternalWalletState, - public peerPushCreditId: string, - ) { - this.transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushCredit, - peerPushCreditId, - }); - this.retryTag = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushCredit, - peerPushCreditId, - }); - } - - async deleteTransaction(): Promise<void> { - const { ws, peerPushCreditId } = this; - await ws.db.runReadWriteTx( - ["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 { ws, peerPushCreditId, retryTag, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.taskScheduler.stopShepherdTask(retryTag); - } - - async abortTransaction(): Promise<void> { - const { ws, peerPushCreditId, retryTag, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } - - async resumeTransaction(): Promise<void> { - const { ws, peerPushCreditId, retryTag, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } - - async failTransaction(): Promise<void> { - const { ws, peerPushCreditId, retryTag, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } -} - -export async function preparePeerPushCredit( - ws: InternalWalletState, - req: PreparePeerPushCreditRequest, -): Promise<PreparePeerPushCreditResponse> { - const uri = parsePayPushUri(req.talerUri); - - if (!uri) { - throw Error("got invalid taler://pay-push URI"); - } - - const existing = await ws.db.runReadOnlyTx( - ["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(ws, exchangeBaseUrl); - - const contractPriv = uri.contractPriv; - const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); - - const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); - - const contractHttpResp = await ws.http.fetch(getContractUrl.href); - - const contractResp = await readSuccessResponseJsonOrThrow( - contractHttpResp, - codecForExchangeGetContractResponse(), - ); - - const pursePub = contractResp.purse_pub; - - const dec = await ws.cryptoApi.decryptContractForMerge({ - ciphertext: contractResp.econtract, - contractPriv: contractPriv, - pursePub: pursePub, - }); - - const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); - - const purseHttpResp = await ws.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( - ws, - exchangeBaseUrl, - Amounts.parseOrThrow(purseStatus.balance), - undefined, - ); - - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - - 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( - ws: InternalWalletState, - peerPushCreditId: string, - exchangeUrl: string, - kycInfo: KycPendingInfo, - userType: KycUserType, - cancellationToken: CancellationToken, -): 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", "10000"); - logger.info(`kyc url ${url.href}`); - const kycStatusRes = await ws.http.fetch(url.href, { - method: "GET", - 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 ws.db.runReadWriteTx( - ["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(ws, 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(); -} - -async function processPeerPushCreditKycRequired( - ws: InternalWalletState, - 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 ws.http.fetch(url.href, { - method: "GET", - }); - - 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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - return result; - } else { - throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); - } -} - -async function handlePendingMerge( - ws: InternalWalletState, - 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(ws, { - exchangeBaseUrl: peerInc.exchangeBaseUrl, - }); - - const mergeTimestamp = TalerProtocolTimestamp.now(); - - const reservePayto = talerPaytoFromExchangeReserve( - peerInc.exchangeBaseUrl, - mergeReserveInfo.reservePub, - ); - - const sigRes = await ws.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 ws.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(ws, peerInc, kycPending); - } - - logger.trace(`merge request: ${j2s(mergeReq)}`); - const res = await readSuccessResponseJsonOrThrow( - mergeHttpResp, - codecForAny(), - ); - logger.trace(`merge response: ${j2s(res)}`); - - const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(ws, { - amount, - wgInfo: { - withdrawalType: WithdrawalRecordType.PeerPushCredit, - }, - forcedWithdrawalGroupId: peerInc.withdrawalGroupId, - exchangeBaseUrl: peerInc.exchangeBaseUrl, - reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, - reserveKeyPair: { - priv: mergeReserveInfo.reservePriv, - pub: mergeReserveInfo.reservePub, - }, - }); - - const txRes = await ws.db.runReadWriteTx( - [ - "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( - ws, - 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) { - ws.notify(txRes.wgCreateRes.exchangeNotif); - } - notifyTransition( - ws, - withdrawalGroupPrep.transactionId, - txRes?.wgCreateRes?.transitionInfo, - ); - notifyTransition(ws, transactionId, txRes?.peerPushCreditTransition); - - return TaskRunResult.backoff(); -} - -async function handlePendingWithdrawing( - ws: InternalWalletState, - peerInc: PeerPushPaymentIncomingRecord, -): Promise<TaskRunResult> { - if (!peerInc.withdrawalGroupId) { - throw Error("invalid db state (withdrawing, but no withdrawal group ID"); - } - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushCredit, - peerPushCreditId: peerInc.peerPushCreditId, - }); - const wgId = peerInc.withdrawalGroupId; - let finished: boolean = false; - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, 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( - ws: InternalWalletState, - peerPushCreditId: string, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - let peerInc: PeerPushPaymentIncomingRecord | undefined; - let contractTerms: PeerContractTerms | undefined; - await ws.db.runReadWriteTx( - ["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( - ws, - peerPushCreditId, - peerInc.exchangeBaseUrl, - peerInc.kycInfo, - "individual", - cancellationToken, - ); - } - - case PeerPushCreditStatus.PendingMerge: - return handlePendingMerge(ws, peerInc, contractTerms); - - case PeerPushCreditStatus.PendingWithdrawing: - return handlePendingWithdrawing(ws, peerInc); - - default: - return TaskRunResult.finished(); - } -} - -export async function confirmPeerPushCredit( - ws: InternalWalletState, - req: ConfirmPeerPushCreditRequest, -): Promise<AcceptPeerPushPaymentResponse> { - let peerInc: PeerPushPaymentIncomingRecord | undefined; - let peerPushCreditId: string; - if (req.peerPushCreditId) { - peerPushCreditId = req.peerPushCreditId; - } else if (req.transactionId) { - 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; - } else { - throw Error("no transaction ID (or deprecated peerPushCreditId) provided"); - } - - await ws.db.runReadWriteTx( - ["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.peerPushCreditId})`, - ); - } - - const ctx = new PeerPushCreditTransactionContext(ws, peerPushCreditId); - - ws.taskScheduler.startShepherdTask(ctx.retryTag); - - 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/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts @@ -1,1150 +0,0 @@ -/* - 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, - CancellationToken, - CheckPeerPushDebitRequest, - CheckPeerPushDebitResponse, - CoinRefreshRequest, - ContractTermsUtil, - HttpStatusCode, - InitiatePeerPushDebitRequest, - InitiatePeerPushDebitResponse, - Logger, - NotificationType, - RefreshReason, - TalerError, - TalerErrorCode, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TalerProtocolViolationError, - TransactionAction, - TransactionIdStr, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, - encodeCrock, - getRandomBytes, - j2s, -} from "@gnu-taler/taler-util"; -import { - HttpResponse, - readSuccessResponseJsonOrThrow, - readTalerErrorResponse, -} from "@gnu-taler/taler-util/http"; -import { EncryptContractRequest } from "../crypto/cryptoTypes.js"; -import { - PeerPushDebitRecord, - PeerPushDebitStatus, - RefreshOperationStatus, - createRefreshGroup, - timestampPreciseToDb, - timestampProtocolFromDb, - timestampProtocolToDb, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { PendingTaskType, TaskId } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js"; -import { checkLogicInvariant } from "../util/invariants.js"; -import { - TaskRunResult, - TaskRunResultType, - TransactionContext, - constructTaskIdentifier, - spendCoins, -} from "./common.js"; -import { - codecForExchangePurseStatus, - getTotalPeerPaymentCost, - queryCoinInfosForSelection, -} from "./pay-peer-common.js"; -import { - constructTransactionIdentifier, - notifyTransition, -} from "./transactions.js"; - -const logger = new Logger("pay-peer-push-debit.ts"); - -export class PeerPushDebitTransactionContext implements TransactionContext { - readonly transactionId: TransactionIdStr; - readonly retryTag: TaskId; - - constructor( - public ws: InternalWalletState, - public pursePub: string, - ) { - this.transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub, - }); - this.retryTag = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - } - - async deleteTransaction(): Promise<void> { - const { ws, pursePub, transactionId } = this; - await ws.db.runReadWriteTx(["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 { ws, pursePub, transactionId, retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - } - - async abortTransaction(): Promise<void> { - const { ws, pursePub, transactionId, retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } - - async resumeTransaction(): Promise<void> { - const { ws, pursePub, transactionId, retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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; - }, - ); - ws.taskScheduler.startShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - } - - async failTransaction(): Promise<void> { - const { ws, pursePub, transactionId, retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["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; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } -} - -export async function checkPeerPushDebit( - ws: InternalWalletState, - req: CheckPeerPushDebitRequest, -): Promise<CheckPeerPushDebitResponse> { - const instructedAmount = Amounts.parseOrThrow(req.amount); - logger.trace( - `checking peer push debit for ${Amounts.stringify(instructedAmount)}`, - ); - const coinSelRes = await selectPeerCoins(ws, { instructedAmount }); - if (coinSelRes.type === "failure") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); - } - logger.trace(`selected peer coins (len=${coinSelRes.result.coins.length})`); - const totalAmount = await getTotalPeerPaymentCost( - ws, - coinSelRes.result.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( - ws: InternalWalletState, - peerPushInitiation: PeerPushDebitRecord, - resp: HttpResponse, -): Promise<TaskRunResult> { - const pursePub = peerPushInitiation.pursePub; - const errResp = await readTalerErrorResponse(resp); - const ctx = new PeerPushDebitTransactionContext(ws, 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; - - const repair: PeerCoinRepair = { - coinPubs: [], - contribs: [], - exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl, - }; - - for (let i = 0; i < sel.coinPubs.length; i++) { - if (sel.coinPubs[i] != brokenCoinPub) { - repair.coinPubs.push(sel.coinPubs[i]); - repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i])); - } - } - - const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); - - if (coinSelRes.type == "failure") { - // FIXME: Details! - throw Error( - "insufficient balance to re-select coins to repair double spending", - ); - } - - await ws.db.runReadWriteTx(["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( - ws: InternalWalletState, - peerPushInitiation: PeerPushDebitRecord, -): Promise<TaskRunResult> { - const pursePub = peerPushInitiation.pursePub; - const purseExpiration = peerPushInitiation.purseExpiration; - const hContractTerms = peerPushInitiation.contractTermsHash; - const ctx = new PeerPushDebitTransactionContext(ws, pursePub); - const transactionId = ctx.transactionId; - - logger.trace(`processing ${transactionId} pending(create-reserve)`); - - const contractTermsRecord = await ws.db.runReadOnlyTx( - ["contractTerms"], - async (tx) => { - return tx.contractTerms.get(hContractTerms); - }, - ); - - if (!contractTermsRecord) { - throw Error( - `db invariant failed, contract terms for ${transactionId} missing`, - ); - } - - const purseSigResp = await ws.cryptoApi.signPurseCreation({ - hContractTerms, - mergePub: peerPushInitiation.mergePub, - minAge: 0, - purseAmount: peerPushInitiation.amount, - purseExpiration: timestampProtocolFromDb(purseExpiration), - pursePriv: peerPushInitiation.pursePriv, - }); - - const coins = await queryCoinInfosForSelection( - ws, - peerPushInitiation.coinSel, - ); - - const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ - exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl, - pursePub: peerPushInitiation.pursePub, - coins, - }); - - const encryptContractRequest: EncryptContractRequest = { - contractTerms: contractTermsRecord.contractTermsRaw, - mergePriv: peerPushInitiation.mergePriv, - pursePriv: peerPushInitiation.pursePriv, - pursePub: peerPushInitiation.pursePub, - contractPriv: peerPushInitiation.contractPriv, - contractPub: peerPushInitiation.contractPub, - nonce: peerPushInitiation.contractEncNonce, - }; - - logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`); - - const econtractResp = await ws.cryptoApi.encryptContractForMerge( - 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, - }; - - logger.trace(`request body: ${j2s(reqBody)}`); - - const httpResp = await ws.http.fetch(createPurseUrl.href, { - method: "POST", - body: reqBody, - }); - - { - const resp = await httpResp.json(); - logger.info(`resp: ${j2s(resp)}`); - } - - switch (httpResp.status) { - case HttpStatusCode.Ok: - break; - case HttpStatusCode.Forbidden: { - // FIXME: Store this error! - await ctx.failTransaction(); - return TaskRunResult.finished(); - } - case HttpStatusCode.Conflict: { - // Handle double-spending - return handlePurseCreationConflict(ws, peerPushInitiation, httpResp); - } - default: { - const errResp = await readTalerErrorResponse(httpResp); - return { - type: TaskRunResultType.Error, - errorDetail: errResp, - }; - } - } - - if (httpResp.status !== HttpStatusCode.Ok) { - // FIXME: do proper error reporting - throw Error("got error response from exchange"); - } - - await transitionPeerPushDebitTransaction(ws, pursePub, { - stFrom: PeerPushDebitStatus.PendingCreatePurse, - stTo: PeerPushDebitStatus.PendingReady, - }); - - return TaskRunResult.backoff(); -} - -async function processPeerPushDebitAbortingDeletePurse( - ws: InternalWalletState, - peerPushInitiation: PeerPushDebitRecord, -): Promise<TaskRunResult> { - const { pursePub, pursePriv } = peerPushInitiation; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub, - }); - - const sigResp = await ws.cryptoApi.signDeletePurse({ - pursePriv, - }); - const purseUrl = new URL( - `purses/${pursePub}`, - peerPushInitiation.exchangeBaseUrl, - ); - const resp = await ws.http.fetch(purseUrl.href, { - method: "DELETE", - headers: { - "taler-purse-signature": sigResp.sig, - }, - }); - logger.info(`deleted purse with response status ${resp.status}`); - - const transitionInfo = await ws.db.runReadWriteTx( - [ - "peerPushDebit", - "refreshGroups", - "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[] = []; - - 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( - ws, - 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(ws, transactionId, transitionInfo); - - return TaskRunResult.backoff(); -} - -interface SimpleTransition { - stFrom: PeerPushDebitStatus; - stTo: PeerPushDebitStatus; -} - -async function transitionPeerPushDebitTransaction( - ws: InternalWalletState, - pursePub: string, - transitionSpec: SimpleTransition, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub, - }); - const transitionInfo = await ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); -} - -async function processPeerPushDebitAbortingRefreshDeleted( - ws: InternalWalletState, - 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 ws.db.runReadWriteTx( - ["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(ws, transactionId, transitionInfo); - // FIXME: Shouldn't this be finished in some cases?! - return TaskRunResult.backoff(); -} - -async function processPeerPushDebitAbortingRefreshExpired( - ws: InternalWalletState, - 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 ws.db.runReadWriteTx( - ["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(ws, 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( - ws: InternalWalletState, - peerPushInitiation: PeerPushDebitRecord, - cancellationToken: CancellationToken, -): 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 ws.http.fetch(mergeUrl.href, { - // timeout: getReserveRequestTimeout(withdrawalGroup), - 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( - ws, - peerPushInitiation.pursePub, - { - stFrom: PeerPushDebitStatus.PendingReady, - stTo: PeerPushDebitStatus.Done, - }, - ); - return TaskRunResult.finished(); - } - } else if (resp.status === HttpStatusCode.Gone) { - logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`); - const transitionInfo = await ws.db.runReadWriteTx( - [ - "peerPushDebit", - "refreshGroups", - "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[] = []; - - 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( - ws, - tx, - currency, - coinPubs, - RefreshReason.AbortPeerPushDebit, - transactionId, - ); - ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired; - ppiRec.abortRefreshGroupId = refresh.refreshGroupId; - await tx.peerPushDebit.put(ppiRec); - const newTxState = computePeerPushDebitTransactionState(ppiRec); - return { - oldTxState, - newTxState, - }; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - return TaskRunResult.backoff(); - } else { - logger.warn(`unexpected HTTP status for purse: ${resp.status}`); - return TaskRunResult.backoff(); - } -} - -export async function processPeerPushDebit( - ws: InternalWalletState, - pursePub: string, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - const peerPushInitiation = await ws.db.runReadOnlyTx( - ["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(ws, peerPushInitiation); - case PeerPushDebitStatus.PendingReady: - return processPeerPushDebitReady( - ws, - peerPushInitiation, - cancellationToken, - ); - case PeerPushDebitStatus.AbortingDeletePurse: - return processPeerPushDebitAbortingDeletePurse(ws, peerPushInitiation); - case PeerPushDebitStatus.AbortingRefreshDeleted: - return processPeerPushDebitAbortingRefreshDeleted(ws, peerPushInitiation); - case PeerPushDebitStatus.AbortingRefreshExpired: - return processPeerPushDebitAbortingRefreshExpired(ws, 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( - ws: InternalWalletState, - req: InitiatePeerPushDebitRequest, -): Promise<InitiatePeerPushDebitResponse> { - const instructedAmount = Amounts.parseOrThrow( - req.partialContractTerms.amount, - ); - const purseExpiration = req.partialContractTerms.purse_expiration; - const contractTerms = req.partialContractTerms; - - const pursePair = await ws.cryptoApi.createEddsaKeypair({}); - const mergePair = await ws.cryptoApi.createEddsaKeypair({}); - - const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); - - const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({}); - - const coinSelRes = await selectPeerCoins(ws, { instructedAmount }); - - if (coinSelRes.type !== "success") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, - }, - ); - } - - const sel = coinSelRes.result; - - logger.info(`selected p2p coins (push):`); - logger.trace(`${j2s(coinSelRes)}`); - - const totalAmount = await getTotalPeerPaymentCost( - ws, - coinSelRes.result.coins, - ); - - logger.info(`computed total peer payment cost`); - - const pursePub = pursePair.pub; - - const ctx = new PeerPushDebitTransactionContext(ws, pursePub); - - const transactionId = ctx.transactionId; - - const contractEncNonce = encodeCrock(getRandomBytes(24)); - - const transitionInfo = await ws.db.runReadWriteTx( - [ - "exchanges", - "contractTerms", - "coins", - "coinAvailability", - "denominations", - "refreshGroups", - "peerPushDebit", - ], - async (tx) => { - // 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(ws, tx, { - allocationId: constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub: pursePair.pub, - }), - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ), - refreshReason: RefreshReason.PayPeerPush, - }); - - 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, - coinSel: { - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => x.contribution), - }, - totalCost: Amounts.stringify(totalAmount), - }; - - await tx.peerPushDebit.add(ppi); - - await tx.contractTerms.put({ - h: hContractTerms, - contractTermsRaw: contractTerms, - }); - - const newTxState = computePeerPushDebitTransactionState(ppi); - return { - oldTxState: { major: TransactionMajorState.None }, - newTxState, - }; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - - ws.taskScheduler.startShepherdTask(ctx.retryTag); - - 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/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts @@ -1,535 +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, - CoinStatus, - Logger, - RefreshReason, - TalerPreciseTimestamp, - TransactionType, - URL, - codecForRecoupConfirmation, - codecForReserveStatus, - encodeCrock, - getRandomBytes, - j2s, -} from "@gnu-taler/taler-util"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { - CoinRecord, - CoinSourceType, - RecoupGroupRecord, - RecoupOperationStatus, - RefreshCoinSource, - WalletDbReadWriteTransaction, - WithdrawCoinSource, - WithdrawalGroupStatus, - WithdrawalRecordType, - timestampPreciseToDb, -} from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { PendingTaskType } from "../pending-types.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { - TaskRunResult, - TransactionContext, - constructTaskIdentifier, -} from "./common.js"; -import { createRefreshGroup } from "./refresh.js"; -import { constructTransactionIdentifier } from "./transactions.js"; -import { internalCreateWithdrawalGroup } from "./withdraw.js"; - -const logger = new Logger("operations/recoup.ts"); - -/** - * Store a recoup group record in the database after marking - * a coin in the group as finished. - */ -async function putGroupAsFinished( - ws: InternalWalletState, - 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( - 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.runReadWriteTx( - ["recoupGroups", "denominations", "refreshGroups", "coins"], - 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 denomInfo = await ws.db.runReadOnlyTx(["denominations"], async (tx) => { - const denomInfo = await ws.getDenomInfo( - ws, - 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 ws.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 ws.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 ws.db.runReadWriteTx( - ["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(ws, tx, recoupGroup, coinIdx); - }, - ); -} - -async function recoupRefreshCoin( - ws: InternalWalletState, - recoupGroupId: string, - coinIdx: number, - coin: CoinRecord, - cs: RefreshCoinSource, -): Promise<void> { - const d = await ws.db.runReadOnlyTx( - ["coins", "denominations"], - async (tx) => { - const denomInfo = await ws.getDenomInfo( - ws, - 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 ws.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 ws.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 ws.db.runReadWriteTx( - ["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 ws.getDenomInfo( - ws, - tx, - oldCoin.exchangeBaseUrl, - oldCoin.denomPubHash, - ); - const revokedCoinDenom = await ws.getDenomInfo( - ws, - 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(ws, tx, recoupGroup, coinIdx); - }, - ); -} - -export async function processRecoupGroup( - ws: InternalWalletState, - recoupGroupId: string, -): Promise<TaskRunResult> { - let recoupGroup = await ws.db.runReadOnlyTx(["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(ws, recoupGroupId, i); - } catch (e) { - logger.warn(`processRecoup failed: ${e}`); - throw e; - } - }); - await Promise.all(ps); - - recoupGroup = await ws.db.runReadOnlyTx(["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 ws.db.runReadOnlyTx(["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 ws.http.fetch(reserveUrl.href); - - const result = await readSuccessResponseJsonOrThrow( - resp, - codecForReserveStatus(), - ); - await internalCreateWithdrawalGroup(ws, { - amount: Amounts.parseOrThrow(result.balance), - exchangeBaseUrl: recoupGroup.exchangeBaseUrl, - reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, - reserveKeyPair: { - pub: reservePub, - priv: reservePrivMap[reservePub], - }, - wgInfo: { - withdrawalType: WithdrawalRecordType.Recoup, - }, - }); - } - - await ws.db.runReadWriteTx( - [ - "recoupGroups", - "coinAvailability", - "denominations", - "refreshGroups", - "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( - ws, - 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 RewardTransactionContext 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: string; - public retryTag: string; - - constructor( - public ws: InternalWalletState, - private recoupGroupId: string, - ) { - this.transactionId = constructTransactionIdentifier({ - tag: TransactionType.Recoup, - recoupGroupId, - }); - this.retryTag = constructTaskIdentifier({ - tag: PendingTaskType.Recoup, - recoupGroupId, - }); - } -} - -export async function createRecoupGroup( - ws: InternalWalletState, - 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(ws, tx, recoupGroup, coinIdx); - continue; - } - await tx.coins.put(coin); - } - - await tx.recoupGroups.put(recoupGroup); - - return recoupGroupId; -} - -/** - * Run the recoup protocol for a single coin in a recoup group. - */ -async function processRecoupForCoin( - ws: InternalWalletState, - recoupGroupId: string, - coinIdx: number, -): Promise<void> { - const coin = await ws.db.runReadOnlyTx( - ["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(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 @@ -1,1430 +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 { - AgeCommitment, - AgeRestriction, - AmountJson, - Amounts, - amountToPretty, - CancellationToken, - codecForExchangeMeltResponse, - codecForExchangeRevealResponse, - CoinPublicKeyString, - CoinRefreshRequest, - CoinStatus, - DenominationInfo, - DenomKeyType, - Duration, - encodeCrock, - ExchangeMeltRequest, - ExchangeProtocolVersion, - ExchangeRefreshRevealRequest, - fnutil, - ForceRefreshRequest, - getErrorDetailFromException, - getRandomBytes, - HashCodeString, - HttpStatusCode, - j2s, - Logger, - makeErrorDetail, - NotificationType, - RefreshGroupId, - RefreshReason, - TalerError, - TalerErrorCode, - TalerErrorDetail, - TalerPreciseTimestamp, - TransactionAction, - TransactionMajorState, - TransactionState, - TransactionType, - URL, -} from "@gnu-taler/taler-util"; -import { - readSuccessResponseJsonOrThrow, - readUnexpectedResponseDetails, -} from "@gnu-taler/taler-util/http"; -import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js"; -import { - DerivedRefreshSession, - RefreshNewDenomInfo, -} from "../crypto/cryptoTypes.js"; -import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js"; -import { - CoinRecord, - CoinSourceType, - DenominationRecord, - RefreshCoinStatus, - RefreshGroupRecord, - RefreshOperationStatus, -} from "../db.js"; -import { - getCandidateWithdrawalDenomsTx, - PendingTaskType, - RefreshGroupPerExchangeInfo, - RefreshSessionRecord, - TaskId, - timestampPreciseToDb, - WalletDbReadOnlyTransaction, - WalletDbReadWriteTransaction, -} from "../index.js"; -import { - EXCHANGE_COINS_LOCK, - InternalWalletState, -} from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { selectWithdrawalDenominations } from "../util/coinSelection.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { - constructTaskIdentifier, - makeCoinAvailable, - makeCoinsVisible, - TaskRunResult, - TaskRunResultType, - TombstoneTag, - TransactionContext, -} from "./common.js"; -import { fetchFreshExchange } from "./exchanges.js"; -import { - constructTransactionIdentifier, - notifyTransition, -} from "./transactions.js"; - -const logger = new Logger("refresh.ts"); - -export class RefreshTransactionContext implements TransactionContext { - public transactionId: string; - readonly taskId: TaskId; - - constructor( - public ws: InternalWalletState, - public refreshGroupId: string, - ) { - this.transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - this.taskId = constructTaskIdentifier({ - tag: PendingTaskType.Refresh, - refreshGroupId, - }); - } - - async deleteTransaction(): Promise<void> { - const refreshGroupId = this.refreshGroupId; - const ws = this.ws; - await ws.db.runReadWriteTx(["refreshGroups", "tombstones"], async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (rg) { - await tx.refreshGroups.delete(refreshGroupId); - await tx.tombstones.put({ - id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId, - }); - } - }); - } - - async suspendTransaction(): Promise<void> { - const { ws, refreshGroupId, transactionId } = this; - let res = await ws.db.runReadWriteTx(["refreshGroups"], async (tx) => { - const dg = await tx.refreshGroups.get(refreshGroupId); - if (!dg) { - logger.warn( - `can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`, - ); - return undefined; - } - const oldState = computeRefreshTransactionState(dg); - switch (dg.operationStatus) { - case RefreshOperationStatus.Finished: - return undefined; - case RefreshOperationStatus.Pending: { - dg.operationStatus = RefreshOperationStatus.Suspended; - await tx.refreshGroups.put(dg); - return { - oldTxState: oldState, - newTxState: computeRefreshTransactionState(dg), - }; - } - case RefreshOperationStatus.Suspended: - return undefined; - } - return undefined; - }); - if (res) { - ws.notify({ - type: NotificationType.TransactionStateTransition, - transactionId, - oldTxState: res.oldTxState, - newTxState: res.newTxState, - }); - } - } - - async abortTransaction(): Promise<void> { - // Refresh transactions only support fail, not abort. - throw new Error("refresh transactions cannot be aborted"); - } - - async resumeTransaction(): Promise<void> { - const { ws, refreshGroupId, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["refreshGroups"], - async (tx) => { - const dg = await tx.refreshGroups.get(refreshGroupId); - if (!dg) { - logger.warn( - `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`, - ); - return; - } - const oldState = computeRefreshTransactionState(dg); - switch (dg.operationStatus) { - case RefreshOperationStatus.Finished: - return; - case RefreshOperationStatus.Pending: { - return; - } - case RefreshOperationStatus.Suspended: - dg.operationStatus = RefreshOperationStatus.Pending; - await tx.refreshGroups.put(dg); - return { - oldTxState: oldState, - newTxState: computeRefreshTransactionState(dg), - }; - } - return undefined; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(this.taskId); - } - - async failTransaction(): Promise<void> { - const { ws, refreshGroupId, transactionId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["refreshGroups"], - async (tx) => { - const dg = await tx.refreshGroups.get(refreshGroupId); - if (!dg) { - logger.warn( - `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`, - ); - return; - } - const oldState = computeRefreshTransactionState(dg); - let newStatus: RefreshOperationStatus | undefined; - switch (dg.operationStatus) { - case RefreshOperationStatus.Finished: - break; - case RefreshOperationStatus.Pending: - case RefreshOperationStatus.Suspended: - newStatus = RefreshOperationStatus.Failed; - break; - case RefreshOperationStatus.Failed: - break; - default: - assertUnreachable(dg.operationStatus); - } - if (newStatus) { - dg.operationStatus = newStatus; - await tx.refreshGroups.put(dg); - } - return { - oldTxState: oldState, - newTxState: computeRefreshTransactionState(dg), - }; - }, - ); - ws.taskScheduler.stopShepherdTask(this.taskId); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(this.taskId); - } -} - -/** - * 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: DenominationInfo, - amountLeft: AmountJson, - denomselAllowLate: boolean, -): AmountJson { - const withdrawAmount = Amounts.sub( - amountLeft, - refreshedDenom.feeRefresh, - ).amount; - const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x])); - const withdrawDenoms = selectWithdrawalDenominations( - withdrawAmount, - denoms, - denomselAllowLate, - ); - 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; -} - -function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } { - 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; - } - return { final: true }; - } - return { final: false }; -} - -/** - * Create a refresh session for one particular coin inside a refresh group. - * - * If the session already exists, return the existing one. - * - * If the session doesn't need to be created (refresh group gone or session already - * finished), return undefined. - */ -async function provideRefreshSession( - ws: InternalWalletState, - refreshGroupId: string, - coinIndex: number, -): Promise<RefreshSessionRecord | undefined> { - logger.trace( - `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`, - ); - - const d = await ws.db.runReadWriteTx( - ["coins", "refreshGroups", "refreshSessions"], - async (tx) => { - const refreshGroup = await tx.refreshGroups.get(refreshGroupId); - if (!refreshGroup) { - return; - } - if ( - refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished - ) { - return; - } - const existingRefreshSession = await tx.refreshSessions.get([ - refreshGroupId, - coinIndex, - ]); - 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, existingRefreshSession }; - }, - ); - - if (!d) { - return undefined; - } - - if (d.existingRefreshSession) { - return d.existingRefreshSession; - } - - const { refreshGroup, coin } = d; - - const exch = await fetchFreshExchange(ws, coin.exchangeBaseUrl); - - // FIXME: use helper functions from withdraw.ts - // to update and filter withdrawable denoms. - - const { availableAmount, availableDenoms } = await ws.db.runReadOnlyTx( - ["denominations"], - async (tx) => { - const oldDenom = await ws.getDenomInfo( - ws, - tx, - exch.exchangeBaseUrl, - coin.denomPubHash, - ); - - if (!oldDenom) { - throw Error("db inconsistent: denomination for coin not found"); - } - - // FIXME: Use denom groups instead of querying all denominations! - const availableDenoms: DenominationRecord[] = - await tx.denominations.indexes.byExchangeBaseUrl - .iter(exch.exchangeBaseUrl) - .toArray(); - - const availableAmount = Amounts.sub( - refreshGroup.inputPerCoin[coinIndex], - oldDenom.feeRefresh, - ).amount; - return { availableAmount, availableDenoms }; - }, - ); - - const newCoinDenoms = selectWithdrawalDenominations( - availableAmount, - availableDenoms, - ws.config.testing.denomselAllowLate, - ); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - - if (newCoinDenoms.selectedDenoms.length === 0) { - logger.trace( - `not refreshing, available amount ${amountToPretty( - availableAmount, - )} too small`, - ); - const transitionInfo = await ws.db.runReadWriteTx( - ["refreshGroups", "coins", "coinAvailability"], - async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - return; - } - const oldTxState = computeRefreshTransactionState(rg); - rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; - const updateRes = updateGroupStatus(rg); - if (updateRes.final) { - await makeCoinsVisible(ws, tx, transactionId); - } - await tx.refreshGroups.put(rg); - const newTxState = computeRefreshTransactionState(rg); - return { oldTxState, newTxState }; - }, - ); - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - notifyTransition(ws, transactionId, transitionInfo); - return; - } - - const sessionSecretSeed = encodeCrock(getRandomBytes(64)); - - // Store refresh session for this coin in the database. - const mySession = await ws.db.runReadWriteTx( - ["refreshGroups", "refreshSessions"], - async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - return; - } - const existingSession = await tx.refreshSessions.get([ - refreshGroupId, - coinIndex, - ]); - if (existingSession) { - return existingSession; - } - 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); - return newSession; - }, - ); - logger.trace( - `found/created refresh session for coin #${coinIndex} in ${refreshGroupId}`, - ); - return mySession; -} - -function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration { - return Duration.fromSpec({ - seconds: 5, - }); -} - -async function refreshMelt( - ws: InternalWalletState, - refreshGroupId: string, - coinIndex: number, -): Promise<void> { - const d = await ws.db.runReadWriteTx( - ["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 ws.getDenomInfo( - ws, - 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 ws.getDenomInfo( - ws, - 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 ws.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 ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => { - return await ws.http.fetch(reqUrl.href, { - method: "POST", - body: meltReqBody, - timeout: getRefreshRequestTimeout(refreshGroup), - }); - }); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - - if (resp.status === HttpStatusCode.NotFound) { - const errDetails = await readUnexpectedResponseDetails(resp); - const transitionInfo = await ws.db.runReadWriteTx( - ["refreshGroups", "refreshSessions", "coins", "coinAvailability"], - async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - return; - } - if (rg.timestampFinished) { - return; - } - if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { - return; - } - const oldTxState = computeRefreshTransactionState(rg); - rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; - const refreshSession = await tx.refreshSessions.get([ - refreshGroupId, - coinIndex, - ]); - if (!refreshSession) { - throw Error( - "db invariant failed: missing refresh session in database", - ); - } - refreshSession.lastError = errDetails; - const updateRes = updateGroupStatus(rg); - if (updateRes.final) { - await makeCoinsVisible(ws, tx, transactionId); - } - await tx.refreshGroups.put(rg); - await tx.refreshSessions.put(refreshSession); - const newTxState = computeRefreshTransactionState(rg); - return { - oldTxState, - newTxState, - }; - }, - ); - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - notifyTransition(ws, transactionId, transitionInfo); - return; - } - - if (resp.status === HttpStatusCode.Conflict) { - // 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 ${refreshGroupId} due to conflict`, - ); - - const historySig = await ws.cryptoApi.signCoinHistoryRequest({ - coinPriv: oldCoin.coinPriv, - coinPub: oldCoin.coinPub, - startOffset: 0, - }); - - const historyUrl = new URL( - `coins/${oldCoin.coinPub}/history`, - oldCoin.exchangeBaseUrl, - ); - - const historyResp = await ws.http.fetch(historyUrl.href, { - method: "GET", - headers: { - "Taler-Coin-History-Signature": historySig.sig, - }, - }); - - const historyJson = await historyResp.json(); - logger.info(`coin history: ${j2s(historyJson)}`); - - // FIXME: Before failing and re-trying, analyse response and adjust amount - } - - const meltResponse = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeMeltResponse(), - ); - - const norevealIndex = meltResponse.noreveal_index; - - refreshSession.norevealIndex = norevealIndex; - - await ws.db.runReadWriteTx( - ["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); - }, - ); -} - -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( - ws: InternalWalletState, - refreshGroupId: string, - coinIndex: number, -): Promise<void> { - logger.trace( - `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`, - ); - const d = await ws.db.runReadOnlyTx( - ["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 ws.getDenomInfo( - ws, - 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 ws.getDenomInfo( - ws, - 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 ws.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: ws.cryptoApi, - derived, - newDenoms: newCoinDenoms, - norevealIndex: norevealIndex, - oldCoinPriv: oldCoin.coinPriv, - oldCoinPub: oldCoin.coinPub, - oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment, - }); - - const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => { - return await ws.http.fetch(reqUrl.href, { - body: req, - method: "POST", - timeout: getRefreshRequestTimeout(refreshGroup), - }); - }); - - 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 ws.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); - } - } - - const transitionInfo = await ws.db.runReadWriteTx( - [ - "coins", - "denominations", - "coinAvailability", - "refreshGroups", - "refreshSessions", - ], - async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (!rg) { - logger.warn("no refresh session found"); - return; - } - const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); - if (!rs) { - return; - } - const oldTxState = computeRefreshTransactionState(rg); - rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; - updateGroupStatus(rg); - for (const coin of coins) { - await makeCoinAvailable(ws, tx, coin); - } - await makeCoinsVisible(ws, tx, transactionId); - await tx.refreshGroups.put(rg); - const newTxState = computeRefreshTransactionState(rg); - return { oldTxState, newTxState }; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - logger.trace("refresh finished (end of reveal)"); -} - -export async function processRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - logger.trace(`processing refresh group ${refreshGroupId}`); - - const refreshGroup = await ws.db.runReadOnlyTx( - ["refreshGroups"], - async (tx) => tx.refreshGroups.get(refreshGroupId), - ); - if (!refreshGroup) { - return TaskRunResult.finished(); - } - if (refreshGroup.timestampFinished) { - return TaskRunResult.finished(); - } - // 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(ws, 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)); - }), - ); - try { - logger.info("waiting for refreshes"); - await Promise.all(ps); - logger.info("refresh group finished"); - } catch (e) { - logger.warn("process refresh sessions got exception"); - logger.warn(`exception: ${e}`); - } - if (inShutdown) { - return TaskRunResult.backoff(); - } - 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( - ws: InternalWalletState, - refreshGroupId: string, - coinIndex: number, -): Promise<void> { - logger.trace( - `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`, - ); - let { refreshGroup, refreshSession } = await ws.db.runReadOnlyTx( - ["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) { - refreshSession = await provideRefreshSession(ws, refreshGroupId, coinIndex); - } - if (!refreshSession) { - // We tried to create the refresh session, but didn't get a result back. - // This means that either the session is finished, or that creating - // one isn't necessary. - return; - } - if (refreshSession.norevealIndex === undefined) { - await refreshMelt(ws, refreshGroupId, coinIndex); - } - await refreshReveal(ws, refreshGroupId, coinIndex); -} - -export interface RefreshOutputInfo { - outputPerCoin: AmountJson[]; - perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>; -} - -export async function calculateRefreshOutput( - ws: InternalWalletState, - 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> = {}; - - // FIXME: Use denom groups instead of querying all denominations! - const getDenoms = async ( - exchangeBaseUrl: string, - ): Promise<DenominationRecord[]> => { - if (denomsPerExchange[exchangeBaseUrl]) { - return denomsPerExchange[exchangeBaseUrl]; - } - const allDenoms = await getCandidateWithdrawalDenomsTx( - ws, - tx, - exchangeBaseUrl, - currency, - ); - 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 ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant( - !!denom, - "denomination for existing coin must be in database", - ); - const refreshAmount = ocp.amount; - const denoms = await getDenoms(coin.exchangeBaseUrl); - const cost = getTotalRefreshCost( - denoms, - denom, - Amounts.parseOrThrow(refreshAmount), - ws.config.testing.denomselAllowLate, - ); - 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 applyRefresh( - ws: InternalWalletState, - 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 ws.getDenomInfo( - ws, - 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; - } - 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; -} - -/** - * 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( - ws: InternalWalletState, - tx: WalletDbReadWriteTransaction< - ["denominations", "coins", "refreshGroups", "coinAvailability"] - >, - currency: string, - oldCoinPubs: CoinRefreshRequest[], - refreshReason: RefreshReason, - originatingTransactionId: string | undefined, -): Promise<CreateRefreshGroupResult> { - const refreshGroupId = encodeCrock(getRandomBytes(32)); - - const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs); - - const estimatedOutputPerCoin = outInfo.outputPerCoin; - - await applyRefresh(ws, 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), - ), - 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; - } - - await tx.refreshGroups.put(refreshGroup); - - logger.trace(`created refresh group ${refreshGroupId}`); - - const ctx = new RefreshTransactionContext(ws, refreshGroupId); - - // Shepherd the task. - // If the current transaction fails to commit the refresh - // group to the DB, the shepherd will give up. - ws.taskScheduler.startShepherdTask(ctx.taskId); - - return { - refreshGroupId, - }; -} - -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( - ws: InternalWalletState, - transactionId: string, -): Promise<string[]> { - return ws.db.runReadOnlyTx(["refreshGroups"], async (tx) => { - const groups = - await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll( - transactionId, - ); - return groups.map((x) => - constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId: x.refreshGroupId, - }), - ); - }); -} - -export async function forceRefresh( - ws: InternalWalletState, - req: ForceRefreshRequest, -): Promise<{ refreshGroupId: RefreshGroupId }> { - if (req.coinPubList.length == 0) { - throw Error("refusing to create empty refresh group"); - } - const refreshGroupId = await ws.db.runReadWriteTx( - ["refreshGroups", "coinAvailability", "denominations", "coins"], - async (tx) => { - let coinPubs: CoinRefreshRequest[] = []; - for (const c of req.coinPubList) { - const coin = await tx.coins.get(c); - if (!coin) { - throw Error(`coin (pubkey ${c}) not found`); - } - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant(!!denom); - coinPubs.push({ - coinPub: c, - amount: denom?.value, - }); - } - return await createRefreshGroup( - ws, - tx, - Amounts.currencyOf(coinPubs[0].amount), - coinPubs, - RefreshReason.Manual, - undefined, - ); - }, - ); - - return { - refreshGroupId, - }; -} diff --git a/packages/taler-wallet-core/src/operations/reward.ts b/packages/taler-wallet-core/src/operations/reward.ts @@ -1,321 +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 { - AcceptTipResponse, - Logger, - PrepareTipResult, - TransactionAction, - TransactionIdStr, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, -} from "@gnu-taler/taler-util"; -import { RewardRecord, RewardRecordStatus } from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { PendingTaskType } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { - TaskRunResult, - TombstoneTag, - TransactionContext, - constructTaskIdentifier, -} from "./common.js"; -import { - constructTransactionIdentifier, - notifyTransition, -} from "./transactions.js"; - -const logger = new Logger("operations/tip.ts"); - -export class RewardTransactionContext implements TransactionContext { - public transactionId: string; - public retryTag: string; - - constructor( - public ws: InternalWalletState, - public walletRewardId: string, - ) { - this.transactionId = constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId, - }); - this.retryTag = constructTaskIdentifier({ - tag: PendingTaskType.RewardPickup, - walletRewardId, - }); - } - - async deleteTransaction(): Promise<void> { - const { ws, walletRewardId } = this; - await ws.db.runReadWriteTx(["rewards", "tombstones"], async (tx) => { - const tipRecord = await tx.rewards.get(walletRewardId); - if (tipRecord) { - await tx.rewards.delete(walletRewardId); - await tx.tombstones.put({ - id: TombstoneTag.DeleteReward + ":" + walletRewardId, - }); - } - }); - } - - async suspendTransaction(): Promise<void> { - const { ws, walletRewardId, transactionId, retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["rewards"], - async (tx) => { - const tipRec = await tx.rewards.get(walletRewardId); - if (!tipRec) { - logger.warn(`transaction tip ${walletRewardId} not found`); - return; - } - let newStatus: RewardRecordStatus | undefined = undefined; - switch (tipRec.status) { - case RewardRecordStatus.Done: - case RewardRecordStatus.SuspendedPickup: - case RewardRecordStatus.Aborted: - case RewardRecordStatus.DialogAccept: - case RewardRecordStatus.Failed: - break; - case RewardRecordStatus.PendingPickup: - newStatus = RewardRecordStatus.SuspendedPickup; - break; - - default: - assertUnreachable(tipRec.status); - } - if (newStatus != null) { - const oldTxState = computeRewardTransactionStatus(tipRec); - tipRec.status = newStatus; - const newTxState = computeRewardTransactionStatus(tipRec); - await tx.rewards.put(tipRec); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - } - - async abortTransaction(): Promise<void> { - const { ws, walletRewardId, transactionId, retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["rewards"], - async (tx) => { - const tipRec = await tx.rewards.get(walletRewardId); - if (!tipRec) { - logger.warn(`transaction tip ${walletRewardId} not found`); - return; - } - let newStatus: RewardRecordStatus | undefined = undefined; - switch (tipRec.status) { - case RewardRecordStatus.Done: - case RewardRecordStatus.Aborted: - case RewardRecordStatus.PendingPickup: - case RewardRecordStatus.DialogAccept: - case RewardRecordStatus.Failed: - break; - case RewardRecordStatus.SuspendedPickup: - newStatus = RewardRecordStatus.Aborted; - break; - default: - assertUnreachable(tipRec.status); - } - if (newStatus != null) { - const oldTxState = computeRewardTransactionStatus(tipRec); - tipRec.status = newStatus; - const newTxState = computeRewardTransactionStatus(tipRec); - await tx.rewards.put(tipRec); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - } - - async resumeTransaction(): Promise<void> { - const { ws, walletRewardId, transactionId, retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["rewards"], - async (tx) => { - const rewardRec = await tx.rewards.get(walletRewardId); - if (!rewardRec) { - logger.warn(`transaction reward ${walletRewardId} not found`); - return; - } - let newStatus: RewardRecordStatus | undefined = undefined; - switch (rewardRec.status) { - case RewardRecordStatus.Done: - case RewardRecordStatus.PendingPickup: - case RewardRecordStatus.Aborted: - case RewardRecordStatus.DialogAccept: - case RewardRecordStatus.Failed: - break; - case RewardRecordStatus.SuspendedPickup: - newStatus = RewardRecordStatus.PendingPickup; - break; - default: - assertUnreachable(rewardRec.status); - } - if (newStatus != null) { - const oldTxState = computeRewardTransactionStatus(rewardRec); - rewardRec.status = newStatus; - const newTxState = computeRewardTransactionStatus(rewardRec); - await tx.rewards.put(rewardRec); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - } - - async failTransaction(): Promise<void> { - const { ws, walletRewardId, transactionId, retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["rewards"], - async (tx) => { - const tipRec = await tx.rewards.get(walletRewardId); - if (!tipRec) { - logger.warn(`transaction tip ${walletRewardId} not found`); - return; - } - let newStatus: RewardRecordStatus | undefined = undefined; - switch (tipRec.status) { - case RewardRecordStatus.Done: - case RewardRecordStatus.Aborted: - case RewardRecordStatus.Failed: - break; - case RewardRecordStatus.PendingPickup: - case RewardRecordStatus.DialogAccept: - case RewardRecordStatus.SuspendedPickup: - newStatus = RewardRecordStatus.Failed; - break; - default: - assertUnreachable(tipRec.status); - } - if (newStatus != null) { - const oldTxState = computeRewardTransactionStatus(tipRec); - tipRec.status = newStatus; - const newTxState = computeRewardTransactionStatus(tipRec); - await tx.rewards.put(tipRec); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - } -} - -/** - * Get the (DD37-style) transaction status based on the - * database record of a reward. - */ -export function computeRewardTransactionStatus( - tipRecord: RewardRecord, -): TransactionState { - switch (tipRecord.status) { - case RewardRecordStatus.Done: - return { - major: TransactionMajorState.Done, - }; - case RewardRecordStatus.Aborted: - return { - major: TransactionMajorState.Aborted, - }; - case RewardRecordStatus.PendingPickup: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.Pickup, - }; - case RewardRecordStatus.DialogAccept: - return { - major: TransactionMajorState.Dialog, - minor: TransactionMinorState.Proposed, - }; - case RewardRecordStatus.SuspendedPickup: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.Pickup, - }; - case RewardRecordStatus.Failed: - return { - major: TransactionMajorState.Failed, - }; - default: - assertUnreachable(tipRecord.status); - } -} - -export function computeTipTransactionActions( - tipRecord: RewardRecord, -): TransactionAction[] { - switch (tipRecord.status) { - case RewardRecordStatus.Done: - return [TransactionAction.Delete]; - case RewardRecordStatus.Failed: - return [TransactionAction.Delete]; - case RewardRecordStatus.Aborted: - return [TransactionAction.Delete]; - case RewardRecordStatus.PendingPickup: - return [TransactionAction.Suspend, TransactionAction.Fail]; - case RewardRecordStatus.SuspendedPickup: - return [TransactionAction.Resume, TransactionAction.Fail]; - case RewardRecordStatus.DialogAccept: - return [TransactionAction.Abort]; - default: - assertUnreachable(tipRecord.status); - } -} - -export async function prepareReward( - ws: InternalWalletState, - talerTipUri: string, -): Promise<PrepareTipResult> { - throw Error("the rewards feature is not supported anymore"); -} - -export async function processTip( - ws: InternalWalletState, - walletTipId: string, -): Promise<TaskRunResult> { - return TaskRunResult.finished(); -} - -export async function acceptTip( - ws: InternalWalletState, - transactionId: TransactionIdStr, -): Promise<AcceptTipResponse> { - throw Error("the rewards feature is not supported anymore"); -} diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts @@ -1,913 +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/> - */ - -/** - * @file - * Implementation of wallet-core operations that are used for testing, - * but typically not in the production wallet. - */ - -/** - * Imports. - */ -import { - AbsoluteTime, - addPaytoQueryParams, - Amounts, - AmountString, - CheckPaymentResponse, - codecForAny, - codecForCheckPaymentResponse, - ConfirmPayResultType, - Duration, - IntegrationTestArgs, - IntegrationTestV2Args, - j2s, - Logger, - NotificationType, - OpenedPromise, - openPromise, - 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 { getRefreshesForTransaction } from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { checkLogicInvariant } from "../util/invariants.js"; -import { getBalances } from "./balance.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 { getTransactionById, getTransactions } from "./transactions.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( - ws: InternalWalletState, - 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(ws, { - 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( - ws: InternalWalletState, - merchant: MerchantBackendInfo, - amount: string, - summary: string, -): Promise<MakePaymentResult> { - 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.transactionId, - 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, - paymentTransactionId: preparePayResult.transactionId, - }; -} - -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"); - const withdrawRes1 = await withdrawTestBalance(ws, { - amount: args.amountToWithdraw, - corebankApiBaseUrl: args.corebankApiBaseUrl, - exchangeBaseUrl: args.exchangeBaseUrl, - }); - await waitUntilGivenTransactionsFinal(ws, [withdrawRes1.transactionId]); - 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, - }; - - const makePaymentRes = await makePayment( - ws, - myMerchant, - args.amountToSpend, - "hello world", - ); - - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - 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(ws, { - amount: Amounts.stringify(withdrawAmountTwo), - corebankApiBaseUrl: args.corebankApiBaseUrl, - exchangeBaseUrl: args.exchangeBaseUrl, - }); - - await waitUntilGivenTransactionsFinal(ws, [withdrawRes2.transactionId]); - - 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); - - const refundResp = await startRefundQueryForUri(ws, refundUri); - - logger.trace("integration test: applied refund"); - - // Wait until the refund is done - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - refundResp.transactionId, - ); - - logger.trace("integration test: making payment after refund"); - - const paymentResp2 = await makePayment( - ws, - myMerchant, - Amounts.stringify(spendAmountThree), - "payment after refund", - ); - - logger.trace("integration test: make payment done"); - - await waitUntilGivenTransactionsFinal(ws, [ - paymentResp2.paymentTransactionId, - ]); - await waitUntilGivenTransactionsFinal( - ws, - await getRefreshesForTransaction(ws, paymentResp2.paymentTransactionId), - ); - - logger.trace("integration test: all done!"); -} - -/** - * Wait until all transactions are in a final state. - */ -export async function waitUntilAllTransactionsFinal( - ws: InternalWalletState, -): Promise<void> { - logger.info("waiting until all transactions are in a final state"); - ws.ensureTaskLoopRunning(); - let p: OpenedPromise<void> | undefined = undefined; - const cancelNotifs = ws.addNotificationListener((notif) => { - if (!p) { - return; - } - if (notif.type === NotificationType.TransactionStateTransition) { - switch (notif.newTxState.major) { - case TransactionMajorState.Pending: - case TransactionMajorState.Aborting: - break; - default: - p.resolve(); - } - } - }); - while (1) { - p = openPromise(); - const txs = await getTransactions(ws, { - includeRefreshes: true, - filterByState: "nonfinal", - }); - let finished = true; - for (const tx of txs.transactions) { - switch (tx.txState.major) { - case TransactionMajorState.Pending: - case TransactionMajorState.Aborting: - case TransactionMajorState.Suspended: - case TransactionMajorState.SuspendedAborting: - finished = false; - logger.info( - `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, - ); - break; - } - } - if (finished) { - break; - } - // Wait until transaction state changed - await p.promise; - } - cancelNotifs(); - logger.info("done waiting until all transactions are in a final state"); -} - -/** - * Wait until all chosen transactions are in a final state. - */ -export async function waitUntilGivenTransactionsFinal( - ws: InternalWalletState, - 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; - } - ws.ensureTaskLoopRunning(); - const txIdSet = new Set(transactionIds); - let p: OpenedPromise<void> | undefined = undefined; - const cancelNotifs = ws.addNotificationListener((notif) => { - if (!p) { - return; - } - if (notif.type === NotificationType.TransactionStateTransition) { - if (!txIdSet.has(notif.transactionId)) { - return; - } - switch (notif.newTxState.major) { - case TransactionMajorState.Pending: - case TransactionMajorState.Aborting: - case TransactionMajorState.Suspended: - case TransactionMajorState.SuspendedAborting: - break; - default: - p.resolve(); - } - } - }); - while (1) { - p = openPromise(); - const txs = await getTransactions(ws, { - includeRefreshes: true, - filterByState: "nonfinal", - }); - let finished = true; - 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: - finished = false; - logger.info( - `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, - ); - break; - } - } - if (finished) { - break; - } - // Wait until transaction state changed - await p.promise; - } - cancelNotifs(); - logger.info("done waiting until given transactions are in a final state"); -} - -export async function waitUntilRefreshesDone( - ws: InternalWalletState, -): Promise<void> { - logger.info("waiting until all refresh transactions are in a final state"); - ws.ensureTaskLoopRunning(); - let p: OpenedPromise<void> | undefined = undefined; - const cancelNotifs = ws.addNotificationListener((notif) => { - if (!p) { - return; - } - if (notif.type === NotificationType.TransactionStateTransition) { - switch (notif.newTxState.major) { - case TransactionMajorState.Pending: - case TransactionMajorState.Aborting: - break; - default: - p.resolve(); - } - } - }); - while (1) { - p = openPromise(); - const txs = await getTransactions(ws, { - includeRefreshes: true, - filterByState: "nonfinal", - }); - let finished = true; - 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: - finished = false; - logger.info( - `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, - ); - break; - } - } - if (finished) { - break; - } - // Wait until transaction state changed - await p.promise; - } - cancelNotifs(); - logger.info("done waiting until all refreshes are in a final state"); -} - -async function waitUntilTransactionPendingReady( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - logger.info(`starting waiting for ${transactionId} to be in pending(ready)`); - ws.ensureTaskLoopRunning(); - let p: OpenedPromise<void> | undefined = undefined; - const cancelNotifs = ws.addNotificationListener((notif) => { - if (!p) { - return; - } - if (notif.type === NotificationType.TransactionStateTransition) { - p.resolve(); - } - }); - while (1) { - p = openPromise(); - const tx = await getTransactionById(ws, { - transactionId, - }); - if ( - tx.txState.major == TransactionMajorState.Pending && - tx.txState.minor === TransactionMinorState.Ready - ) { - break; - } - // Wait until transaction state changed - await p.promise; - } - logger.info(`done waiting for ${transactionId} to be in pending(ready)`); - cancelNotifs(); -} - -/** - * Wait until a transaction is in a particular state. - */ -export async function waitTransactionState( - ws: InternalWalletState, - transactionId: string, - txState: TransactionState, -): Promise<void> { - logger.info( - `starting waiting for ${transactionId} to be in ${JSON.stringify( - txState, - )})`, - ); - ws.ensureTaskLoopRunning(); - let p: OpenedPromise<void> | undefined = undefined; - const cancelNotifs = ws.addNotificationListener((notif) => { - if (!p) { - return; - } - if (notif.type === NotificationType.TransactionStateTransition) { - p.resolve(); - } - }); - while (1) { - p = openPromise(); - const tx = await getTransactionById(ws, { - transactionId, - }); - if ( - tx.txState.major === txState.major && - tx.txState.minor === txState.minor - ) { - break; - } - // Wait until transaction state changed - await p.promise; - } - logger.info( - `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`, - ); - cancelNotifs(); -} - -export async function waitUntilTransactionWithAssociatedRefreshesFinal( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - await waitUntilGivenTransactionsFinal(ws, [transactionId]); - await waitUntilGivenTransactionsFinal( - ws, - await getRefreshesForTransaction(ws, transactionId), - ); -} - -export async function waitUntilTransactionFinal( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - await waitUntilGivenTransactionsFinal(ws, [transactionId]); -} - -export async function runIntegrationTest2( - ws: InternalWalletState, - args: IntegrationTestV2Args, -): Promise<void> { - ws.ensureTaskLoopRunning(); - logger.info("running test with arguments", args); - - const exchangeInfo = await fetchFreshExchange(ws, 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(ws, { - amount: Amounts.stringify(amountToWithdraw), - corebankApiBaseUrl: args.corebankApiBaseUrl, - exchangeBaseUrl: args.exchangeBaseUrl, - }); - await waitUntilTransactionFinal(ws, withdrawalRes.transactionId); - 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, - }; - - const makePaymentRes = await makePayment( - ws, - myMerchant, - Amounts.stringify(amountToSpend), - "hello world", - ); - - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - 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(ws, { - amount: Amounts.stringify(withdrawAmountTwo), - corebankApiBaseUrl: args.corebankApiBaseUrl, - exchangeBaseUrl: args.exchangeBaseUrl, - }); - - // Wait until the withdraw is done - await waitUntilTransactionFinal(ws, withdrawalRes2.transactionId); - - 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); - - const refundResp = await startRefundQueryForUri(ws, refundUri); - - logger.trace("integration test: applied refund"); - - // Wait until the refund is done - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - refundResp.transactionId, - ); - - logger.trace("integration test: making payment after refund"); - - const makePaymentRes2 = await makePayment( - ws, - myMerchant, - Amounts.stringify(spendAmountThree), - "payment after refund", - ); - - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - makePaymentRes2.paymentTransactionId, - ); - - logger.trace("integration test: make payment done"); - - const peerPushInit = await initiatePeerPushDebit(ws, { - 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(ws, peerPushInit.transactionId); - const txDetails = await getTransactionById(ws, { - 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(ws, { - talerUri: txDetails.talerUri, - }); - - await confirmPeerPushCredit(ws, { - transactionId: peerPushCredit.transactionId, - }); - - const peerPullInit = await initiatePeerPullPayment(ws, { - 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(ws, peerPullInit.transactionId); - - const peerPullInc = await preparePeerPullDebit(ws, { - talerUri: peerPullInit.talerUri, - }); - - await confirmPeerPullDebit(ws, { - peerPullDebitId: peerPullInc.peerPullDebitId, - }); - - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - peerPullInc.transactionId, - ); - - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - peerPullInit.transactionId, - ); - - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - peerPushCredit.transactionId, - ); - - await waitUntilTransactionWithAssociatedRefreshesFinal( - ws, - 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(ws, { - amount: `${currency}:5` as AmountString, - depositPaytoUri: depositPayto, - }); - - logger.trace("integration test: all done!"); -} - -export async function testPay( - ws: InternalWalletState, - args: TestPayArgs, -): Promise<TestPayResult> { - 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); - } - 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}`); - } - const r = await confirmPay( - ws, - result.transactionId, - undefined, - args.forcedCoinSel, - ); - if (r.type != ConfirmPayResultType.Done) { - throw Error("payment not done"); - } - const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => { - return tx.purchases.get(result.proposalId); - }); - checkLogicInvariant(!!purchase); - return { - payCoinSelection: purchase.payInfo?.payCoinSelection!, - }; -} diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts @@ -1,2007 +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 { GlobalIDB } from "@gnu-taler/idb-bridge"; -import { - AbsoluteTime, - Amounts, - DepositTransactionTrackingState, - j2s, - Logger, - NotificationType, - OrderShortInfo, - PeerContractTerms, - RefundInfoShort, - RefundPaymentInfo, - ScopeType, - stringifyPayPullUri, - stringifyPayPushUri, - TalerErrorCode, - TalerPreciseTimestamp, - Transaction, - TransactionByIdRequest, - TransactionIdStr, - TransactionMajorState, - TransactionRecordFilter, - TransactionsRequest, - TransactionsResponse, - TransactionState, - TransactionType, - TransactionWithdrawal, - WalletContractData, - WithdrawalTransactionByURIRequest, - WithdrawalType, -} from "@gnu-taler/taler-util"; -import { - DepositElementStatus, - DepositGroupRecord, - OperationRetryRecord, - PeerPullCreditRecord, - PeerPullDebitRecordStatus, - PeerPullPaymentIncomingRecord, - PeerPushCreditStatus, - PeerPushDebitRecord, - PeerPushPaymentIncomingRecord, - PurchaseRecord, - PurchaseStatus, - RefreshGroupRecord, - RefreshOperationStatus, - RefundGroupRecord, - RewardRecord, - WithdrawalGroupRecord, - WithdrawalGroupStatus, - WithdrawalRecordType, -} from "../db.js"; -import { - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, - PeerPushDebitStatus, - timestampPreciseFromDb, - timestampProtocolFromDb, - WalletDbReadOnlyTransaction, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { PendingTaskType, TaskId } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { - constructTaskIdentifier, - TaskIdentifiers, - TransactionContext, -} from "./common.js"; -import { - computeDepositTransactionActions, - computeDepositTransactionStatus, - DepositTransactionContext, -} from "./deposits.js"; -import { - 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 { - computeRewardTransactionStatus, - computeTipTransactionActions, - RewardTransactionContext, -} from "./reward.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.Reward]: 2, - [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, -}; - -export async function getTransactionById( - ws: InternalWalletState, - 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 ws.db.runReadWriteTx( - [ - "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.Recoup: - throw new Error("not yet supported"); - - case TransactionType.Payment: { - const proposalId = parsedTx.proposalId; - return await ws.db.runReadWriteTx( - [ - "purchases", - "tombstones", - "operationRetries", - "contractTerms", - "refundGroups", - ], - async (tx) => { - const purchase = await tx.purchases.get(proposalId); - if (!purchase) throw Error("not found"); - const download = await expectProposalDownload(ws, 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 ws.db.runReadOnlyTx( - ["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.Reward: { - const tipId = parsedTx.walletRewardId; - return await ws.db.runReadWriteTx( - ["rewards", "operationRetries"], - async (tx) => { - const tipRecord = await tx.rewards.get(tipId); - if (!tipRecord) throw Error("not found"); - - const retries = await tx.operationRetries.get( - TaskIdentifiers.forTipPickup(tipRecord), - ); - return buildTransactionForTip(tipRecord, retries); - }, - ); - } - - case TransactionType.Deposit: { - const depositGroupId = parsedTx.depositGroupId; - return await ws.db.runReadWriteTx( - ["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 ws.db.runReadOnlyTx( - ["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 ws.db.runReadWriteTx( - ["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 ws.db.runReadWriteTx( - ["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 ws.db.runReadWriteTx( - [ - "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 ws.db.runReadWriteTx( - [ - "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 } : {}), - }; -} - -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 buildTransactionForDeposit( - dg: DepositGroupRecord, - ort?: OperationRetryRecord, -): Transaction { - let deposited = true; - for (const d of dg.statusPerCoin) { - if (d == DepositElementStatus.DepositPending) { - 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, - }); - } - - 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: - (100 * - dg.statusPerCoin.reduce( - (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0), - 0, - )) / - dg.statusPerCoin.length, - depositGroupId: dg.depositGroupId, - trackingState, - deposited, - ...(ort?.lastError ? { error: ort.lastError } : {}), - }; -} - -function buildTransactionForTip( - tipRecord: RewardRecord, - ort?: OperationRetryRecord, -): Transaction { - checkLogicInvariant(!!tipRecord.acceptedTimestamp); - - const txState = computeRewardTransactionStatus(tipRecord); - return { - type: TransactionType.Reward, - txState, - txActions: computeTipTransactionActions(tipRecord), - amountEffective: isUnsuccessfulTransaction(txState) - ? Amounts.stringify(Amounts.zeroOfAmount(tipRecord.rewardAmountEffective)) - : Amounts.stringify(tipRecord.rewardAmountEffective), - amountRaw: Amounts.stringify(tipRecord.rewardAmountRaw), - timestamp: timestampPreciseFromDb(tipRecord.acceptedTimestamp), - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: tipRecord.walletRewardId, - }), - merchantBaseUrl: tipRecord.merchantBaseUrl, - ...(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: contractData.merchant, - 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( - ws: InternalWalletState, - request: WithdrawalTransactionByURIRequest, -): Promise<TransactionWithdrawal | undefined> { - return await ws.db.runReadWriteTx( - ["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( - ws: InternalWalletState, - transactionsRequest?: TransactionsRequest, -): Promise<TransactionsResponse> { - const transactions: Transaction[] = []; - - const filter: TransactionRecordFilter = {}; - if (transactionsRequest?.filterByState) { - filter.onlyState = transactionsRequest.filterByState; - } - - await ws.db.runReadOnlyTx( - [ - "coins", - "denominations", - "depositGroups", - "exchangeDetails", - "exchanges", - "operationRetries", - "peerPullDebit", - "peerPushDebit", - "peerPushCredit", - "peerPullCredit", - "planchets", - "purchases", - "contractTerms", - "recoupGroups", - "rewards", - "tombstones", - "withdrawalGroups", - "refreshGroups", - "refundGroups", - ], - 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) return; //refund with no payment - - // 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 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, - ), - ); - }); - - //FIXME: remove rewards - await iterRecordsForReward(tx, filter, async (tipRecord) => { - if ( - shouldSkipCurrency( - transactionsRequest, - Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency, - [tipRecord.exchangeBaseUrl], - ) - ) { - return; - } - if (!tipRecord.acceptedTimestamp) { - return; - } - const opId = TaskIdentifiers.forTipPickup(tipRecord); - const retryRecord = await tx.operationRetries.get(opId); - transactions.push(buildTransactionForTip(tipRecord, retryRecord)); - }); - //ends REMOVE REWARDS - }, - ); - - // 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; - - const txPending = transactions.filter((x) => isPending(x)); - const txNotPending = transactions.filter((x) => !isPending(x)); - - 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; - }; - - txPending.sort(txCmp); - txNotPending.sort(txCmp); - - return { transactions: [...txNotPending, ...txPending] }; -} - -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.Reward; walletRewardId: string } - | { tag: TransactionType.Withdrawal; withdrawalGroupId: string } - | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string } - | { tag: TransactionType.Recoup; recoupGroupId: 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.Reward: - return `txn:${pTxId.tag}:${pTxId.walletRewardId}` 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; - 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.Reward: - return { - tag: TransactionType.Reward, - walletRewardId: rest[0], - }; - case TransactionType.Withdrawal: - return { - tag: TransactionType.Withdrawal, - withdrawalGroupId: rest[0], - }; - default: - return undefined; - } -} - -function maybeTaskFromTransaction(transactionId: string): TaskId | 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.Reward: - return constructTaskIdentifier({ - tag: PendingTaskType.RewardPickup, - walletRewardId: parsedTx.walletRewardId, - }); - 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, - }); - default: - assertUnreachable(parsedTx); - } -} - -/** - * Immediately retry the underlying operation - * of a transaction. - */ -export async function retryTransaction( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - logger.info(`resetting retry timeout for ${transactionId}`); - const taskId = maybeTaskFromTransaction(transactionId); - if (taskId) { - ws.taskScheduler.resetTaskRetries(taskId); - } -} - -async function getContextForTransaction( - ws: InternalWalletState, - transactionId: string, -): Promise<TransactionContext> { - const tx = parseTransactionIdentifier(transactionId); - if (!tx) { - throw Error("invalid transaction ID"); - } - switch (tx.tag) { - case TransactionType.Deposit: - return new DepositTransactionContext(ws, tx.depositGroupId); - case TransactionType.Refresh: - return new RefreshTransactionContext(ws, tx.refreshGroupId); - case TransactionType.InternalWithdrawal: - case TransactionType.Withdrawal: - return new WithdrawTransactionContext(ws, tx.withdrawalGroupId); - case TransactionType.Payment: - return new PayMerchantTransactionContext(ws, tx.proposalId); - case TransactionType.PeerPullCredit: - return new PeerPullCreditTransactionContext(ws, tx.pursePub); - case TransactionType.PeerPushDebit: - return new PeerPushDebitTransactionContext(ws, tx.pursePub); - case TransactionType.PeerPullDebit: - return new PeerPullDebitTransactionContext(ws, tx.peerPullDebitId); - case TransactionType.PeerPushCredit: - return new PeerPushCreditTransactionContext(ws, tx.peerPushCreditId); - case TransactionType.Refund: - return new RefundTransactionContext(ws, tx.refundGroupId); - case TransactionType.Reward: - return new RewardTransactionContext(ws, tx.walletRewardId); - case TransactionType.Recoup: - throw new Error("not yet supported"); - //return new RecoupTransactionContext(ws, tx.recoupGroupId); - 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( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - const ctx = await getContextForTransaction(ws, transactionId); - await ctx.suspendTransaction(); -} - -export async function failTransaction( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - const ctx = await getContextForTransaction(ws, transactionId); - await ctx.failTransaction(); -} - -/** - * Resume a suspended transaction. - */ -export async function resumeTransaction( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - const ctx = await getContextForTransaction(ws, transactionId); - await ctx.resumeTransaction(); -} - -/** - * Permanently delete a transaction based on the transaction ID. - */ -export async function deleteTransaction( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - const ctx = await getContextForTransaction(ws, transactionId); - await ctx.deleteTransaction(); -} - -export async function abortTransaction( - ws: InternalWalletState, - transactionId: string, -): Promise<void> { - const ctx = await getContextForTransaction(ws, transactionId); - await ctx.abortTransaction(); -} - -export interface TransitionInfo { - oldTxState: TransactionState; - newTxState: TransactionState; -} - -/** - * Notify of a state transition if necessary. - */ -export function notifyTransition( - ws: InternalWalletState, - transactionId: string, - transitionInfo: TransitionInfo | undefined, - experimentalUserData: any = undefined, -): void { - if ( - transitionInfo && - !( - transitionInfo.oldTxState.major === transitionInfo.newTxState.major && - transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor - ) - ) { - 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 iterRecordsForReward( - tx: WalletDbReadOnlyTransaction<["rewards"]>, - filter: TransactionRecordFilter, - f: (r: RewardRecord) => Promise<void>, -): Promise<void> { - if (filter.onlyState === "nonfinal") { - const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, - ); - await tx.rewards.indexes.byStatus.iter(keyRange).forEachAsync(f); - } else { - await tx.rewards.indexes.byStatus.iter().forEachAsync(f); - } -} - -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/operations/withdraw.test.ts b/packages/taler-wallet-core/src/operations/withdraw.test.ts @@ -1,370 +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 { AmountString, Amounts, DenomKeyType } from "@gnu-taler/taler-util"; -import test from "ava"; -import { - DenominationRecord, - DenominationVerificationStatus, - timestampProtocolToDb, -} from "../db.js"; -import { selectWithdrawalDenominations } from "../util/coinSelection.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, - listIssueDate: timestampProtocolToDb({ t_s: 0 }), - }, - { - 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", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), - }, - { - 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", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), - }, - { - 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", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), - }, - { - 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", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), - }, - { - 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", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), - }, - ]; - - const res = selectWithdrawalDenominations(amount, denoms); - - 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 @@ -1,2754 +0,0 @@ -/* - 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/> - */ - -/** - * Imports. - */ -import { - AbsoluteTime, - AcceptManualWithdrawalResult, - AcceptWithdrawalResponse, - AgeRestriction, - AmountJson, - AmountLike, - AmountString, - Amounts, - BankWithdrawDetails, - CancellationToken, - CoinStatus, - CurrencySpecification, - DenomKeyType, - DenomSelectionState, - Duration, - ExchangeBatchWithdrawRequest, - ExchangeUpdateStatus, - ExchangeWireAccount, - ExchangeWithdrawBatchResponse, - ExchangeWithdrawRequest, - ExchangeWithdrawResponse, - ExchangeWithdrawalDetails, - ForcedDenomSel, - HttpStatusCode, - LibtoolVersion, - Logger, - NotificationType, - TalerBankIntegrationHttpClient, - TalerError, - TalerErrorCode, - TalerErrorDetail, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TransactionAction, - TransactionIdStr, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, - URL, - UnblindedSignature, - WalletNotification, - WithdrawUriInfoResponse, - WithdrawalExchangeAccountDetails, - addPaytoQueryParams, - canonicalizeBaseUrl, - codecForAny, - codecForCashinConversionResponse, - codecForConversionBankConfig, - codecForExchangeWithdrawBatchResponse, - codecForReserveStatus, - codecForWalletKycUuid, - codecForWithdrawOperationStatusResponse, - encodeCrock, - getErrorDetailFromException, - getRandomBytes, - j2s, - makeErrorDetail, - parseWithdrawUri, -} from "@gnu-taler/taler-util"; -import { - HttpRequestLibrary, - HttpResponse, - readSuccessResponseJsonOrErrorCode, - readSuccessResponseJsonOrThrow, - throwUnexpectedRequestError, -} from "@gnu-taler/taler-util/http"; -import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; -import { - CoinRecord, - CoinSourceType, - DenominationRecord, - DenominationVerificationStatus, - KycPendingInfo, - PlanchetRecord, - PlanchetStatus, - WalletStoresV1, - WgInfo, - WithdrawalGroupRecord, - WithdrawalGroupStatus, - WithdrawalRecordType, -} from "../db.js"; -import { - WalletDbReadOnlyTransaction, - WalletDbReadWriteTransaction, - isWithdrawableDenom, - timestampPreciseToDb, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { - TaskRunResult, - TaskRunResultType, - TombstoneTag, - TransactionContext, - constructTaskIdentifier, - makeCoinAvailable, - makeCoinsVisible, -} from "../operations/common.js"; -import { PendingTaskType, TaskId } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { - selectForcedWithdrawalDenominations, - selectWithdrawalDenominations, -} from "../util/coinSelection.js"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { DbAccess } from "../util/query.js"; -import { - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - WALLET_EXCHANGE_PROTOCOL_VERSION, -} from "../versions.js"; -import { - ReadyExchangeSummary, - fetchFreshExchange, - getExchangePaytoUri, - getExchangeWireDetailsInTx, - listExchanges, - markExchangeUsed, -} from "./exchanges.js"; -import { - TransitionInfo, - constructTransactionIdentifier, - notifyTransition, -} from "./transactions.js"; - -/** - * Logger for this file. - */ -const logger = new Logger("operations/withdraw.ts"); - -export class WithdrawTransactionContext implements TransactionContext { - readonly transactionId: TransactionIdStr; - readonly taskId: TaskId; - - constructor( - public ws: InternalWalletState, - public withdrawalGroupId: string, - ) { - this.transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - this.taskId = constructTaskIdentifier({ - tag: PendingTaskType.Withdraw, - withdrawalGroupId, - }); - } - - async deleteTransaction(): Promise<void> { - const { ws, withdrawalGroupId } = this; - await ws.db.runReadWriteTx( - ["withdrawalGroups", "tombstones"], - 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; - } - }, - ); - } - - async suspendTransaction(): Promise<void> { - const { ws, withdrawalGroupId, transactionId, taskId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return; - } - 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}`, - ); - } - if (newStatus != null) { - const oldTxState = computeWithdrawalTransactionStatus(wg); - wg.status = newStatus; - const newTxState = computeWithdrawalTransactionStatus(wg); - await tx.withdrawalGroups.put(wg); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }, - ); - ws.taskScheduler.stopShepherdTask(taskId); - notifyTransition(ws, transactionId, transitionInfo); - } - - async abortTransaction(): Promise<void> { - const { ws, withdrawalGroupId, transactionId, taskId } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return; - } - 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: - // No transition needed, but not an error - break; - case WithdrawalGroupStatus.Done: - case WithdrawalGroupStatus.FailedBankAborted: - case WithdrawalGroupStatus.AbortedExchange: - case WithdrawalGroupStatus.AbortedBank: - case WithdrawalGroupStatus.FailedAbortingBank: - // Not allowed - throw Error("abort not allowed in current state"); - default: - assertUnreachable(wg.status); - } - if (newStatus != null) { - const oldTxState = computeWithdrawalTransactionStatus(wg); - wg.status = newStatus; - const newTxState = computeWithdrawalTransactionStatus(wg); - await tx.withdrawalGroups.put(wg); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }, - ); - ws.taskScheduler.stopShepherdTask(taskId); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(taskId); - } - - async resumeTransaction(): Promise<void> { - const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this; - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return; - } - 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}`, - ); - } - if (newStatus != null) { - const oldTxState = computeWithdrawalTransactionStatus(wg); - wg.status = newStatus; - const newTxState = computeWithdrawalTransactionStatus(wg); - await tx.withdrawalGroups.put(wg); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - ws.taskScheduler.startShepherdTask(retryTag); - } - - async failTransaction(): Promise<void> { - const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this; - const stateUpdate = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return; - } - let newStatus: WithdrawalGroupStatus | undefined = undefined; - switch (wg.status) { - case WithdrawalGroupStatus.SuspendedAbortingBank: - case WithdrawalGroupStatus.AbortingBank: - newStatus = WithdrawalGroupStatus.FailedAbortingBank; - break; - default: - break; - } - if (newStatus != null) { - const oldTxState = computeWithdrawalTransactionStatus(wg); - wg.status = newStatus; - const newTxState = computeWithdrawalTransactionStatus(wg); - await tx.withdrawalGroups.put(wg); - return { - oldTxState, - newTxState, - }; - } - return undefined; - }, - ); - ws.taskScheduler.stopShepherdTask(retryTag); - notifyTransition(ws, transactionId, stateUpdate); - ws.taskScheduler.startShepherdTask(retryTag); - } -} - -/** - * 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, - }; - } -} - -/** - * 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: - return [TransactionAction.Delete]; - case WithdrawalGroupStatus.AbortedExchange: - return [TransactionAction.Delete]; - case WithdrawalGroupStatus.AbortedBank: - return [TransactionAction.Delete]; - } -} - -/** - * 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; - - logger.info(`bank withdrawal operation status: ${j2s(status)}`); - - 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( - ws: InternalWalletState, - exchangeBaseUrl: string, - currency: string, -): Promise<DenominationRecord[]> { - return await ws.db.runReadOnlyTx(["denominations"], async (tx) => { - return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency); - }); -} - -export async function getCandidateWithdrawalDenomsTx( - ws: InternalWalletState, - tx: WalletDbReadOnlyTransaction<["denominations"]>, - exchangeBaseUrl: string, - currency: string, -): Promise<DenominationRecord[]> { - // FIXME: 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, 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( - ws: InternalWalletState, - withdrawalGroup: WithdrawalGroupRecord, - coinIdx: number, -): Promise<void> { - let planchet = await ws.db.runReadOnlyTx(["planchets"], async (tx) => { - return tx.planchets.indexes.byGroupAndIndex.get([ - withdrawalGroup.withdrawalGroupId, - coinIdx, - ]); - }); - if (planchet) { - return; - } - let ci = 0; - 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; - break; - } - ci += d.count; - } - if (!maybeDenomPubHash) { - throw Error("invariant violated"); - } - const denomPubHash = maybeDenomPubHash; - - const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => { - return ws.getDenomInfo( - ws, - tx, - withdrawalGroup.exchangeBaseUrl, - denomPubHash, - ); - }); - checkDbInvariant(!!denom); - const r = await ws.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 ws.db.runReadWriteTx(["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; -} - -enum AmlStatus { - normal = 0, - pending = 1, - fronzen = 2, -} - -/** - * Transition a withdrawal transaction with a (new) KYC URL. - * - * Emit a notification for the (self-)transition. - */ -async function transitionKycUrlUpdate( - ws: InternalWalletState, - withdrawalGroupId: string, - kycUrl: string, -): Promise<void> { - let notificationKycUrl: string | undefined = undefined; - const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId); - const transactionId = ctx.transactionId; - - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg2) { - return; - } - const oldTxState = computeWithdrawalTransactionStatus(wg2); - switch (wg2.status) { - case WithdrawalGroupStatus.PendingReady: { - wg2.kycUrl = kycUrl; - notificationKycUrl = kycUrl; - await tx.withdrawalGroups.put(wg2); - const newTxState = computeWithdrawalTransactionStatus(wg2); - return { - oldTxState, - newTxState, - }; - } - default: - return undefined; - } - }, - ); - if (transitionInfo) { - // Always notify, even on self-transition, as the KYC URL might have changed. - ws.notify({ - type: NotificationType.TransactionStateTransition, - oldTxState: transitionInfo.oldTxState, - newTxState: transitionInfo.newTxState, - transactionId, - experimentalUserData: notificationKycUrl, - }); - } - ws.taskScheduler.startShepherdTask(ctx.taskId); -} - -async function handleKycRequired( - ws: InternalWalletState, - 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 transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - 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 ws.http.fetch(url.href, { - method: "GET", - }); - let kycUrl: string; - let amlStatus: AmlStatus | 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})`); - } - - let notificationKycUrl: string | undefined = undefined; - - const transitionInfo = await ws.db.runReadWriteTx( - ["planchets", "withdrawalGroups"], - async (tx) => { - 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); - } - const wg2 = await tx.withdrawalGroups.get( - withdrawalGroup.withdrawalGroupId, - ); - if (!wg2) { - return; - } - const oldTxState = computeWithdrawalTransactionStatus(wg2); - switch (wg2.status) { - case WithdrawalGroupStatus.PendingReady: { - wg2.kycPending = { - paytoHash: uuidResp.h_payto, - requirementRow: uuidResp.requirement_row, - }; - wg2.kycUrl = kycUrl; - wg2.status = - amlStatus === AmlStatus.normal || amlStatus === undefined - ? WithdrawalGroupStatus.PendingKyc - : amlStatus === AmlStatus.pending - ? WithdrawalGroupStatus.PendingAml - : amlStatus === AmlStatus.fronzen - ? WithdrawalGroupStatus.SuspendedAml - : assertUnreachable(amlStatus); - - notificationKycUrl = kycUrl; - - await tx.withdrawalGroups.put(wg2); - const newTxState = computeWithdrawalTransactionStatus(wg2); - return { - oldTxState, - newTxState, - }; - } - default: - return undefined; - } - }, - ); - notifyTransition(ws, transactionId, transitionInfo, notificationKycUrl); -} - -/** - * 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( - ws: InternalWalletState, - wgContext: WithdrawalGroupContext, - 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 ws.db.runReadOnlyTx(["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; - } - const denom = await ws.getDenomInfo( - ws, - 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(e: any, coinIdx: number): Promise<void> { - const errDetail = getErrorDetailFromException(e); - logger.trace("withdrawal request failed", e); - logger.trace(String(e)); - await ws.db.runReadWriteTx(["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 ws.http.fetch(reqUrl, { - method: "POST", - body: batchReq, - }); - if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { - await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs); - return { - batchResp: { ev_sigs: [] }, - coinIdxs: [], - }; - } - const r = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeWithdrawBatchResponse(), - ); - return { - coinIdxs: requestCoinIdxs, - batchResp: r, - }; - } catch (e) { - await storeCoinError(e, requestCoinIdxs[0]); - return { - batchResp: { ev_sigs: [] }, - coinIdxs: [], - }; - } -} - -async function processPlanchetVerifyAndStoreCoin( - ws: InternalWalletState, - wgContext: WithdrawalGroupContext, - coinIdx: number, - resp: ExchangeWithdrawResponse, -): Promise<void> { - const withdrawalGroup = wgContext.wgRecord; - logger.trace(`checking and storing planchet idx=${coinIdx}`); - const d = await ws.db.runReadOnlyTx( - ["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 ws.getDenomInfo( - ws, - 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 ws.cryptoApi.rsaUnblind({ - bk: planchet.blindingKey, - blindedSig: evSig.blinded_rsa_signature, - pk: planchetDenomPub.rsa_public_key, - }); - - const isValid = await ws.cryptoApi.rsaVerify({ - hm: planchet.coinPub, - pk: planchetDenomPub.rsa_public_key, - sig: denomSigRsa.sig, - }); - - if (!isValid) { - await ws.db.runReadWriteTx(["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 ws.db.runReadWriteTx( - ["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(ws, 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. - */ -async function updateWithdrawalDenoms( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise<void> { - logger.trace( - `updating denominations used for withdrawal for ${exchangeBaseUrl}`, - ); - const exchangeDetails = await ws.db.runReadOnlyTx( - ["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( - ws, - 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 (ws.config.testing.insecureTrustExchange) { - valid = true; - } else { - const res = await ws.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 ws.db.runReadWriteTx(["denominations"], 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"); - } - } -} - -/** - * 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 queryReserve( - ws: InternalWalletState, - withdrawalGroupId: string, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { - withdrawalGroupId, - }); - checkDbInvariant(!!withdrawalGroup); - 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 ws.http.fetch(reserveUrl.href, { - timeout: getReserveRequestTimeout(withdrawalGroup), - 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.backoff(); - } else { - throwUnexpectedRequestError(resp, result.talerErrorResponse); - } - } - - logger.trace(`got reserve status ${j2s(result.response)}`); - - const transitionResult = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return undefined; - } - const txStateOld = computeWithdrawalTransactionStatus(wg); - wg.status = WithdrawalGroupStatus.PendingReady; - const txStateNew = computeWithdrawalTransactionStatus(wg); - wg.reserveBalanceAmount = Amounts.stringify(result.response.balance); - await tx.withdrawalGroups.put(wg); - return { - oldTxState: txStateOld, - newTxState: txStateNew, - }; - }, - ); - - notifyTransition(ws, transactionId, transitionResult); - - return TaskRunResult.backoff(); -} - -/** - * Withdrawal context that is kept in-memory. - * - * Used to store some cached info during a withdrawal operation. - */ -export interface WithdrawalGroupContext { - numPlanchets: number; - planchetsFinished: Set<string>; - - /** - * Cached withdrawal group record from the database. - */ - wgRecord: WithdrawalGroupRecord; -} - -async function processWithdrawalGroupAbortingBank( - ws: InternalWalletState, - withdrawalGroup: WithdrawalGroupRecord, -): Promise<TaskRunResult> { - const { withdrawalGroupId } = withdrawalGroup; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - 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 ws.http.fetch(abortUrl, { - method: "POST", - body: {}, - }); - logger.info(`abort response status: ${abortResp.status}`); - - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - return undefined; - } - const txStatusOld = computeWithdrawalTransactionStatus(wg); - wg.status = WithdrawalGroupStatus.AbortedBank; - wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); - const txStatusNew = computeWithdrawalTransactionStatus(wg); - await tx.withdrawalGroups.put(wg); - return { - oldTxState: txStatusOld, - newTxState: txStatusNew, - }; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - return TaskRunResult.finished(); -} - -/** - * Store in the database that the KYC for a withdrawal is now - * satisfied. - */ -async function transitionKycSatisfied( - ws: InternalWalletState, - withdrawalGroup: WithdrawalGroupRecord, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId: withdrawalGroup.withdrawalGroupId, - }); - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg2 = await tx.withdrawalGroups.get( - withdrawalGroup.withdrawalGroupId, - ); - if (!wg2) { - return; - } - const oldTxState = computeWithdrawalTransactionStatus(wg2); - switch (wg2.status) { - case WithdrawalGroupStatus.PendingKyc: { - delete wg2.kycPending; - delete wg2.kycUrl; - wg2.status = WithdrawalGroupStatus.PendingReady; - await tx.withdrawalGroups.put(wg2); - const newTxState = computeWithdrawalTransactionStatus(wg2); - return { - oldTxState, - newTxState, - }; - } - default: - return undefined; - } - }, - ); - notifyTransition(ws, transactionId, transitionInfo); -} - -async function processWithdrawalGroupPendingKyc( - ws: InternalWalletState, - withdrawalGroup: WithdrawalGroupRecord, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - 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"); - - const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; - - logger.info(`long-polling for withdrawal KYC status via ${url.href}`); - const kycStatusRes = await ws.http.fetch(url.href, { - method: "GET", - 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 transitionKycSatisfied(ws, withdrawalGroup); - } 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 transitionKycUrlUpdate(ws, withdrawalGroupId, kycUrl); - } - } 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(); -} - -async function processWithdrawalGroupPendingReady( - ws: InternalWalletState, - withdrawalGroup: WithdrawalGroupRecord, -): Promise<TaskRunResult> { - const { withdrawalGroupId } = withdrawalGroup; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - - await fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl); - - if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { - logger.warn("Finishing empty withdrawal group (no denoms)"); - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - return undefined; - } - const txStatusOld = computeWithdrawalTransactionStatus(wg); - wg.status = WithdrawalGroupStatus.Done; - wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); - const txStatusNew = computeWithdrawalTransactionStatus(wg); - await tx.withdrawalGroups.put(wg); - return { - oldTxState: txStatusOld, - newTxState: txStatusNew, - }; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - return TaskRunResult.finished(); - } - - const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms - .map((x) => x.count) - .reduce((a, b) => a + b); - - const wgContext: WithdrawalGroupContext = { - numPlanchets: numTotalCoins, - planchetsFinished: new Set<string>(), - wgRecord: withdrawalGroup, - }; - - await ws.db.runReadOnlyTx(["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(ws, withdrawalGroup, i); - } - - const maxBatchSize = 100; - - for (let i = 0; i < numTotalCoins; i += maxBatchSize) { - const resp = await processPlanchetExchangeBatchRequest(ws, 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( - ws, - wgContext, - resp.coinIdxs[j], - resp.batchResp.ev_sigs[j], - ), - ); - } - await Promise.all(work); - } - - let numFinished = 0; - const errorsPerCoin: Record<number, TalerErrorDetail> = {}; - let numPlanchetErrors = 0; - const maxReportedErrors = 5; - - const res = await ws.db.runReadWriteTx( - ["coins", "coinAvailability", "withdrawalGroups", "planchets"], - async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - return; - } - - await tx.planchets.indexes.byGroup - .iter(withdrawalGroupId) - .forEach((x) => { - if (x.planchetStatus === PlanchetStatus.WithdrawalDone) { - numFinished++; - } - if (x.lastError) { - numPlanchetErrors++; - if (numPlanchetErrors < maxReportedErrors) { - errorsPerCoin[x.coinIdx] = x.lastError; - } - } - }); - const oldTxState = computeWithdrawalTransactionStatus(wg); - logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`); - if (wg.timestampFinish === undefined && numFinished === numTotalCoins) { - wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); - wg.status = WithdrawalGroupStatus.Done; - await makeCoinsVisible(ws, tx, transactionId); - } - - const newTxState = computeWithdrawalTransactionStatus(wg); - await tx.withdrawalGroups.put(wg); - - return { - kycInfo: wg.kycPending, - transitionInfo: { - oldTxState, - newTxState, - }, - }; - }, - ); - - if (!res) { - throw Error("withdrawal group does not exist anymore"); - } - - notifyTransition(ws, transactionId, res.transitionInfo); - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: 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( - ws: InternalWalletState, - withdrawalGroupId: string, - cancellationToken: CancellationToken, -): Promise<TaskRunResult> { - logger.trace("processing withdrawal group", withdrawalGroupId); - const withdrawalGroup = await ws.db.runReadOnlyTx( - ["withdrawalGroups"], - async (tx) => { - return tx.withdrawalGroups.get(withdrawalGroupId); - }, - ); - - if (!withdrawalGroup) { - throw Error(`withdrawal group ${withdrawalGroupId} not found`); - } - - switch (withdrawalGroup.status) { - case WithdrawalGroupStatus.PendingRegisteringBank: - await processReserveBankStatus(ws, withdrawalGroupId); - // FIXME: This will get called by the main task loop, why call it here?! - return await processWithdrawalGroup( - ws, - withdrawalGroupId, - cancellationToken, - ); - case WithdrawalGroupStatus.PendingQueryingStatus: { - return queryReserve(ws, withdrawalGroupId, cancellationToken); - } - case WithdrawalGroupStatus.PendingWaitConfirmBank: { - return await processReserveBankStatus(ws, withdrawalGroupId); - } - case WithdrawalGroupStatus.PendingAml: - // FIXME: Handle this case, withdrawal doesn't support AML yet. - return TaskRunResult.backoff(); - case WithdrawalGroupStatus.PendingKyc: - return processWithdrawalGroupPendingKyc( - ws, - withdrawalGroup, - cancellationToken, - ); - case WithdrawalGroupStatus.PendingReady: - // Continue with the actual withdrawal! - return await processWithdrawalGroupPendingReady(ws, withdrawalGroup); - case WithdrawalGroupStatus.AbortingBank: - return await processWithdrawalGroupAbortingBank(ws, 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: - // 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( - ws: InternalWalletState, - exchangeBaseUrl: string, - instructedAmount: AmountJson, - ageRestricted: number | undefined, -): Promise<ExchangeWithdrawalDetails> { - logger.trace("updating exchange"); - const exchange = await fetchFreshExchange(ws, exchangeBaseUrl); - - 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(ws, { - exchange, - instructedAmount, - }); - - logger.trace("updating withdrawal denoms"); - await updateWithdrawalDenoms(ws, exchangeBaseUrl); - - logger.trace("getting candidate denoms"); - const denoms = await getCandidateWithdrawalDenoms( - ws, - exchangeBaseUrl, - instructedAmount.currency, - ); - logger.trace("selecting withdrawal denoms"); - const selectedDenoms = selectWithdrawalDenominations( - instructedAmount, - denoms, - 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 hasDenomWithAgeRestriction = false; - - logger.trace("computing earliest deposit expiration"); - - let earliestDepositExpiration: TalerProtocolTimestamp | undefined; - for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) { - const ds = selectedDenoms.selectedDenoms[i]; - // FIXME: Do in one transaction! - const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => { - return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash); - }); - checkDbInvariant(!!denom); - hasDenomWithAgeRestriction = - hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0; - const expireDeposit = denom.stampExpireDeposit; - if (!earliestDepositExpiration) { - earliestDepositExpiration = expireDeposit; - continue; - } - if ( - AbsoluteTime.cmp( - AbsoluteTime.fromProtocolTimestamp(expireDeposit), - AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration), - ) < 0 - ) { - earliestDepositExpiration = expireDeposit; - } - } - - checkLogicInvariant(!!earliestDepositExpiration); - - const possibleDenoms = await getCandidateWithdrawalDenoms( - ws, - exchangeBaseUrl, - instructedAmount.currency, - ); - - 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, - exchangePaytoUris: paytoUris, - exchangeWireAccounts, - exchangeCreditAccountDetails: withdrawalAccountsList, - exchangeVersion: exchange.protocolVersionRange || "unknown", - numOfferedDenoms: possibleDenoms.length, - selectedDenoms, - // FIXME: delete this field / replace by something we can display to the user - trustedAuditorPubs: [], - 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: hasDenomWithAgeRestriction - ? AGE_MASK_GROUPS - : undefined, - scopeInfo: exchange.scopeInfo, - }; - return ret; -} - -export interface GetWithdrawalDetailsForUriOpts { - restrictAge?: number; - notifyChangeFromPendingTimeoutMs?: number; -} - -type WithdrawalOperationMemoryMap = { - [uri: string]: boolean | undefined; -}; -const ongoingChecks: WithdrawalOperationMemoryMap = {}; -/** - * 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( - ws: InternalWalletState, - talerWithdrawUri: string, - opts: GetWithdrawalDetailsForUriOpts = {}, -): Promise<WithdrawUriInfoResponse> { - logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); - const info = await getBankWithdrawalInfo(ws.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(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 currency = Amounts.currencyOf(info.amount); - - const listExchangesResp = await listExchanges(ws); - const possibleExchanges = listExchangesResp.exchanges.filter((x) => { - return ( - x.currency === currency && - (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || - x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) - ); - }); - - // FIXME: this should be removed after the extended version of - // withdrawal state machine. issue #8099 - if ( - info.status === "pending" && - opts.notifyChangeFromPendingTimeoutMs !== undefined && - !ongoingChecks[talerWithdrawUri] - ) { - ongoingChecks[talerWithdrawUri] = true; - const bankApi = new TalerBankIntegrationHttpClient( - info.apiBaseUrl, - ws.http, - ); - console.log( - `waiting operation (${info.operationId}) to change from pending`, - ); - bankApi - .getWithdrawalOperationById(info.operationId, { - old_state: "pending", - timeoutMs: opts.notifyChangeFromPendingTimeoutMs, - }) - .then((resp) => { - console.log( - `operation (${info.operationId}) to change to ${JSON.stringify( - resp, - undefined, - 2, - )}`, - ); - ws.notify({ - type: NotificationType.WithdrawalOperationTransition, - operationId: info.operationId, - state: resp.type === "fail" ? info.status : resp.body.status, - }); - ongoingChecks[talerWithdrawUri] = false; - }); - } - - 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 Withdrawal ${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(["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( - ws: InternalWalletState, - withdrawalGroupId: string, -): Promise<void> { - const withdrawalGroup = await ws.db.runReadOnlyTx( - ["withdrawalGroups"], - async (tx) => { - return await tx.withdrawalGroups.get(withdrawalGroupId); - }, - ); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - switch (withdrawalGroup?.status) { - case WithdrawalGroupStatus.PendingWaitConfirmBank: - case WithdrawalGroupStatus.PendingRegisteringBank: - break; - default: - return; - } - if ( - withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated - ) { - throw Error("expecting withdrarwal 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 ws.http.fetch(bankStatusUrl, { - method: "POST", - body: reqBody, - timeout: getReserveRequestTimeout(withdrawalGroup), - }); - // FIXME: libeufin-bank currently doesn't return a response in the right format, so we don't validate at all. - await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const r = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!r) { - return undefined; - } - switch (r.status) { - case WithdrawalGroupStatus.PendingRegisteringBank: - case WithdrawalGroupStatus.PendingWaitConfirmBank: - break; - default: - return; - } - if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { - throw Error("invariant failed"); - } - r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb( - AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()), - ); - const oldTxState = computeWithdrawalTransactionStatus(r); - r.status = WithdrawalGroupStatus.PendingWaitConfirmBank; - const newTxState = computeWithdrawalTransactionStatus(r); - await tx.withdrawalGroups.put(r); - return { - oldTxState, - newTxState, - }; - }, - ); - - notifyTransition(ws, transactionId, transitionInfo); -} - -async function processReserveBankStatus( - ws: InternalWalletState, - withdrawalGroupId: string, -): Promise<TaskRunResult> { - const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { - withdrawalGroupId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - switch (withdrawalGroup?.status) { - case WithdrawalGroupStatus.PendingWaitConfirmBank: - case WithdrawalGroupStatus.PendingRegisteringBank: - break; - default: - return TaskRunResult.backoff(); - } - - if ( - withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated - ) { - throw Error("wrong withdrawal record type"); - } - const bankInfo = withdrawalGroup.wgInfo.bankInfo; - if (!bankInfo) { - return TaskRunResult.backoff(); - } - - const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri); - - const statusResp = await ws.http.fetch(bankStatusUrl, { - timeout: getReserveRequestTimeout(withdrawalGroup), - }); - const status = await readSuccessResponseJsonOrThrow( - statusResp, - codecForWithdrawOperationStatusResponse(), - ); - - if (status.aborted) { - logger.info("bank aborted the withdrawal"); - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const r = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!r) { - return; - } - switch (r.status) { - case WithdrawalGroupStatus.PendingRegisteringBank: - case WithdrawalGroupStatus.PendingWaitConfirmBank: - break; - default: - return; - } - if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { - throw Error("invariant failed"); - } - const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); - const oldTxState = computeWithdrawalTransactionStatus(r); - r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); - r.status = WithdrawalGroupStatus.FailedBankAborted; - const newTxState = computeWithdrawalTransactionStatus(r); - await tx.withdrawalGroups.put(r); - return { - oldTxState, - newTxState, - }; - }, - ); - notifyTransition(ws, transactionId, transitionInfo); - return TaskRunResult.finished(); - } - - // Bank still needs to know our reserve info - if (!status.selection_done) { - await registerReserveWithBank(ws, withdrawalGroupId); - return await processReserveBankStatus(ws, withdrawalGroupId); - } - - // FIXME: Why do we do this?! - if (withdrawalGroup.status === WithdrawalGroupStatus.PendingRegisteringBank) { - await registerReserveWithBank(ws, withdrawalGroupId); - return await processReserveBankStatus(ws, withdrawalGroupId); - } - - const transitionInfo = await ws.db.runReadWriteTx( - ["withdrawalGroups"], - async (tx) => { - const r = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!r) { - return undefined; - } - // Re-check reserve status within transaction - switch (r.status) { - case WithdrawalGroupStatus.PendingRegisteringBank: - case WithdrawalGroupStatus.PendingWaitConfirmBank: - break; - default: - return undefined; - } - if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { - throw Error("invariant failed"); - } - const oldTxState = computeWithdrawalTransactionStatus(r); - 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; - } else { - logger.trace("withdrawal: transfer not yet confirmed by bank"); - r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url; - r.senderWire = status.sender_wire; - } - const newTxState = computeWithdrawalTransactionStatus(r); - await tx.withdrawalGroups.put(r); - return { - oldTxState, - newTxState, - }; - }, - ); - - notifyTransition(ws, transactionId, transitionInfo); - - 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( - ws: InternalWalletState, - args: { - reserveStatus: WithdrawalGroupStatus; - amount: AmountJson; - exchangeBaseUrl: string; - forcedWithdrawalGroupId?: string; - forcedDenomSel?: ForcedDenomSel; - reserveKeyPair?: EddsaKeypair; - restrictAge?: number; - wgInfo: WgInfo; - }, -): Promise<PrepareCreateWithdrawalGroupResult> { - const reserveKeyPair = - args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({})); - const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); - const secretSeed = encodeCrock(getRandomBytes(32)); - const canonExchange = canonicalizeBaseUrl(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 ws.db.runReadOnlyTx( - ["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(ws, canonExchange); - const denoms = await getCandidateWithdrawalDenoms( - ws, - canonExchange, - currency, - ); - - let initialDenomSel: DenomSelectionState; - const denomSelUid = encodeCrock(getRandomBytes(16)); - if (args.forcedDenomSel) { - logger.warn("using forced denom selection"); - initialDenomSel = selectForcedWithdrawalDenominations( - amount, - denoms, - args.forcedDenomSel, - ws.config.testing.denomselAllowLate, - ); - } else { - initialDenomSel = selectWithdrawalDenominations( - amount, - denoms, - ws.config.testing.denomselAllowLate, - ); - } - - const withdrawalGroup: WithdrawalGroupRecord = { - denomSelUid, - denomsSel: initialDenomSel, - exchangeBaseUrl: canonExchange, - 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(ws, canonExchange); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId: withdrawalGroup.withdrawalGroupId, - }); - - return { - withdrawalGroup, - transactionId, - creationInfo: { - canonExchange, - 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( - ws: InternalWalletState, - 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( - ws, - tx, - prep.withdrawalGroup.exchangeBaseUrl, - ); - - const ctx = new WithdrawTransactionContext( - ws, - withdrawalGroup.withdrawalGroupId, - ); - - ws.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( - ws: InternalWalletState, - args: { - reserveStatus: WithdrawalGroupStatus; - amount: AmountJson; - exchangeBaseUrl: string; - forcedWithdrawalGroupId?: string; - forcedDenomSel?: ForcedDenomSel; - reserveKeyPair?: EddsaKeypair; - restrictAge?: number; - wgInfo: WgInfo; - }, -): Promise<WithdrawalGroupRecord> { - const prep = await internalPrepareCreateWithdrawalGroup(ws, args); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId, - }); - const res = await ws.db.runReadWriteTx( - ["withdrawalGroups", "reserves", "exchanges", "exchangeDetails"], - async (tx) => { - return await internalPerformCreateWithdrawalGroup(ws, tx, prep); - }, - ); - if (res.exchangeNotif) { - ws.notify(res.exchangeNotif); - } - notifyTransition(ws, transactionId, res.transitionInfo); - return res.withdrawalGroup; -} - -export async function acceptWithdrawalFromUri( - ws: InternalWalletState, - req: { - talerWithdrawUri: string; - selectedExchange: string; - forcedDenomSel?: ForcedDenomSel; - restrictAge?: number; - }, -): Promise<AcceptWithdrawalResponse> { - const selectedExchange = canonicalizeBaseUrl(req.selectedExchange); - logger.info( - `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, - ); - const existingWithdrawalGroup = await ws.db.runReadOnlyTx( - ["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, - }), - }; - } - - await fetchFreshExchange(ws, selectedExchange); - const withdrawInfo = await getBankWithdrawalInfo( - ws.http, - req.talerWithdrawUri, - ); - const exchangePaytoUri = await getExchangePaytoUri( - ws, - selectedExchange, - withdrawInfo.wireTypes, - ); - - const exchange = await fetchFreshExchange(ws, selectedExchange); - - const withdrawalAccountList = await fetchWithdrawalAccountInfo(ws, { - exchange, - instructedAmount: withdrawInfo.amount, - }); - - const withdrawalGroup = await internalCreateWithdrawalGroup(ws, { - 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(ws, withdrawalGroupId); - - const transactionId = ctx.transactionId; - - // 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, withdrawalGroupId); - const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { - withdrawalGroupId, - }); - if ( - processedWithdrawalGroup?.status === WithdrawalGroupStatus.FailedBankAborted - ) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, - {}, - ); - } - - ws.taskScheduler.startShepherdTask(ctx.taskId); - - return { - reservePub: withdrawalGroup.reservePub, - confirmTransferUrl: withdrawInfo.confirmTransferUrl, - transactionId, - }; -} - -async function fetchAccount( - ws: InternalWalletState, - instructedAmount: AmountJson, - acct: ExchangeWireAccount, - reservePub?: string, -): 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 ws.http.fetch(reqUrl.href); - 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 ws.http.fetch(configUrl.href); - 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 Withdrawal ${reservePub}`, - }); - } - const acctInfo: WithdrawalExchangeAccountDetails = { - status: "ok", - paytoUri, - transferAmount, - 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( - ws: InternalWalletState, - req: { - exchange: ReadyExchangeSummary; - instructedAmount: AmountJson; - reservePub?: string; - }, -): Promise<WithdrawalExchangeAccountDetails[]> { - const { exchange, instructedAmount } = req; - const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = []; - for (let acct of exchange.wireInfo.accounts) { - const acctInfo = await fetchAccount( - ws, - req.instructedAmount, - acct, - req.reservePub, - ); - withdrawalAccounts.push(acctInfo); - } - 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( - ws: InternalWalletState, - req: { - exchangeBaseUrl: string; - amount: AmountLike; - restrictAge?: number; - forcedDenomSel?: ForcedDenomSel; - }, -): Promise<AcceptManualWithdrawalResult> { - const { exchangeBaseUrl } = req; - const amount = Amounts.parseOrThrow(req.amount); - const exchange = await fetchFreshExchange(ws, exchangeBaseUrl); - - if (exchange.currency != amount.currency) { - throw Error( - "manual withdrawal with conversion from foreign currency is not yet supported", - ); - } - const reserveKeyPair: EddsaKeypair = await ws.cryptoApi.createEddsaKeypair( - {}, - ); - - const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, { - exchange, - instructedAmount: amount, - reservePub: reserveKeyPair.pub, - }); - - const withdrawalGroup = await internalCreateWithdrawalGroup(ws, { - 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 withdrawalGroupId = withdrawalGroup.withdrawalGroupId; - const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId); - - const transactionId = ctx.transactionId; - - const exchangePaytoUris = await ws.db.runReadOnlyTx( - ["withdrawalGroups", "exchanges", "exchangeDetails"], - async (tx) => { - return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId); - }, - ); - - ws.taskScheduler.startShepherdTask(ctx.taskId); - - return { - reservePub: withdrawalGroup.reservePub, - exchangePaytoUris: exchangePaytoUris, - withdrawalAccountsList: withdrawalAccountsList, - transactionId, - }; -} diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -0,0 +1,3232 @@ +/* + 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, + AsyncFlag, + codecForAbortResponse, + codecForMerchantContractTerms, + codecForMerchantOrderRefundPickupResponse, + codecForMerchantOrderStatusPaid, + codecForMerchantPayResponse, + codecForMerchantPostOrderResponse, + codecForProposal, + CoinDepositPermission, + CoinRefreshRequest, + ConfirmPayResult, + ConfirmPayResultType, + ContractTermsUtil, + Duration, + encodeCrock, + ForcedCoinSel, + getRandomBytes, + HttpStatusCode, + j2s, + Logger, + makeErrorDetail, + makePendingOperationFailedError, + MerchantCoinRefundStatus, + MerchantContractTerms, + MerchantPayResponse, + MerchantUsingTemplateDetails, + NotificationType, + parsePayTemplateUri, + parsePayUri, + parseTalerUri, + PayCoinSelection, + PreparePayResult, + PreparePayResultType, + PreparePayTemplateRequest, + randomBytes, + RefreshReason, + 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 { EddsaKeypair } from "./crypto/cryptoImplementation.js"; +import { + CoinRecord, + DenominationRecord, + PurchaseRecord, + PurchaseStatus, + RefundReason, + WalletStoresV1, +} from "./db.js"; +import { + getCandidateWithdrawalDenomsTx, + PendingTaskType, + RefundGroupRecord, + RefundGroupStatus, + RefundItemRecord, + RefundItemStatus, + TaskId, + timestampPreciseToDb, + timestampProtocolFromDb, + timestampProtocolToDb, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, +} from "./index.js"; +import { + EXCHANGE_COINS_LOCK, + InternalWalletState, +} from "./internal-wallet-state.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { PreviousPayCoins, selectPayCoinsNew } from "./util/coinSelection.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { DbReadWriteTransaction, StoreNames } from "./query.js"; +import { + constructTaskIdentifier, + DbRetryInfo, + spendCoins, + TaskIdentifiers, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + TransitionResult, +} from "./common.js"; +import { + calculateRefreshOutput, + createRefreshGroup, + getTotalRefreshCost, +} from "./refresh.js"; +import { + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; + +/** + * Logger. + */ +const logger = new Logger("pay-merchant.ts"); + +export class PayMerchantTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskId; + + constructor( + public ws: InternalWalletState, + 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<TransitionResult>, + ): 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<TransitionResult>, + ): Promise<void> { + const ws = this.ws; + const extraStores = opts.extraStores ?? []; + const transitionInfo = await ws.db.runReadWriteTx( + ["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 TransitionResult.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 { ws, proposalId } = this; + await ws.db.runReadWriteTx(["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 { ws, proposalId, transactionId } = this; + ws.taskScheduler.stopShepherdTask(this.taskId); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { ws, proposalId, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + [ + "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); + const oldStatus = purchase.purchaseStatus; + if (purchase.timestampFirstSuccessfulPay) { + // No point in aborting it. We don't even report an error. + logger.warn(`tried to abort successful payment`); + return; + } + switch (oldStatus) { + case PurchaseStatus.Done: + return; + case PurchaseStatus.PendingPaying: + case PurchaseStatus.SuspendedPaying: { + purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; + if (purchase.payInfo) { + 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( + ws, + tx, + currency, + refreshCoins, + RefreshReason.AbortPay, + this.transactionId, + ); + } + break; + } + case PurchaseStatus.DialogProposed: + purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused; + break; + } + await tx.purchases.put(purchase); + await tx.operationRetries.delete(this.taskId); + const newTxState = computePayMerchantTransactionState(purchase); + return { oldTxState, newTxState }; + }, + ); + ws.taskScheduler.stopShepherdTask(this.taskId); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(this.taskId); + } + + async resumeTransaction(): Promise<void> { + const { ws, proposalId, transactionId, taskId: retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(this.taskId); + } + + async failTransaction(): Promise<void> { + const { ws, proposalId, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + [ + "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(ws, transactionId, transitionInfo); + ws.taskScheduler.stopShepherdTask(this.taskId); + } +} + +export class RefundTransactionContext implements TransactionContext { + public transactionId: string; + constructor( + public ws: InternalWalletState, + public refundGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refund, + refundGroupId, + }); + } + + async deleteTransaction(): Promise<void> { + const { ws, refundGroupId, transactionId } = this; + await ws.db.runReadWriteTx(["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( + ws: InternalWalletState, + pcs: PayCoinSelection, +): Promise<AmountJson> { + const currency = Amounts.currencyOf(pcs.paymentAmount); + return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { + const costs: AmountJson[] = []; + 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 getCandidateWithdrawalDenomsTx( + ws, + tx, + coin.exchangeBaseUrl, + currency, + ); + const amountLeft = Amounts.sub( + denom.value, + pcs.coinContributions[i], + ).amount; + const refreshCost = getTotalRefreshCost( + allDenoms, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ws.config.testing.denomselAllowLate, + ); + costs.push(Amounts.parseOrThrow(pcs.coinContributions[i])); + costs.push(refreshCost); + } + const zero = Amounts.zeroOfAmount(pcs.paymentAmount); + return Amounts.sum([zero, ...costs]).amount; + }); +} + +async function failProposalPermanently( + ws: InternalWalletState, + proposalId: string, + err: TalerErrorDetail, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); +} + +function getProposalRequestTimeout(retryInfo?: DbRetryInfo): Duration { + return Duration.clamp({ + lower: Duration.fromSpec({ seconds: 1 }), + upper: Duration.fromSpec({ seconds: 60 }), + value: retryInfo + ? DbRetryInfo.getDuration(retryInfo) + : Duration.fromSpec({}), + }); +} + +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( + ws: InternalWalletState, + 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 ws.db.runReadOnlyTx(["contractTerms"], getFromTransaction); +} + +export function extractContractData( + parsedContractTerms: MerchantContractTerms, + 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.zeroOfCurrency(amount.currency); + } + 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, + maxWireFee: Amounts.stringify(maxWireFee), + payDeadline: parsedContractTerms.pay_deadline, + refundDeadline: parsedContractTerms.refund_deadline, + wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, + 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( + ws: InternalWalletState, + proposalId: string, +): Promise<TaskRunResult> { + const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => { + return await tx.purchases.get(proposalId); + }); + + if (!proposal) { + return TaskRunResult.finished(); + } + + const ctx = new PayMerchantTransactionContext(ws, 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 opId = TaskIdentifiers.forPay(proposal); + const retryRecord = await ws.db.runReadOnlyTx( + ["operationRetries"], + async (tx) => { + return tx.operationRetries.get(opId); + }, + ); + + const httpResponse = await ws.http.fetch(orderClaimUrl, { + method: "POST", + body: requestBody, + timeout: getProposalRequestTimeout(retryRecord?.retryInfo), + }); + 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(ws, 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(ws, proposalId, err); + throw makePendingOperationFailedError( + err, + TransactionType.Payment, + proposalId, + ); + } + + const sigValid = await ws.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(ws, 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(ws, 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 ws.db.runReadWriteTx( + ["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.refundAmountAwaiting === undefined) { + 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(ws, 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( + ws: InternalWalletState, + merchantBaseUrl: string, + orderId: string, + sessionId: string | undefined, + claimToken: string | undefined, + noncePriv: string | undefined, +): Promise<string> { + const oldProposals = await ws.db.runReadOnlyTx(["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(ws, oldProposal); + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + 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 ws.db.runReadWriteTx( + ["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.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: oldProposal.proposalId, + }); + notifyTransition(ws, transactionId, transitionInfo); + } + } + return oldProposal.proposalId; + } + + let noncePair: EddsaKeypair; + let shared = false; + if (noncePriv) { + shared = true; + noncePair = { + priv: noncePriv, + pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, + }; + } else { + noncePair = await ws.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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return proposalId; +} + +async function storeFirstPaySuccess( + ws: InternalWalletState, + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); +} + +async function storePayReplaySuccess( + ws: InternalWalletState, + proposalId: string, + sessionId: string | undefined, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, 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( + ws: InternalWalletState, + proposalId: string, + err: TalerErrorDetail, +): Promise<void> { + logger.trace("handling insufficient funds, trying to re-select coins"); + + const proposal = await ws.db.runReadOnlyTx(["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(ws, proposal); + + const prevPayCoins: PreviousPayCoins = []; + + const payInfo = proposal.payInfo; + if (!payInfo) { + return; + } + + const payCoinSelection = payInfo.payCoinSelection; + + await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + if (coinPub === brokenCoinPub) { + continue; + } + const contrib = 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: Amounts.parseOrThrow(contrib), + exchangeBaseUrl: coin.exchangeBaseUrl, + feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit), + }); + } + }); + + const res = await selectPayCoinsNew(ws, { + auditors: [], + exchanges: contractData.allowedExchanges, + wireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), + prevPayCoins, + requiredMinimumAge: contractData.minimumAge, + }); + + if (res.type !== "success") { + logger.trace("insufficient funds for coin re-selection"); + return; + } + + logger.trace("re-selected coins"); + + await ws.db.runReadWriteTx( + [ + "purchases", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + ], + async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + return; + } + const payInfo = p.payInfo; + if (!payInfo) { + return; + } + payInfo.payCoinSelection = res.coinSel; + payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); + await tx.purchases.put(p); + await spendCoins(ws, 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, + }); + }, + ); + + 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( + ws: InternalWalletState, + proposalId: string, + sessionId?: string, +): Promise<PreparePayResult> { + let proposal = await ws.db.runReadOnlyTx(["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 ws.db.runReadOnlyTx( + ["purchases"], + async (tx) => { + return tx.purchases.get(existingProposalId); + }, + ); + if (oldProposal) { + proposal = oldProposal; + } + } + } + const d = await expectProposalDownload(ws, proposal); + const contractData = d.contractData; + const merchantSig = d.contractData.merchantSig; + if (!merchantSig) { + throw Error("BUG: proposal is in invalid state"); + } + + proposalId = proposal.proposalId; + + const ctx = new PayMerchantTransactionContext(ws, 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 ws.db.runReadOnlyTx(["purchases"], async (tx) => { + return tx.purchases.get(proposalId); + }); + + if ( + !purchase || + purchase.purchaseStatus === PurchaseStatus.DialogProposed || + purchase.purchaseStatus === PurchaseStatus.DialogShared + ) { + // If not already paid, check if we could pay for it. + const res = await selectPayCoinsNew(ws, { + auditors: [], + exchanges: contractData.allowedExchanges, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + wireMethod: contractData.wireMethod, + }); + + if (res.type !== "success") { + 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, + }; + } + + const totalCost = await getTotalPaymentCost(ws, res.coinSel); + 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(res.coinSel.paymentAmount), + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(ctx.taskId); + + // FIXME: Consider changing the API here so that we don't have to + // wait inline for the repurchase. + + await waitPaymentResult(ws, proposalId, sessionId); + const download = await expectProposalDownload(ws, 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(ws, purchase); + return { + status: PreparePayResultType.AlreadyConfirmed, + contractTerms: download.contractTermsRaw, + contractTermsHash: download.contractData.contractTermsHash, + paid: false, + 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(ws, 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( + ws: InternalWalletState, + proposalId: string, +): Promise<WalletContractData> { + const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => { + return tx.purchases.get(proposalId); + }); + + if (!proposal) { + throw Error(`proposal with id ${proposalId} not found`); + } + + const d = await expectProposalDownload(ws, 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( + ws: InternalWalletState, + 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( + ws, + uriResult.merchantBaseUrl, + uriResult.orderId, + uriResult.sessionId, + uriResult.claimToken, + uriResult.noncePriv, + ); + + await waitProposalDownloaded(ws, proposalId); + + return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId); +} + +/** + * Wait until a proposal is at least downloaded. + */ +async function waitProposalDownloaded( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const ctx = new PayMerchantTransactionContext(ws, proposalId); + + logger.info(`waiting for ${ctx.transactionId} to be downloaded`); + + ws.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 = 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.ws.db.runReadOnlyTx( + ["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( + ws: InternalWalletState, + 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 ws.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(ws, payUri); +} + +/** + * 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.runReadOnlyTx(["coins", "denominations"], 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]; + let wireInfoHash: string; + wireInfoHash = contractData.wireInfoHash; + logger.trace( + `signing deposit permission for coin with ageRestriction=${j2s( + coin.ageCommitmentProof, + )}`, + ); + const dp = await ws.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.ws.db.runReadOnlyTx( + ["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.ws, 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( + ws: InternalWalletState, + proposalId: string, + waitSessionId?: string, +): Promise<ConfirmPayResult> { + const ctx = new PayMerchantTransactionContext(ws, proposalId); + + ws.ensureTaskLoopRunning(); + + ws.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 = 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( + ws: InternalWalletState, + 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 ws.db.runReadOnlyTx(["purchases"], async (tx) => { + return tx.purchases.get(proposalId); + }); + + if (!proposal) { + throw Error(`proposal with id ${proposalId} not found`); + } + + const d = await expectProposalDownload(ws, proposal); + if (!d) { + throw Error("proposal is in invalid state"); + } + + const existingPurchase = await ws.db.runReadWriteTx( + ["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( + ws, + existingPurchase.proposalId, + ); + await ws.taskScheduler.resetTaskRetries(ctx.taskId); + return waitPaymentResult(ws, proposalId); + } + + logger.trace("confirmPay: purchase record does not exist yet"); + + const contractData = d.contractData; + + const selectCoinsResult = await selectPayCoinsNew(ws, { + auditors: [], + exchanges: contractData.allowedExchanges, + wireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + forcedSelection: forcedCoinSel, + }); + + logger.trace("coin selection result", selectCoinsResult); + + if (selectCoinsResult.type === "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"); + } + + const coinSelection = selectCoinsResult.coinSel; + const payCostInfo = await getTotalPaymentCost(ws, coinSelection); + + 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 ws.db.runReadWriteTx( + [ + "purchases", + "coins", + "refreshGroups", + "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 = { + payCoinSelection: coinSelection, + payCoinSelectionUid: encodeCrock(getRandomBytes(16)), + totalPayCost: Amounts.stringify(payCostInfo), + }; + p.lastSessionId = sessionId; + p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now()); + p.purchaseStatus = PurchaseStatus.PendingPaying; + await tx.purchases.put(p); + await spendCoins(ws, tx, { + //`txn:proposal:${p.proposalId}` + allocationId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: proposalId, + }), + coinPubs: coinSelection.coinPubs, + contributions: coinSelection.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(ws, transactionId, transitionInfo); + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + // Wait until we have completed the first attempt to pay. + return waitPaymentResult(ws, proposalId); +} + +export async function processPurchase( + ws: InternalWalletState, + proposalId: string, +): Promise<TaskRunResult> { + const purchase = await ws.db.runReadOnlyTx(["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(ws, proposalId); + case PurchaseStatus.PendingPaying: + case PurchaseStatus.PendingPayingReplay: + return processPurchasePay(ws, proposalId); + case PurchaseStatus.PendingQueryingRefund: + return processPurchaseQueryRefund(ws, purchase); + case PurchaseStatus.PendingQueryingAutoRefund: + return processPurchaseAutoRefund(ws, purchase); + case PurchaseStatus.AbortingWithRefund: + return processPurchaseAbortingRefund(ws, purchase); + case PurchaseStatus.PendingAcceptRefund: + return processPurchaseAcceptRefund(ws, purchase); + case PurchaseStatus.DialogShared: + return processPurchaseDialogShared(ws, 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: + return TaskRunResult.finished(); + default: + assertUnreachable(purchase.purchaseStatus); + // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`); + } +} + +async function processPurchasePay( + ws: InternalWalletState, + proposalId: string, +): Promise<TaskRunResult> { + const purchase = await ws.db.runReadOnlyTx(["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 sessionId = purchase.lastSessionId; + + logger.trace(`paying with session ID ${sessionId}`); + const payInfo = purchase.payInfo; + checkDbInvariant(!!payInfo, "payInfo"); + + const download = await expectProposalDownload(ws, purchase); + + if (purchase.shared) { + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + + if (paid) { + const transitionInfo = await ws.db.runReadWriteTx( + ["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.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + + return { + type: TaskRunResultType.Error, + errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, { + orderId: purchase.orderId, + fulfillmentUrl: download.contractData.fulfillmentUrl, + }), + }; + } + } + + if (!purchase.merchantPaySig) { + const payUrl = new URL( + `orders/${download.contractData.orderId}/pay`, + download.contractData.merchantBaseUrl, + ).href; + + let depositPermissions: CoinDepositPermission[]; + // FIXME: Cache! + depositPermissions = await generateDepositPermissions( + ws, + payInfo.payCoinSelection, + 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.fetch(payUrl, { + method: "POST", + body: reqBody, + timeout: getPayRequestTimeout(purchase), + }), + ); + + 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 + handleInsufficientFunds(ws, 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); + 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 ws.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(ws, 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 ws.runSequentialized([EXCHANGE_COINS_LOCK], () => + ws.http.fetch(payAgainUrl, { method: "POST", body: reqBody }), + ); + 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(ws, proposalId, sessionId); + } + + return TaskRunResult.progress(); +} + +export async function refuseProposal( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, 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, + }; + } +} + +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]; + } +} + +export async function sharePayment( + ws: InternalWalletState, + merchantBaseUrl: string, + orderId: string, +): Promise<SharePaymentResult> { + const result = await ws.db.runReadWriteTx(["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; + } + if (p.purchaseStatus === PurchaseStatus.DialogProposed) { + p.purchaseStatus = PurchaseStatus.DialogShared; + p.shared = true; + tx.purchases.put(p); + } + + return { + nonce: p.noncePriv, + session: p.lastSessionId ?? p.downloadSessionId, + token: p.claimToken, + }; + }); + + if (result === undefined) { + throw Error("This purchase can't be shared"); + } + const privatePayUri = stringifyPayUri({ + merchantBaseUrl, + orderId, + sessionId: result.session ?? "", + noncePriv: result.nonce, + claimToken: result.token, + }); + return { privatePayUri }; +} + +async function checkIfOrderIsAlreadyPaid( + ws: InternalWalletState, + contract: WalletContractData, +) { + const requestUrl = new URL( + `orders/${contract.orderId}`, + contract.merchantBaseUrl, + ); + requestUrl.searchParams.set("h_contract", contract.contractTermsHash); + + requestUrl.searchParams.set("timeout_ms", "1000"); + + const resp = await ws.http.fetch(requestUrl.href); + 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( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + const proposalId = purchase.proposalId; + logger.trace(`processing dialog-shared for proposal ${proposalId}`); + const download = await expectProposalDownload(ws, purchase); + + if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) { + return TaskRunResult.finished(); + } + + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + if (paid) { + const transitionInfo = await ws.db.runReadWriteTx( + ["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.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + } + + return TaskRunResult.backoff(); +} + +async function processPurchaseAutoRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + const proposalId = purchase.proposalId; + logger.trace(`processing auto-refund for proposal ${proposalId}`); + + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId, + }); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + const download = await expectProposalDownload(ws, purchase); + + if ( + !purchase.autoRefundDeadline || + AbsoluteTime.isExpired( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(purchase.autoRefundDeadline), + ), + ) + ) { + const transitionInfo = await ws.db.runReadWriteTx( + ["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.purchaseStatus = PurchaseStatus.Done; + p.refundAmountAwaiting = undefined; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(ws, 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", "1000"); + requestUrl.searchParams.set("await_refund_obtained", "yes"); + + const resp = await ws.http.fetch(requestUrl.href); + + // FIXME: Check other status codes! + + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); + + if (orderStatus.refund_pending) { + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + } + + return TaskRunResult.backoff(); +} + +async function processPurchaseAbortingRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + const proposalId = purchase.proposalId; + const download = await expectProposalDownload(ws, 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 ws.db.runReadOnlyTx(["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 ws.http.fetch(requestUrl.href, { + method: "POST", + body: abortReq, + }); + + 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(ws, proposalId); + await ctx.transition(async (rec) => { + if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) { + rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; + return TransitionResult.Transition; + } + return TransitionResult.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(ws, purchase, refunds, RefundReason.AbortRefund); +} + +async function processPurchaseQueryRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + const proposalId = purchase.proposalId; + logger.trace(`processing query-refund for proposal ${proposalId}`); + + const download = await expectProposalDownload(ws, purchase); + + const requestUrl = new URL( + `orders/${download.contractData.orderId}`, + download.contractData.merchantBaseUrl, + ); + requestUrl.searchParams.set( + "h_contract", + download.contractData.contractTermsHash, + ); + + const resp = await ws.http.fetch(requestUrl.href); + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + if (!orderStatus.refund_pending) { + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.progress(); + } else { + const refundAwaiting = Amounts.sub( + Amounts.parseOrThrow(orderStatus.refund_amount), + Amounts.parseOrThrow(orderStatus.refund_taken), + ).amount; + + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.progress(); + } +} + +async function processPurchaseAcceptRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise<TaskRunResult> { + const download = await expectProposalDownload(ws, purchase); + + const requestUrl = new URL( + `orders/${download.contractData.orderId}/refund`, + download.contractData.merchantBaseUrl, + ); + + logger.trace(`making refund request to ${requestUrl.href}`); + + const request = await ws.http.fetch(requestUrl.href, { + method: "POST", + body: { + h_contract: download.contractData.contractTermsHash, + }, + }); + + const refundResponse = await readSuccessResponseJsonOrThrow( + request, + codecForMerchantOrderRefundPickupResponse(), + ); + return await storeRefunds( + ws, + purchase, + refundResponse.refunds, + RefundReason.AbortRefund, + ); +} + +export async function startRefundQueryForUri( + ws: InternalWalletState, + 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 ws.db.runReadOnlyTx( + ["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(ws, proposalId); + return { + transactionId, + }; +} + +export async function startQueryRefund( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const ctx = new PayMerchantTransactionContext(ws, proposalId); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, ctx.transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(ctx.taskId); +} + +async function computeRefreshRequest( + ws: InternalWalletState, + 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 ws.getDenomInfo( + ws, + 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( + ws: InternalWalletState, + 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(ws, purchase); + const currency = Amounts.currencyOf(download.contractData.amount); + + const result = await ws.db.runReadWriteTx( + [ + "coins", + "denominations", + "purchases", + "refundItems", + "refundGroups", + "denominations", + "coins", + "coinAvailability", + "refreshGroups", + ], + 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( + ws, + tx, + newGroupRefunds, + ); + const outInfo = await calculateRefreshOutput( + ws, + 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(ws, tx, items); + await createRefreshGroup( + ws, + 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); + if (numPendingItemsTotal === 0) { + if (isAborting) { + myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; + } 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(ws, 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 @@ -0,0 +1,172 @@ +/* + 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, + Logger, + TalerProtocolTimestamp, + buildCodecForObject, + codecForAmountString, + codecForTimestamp, + codecOptional, +} from "@gnu-taler/taler-util"; +import { SpendCoinDetails } from "./crypto/cryptoImplementation.js"; +import { PeerPushPaymentCoinSelection, ReserveRecord } from "./db.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import type { SelectedPeerCoin } from "./util/coinSelection.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { getTotalRefreshCost } from "./refresh.js"; +import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; + +const logger = new Logger("operations/peer-to-peer.ts"); + +/** + * Get information about the coin selected for signatures. + */ +export async function queryCoinInfosForSelection( + ws: InternalWalletState, + csel: PeerPushPaymentCoinSelection, +): Promise<SpendCoinDetails[]> { + let infos: SpendCoinDetails[] = []; + await ws.db.runReadOnlyTx(["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 ws.getDenomInfo( + ws, + 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( + ws: InternalWalletState, + pcs: SelectedPeerCoin[], +): Promise<AmountJson> { + const currency = Amounts.currencyOf(pcs[0].contribution); + return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => { + const costs: AmountJson[] = []; + for (let i = 0; i < pcs.length; i++) { + const coin = await tx.coins.get(pcs[i].coinPub); + if (!coin) { + throw Error("can't calculate payment cost, coin not found"); + } + const denomInfo = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denomInfo) { + throw Error( + "can't calculate payment cost, denomination for coin not found", + ); + } + const allDenoms = await getCandidateWithdrawalDenomsTx( + ws, + tx, + coin.exchangeBaseUrl, + currency, + ); + const amountLeft = Amounts.sub( + denomInfo.value, + pcs[i].contribution, + ).amount; + const refreshCost = getTotalRefreshCost( + allDenoms, + denomInfo, + amountLeft, + ws.config.testing.denomselAllowLate, + ); + 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( + ws: InternalWalletState, + 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 ws.cryptoApi.createEddsaKeypair({}); + + const mergeReserveRecord: ReserveRecord = await ws.db.runReadWriteTx( + ["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 @@ -0,0 +1,1204 @@ +/* + 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, + CancellationToken, + CheckPeerPullCreditRequest, + CheckPeerPullCreditResponse, + ContractTermsUtil, + ExchangeReservePurseRequest, + HttpStatusCode, + InitiatePeerPullCreditRequest, + InitiatePeerPullCreditResponse, + Logger, + NotificationType, + PeerContractTerms, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TalerUriAction, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + WalletAccountMergeFlags, + WalletKycUuid, + codecForAny, + codecForWalletKycUuid, + encodeCrock, + getRandomBytes, + j2s, + makeErrorDetail, + stringifyTalerUri, + talerPaytoFromExchangeReserve, +} from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + KycPendingInfo, + KycUserType, + PeerPullCreditRecord, + PeerPullPaymentCreditStatus, + WithdrawalGroupStatus, + WithdrawalRecordType, + fetchFreshExchange, + timestampOptionalPreciseFromDb, + timestampPreciseFromDb, + timestampPreciseToDb, +} from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType, TaskId } from "./pending-types.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { + codecForExchangePurseStatus, + getMergeReserveInfo, +} from "./pay-peer-common.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; +import { + getExchangeWithdrawalInfo, + internalCreateWithdrawalGroup, +} from "./withdraw.js"; + +const logger = new Logger("pay-peer-pull-credit.ts"); + +export class PeerPullCreditTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly retryTag: TaskId; + + constructor( + public ws: InternalWalletState, + public pursePub: string, + ) { + this.retryTag = constructTaskIdentifier({ + tag: PendingTaskType.PeerPullCredit, + pursePub, + }); + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub, + }); + } + + async deleteTransaction(): Promise<void> { + const { ws, pursePub } = this; + await ws.db.runReadWriteTx( + ["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 { ws, pursePub, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + } + + async failTransaction(): Promise<void> { + const { ws, pursePub, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.stopShepherdTask(retryTag); + } + + async resumeTransaction(): Promise<void> { + const { ws, pursePub, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async abortTransaction(): Promise<void> { + const { ws, pursePub, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } +} + +async function queryPurseForPeerPullCredit( + ws: InternalWalletState, + pullIni: PeerPullCreditRecord, + cancellationToken: CancellationToken, +): 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 ws.http.fetch(purseDepositUrl.href, { + timeout: { d_ms: 60000 }, + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.backoff(); + } + case HttpStatusCode.NotFound: + return TaskRunResult.backoff(); + } + + 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 ws.db.runReadOnlyTx(["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(ws, { + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.backoff(); +} + +async function longpollKycStatus( + ws: InternalWalletState, + pursePub: string, + exchangeUrl: string, + kycInfo: KycPendingInfo, + userType: KycUserType, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub, + }); + const retryTag = constructTaskIdentifier({ + tag: PendingTaskType.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 ws.http.fetch(url.href, { + method: "GET", + 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 ws.db.runReadWriteTx( + ["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(ws, 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(); +} + +async function processPeerPullCreditAbortingDeletePurse( + ws: InternalWalletState, + peerPullIni: PeerPullCreditRecord, +): Promise<TaskRunResult> { + const { pursePub, pursePriv } = peerPullIni; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub, + }); + + const sigResp = await ws.cryptoApi.signDeletePurse({ + pursePriv, + }); + const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl); + const resp = await ws.http.fetch(purseUrl.href, { + method: "DELETE", + headers: { + "taler-purse-signature": sigResp.sig, + }, + }); + logger.info(`deleted purse with response status ${resp.status}`); + + const transitionInfo = await ws.db.runReadWriteTx( + [ + "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(ws, transactionId, transitionInfo); + + return TaskRunResult.backoff(); +} + +async function handlePeerPullCreditWithdrawing( + ws: InternalWalletState, + pullIni: PeerPullCreditRecord, +): Promise<TaskRunResult> { + if (!pullIni.withdrawalGroupId) { + throw Error("invalid db state (withdrawing, but no withdrawal group ID"); + } + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub: pullIni.pursePub, + }); + const wgId = pullIni.withdrawalGroupId; + let finished: boolean = false; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + if (finished) { + return TaskRunResult.finished(); + } else { + // FIXME: Return indicator that we depend on the other operation! + return TaskRunResult.backoff(); + } +} + +async function handlePeerPullCreditCreatePurse( + ws: InternalWalletState, + pullIni: PeerPullCreditRecord, +): Promise<TaskRunResult> { + const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount)); + const pursePub = pullIni.pursePub; + const mergeReserve = await ws.db.runReadOnlyTx(["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 ws.db.runReadOnlyTx( + ["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 ws.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 ws.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 ws.http.fetch(reservePurseMergeUrl.href, { + method: "POST", + body: reservePurseReqBody, + }); + + if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) { + const respJson = await httpResp.json(); + const kycPending = codecForWalletKycUuid().decode(respJson); + logger.info(`kyc uuid response: ${j2s(kycPending)}`); + return processPeerPullCreditKycRequired(ws, 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return TaskRunResult.backoff(); +} + +export async function processPeerPullCredit( + ws: InternalWalletState, + pursePub: string, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + const pullIni = await ws.db.runReadOnlyTx(["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(ws, pullIni, cancellationToken); + case PeerPullPaymentCreditStatus.PendingMergeKycRequired: { + if (!pullIni.kycInfo) { + throw Error("invalid state, kycInfo required"); + } + return await longpollKycStatus( + ws, + pursePub, + pullIni.exchangeBaseUrl, + pullIni.kycInfo, + "individual", + cancellationToken, + ); + } + case PeerPullPaymentCreditStatus.PendingCreatePurse: + return handlePeerPullCreditCreatePurse(ws, pullIni); + case PeerPullPaymentCreditStatus.AbortingDeletePurse: + return await processPeerPullCreditAbortingDeletePurse(ws, pullIni); + case PeerPullPaymentCreditStatus.PendingWithdrawing: + return handlePeerPullCreditWithdrawing(ws, 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( + ws: InternalWalletState, + 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 ws.http.fetch(url.href, { + method: "GET", + }); + + 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 ws.db.runReadWriteTx( + ["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(ws, 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( + ws: InternalWalletState, + 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(ws, 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( + ws, + 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( + ws: InternalWalletState, + currency: string, +): Promise<string | undefined> { + // Find an exchange with the matching currency. + // Prefer exchanges with the most recent withdrawal. + const url = await ws.db.runReadOnlyTx(["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( + ws: InternalWalletState, + 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(ws, currency); + } + + if (!maybeExchangeBaseUrl) { + throw Error("no exchange found for initiating a peer pull payment"); + } + + const exchangeBaseUrl = maybeExchangeBaseUrl; + + await fetchFreshExchange(ws, exchangeBaseUrl); + + const mergeReserveInfo = await getMergeReserveInfo(ws, { + exchangeBaseUrl: exchangeBaseUrl, + }); + + const pursePair = await ws.cryptoApi.createEddsaKeypair({}); + const mergePair = await ws.cryptoApi.createEddsaKeypair({}); + + const contractTerms = req.partialContractTerms; + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + + const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({}); + + const withdrawalGroupId = encodeCrock(getRandomBytes(32)); + + const mergeReserveRowId = mergeReserveInfo.rowId; + checkDbInvariant(!!mergeReserveRowId); + + const contractEncNonce = encodeCrock(getRandomBytes(24)); + + const wi = await getExchangeWithdrawalInfo( + ws, + exchangeBaseUrl, + Amounts.parseOrThrow(req.partialContractTerms.amount), + undefined, + ); + + const mergeTimestamp = TalerPreciseTimestamp.now(); + + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, pursePair.pub); + + // The pending-incoming balance has changed. + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + notifyTransition(ws, ctx.transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(ctx.retryTag); + + 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 @@ -0,0 +1,883 @@ +/* + 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, + PeerContractTerms, + PreparePeerPullDebitRequest, + PreparePeerPullDebitResponse, + RefreshReason, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolViolationError, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + 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 { + DbReadWriteTransaction, + InternalWalletState, + PeerPullDebitRecordStatus, + PeerPullPaymentIncomingRecord, + PendingTaskType, + RefreshOperationStatus, + StoreNames, + TaskId, + WalletStoresV1, + createRefreshGroup, + timestampPreciseToDb, +} from "./index.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { PeerCoinRepair, selectPeerCoins } from "./util/coinSelection.js"; +import { checkLogicInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TaskRunResultType, + TransactionContext, + TransitionResult, + constructTaskIdentifier, + spendCoins, +} from "./common.js"; +import { + codecForExchangePurseStatus, + getTotalPeerPaymentCost, + queryCoinInfosForSelection, +} from "./pay-peer-common.js"; +import { + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; + +const logger = new Logger("pay-peer-pull-debit.ts"); + +/** + * Common context for a peer-pull-debit transaction. + */ +export class PeerPullDebitTransactionContext implements TransactionContext { + ws: InternalWalletState; + readonly transactionId: TransactionIdStr; + readonly taskId: TaskId; + peerPullDebitId: string; + + constructor(ws: InternalWalletState, peerPullDebitId: string) { + this.ws = ws; + 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.ws; + const peerPullDebitId = this.peerPullDebitId; + await ws.db.runReadWriteTx(["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 ws = this.ws; + const peerPullDebitId = this.peerPullDebitId; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.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 TransitionResult.Transition; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + return TransitionResult.Transition; + case PeerPullDebitRecordStatus.Aborted: + case PeerPullDebitRecordStatus.AbortingRefresh: + case PeerPullDebitRecordStatus.Failed: + case PeerPullDebitRecordStatus.DialogProposed: + case PeerPullDebitRecordStatus.Done: + case PeerPullDebitRecordStatus.PendingDeposit: + return TransitionResult.Stay; + } + }); + this.ws.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 TransitionResult.Transition; + default: + return TransitionResult.Stay; + } + }); + this.ws.taskScheduler.stopShepherdTask(this.taskId); + } + + async abortTransaction(): Promise<void> { + const ctx = this; + await ctx.transitionExtra( + { + extraStores: [ + "coinAvailability", + "denominations", + "refreshGroups", + "coins", + "coinAvailability", + ], + }, + async (pi, tx) => { + switch (pi.status) { + case PeerPullDebitRecordStatus.SuspendedDeposit: + case PeerPullDebitRecordStatus.PendingDeposit: + break; + default: + return TransitionResult.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.ws, + tx, + currency, + coinPubs, + RefreshReason.AbortPeerPullDebit, + this.transactionId, + ); + + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + pi.abortRefreshGroupId = refresh.refreshGroupId; + return TransitionResult.Transition; + }, + ); + } + + async transition( + f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResult>, + ): 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<TransitionResult>, + ): Promise<void> { + const ws = this.ws; + const extraStores = opts.extraStores ?? []; + const transitionInfo = await ws.db.runReadWriteTx( + ["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 TransitionResult.Transition: { + await tx.peerPullDebit.put(pi); + const newTxState = computePeerPullDebitTransactionState(pi); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + ws.taskScheduler.stopShepherdTask(this.taskId); + notifyTransition(ws, this.transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(this.taskId); + } +} + +async function handlePurseCreationConflict( + ctx: PeerPullDebitTransactionContext, + peerPullInc: PeerPullPaymentIncomingRecord, + resp: HttpResponse, +): Promise<TaskRunResult> { + const ws = ctx.ws; + 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: PeerCoinRepair = { + coinPubs: [], + contribs: [], + exchangeBaseUrl: peerPullInc.exchangeBaseUrl, + }; + + for (let i = 0; i < sel.coinPubs.length; i++) { + if (sel.coinPubs[i] != brokenCoinPub) { + repair.coinPubs.push(sel.coinPubs[i]); + repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i])); + } + } + + const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); + + if (coinSelRes.type == "failure") { + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + } + + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.coins, + ); + + await ws.db.runReadWriteTx(["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( + ws: InternalWalletState, + peerPullInc: PeerPullPaymentIncomingRecord, +): Promise<TaskRunResult> { + const pursePub = peerPullInc.pursePub; + + const coinSel = peerPullInc.coinSel; + if (!coinSel) { + throw Error("invalid state, no coins selected"); + } + + const coins = await queryCoinInfosForSelection(ws, coinSel); + + const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ + exchangeBaseUrl: peerPullInc.exchangeBaseUrl, + pursePub: peerPullInc.pursePub, + coins, + }); + + const purseDepositUrl = new URL( + `purses/${pursePub}/deposit`, + peerPullInc.exchangeBaseUrl, + ); + + const depositPayload: ExchangePurseDeposits = { + deposits: depositSigsResp.deposits, + }; + + if (logger.shouldLogTrace()) { + logger.trace(`purse deposit payload: ${j2s(depositPayload)}`); + } + + const httpResp = await ws.http.fetch(purseDepositUrl.href, { + method: "POST", + body: depositPayload, + }); + + const ctx = new PeerPullDebitTransactionContext( + ws, + peerPullInc.peerPullDebitId, + ); + + switch (httpResp.status) { + case HttpStatusCode.Ok: { + const resp = await readSuccessResponseJsonOrThrow( + httpResp, + codecForAny(), + ); + logger.trace(`purse deposit response: ${j2s(resp)}`); + + await ctx.transition(async (r) => { + if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return TransitionResult.Stay; + } + r.status = PeerPullDebitRecordStatus.Done; + return TransitionResult.Transition; + }); + return TaskRunResult.finished(); + } + 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, + }; + } + } +} + +async function processPeerPullDebitAbortingRefresh( + ws: InternalWalletState, + peerPullInc: PeerPullPaymentIncomingRecord, +): Promise<TaskRunResult> { + const peerPullDebitId = peerPullInc.peerPullDebitId; + const abortRefreshGroupId = peerPullInc.abortRefreshGroupId; + checkLogicInvariant(!!abortRefreshGroupId); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + // FIXME: Shouldn't this be finished in some cases?! + return TaskRunResult.backoff(); +} + +export async function processPeerPullDebit( + ws: InternalWalletState, + peerPullDebitId: string, +): Promise<TaskRunResult> { + const peerPullInc = await ws.db.runReadOnlyTx( + ["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(ws, peerPullInc); + case PeerPullDebitRecordStatus.AbortingRefresh: + return await processPeerPullDebitAbortingRefresh(ws, peerPullInc); + } + return TaskRunResult.finished(); +} + +export async function confirmPeerPullDebit( + ws: InternalWalletState, + req: ConfirmPeerPullDebitRequest, +): Promise<AcceptPeerPullPaymentResponse> { + let peerPullDebitId: string; + + if (req.transactionId) { + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) { + throw Error("invalid peer-pull-debit transaction identifier"); + } + peerPullDebitId = parsedTx.peerPullDebitId; + } else if (req.peerPullDebitId) { + peerPullDebitId = req.peerPullDebitId; + } else { + throw Error("invalid request, transactionId or peerPullDebitId required"); + } + + const peerPullInc = await ws.db.runReadOnlyTx( + ["peerPullDebit"], + async (tx) => { + return tx.peerPullDebit.get(peerPullDebitId); + }, + ); + + if (!peerPullInc) { + throw Error( + `can't accept unknown incoming p2p pull payment (${req.peerPullDebitId})`, + ); + } + + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const coinSelRes = await selectPeerCoins(ws, { instructedAmount }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + if (coinSelRes.type !== "success") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + } + + const sel = coinSelRes.result; + + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.coins, + ); + + await ws.db.runReadWriteTx( + [ + "exchanges", + "coins", + "denominations", + "refreshGroups", + "peerPullDebit", + "coinAvailability", + ], + async (tx) => { + await spendCoins(ws, tx, { + // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); + + const pi = await tx.peerPullDebit.get(peerPullDebitId); + if (!pi) { + throw Error(); + } + if (pi.status === PeerPullDebitRecordStatus.DialogProposed) { + pi.status = PeerPullDebitRecordStatus.PendingDeposit; + pi.coinSel = { + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + } + await tx.peerPullDebit.put(pi); + }, + ); + + const ctx = new PeerPullDebitTransactionContext(ws, peerPullDebitId); + + const transactionId = ctx.transactionId; + + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + ws.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( + ws: InternalWalletState, + req: PreparePeerPullDebitRequest, +): Promise<PreparePeerPullDebitResponse> { + const uri = parsePayPullUri(req.talerUri); + + if (!uri) { + throw Error("got invalid taler://pay-pull URI"); + } + + const existing = await ws.db.runReadOnlyTx( + ["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 ws.http.fetch(getContractUrl.href); + + const contractResp = await readSuccessResponseJsonOrThrow( + contractHttpResp, + codecForExchangeGetContractResponse(), + ); + + const pursePub = contractResp.purse_pub; + + const dec = await ws.cryptoApi.decryptContractForDeposit({ + ciphertext: contractResp.econtract, + contractPriv: contractPriv, + pursePub: pursePub, + }); + + const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl); + + const purseHttpResp = await ws.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(ws, { instructedAmount }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + if (coinSelRes.type !== "success") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + } + + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.coins, + ); + + await ws.db.runReadWriteTx(["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 @@ -0,0 +1,1037 @@ +/* + 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, + CancellationToken, + ConfirmPeerPushCreditRequest, + ContractTermsUtil, + ExchangePurseMergeRequest, + HttpStatusCode, + Logger, + NotificationType, + PeerContractTerms, + PreparePeerPushCreditRequest, + PreparePeerPushCreditResponse, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TransactionAction, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + WalletAccountMergeFlags, + WalletKycUuid, + 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 { + InternalWalletState, + KycPendingInfo, + KycUserType, + PeerPushCreditStatus, + PeerPushPaymentIncomingRecord, + PendingTaskType, + TaskId, + WithdrawalGroupStatus, + WithdrawalRecordType, + timestampPreciseToDb, +} from "./index.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { fetchFreshExchange } from "./exchanges.js"; +import { + codecForExchangePurseStatus, + getMergeReserveInfo, +} from "./pay-peer-common.js"; +import { + TransitionInfo, + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; +import { + PerformCreateWithdrawalGroupResult, + getExchangeWithdrawalInfo, + internalPerformCreateWithdrawalGroup, + internalPrepareCreateWithdrawalGroup, +} from "./withdraw.js"; + +const logger = new Logger("pay-peer-push-credit.ts"); + +export class PeerPushCreditTransactionContext implements TransactionContext { + readonly transactionId: string; + readonly retryTag: TaskId; + + constructor( + public ws: InternalWalletState, + public peerPushCreditId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId, + }); + this.retryTag = constructTaskIdentifier({ + tag: PendingTaskType.PeerPushCredit, + peerPushCreditId, + }); + } + + async deleteTransaction(): Promise<void> { + const { ws, peerPushCreditId } = this; + await ws.db.runReadWriteTx( + ["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 { ws, peerPushCreditId, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.stopShepherdTask(retryTag); + } + + async abortTransaction(): Promise<void> { + const { ws, peerPushCreditId, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async resumeTransaction(): Promise<void> { + const { ws, peerPushCreditId, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async failTransaction(): Promise<void> { + const { ws, peerPushCreditId, retryTag, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } +} + +export async function preparePeerPushCredit( + ws: InternalWalletState, + req: PreparePeerPushCreditRequest, +): Promise<PreparePeerPushCreditResponse> { + const uri = parsePayPushUri(req.talerUri); + + if (!uri) { + throw Error("got invalid taler://pay-push URI"); + } + + const existing = await ws.db.runReadOnlyTx( + ["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(ws, exchangeBaseUrl); + + const contractPriv = uri.contractPriv; + const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); + + const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); + + const contractHttpResp = await ws.http.fetch(getContractUrl.href); + + const contractResp = await readSuccessResponseJsonOrThrow( + contractHttpResp, + codecForExchangeGetContractResponse(), + ); + + const pursePub = contractResp.purse_pub; + + const dec = await ws.cryptoApi.decryptContractForMerge({ + ciphertext: contractResp.econtract, + contractPriv: contractPriv, + pursePub: pursePub, + }); + + const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); + + const purseHttpResp = await ws.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( + ws, + exchangeBaseUrl, + Amounts.parseOrThrow(purseStatus.balance), + undefined, + ); + + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + + 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( + ws: InternalWalletState, + peerPushCreditId: string, + exchangeUrl: string, + kycInfo: KycPendingInfo, + userType: KycUserType, + cancellationToken: CancellationToken, +): 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", "10000"); + logger.info(`kyc url ${url.href}`); + const kycStatusRes = await ws.http.fetch(url.href, { + method: "GET", + 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 ws.db.runReadWriteTx( + ["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(ws, 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(); +} + +async function processPeerPushCreditKycRequired( + ws: InternalWalletState, + 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 ws.http.fetch(url.href, { + method: "GET", + }); + + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + return result; + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } +} + +async function handlePendingMerge( + ws: InternalWalletState, + 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(ws, { + exchangeBaseUrl: peerInc.exchangeBaseUrl, + }); + + const mergeTimestamp = TalerProtocolTimestamp.now(); + + const reservePayto = talerPaytoFromExchangeReserve( + peerInc.exchangeBaseUrl, + mergeReserveInfo.reservePub, + ); + + const sigRes = await ws.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 ws.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(ws, peerInc, kycPending); + } + + logger.trace(`merge request: ${j2s(mergeReq)}`); + const res = await readSuccessResponseJsonOrThrow( + mergeHttpResp, + codecForAny(), + ); + logger.trace(`merge response: ${j2s(res)}`); + + const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(ws, { + amount, + wgInfo: { + withdrawalType: WithdrawalRecordType.PeerPushCredit, + }, + forcedWithdrawalGroupId: peerInc.withdrawalGroupId, + exchangeBaseUrl: peerInc.exchangeBaseUrl, + reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, + reserveKeyPair: { + priv: mergeReserveInfo.reservePriv, + pub: mergeReserveInfo.reservePub, + }, + }); + + const txRes = await ws.db.runReadWriteTx( + [ + "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( + ws, + 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) { + ws.notify(txRes.wgCreateRes.exchangeNotif); + } + notifyTransition( + ws, + withdrawalGroupPrep.transactionId, + txRes?.wgCreateRes?.transitionInfo, + ); + notifyTransition(ws, transactionId, txRes?.peerPushCreditTransition); + + return TaskRunResult.backoff(); +} + +async function handlePendingWithdrawing( + ws: InternalWalletState, + peerInc: PeerPushPaymentIncomingRecord, +): Promise<TaskRunResult> { + if (!peerInc.withdrawalGroupId) { + throw Error("invalid db state (withdrawing, but no withdrawal group ID"); + } + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId: peerInc.peerPushCreditId, + }); + const wgId = peerInc.withdrawalGroupId; + let finished: boolean = false; + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, 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( + ws: InternalWalletState, + peerPushCreditId: string, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + let peerInc: PeerPushPaymentIncomingRecord | undefined; + let contractTerms: PeerContractTerms | undefined; + await ws.db.runReadWriteTx( + ["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( + ws, + peerPushCreditId, + peerInc.exchangeBaseUrl, + peerInc.kycInfo, + "individual", + cancellationToken, + ); + } + + case PeerPushCreditStatus.PendingMerge: + return handlePendingMerge(ws, peerInc, contractTerms); + + case PeerPushCreditStatus.PendingWithdrawing: + return handlePendingWithdrawing(ws, peerInc); + + default: + return TaskRunResult.finished(); + } +} + +export async function confirmPeerPushCredit( + ws: InternalWalletState, + req: ConfirmPeerPushCreditRequest, +): Promise<AcceptPeerPushPaymentResponse> { + let peerInc: PeerPushPaymentIncomingRecord | undefined; + let peerPushCreditId: string; + if (req.peerPushCreditId) { + peerPushCreditId = req.peerPushCreditId; + } else if (req.transactionId) { + 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; + } else { + throw Error("no transaction ID (or deprecated peerPushCreditId) provided"); + } + + await ws.db.runReadWriteTx( + ["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.peerPushCreditId})`, + ); + } + + const ctx = new PeerPushCreditTransactionContext(ws, peerPushCreditId); + + ws.taskScheduler.startShepherdTask(ctx.retryTag); + + 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 @@ -0,0 +1,1150 @@ +/* + 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, + CancellationToken, + CheckPeerPushDebitRequest, + CheckPeerPushDebitResponse, + CoinRefreshRequest, + ContractTermsUtil, + HttpStatusCode, + InitiatePeerPushDebitRequest, + InitiatePeerPushDebitResponse, + Logger, + NotificationType, + RefreshReason, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TalerProtocolViolationError, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + encodeCrock, + getRandomBytes, + j2s, +} from "@gnu-taler/taler-util"; +import { + HttpResponse, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, +} from "@gnu-taler/taler-util/http"; +import { EncryptContractRequest } from "./crypto/cryptoTypes.js"; +import { + PeerPushDebitRecord, + PeerPushDebitStatus, + RefreshOperationStatus, + createRefreshGroup, + timestampPreciseToDb, + timestampProtocolFromDb, + timestampProtocolToDb, +} from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType, TaskId } from "./pending-types.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { PeerCoinRepair, selectPeerCoins } from "./util/coinSelection.js"; +import { checkLogicInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TaskRunResultType, + TransactionContext, + constructTaskIdentifier, + spendCoins, +} from "./common.js"; +import { + codecForExchangePurseStatus, + getTotalPeerPaymentCost, + queryCoinInfosForSelection, +} from "./pay-peer-common.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; + +const logger = new Logger("pay-peer-push-debit.ts"); + +export class PeerPushDebitTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly retryTag: TaskId; + + constructor( + public ws: InternalWalletState, + public pursePub: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub, + }); + this.retryTag = constructTaskIdentifier({ + tag: PendingTaskType.PeerPushDebit, + pursePub, + }); + } + + async deleteTransaction(): Promise<void> { + const { ws, pursePub, transactionId } = this; + await ws.db.runReadWriteTx(["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 { ws, pursePub, transactionId, retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { ws, pursePub, transactionId, retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async resumeTransaction(): Promise<void> { + const { ws, pursePub, transactionId, retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.startShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + } + + async failTransaction(): Promise<void> { + const { ws, pursePub, transactionId, retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["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; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } +} + +export async function checkPeerPushDebit( + ws: InternalWalletState, + req: CheckPeerPushDebitRequest, +): Promise<CheckPeerPushDebitResponse> { + const instructedAmount = Amounts.parseOrThrow(req.amount); + logger.trace( + `checking peer push debit for ${Amounts.stringify(instructedAmount)}`, + ); + const coinSelRes = await selectPeerCoins(ws, { instructedAmount }); + if (coinSelRes.type === "failure") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + } + logger.trace(`selected peer coins (len=${coinSelRes.result.coins.length})`); + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.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( + ws: InternalWalletState, + peerPushInitiation: PeerPushDebitRecord, + resp: HttpResponse, +): Promise<TaskRunResult> { + const pursePub = peerPushInitiation.pursePub; + const errResp = await readTalerErrorResponse(resp); + const ctx = new PeerPushDebitTransactionContext(ws, 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; + + const repair: PeerCoinRepair = { + coinPubs: [], + contribs: [], + exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl, + }; + + for (let i = 0; i < sel.coinPubs.length; i++) { + if (sel.coinPubs[i] != brokenCoinPub) { + repair.coinPubs.push(sel.coinPubs[i]); + repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i])); + } + } + + const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair }); + + if (coinSelRes.type == "failure") { + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + } + + await ws.db.runReadWriteTx(["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( + ws: InternalWalletState, + peerPushInitiation: PeerPushDebitRecord, +): Promise<TaskRunResult> { + const pursePub = peerPushInitiation.pursePub; + const purseExpiration = peerPushInitiation.purseExpiration; + const hContractTerms = peerPushInitiation.contractTermsHash; + const ctx = new PeerPushDebitTransactionContext(ws, pursePub); + const transactionId = ctx.transactionId; + + logger.trace(`processing ${transactionId} pending(create-reserve)`); + + const contractTermsRecord = await ws.db.runReadOnlyTx( + ["contractTerms"], + async (tx) => { + return tx.contractTerms.get(hContractTerms); + }, + ); + + if (!contractTermsRecord) { + throw Error( + `db invariant failed, contract terms for ${transactionId} missing`, + ); + } + + const purseSigResp = await ws.cryptoApi.signPurseCreation({ + hContractTerms, + mergePub: peerPushInitiation.mergePub, + minAge: 0, + purseAmount: peerPushInitiation.amount, + purseExpiration: timestampProtocolFromDb(purseExpiration), + pursePriv: peerPushInitiation.pursePriv, + }); + + const coins = await queryCoinInfosForSelection( + ws, + peerPushInitiation.coinSel, + ); + + const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ + exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl, + pursePub: peerPushInitiation.pursePub, + coins, + }); + + const encryptContractRequest: EncryptContractRequest = { + contractTerms: contractTermsRecord.contractTermsRaw, + mergePriv: peerPushInitiation.mergePriv, + pursePriv: peerPushInitiation.pursePriv, + pursePub: peerPushInitiation.pursePub, + contractPriv: peerPushInitiation.contractPriv, + contractPub: peerPushInitiation.contractPub, + nonce: peerPushInitiation.contractEncNonce, + }; + + logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`); + + const econtractResp = await ws.cryptoApi.encryptContractForMerge( + 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, + }; + + logger.trace(`request body: ${j2s(reqBody)}`); + + const httpResp = await ws.http.fetch(createPurseUrl.href, { + method: "POST", + body: reqBody, + }); + + { + const resp = await httpResp.json(); + logger.info(`resp: ${j2s(resp)}`); + } + + switch (httpResp.status) { + case HttpStatusCode.Ok: + break; + case HttpStatusCode.Forbidden: { + // FIXME: Store this error! + await ctx.failTransaction(); + return TaskRunResult.finished(); + } + case HttpStatusCode.Conflict: { + // Handle double-spending + return handlePurseCreationConflict(ws, peerPushInitiation, httpResp); + } + default: { + const errResp = await readTalerErrorResponse(httpResp); + return { + type: TaskRunResultType.Error, + errorDetail: errResp, + }; + } + } + + if (httpResp.status !== HttpStatusCode.Ok) { + // FIXME: do proper error reporting + throw Error("got error response from exchange"); + } + + await transitionPeerPushDebitTransaction(ws, pursePub, { + stFrom: PeerPushDebitStatus.PendingCreatePurse, + stTo: PeerPushDebitStatus.PendingReady, + }); + + return TaskRunResult.backoff(); +} + +async function processPeerPushDebitAbortingDeletePurse( + ws: InternalWalletState, + peerPushInitiation: PeerPushDebitRecord, +): Promise<TaskRunResult> { + const { pursePub, pursePriv } = peerPushInitiation; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub, + }); + + const sigResp = await ws.cryptoApi.signDeletePurse({ + pursePriv, + }); + const purseUrl = new URL( + `purses/${pursePub}`, + peerPushInitiation.exchangeBaseUrl, + ); + const resp = await ws.http.fetch(purseUrl.href, { + method: "DELETE", + headers: { + "taler-purse-signature": sigResp.sig, + }, + }); + logger.info(`deleted purse with response status ${resp.status}`); + + const transitionInfo = await ws.db.runReadWriteTx( + [ + "peerPushDebit", + "refreshGroups", + "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[] = []; + + 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( + ws, + 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(ws, transactionId, transitionInfo); + + return TaskRunResult.backoff(); +} + +interface SimpleTransition { + stFrom: PeerPushDebitStatus; + stTo: PeerPushDebitStatus; +} + +async function transitionPeerPushDebitTransaction( + ws: InternalWalletState, + pursePub: string, + transitionSpec: SimpleTransition, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); +} + +async function processPeerPushDebitAbortingRefreshDeleted( + ws: InternalWalletState, + 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 ws.db.runReadWriteTx( + ["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(ws, transactionId, transitionInfo); + // FIXME: Shouldn't this be finished in some cases?! + return TaskRunResult.backoff(); +} + +async function processPeerPushDebitAbortingRefreshExpired( + ws: InternalWalletState, + 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 ws.db.runReadWriteTx( + ["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(ws, 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( + ws: InternalWalletState, + peerPushInitiation: PeerPushDebitRecord, + cancellationToken: CancellationToken, +): 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 ws.http.fetch(mergeUrl.href, { + // timeout: getReserveRequestTimeout(withdrawalGroup), + 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( + ws, + peerPushInitiation.pursePub, + { + stFrom: PeerPushDebitStatus.PendingReady, + stTo: PeerPushDebitStatus.Done, + }, + ); + return TaskRunResult.finished(); + } + } else if (resp.status === HttpStatusCode.Gone) { + logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`); + const transitionInfo = await ws.db.runReadWriteTx( + [ + "peerPushDebit", + "refreshGroups", + "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[] = []; + + 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( + ws, + tx, + currency, + coinPubs, + RefreshReason.AbortPeerPushDebit, + transactionId, + ); + ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired; + ppiRec.abortRefreshGroupId = refresh.refreshGroupId; + await tx.peerPushDebit.put(ppiRec); + const newTxState = computePeerPushDebitTransactionState(ppiRec); + return { + oldTxState, + newTxState, + }; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + return TaskRunResult.backoff(); + } else { + logger.warn(`unexpected HTTP status for purse: ${resp.status}`); + return TaskRunResult.backoff(); + } +} + +export async function processPeerPushDebit( + ws: InternalWalletState, + pursePub: string, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + const peerPushInitiation = await ws.db.runReadOnlyTx( + ["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(ws, peerPushInitiation); + case PeerPushDebitStatus.PendingReady: + return processPeerPushDebitReady( + ws, + peerPushInitiation, + cancellationToken, + ); + case PeerPushDebitStatus.AbortingDeletePurse: + return processPeerPushDebitAbortingDeletePurse(ws, peerPushInitiation); + case PeerPushDebitStatus.AbortingRefreshDeleted: + return processPeerPushDebitAbortingRefreshDeleted(ws, peerPushInitiation); + case PeerPushDebitStatus.AbortingRefreshExpired: + return processPeerPushDebitAbortingRefreshExpired(ws, 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( + ws: InternalWalletState, + req: InitiatePeerPushDebitRequest, +): Promise<InitiatePeerPushDebitResponse> { + const instructedAmount = Amounts.parseOrThrow( + req.partialContractTerms.amount, + ); + const purseExpiration = req.partialContractTerms.purse_expiration; + const contractTerms = req.partialContractTerms; + + const pursePair = await ws.cryptoApi.createEddsaKeypair({}); + const mergePair = await ws.cryptoApi.createEddsaKeypair({}); + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + + const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({}); + + const coinSelRes = await selectPeerCoins(ws, { instructedAmount }); + + if (coinSelRes.type !== "success") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + } + + const sel = coinSelRes.result; + + logger.info(`selected p2p coins (push):`); + logger.trace(`${j2s(coinSelRes)}`); + + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.coins, + ); + + logger.info(`computed total peer payment cost`); + + const pursePub = pursePair.pub; + + const ctx = new PeerPushDebitTransactionContext(ws, pursePub); + + const transactionId = ctx.transactionId; + + const contractEncNonce = encodeCrock(getRandomBytes(24)); + + const transitionInfo = await ws.db.runReadWriteTx( + [ + "exchanges", + "contractTerms", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "peerPushDebit", + ], + async (tx) => { + // 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(ws, tx, { + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub: pursePair.pub, + }), + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPush, + }); + + 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, + coinSel: { + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => x.contribution), + }, + totalCost: Amounts.stringify(totalAmount), + }; + + await tx.peerPushDebit.add(ppi); + + await tx.contractTerms.put({ + h: hContractTerms, + contractTermsRaw: contractTerms, + }); + + const newTxState = computePeerPushDebitTransactionState(ppi); + return { + oldTxState: { major: TransactionMajorState.None }, + newTxState, + }; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + ws.taskScheduler.startShepherdTask(ctx.retryTag); + + 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 @@ -25,7 +25,7 @@ * Imports. */ import { TalerErrorDetail, AbsoluteTime } from "@gnu-taler/taler-util"; -import { DbRetryInfo } from "./operations/common.js"; +import { DbRetryInfo } from "./common.js"; export enum PendingTaskType { ExchangeUpdate = "exchange-update", diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/query.ts diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts @@ -0,0 +1,535 @@ +/* + 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, + TransactionType, + URL, + codecForRecoupConfirmation, + codecForReserveStatus, + encodeCrock, + getRandomBytes, + j2s, +} from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + CoinRecord, + CoinSourceType, + RecoupGroupRecord, + RecoupOperationStatus, + RefreshCoinSource, + WalletDbReadWriteTransaction, + WithdrawCoinSource, + WithdrawalGroupStatus, + WithdrawalRecordType, + timestampPreciseToDb, +} from "./db.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType } from "./pending-types.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { + TaskRunResult, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { createRefreshGroup } from "./refresh.js"; +import { constructTransactionIdentifier } from "./transactions.js"; +import { internalCreateWithdrawalGroup } from "./withdraw.js"; + +const logger = new Logger("operations/recoup.ts"); + +/** + * Store a recoup group record in the database after marking + * a coin in the group as finished. + */ +async function putGroupAsFinished( + ws: InternalWalletState, + 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( + 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.runReadWriteTx( + ["recoupGroups", "denominations", "refreshGroups", "coins"], + 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 denomInfo = await ws.db.runReadOnlyTx(["denominations"], async (tx) => { + const denomInfo = await ws.getDenomInfo( + ws, + 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 ws.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 ws.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 ws.db.runReadWriteTx( + ["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(ws, tx, recoupGroup, coinIdx); + }, + ); +} + +async function recoupRefreshCoin( + ws: InternalWalletState, + recoupGroupId: string, + coinIdx: number, + coin: CoinRecord, + cs: RefreshCoinSource, +): Promise<void> { + const d = await ws.db.runReadOnlyTx( + ["coins", "denominations"], + async (tx) => { + const denomInfo = await ws.getDenomInfo( + ws, + 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 ws.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 ws.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 ws.db.runReadWriteTx( + ["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 ws.getDenomInfo( + ws, + tx, + oldCoin.exchangeBaseUrl, + oldCoin.denomPubHash, + ); + const revokedCoinDenom = await ws.getDenomInfo( + ws, + 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(ws, tx, recoupGroup, coinIdx); + }, + ); +} + +export async function processRecoupGroup( + ws: InternalWalletState, + recoupGroupId: string, +): Promise<TaskRunResult> { + let recoupGroup = await ws.db.runReadOnlyTx(["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(ws, recoupGroupId, i); + } catch (e) { + logger.warn(`processRecoup failed: ${e}`); + throw e; + } + }); + await Promise.all(ps); + + recoupGroup = await ws.db.runReadOnlyTx(["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 ws.db.runReadOnlyTx(["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 ws.http.fetch(reserveUrl.href); + + const result = await readSuccessResponseJsonOrThrow( + resp, + codecForReserveStatus(), + ); + await internalCreateWithdrawalGroup(ws, { + amount: Amounts.parseOrThrow(result.balance), + exchangeBaseUrl: recoupGroup.exchangeBaseUrl, + reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, + reserveKeyPair: { + pub: reservePub, + priv: reservePrivMap[reservePub], + }, + wgInfo: { + withdrawalType: WithdrawalRecordType.Recoup, + }, + }); + } + + await ws.db.runReadWriteTx( + [ + "recoupGroups", + "coinAvailability", + "denominations", + "refreshGroups", + "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( + ws, + 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 RewardTransactionContext 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: string; + public retryTag: string; + + constructor( + public ws: InternalWalletState, + private recoupGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Recoup, + recoupGroupId, + }); + this.retryTag = constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId, + }); + } +} + +export async function createRecoupGroup( + ws: InternalWalletState, + 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(ws, tx, recoupGroup, coinIdx); + continue; + } + await tx.coins.put(coin); + } + + await tx.recoupGroups.put(recoupGroup); + + return recoupGroupId; +} + +/** + * Run the recoup protocol for a single coin in a recoup group. + */ +async function processRecoupForCoin( + ws: InternalWalletState, + recoupGroupId: string, + coinIdx: number, +): Promise<void> { + const coin = await ws.db.runReadOnlyTx( + ["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(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/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -0,0 +1,1430 @@ +/* + 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 { + AgeCommitment, + AgeRestriction, + AmountJson, + Amounts, + amountToPretty, + CancellationToken, + codecForExchangeMeltResponse, + codecForExchangeRevealResponse, + CoinPublicKeyString, + CoinRefreshRequest, + CoinStatus, + DenominationInfo, + DenomKeyType, + Duration, + encodeCrock, + ExchangeMeltRequest, + ExchangeProtocolVersion, + ExchangeRefreshRevealRequest, + fnutil, + ForceRefreshRequest, + getErrorDetailFromException, + getRandomBytes, + HashCodeString, + HttpStatusCode, + j2s, + Logger, + makeErrorDetail, + NotificationType, + RefreshGroupId, + RefreshReason, + TalerError, + TalerErrorCode, + TalerErrorDetail, + TalerPreciseTimestamp, + TransactionAction, + TransactionMajorState, + TransactionState, + TransactionType, + URL, +} from "@gnu-taler/taler-util"; +import { + readSuccessResponseJsonOrThrow, + readUnexpectedResponseDetails, +} from "@gnu-taler/taler-util/http"; +import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; +import { + DerivedRefreshSession, + RefreshNewDenomInfo, +} from "./crypto/cryptoTypes.js"; +import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js"; +import { + CoinRecord, + CoinSourceType, + DenominationRecord, + RefreshCoinStatus, + RefreshGroupRecord, + RefreshOperationStatus, +} from "./db.js"; +import { + getCandidateWithdrawalDenomsTx, + PendingTaskType, + RefreshGroupPerExchangeInfo, + RefreshSessionRecord, + TaskId, + timestampPreciseToDb, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, +} from "./index.js"; +import { + EXCHANGE_COINS_LOCK, + InternalWalletState, +} from "./internal-wallet-state.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { selectWithdrawalDenominations } from "./util/coinSelection.js"; +import { checkDbInvariant } from "./util/invariants.js"; +import { + constructTaskIdentifier, + makeCoinAvailable, + makeCoinsVisible, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, +} from "./common.js"; +import { fetchFreshExchange } from "./exchanges.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; + +const logger = new Logger("refresh.ts"); + +export class RefreshTransactionContext implements TransactionContext { + public transactionId: string; + readonly taskId: TaskId; + + constructor( + public ws: InternalWalletState, + public refreshGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.Refresh, + refreshGroupId, + }); + } + + async deleteTransaction(): Promise<void> { + const refreshGroupId = this.refreshGroupId; + const ws = this.ws; + await ws.db.runReadWriteTx(["refreshGroups", "tombstones"], async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (rg) { + await tx.refreshGroups.delete(refreshGroupId); + await tx.tombstones.put({ + id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId, + }); + } + }); + } + + async suspendTransaction(): Promise<void> { + const { ws, refreshGroupId, transactionId } = this; + let res = await ws.db.runReadWriteTx(["refreshGroups"], async (tx) => { + const dg = await tx.refreshGroups.get(refreshGroupId); + if (!dg) { + logger.warn( + `can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`, + ); + return undefined; + } + const oldState = computeRefreshTransactionState(dg); + switch (dg.operationStatus) { + case RefreshOperationStatus.Finished: + return undefined; + case RefreshOperationStatus.Pending: { + dg.operationStatus = RefreshOperationStatus.Suspended; + await tx.refreshGroups.put(dg); + return { + oldTxState: oldState, + newTxState: computeRefreshTransactionState(dg), + }; + } + case RefreshOperationStatus.Suspended: + return undefined; + } + return undefined; + }); + if (res) { + ws.notify({ + type: NotificationType.TransactionStateTransition, + transactionId, + oldTxState: res.oldTxState, + newTxState: res.newTxState, + }); + } + } + + async abortTransaction(): Promise<void> { + // Refresh transactions only support fail, not abort. + throw new Error("refresh transactions cannot be aborted"); + } + + async resumeTransaction(): Promise<void> { + const { ws, refreshGroupId, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["refreshGroups"], + async (tx) => { + const dg = await tx.refreshGroups.get(refreshGroupId); + if (!dg) { + logger.warn( + `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`, + ); + return; + } + const oldState = computeRefreshTransactionState(dg); + switch (dg.operationStatus) { + case RefreshOperationStatus.Finished: + return; + case RefreshOperationStatus.Pending: { + return; + } + case RefreshOperationStatus.Suspended: + dg.operationStatus = RefreshOperationStatus.Pending; + await tx.refreshGroups.put(dg); + return { + oldTxState: oldState, + newTxState: computeRefreshTransactionState(dg), + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(this.taskId); + } + + async failTransaction(): Promise<void> { + const { ws, refreshGroupId, transactionId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["refreshGroups"], + async (tx) => { + const dg = await tx.refreshGroups.get(refreshGroupId); + if (!dg) { + logger.warn( + `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`, + ); + return; + } + const oldState = computeRefreshTransactionState(dg); + let newStatus: RefreshOperationStatus | undefined; + switch (dg.operationStatus) { + case RefreshOperationStatus.Finished: + break; + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: + newStatus = RefreshOperationStatus.Failed; + break; + case RefreshOperationStatus.Failed: + break; + default: + assertUnreachable(dg.operationStatus); + } + if (newStatus) { + dg.operationStatus = newStatus; + await tx.refreshGroups.put(dg); + } + return { + oldTxState: oldState, + newTxState: computeRefreshTransactionState(dg), + }; + }, + ); + ws.taskScheduler.stopShepherdTask(this.taskId); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(this.taskId); + } +} + +/** + * 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: DenominationInfo, + amountLeft: AmountJson, + denomselAllowLate: boolean, +): AmountJson { + const withdrawAmount = Amounts.sub( + amountLeft, + refreshedDenom.feeRefresh, + ).amount; + const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x])); + const withdrawDenoms = selectWithdrawalDenominations( + withdrawAmount, + denoms, + denomselAllowLate, + ); + 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; +} + +function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } { + 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; + } + return { final: true }; + } + return { final: false }; +} + +/** + * Create a refresh session for one particular coin inside a refresh group. + * + * If the session already exists, return the existing one. + * + * If the session doesn't need to be created (refresh group gone or session already + * finished), return undefined. + */ +async function provideRefreshSession( + ws: InternalWalletState, + refreshGroupId: string, + coinIndex: number, +): Promise<RefreshSessionRecord | undefined> { + logger.trace( + `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`, + ); + + const d = await ws.db.runReadWriteTx( + ["coins", "refreshGroups", "refreshSessions"], + async (tx) => { + const refreshGroup = await tx.refreshGroups.get(refreshGroupId); + if (!refreshGroup) { + return; + } + if ( + refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished + ) { + return; + } + const existingRefreshSession = await tx.refreshSessions.get([ + refreshGroupId, + coinIndex, + ]); + 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, existingRefreshSession }; + }, + ); + + if (!d) { + return undefined; + } + + if (d.existingRefreshSession) { + return d.existingRefreshSession; + } + + const { refreshGroup, coin } = d; + + const exch = await fetchFreshExchange(ws, coin.exchangeBaseUrl); + + // FIXME: use helper functions from withdraw.ts + // to update and filter withdrawable denoms. + + const { availableAmount, availableDenoms } = await ws.db.runReadOnlyTx( + ["denominations"], + async (tx) => { + const oldDenom = await ws.getDenomInfo( + ws, + tx, + exch.exchangeBaseUrl, + coin.denomPubHash, + ); + + if (!oldDenom) { + throw Error("db inconsistent: denomination for coin not found"); + } + + // FIXME: Use denom groups instead of querying all denominations! + const availableDenoms: DenominationRecord[] = + await tx.denominations.indexes.byExchangeBaseUrl + .iter(exch.exchangeBaseUrl) + .toArray(); + + const availableAmount = Amounts.sub( + refreshGroup.inputPerCoin[coinIndex], + oldDenom.feeRefresh, + ).amount; + return { availableAmount, availableDenoms }; + }, + ); + + const newCoinDenoms = selectWithdrawalDenominations( + availableAmount, + availableDenoms, + ws.config.testing.denomselAllowLate, + ); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }); + + if (newCoinDenoms.selectedDenoms.length === 0) { + logger.trace( + `not refreshing, available amount ${amountToPretty( + availableAmount, + )} too small`, + ); + const transitionInfo = await ws.db.runReadWriteTx( + ["refreshGroups", "coins", "coinAvailability"], + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + return; + } + const oldTxState = computeRefreshTransactionState(rg); + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; + const updateRes = updateGroupStatus(rg); + if (updateRes.final) { + await makeCoinsVisible(ws, tx, transactionId); + } + await tx.refreshGroups.put(rg); + const newTxState = computeRefreshTransactionState(rg); + return { oldTxState, newTxState }; + }, + ); + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + notifyTransition(ws, transactionId, transitionInfo); + return; + } + + const sessionSecretSeed = encodeCrock(getRandomBytes(64)); + + // Store refresh session for this coin in the database. + const mySession = await ws.db.runReadWriteTx( + ["refreshGroups", "refreshSessions"], + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + return; + } + const existingSession = await tx.refreshSessions.get([ + refreshGroupId, + coinIndex, + ]); + if (existingSession) { + return existingSession; + } + 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); + return newSession; + }, + ); + logger.trace( + `found/created refresh session for coin #${coinIndex} in ${refreshGroupId}`, + ); + return mySession; +} + +function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration { + return Duration.fromSpec({ + seconds: 5, + }); +} + +async function refreshMelt( + ws: InternalWalletState, + refreshGroupId: string, + coinIndex: number, +): Promise<void> { + const d = await ws.db.runReadWriteTx( + ["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 ws.getDenomInfo( + ws, + 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 ws.getDenomInfo( + ws, + 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 ws.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 ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => { + return await ws.http.fetch(reqUrl.href, { + method: "POST", + body: meltReqBody, + timeout: getRefreshRequestTimeout(refreshGroup), + }); + }); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }); + + if (resp.status === HttpStatusCode.NotFound) { + const errDetails = await readUnexpectedResponseDetails(resp); + const transitionInfo = await ws.db.runReadWriteTx( + ["refreshGroups", "refreshSessions", "coins", "coinAvailability"], + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { + return; + } + const oldTxState = computeRefreshTransactionState(rg); + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + const refreshSession = await tx.refreshSessions.get([ + refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + throw Error( + "db invariant failed: missing refresh session in database", + ); + } + refreshSession.lastError = errDetails; + const updateRes = updateGroupStatus(rg); + if (updateRes.final) { + await makeCoinsVisible(ws, tx, transactionId); + } + await tx.refreshGroups.put(rg); + await tx.refreshSessions.put(refreshSession); + const newTxState = computeRefreshTransactionState(rg); + return { + oldTxState, + newTxState, + }; + }, + ); + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + notifyTransition(ws, transactionId, transitionInfo); + return; + } + + if (resp.status === HttpStatusCode.Conflict) { + // 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 ${refreshGroupId} due to conflict`, + ); + + const historySig = await ws.cryptoApi.signCoinHistoryRequest({ + coinPriv: oldCoin.coinPriv, + coinPub: oldCoin.coinPub, + startOffset: 0, + }); + + const historyUrl = new URL( + `coins/${oldCoin.coinPub}/history`, + oldCoin.exchangeBaseUrl, + ); + + const historyResp = await ws.http.fetch(historyUrl.href, { + method: "GET", + headers: { + "Taler-Coin-History-Signature": historySig.sig, + }, + }); + + const historyJson = await historyResp.json(); + logger.info(`coin history: ${j2s(historyJson)}`); + + // FIXME: Before failing and re-trying, analyse response and adjust amount + } + + const meltResponse = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeMeltResponse(), + ); + + const norevealIndex = meltResponse.noreveal_index; + + refreshSession.norevealIndex = norevealIndex; + + await ws.db.runReadWriteTx( + ["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); + }, + ); +} + +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( + ws: InternalWalletState, + refreshGroupId: string, + coinIndex: number, +): Promise<void> { + logger.trace( + `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`, + ); + const d = await ws.db.runReadOnlyTx( + ["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 ws.getDenomInfo( + ws, + 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 ws.getDenomInfo( + ws, + 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 ws.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: ws.cryptoApi, + derived, + newDenoms: newCoinDenoms, + norevealIndex: norevealIndex, + oldCoinPriv: oldCoin.coinPriv, + oldCoinPub: oldCoin.coinPub, + oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment, + }); + + const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => { + return await ws.http.fetch(reqUrl.href, { + body: req, + method: "POST", + timeout: getRefreshRequestTimeout(refreshGroup), + }); + }); + + 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 ws.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); + } + } + + const transitionInfo = await ws.db.runReadWriteTx( + [ + "coins", + "denominations", + "coinAvailability", + "refreshGroups", + "refreshSessions", + ], + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + logger.warn("no refresh session found"); + return; + } + const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); + if (!rs) { + return; + } + const oldTxState = computeRefreshTransactionState(rg); + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; + updateGroupStatus(rg); + for (const coin of coins) { + await makeCoinAvailable(ws, tx, coin); + } + await makeCoinsVisible(ws, tx, transactionId); + await tx.refreshGroups.put(rg); + const newTxState = computeRefreshTransactionState(rg); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + logger.trace("refresh finished (end of reveal)"); +} + +export async function processRefreshGroup( + ws: InternalWalletState, + refreshGroupId: string, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + logger.trace(`processing refresh group ${refreshGroupId}`); + + const refreshGroup = await ws.db.runReadOnlyTx( + ["refreshGroups"], + async (tx) => tx.refreshGroups.get(refreshGroupId), + ); + if (!refreshGroup) { + return TaskRunResult.finished(); + } + if (refreshGroup.timestampFinished) { + return TaskRunResult.finished(); + } + // 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(ws, 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)); + }), + ); + try { + logger.info("waiting for refreshes"); + await Promise.all(ps); + logger.info("refresh group finished"); + } catch (e) { + logger.warn("process refresh sessions got exception"); + logger.warn(`exception: ${e}`); + } + if (inShutdown) { + return TaskRunResult.backoff(); + } + 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( + ws: InternalWalletState, + refreshGroupId: string, + coinIndex: number, +): Promise<void> { + logger.trace( + `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`, + ); + let { refreshGroup, refreshSession } = await ws.db.runReadOnlyTx( + ["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) { + refreshSession = await provideRefreshSession(ws, refreshGroupId, coinIndex); + } + if (!refreshSession) { + // We tried to create the refresh session, but didn't get a result back. + // This means that either the session is finished, or that creating + // one isn't necessary. + return; + } + if (refreshSession.norevealIndex === undefined) { + await refreshMelt(ws, refreshGroupId, coinIndex); + } + await refreshReveal(ws, refreshGroupId, coinIndex); +} + +export interface RefreshOutputInfo { + outputPerCoin: AmountJson[]; + perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>; +} + +export async function calculateRefreshOutput( + ws: InternalWalletState, + 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> = {}; + + // FIXME: Use denom groups instead of querying all denominations! + const getDenoms = async ( + exchangeBaseUrl: string, + ): Promise<DenominationRecord[]> => { + if (denomsPerExchange[exchangeBaseUrl]) { + return denomsPerExchange[exchangeBaseUrl]; + } + const allDenoms = await getCandidateWithdrawalDenomsTx( + ws, + tx, + exchangeBaseUrl, + currency, + ); + 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 ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant( + !!denom, + "denomination for existing coin must be in database", + ); + const refreshAmount = ocp.amount; + const denoms = await getDenoms(coin.exchangeBaseUrl); + const cost = getTotalRefreshCost( + denoms, + denom, + Amounts.parseOrThrow(refreshAmount), + ws.config.testing.denomselAllowLate, + ); + 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 applyRefresh( + ws: InternalWalletState, + 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 ws.getDenomInfo( + ws, + 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; + } + 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; +} + +/** + * 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( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction< + ["denominations", "coins", "refreshGroups", "coinAvailability"] + >, + currency: string, + oldCoinPubs: CoinRefreshRequest[], + refreshReason: RefreshReason, + originatingTransactionId: string | undefined, +): Promise<CreateRefreshGroupResult> { + const refreshGroupId = encodeCrock(getRandomBytes(32)); + + const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs); + + const estimatedOutputPerCoin = outInfo.outputPerCoin; + + await applyRefresh(ws, 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), + ), + 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; + } + + await tx.refreshGroups.put(refreshGroup); + + logger.trace(`created refresh group ${refreshGroupId}`); + + const ctx = new RefreshTransactionContext(ws, refreshGroupId); + + // Shepherd the task. + // If the current transaction fails to commit the refresh + // group to the DB, the shepherd will give up. + ws.taskScheduler.startShepherdTask(ctx.taskId); + + return { + refreshGroupId, + }; +} + +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( + ws: InternalWalletState, + transactionId: string, +): Promise<string[]> { + return ws.db.runReadOnlyTx(["refreshGroups"], async (tx) => { + const groups = + await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll( + transactionId, + ); + return groups.map((x) => + constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId: x.refreshGroupId, + }), + ); + }); +} + +export async function forceRefresh( + ws: InternalWalletState, + req: ForceRefreshRequest, +): Promise<{ refreshGroupId: RefreshGroupId }> { + if (req.coinPubList.length == 0) { + throw Error("refusing to create empty refresh group"); + } + const refreshGroupId = await ws.db.runReadWriteTx( + ["refreshGroups", "coinAvailability", "denominations", "coins"], + async (tx) => { + let coinPubs: CoinRefreshRequest[] = []; + for (const c of req.coinPubList) { + const coin = await tx.coins.get(c); + if (!coin) { + throw Error(`coin (pubkey ${c}) not found`); + } + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant(!!denom); + coinPubs.push({ + coinPub: c, + amount: denom?.value, + }); + } + return await createRefreshGroup( + ws, + tx, + Amounts.currencyOf(coinPubs[0].amount), + coinPubs, + RefreshReason.Manual, + undefined, + ); + }, + ); + + return { + refreshGroupId, + }; +} diff --git a/packages/taler-wallet-core/src/reward.ts b/packages/taler-wallet-core/src/reward.ts @@ -0,0 +1,321 @@ +/* + 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 { + AcceptTipResponse, + Logger, + PrepareTipResult, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, +} from "@gnu-taler/taler-util"; +import { RewardRecord, RewardRecordStatus } from "./db.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType } from "./pending-types.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { + TaskRunResult, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; + +const logger = new Logger("operations/tip.ts"); + +export class RewardTransactionContext implements TransactionContext { + public transactionId: string; + public retryTag: string; + + constructor( + public ws: InternalWalletState, + public walletRewardId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Reward, + walletRewardId, + }); + this.retryTag = constructTaskIdentifier({ + tag: PendingTaskType.RewardPickup, + walletRewardId, + }); + } + + async deleteTransaction(): Promise<void> { + const { ws, walletRewardId } = this; + await ws.db.runReadWriteTx(["rewards", "tombstones"], async (tx) => { + const tipRecord = await tx.rewards.get(walletRewardId); + if (tipRecord) { + await tx.rewards.delete(walletRewardId); + await tx.tombstones.put({ + id: TombstoneTag.DeleteReward + ":" + walletRewardId, + }); + } + }); + } + + async suspendTransaction(): Promise<void> { + const { ws, walletRewardId, transactionId, retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["rewards"], + async (tx) => { + const tipRec = await tx.rewards.get(walletRewardId); + if (!tipRec) { + logger.warn(`transaction tip ${walletRewardId} not found`); + return; + } + let newStatus: RewardRecordStatus | undefined = undefined; + switch (tipRec.status) { + case RewardRecordStatus.Done: + case RewardRecordStatus.SuspendedPickup: + case RewardRecordStatus.Aborted: + case RewardRecordStatus.DialogAccept: + case RewardRecordStatus.Failed: + break; + case RewardRecordStatus.PendingPickup: + newStatus = RewardRecordStatus.SuspendedPickup; + break; + + default: + assertUnreachable(tipRec.status); + } + if (newStatus != null) { + const oldTxState = computeRewardTransactionStatus(tipRec); + tipRec.status = newStatus; + const newTxState = computeRewardTransactionStatus(tipRec); + await tx.rewards.put(tipRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { ws, walletRewardId, transactionId, retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["rewards"], + async (tx) => { + const tipRec = await tx.rewards.get(walletRewardId); + if (!tipRec) { + logger.warn(`transaction tip ${walletRewardId} not found`); + return; + } + let newStatus: RewardRecordStatus | undefined = undefined; + switch (tipRec.status) { + case RewardRecordStatus.Done: + case RewardRecordStatus.Aborted: + case RewardRecordStatus.PendingPickup: + case RewardRecordStatus.DialogAccept: + case RewardRecordStatus.Failed: + break; + case RewardRecordStatus.SuspendedPickup: + newStatus = RewardRecordStatus.Aborted; + break; + default: + assertUnreachable(tipRec.status); + } + if (newStatus != null) { + const oldTxState = computeRewardTransactionStatus(tipRec); + tipRec.status = newStatus; + const newTxState = computeRewardTransactionStatus(tipRec); + await tx.rewards.put(tipRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + } + + async resumeTransaction(): Promise<void> { + const { ws, walletRewardId, transactionId, retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["rewards"], + async (tx) => { + const rewardRec = await tx.rewards.get(walletRewardId); + if (!rewardRec) { + logger.warn(`transaction reward ${walletRewardId} not found`); + return; + } + let newStatus: RewardRecordStatus | undefined = undefined; + switch (rewardRec.status) { + case RewardRecordStatus.Done: + case RewardRecordStatus.PendingPickup: + case RewardRecordStatus.Aborted: + case RewardRecordStatus.DialogAccept: + case RewardRecordStatus.Failed: + break; + case RewardRecordStatus.SuspendedPickup: + newStatus = RewardRecordStatus.PendingPickup; + break; + default: + assertUnreachable(rewardRec.status); + } + if (newStatus != null) { + const oldTxState = computeRewardTransactionStatus(rewardRec); + rewardRec.status = newStatus; + const newTxState = computeRewardTransactionStatus(rewardRec); + await tx.rewards.put(rewardRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + } + + async failTransaction(): Promise<void> { + const { ws, walletRewardId, transactionId, retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["rewards"], + async (tx) => { + const tipRec = await tx.rewards.get(walletRewardId); + if (!tipRec) { + logger.warn(`transaction tip ${walletRewardId} not found`); + return; + } + let newStatus: RewardRecordStatus | undefined = undefined; + switch (tipRec.status) { + case RewardRecordStatus.Done: + case RewardRecordStatus.Aborted: + case RewardRecordStatus.Failed: + break; + case RewardRecordStatus.PendingPickup: + case RewardRecordStatus.DialogAccept: + case RewardRecordStatus.SuspendedPickup: + newStatus = RewardRecordStatus.Failed; + break; + default: + assertUnreachable(tipRec.status); + } + if (newStatus != null) { + const oldTxState = computeRewardTransactionStatus(tipRec); + tipRec.status = newStatus; + const newTxState = computeRewardTransactionStatus(tipRec); + await tx.rewards.put(tipRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + } +} + +/** + * Get the (DD37-style) transaction status based on the + * database record of a reward. + */ +export function computeRewardTransactionStatus( + tipRecord: RewardRecord, +): TransactionState { + switch (tipRecord.status) { + case RewardRecordStatus.Done: + return { + major: TransactionMajorState.Done, + }; + case RewardRecordStatus.Aborted: + return { + major: TransactionMajorState.Aborted, + }; + case RewardRecordStatus.PendingPickup: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Pickup, + }; + case RewardRecordStatus.DialogAccept: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }; + case RewardRecordStatus.SuspendedPickup: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Pickup, + }; + case RewardRecordStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + default: + assertUnreachable(tipRecord.status); + } +} + +export function computeTipTransactionActions( + tipRecord: RewardRecord, +): TransactionAction[] { + switch (tipRecord.status) { + case RewardRecordStatus.Done: + return [TransactionAction.Delete]; + case RewardRecordStatus.Failed: + return [TransactionAction.Delete]; + case RewardRecordStatus.Aborted: + return [TransactionAction.Delete]; + case RewardRecordStatus.PendingPickup: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case RewardRecordStatus.SuspendedPickup: + return [TransactionAction.Resume, TransactionAction.Fail]; + case RewardRecordStatus.DialogAccept: + return [TransactionAction.Abort]; + default: + assertUnreachable(tipRecord.status); + } +} + +export async function prepareReward( + ws: InternalWalletState, + talerTipUri: string, +): Promise<PrepareTipResult> { + throw Error("the rewards feature is not supported anymore"); +} + +export async function processTip( + ws: InternalWalletState, + walletTipId: string, +): Promise<TaskRunResult> { + return TaskRunResult.finished(); +} + +export async function acceptTip( + ws: InternalWalletState, + transactionId: TransactionIdStr, +): Promise<AcceptTipResponse> { + throw Error("the rewards feature is not supported anymore"); +} diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -45,7 +45,7 @@ import { timestampAbsoluteFromDb, } from "./index.js"; import { InternalWalletState } from "./internal-wallet-state.js"; -import { processBackupForProvider } from "./operations/backup/index.js"; +import { processBackupForProvider } from "./backup/index.js"; import { DbRetryInfo, TaskRunResult, @@ -53,18 +53,18 @@ import { constructTaskIdentifier, getExchangeState, parseTaskIdentifier, -} from "./operations/common.js"; -import { processDepositGroup } from "./operations/deposits.js"; -import { updateExchangeFromUrlHandler } from "./operations/exchanges.js"; -import { processPurchase } from "./operations/pay-merchant.js"; -import { processPeerPullCredit } from "./operations/pay-peer-pull-credit.js"; -import { processPeerPullDebit } from "./operations/pay-peer-pull-debit.js"; -import { processPeerPushCredit } from "./operations/pay-peer-push-credit.js"; -import { processPeerPushDebit } from "./operations/pay-peer-push-debit.js"; -import { processRecoupGroup } from "./operations/recoup.js"; -import { processRefreshGroup } from "./operations/refresh.js"; -import { constructTransactionIdentifier } from "./operations/transactions.js"; -import { processWithdrawalGroup } from "./operations/withdraw.js"; +} from "./common.js"; +import { processDepositGroup } from "./deposits.js"; +import { updateExchangeFromUrlHandler } from "./exchanges.js"; +import { processPurchase } from "./pay-merchant.js"; +import { processPeerPullCredit } from "./pay-peer-pull-credit.js"; +import { processPeerPullDebit } from "./pay-peer-pull-debit.js"; +import { processPeerPushCredit } from "./pay-peer-push-credit.js"; +import { processPeerPushDebit } from "./pay-peer-push-debit.js"; +import { processRecoupGroup } from "./recoup.js"; +import { processRefreshGroup } from "./refresh.js"; +import { constructTransactionIdentifier } from "./transactions.js"; +import { processWithdrawalGroup } from "./withdraw.js"; import { PendingTaskType, TaskId } from "./pending-types.js"; const logger = new Logger("shepherd.ts"); diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts @@ -0,0 +1,913 @@ +/* + 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, + CheckPaymentResponse, + codecForAny, + codecForCheckPaymentResponse, + ConfirmPayResultType, + Duration, + IntegrationTestArgs, + IntegrationTestV2Args, + j2s, + Logger, + NotificationType, + OpenedPromise, + openPromise, + 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 { getRefreshesForTransaction } from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { checkLogicInvariant } from "./util/invariants.js"; +import { getBalances } from "./balance.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 { getTransactionById, getTransactions } from "./transactions.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( + ws: InternalWalletState, + 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(ws, { + 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( + ws: InternalWalletState, + merchant: MerchantBackendInfo, + amount: string, + summary: string, +): Promise<MakePaymentResult> { + 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.transactionId, + 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, + paymentTransactionId: preparePayResult.transactionId, + }; +} + +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"); + const withdrawRes1 = await withdrawTestBalance(ws, { + amount: args.amountToWithdraw, + corebankApiBaseUrl: args.corebankApiBaseUrl, + exchangeBaseUrl: args.exchangeBaseUrl, + }); + await waitUntilGivenTransactionsFinal(ws, [withdrawRes1.transactionId]); + 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, + }; + + const makePaymentRes = await makePayment( + ws, + myMerchant, + args.amountToSpend, + "hello world", + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + 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(ws, { + amount: Amounts.stringify(withdrawAmountTwo), + corebankApiBaseUrl: args.corebankApiBaseUrl, + exchangeBaseUrl: args.exchangeBaseUrl, + }); + + await waitUntilGivenTransactionsFinal(ws, [withdrawRes2.transactionId]); + + 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); + + const refundResp = await startRefundQueryForUri(ws, refundUri); + + logger.trace("integration test: applied refund"); + + // Wait until the refund is done + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + refundResp.transactionId, + ); + + logger.trace("integration test: making payment after refund"); + + const paymentResp2 = await makePayment( + ws, + myMerchant, + Amounts.stringify(spendAmountThree), + "payment after refund", + ); + + logger.trace("integration test: make payment done"); + + await waitUntilGivenTransactionsFinal(ws, [ + paymentResp2.paymentTransactionId, + ]); + await waitUntilGivenTransactionsFinal( + ws, + await getRefreshesForTransaction(ws, paymentResp2.paymentTransactionId), + ); + + logger.trace("integration test: all done!"); +} + +/** + * Wait until all transactions are in a final state. + */ +export async function waitUntilAllTransactionsFinal( + ws: InternalWalletState, +): Promise<void> { + logger.info("waiting until all transactions are in a final state"); + ws.ensureTaskLoopRunning(); + let p: OpenedPromise<void> | undefined = undefined; + const cancelNotifs = ws.addNotificationListener((notif) => { + if (!p) { + return; + } + if (notif.type === NotificationType.TransactionStateTransition) { + switch (notif.newTxState.major) { + case TransactionMajorState.Pending: + case TransactionMajorState.Aborting: + break; + default: + p.resolve(); + } + } + }); + while (1) { + p = openPromise(); + const txs = await getTransactions(ws, { + includeRefreshes: true, + filterByState: "nonfinal", + }); + let finished = true; + for (const tx of txs.transactions) { + switch (tx.txState.major) { + case TransactionMajorState.Pending: + case TransactionMajorState.Aborting: + case TransactionMajorState.Suspended: + case TransactionMajorState.SuspendedAborting: + finished = false; + logger.info( + `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, + ); + break; + } + } + if (finished) { + break; + } + // Wait until transaction state changed + await p.promise; + } + cancelNotifs(); + logger.info("done waiting until all transactions are in a final state"); +} + +/** + * Wait until all chosen transactions are in a final state. + */ +export async function waitUntilGivenTransactionsFinal( + ws: InternalWalletState, + 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; + } + ws.ensureTaskLoopRunning(); + const txIdSet = new Set(transactionIds); + let p: OpenedPromise<void> | undefined = undefined; + const cancelNotifs = ws.addNotificationListener((notif) => { + if (!p) { + return; + } + if (notif.type === NotificationType.TransactionStateTransition) { + if (!txIdSet.has(notif.transactionId)) { + return; + } + switch (notif.newTxState.major) { + case TransactionMajorState.Pending: + case TransactionMajorState.Aborting: + case TransactionMajorState.Suspended: + case TransactionMajorState.SuspendedAborting: + break; + default: + p.resolve(); + } + } + }); + while (1) { + p = openPromise(); + const txs = await getTransactions(ws, { + includeRefreshes: true, + filterByState: "nonfinal", + }); + let finished = true; + 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: + finished = false; + logger.info( + `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, + ); + break; + } + } + if (finished) { + break; + } + // Wait until transaction state changed + await p.promise; + } + cancelNotifs(); + logger.info("done waiting until given transactions are in a final state"); +} + +export async function waitUntilRefreshesDone( + ws: InternalWalletState, +): Promise<void> { + logger.info("waiting until all refresh transactions are in a final state"); + ws.ensureTaskLoopRunning(); + let p: OpenedPromise<void> | undefined = undefined; + const cancelNotifs = ws.addNotificationListener((notif) => { + if (!p) { + return; + } + if (notif.type === NotificationType.TransactionStateTransition) { + switch (notif.newTxState.major) { + case TransactionMajorState.Pending: + case TransactionMajorState.Aborting: + break; + default: + p.resolve(); + } + } + }); + while (1) { + p = openPromise(); + const txs = await getTransactions(ws, { + includeRefreshes: true, + filterByState: "nonfinal", + }); + let finished = true; + 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: + finished = false; + logger.info( + `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, + ); + break; + } + } + if (finished) { + break; + } + // Wait until transaction state changed + await p.promise; + } + cancelNotifs(); + logger.info("done waiting until all refreshes are in a final state"); +} + +async function waitUntilTransactionPendingReady( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + logger.info(`starting waiting for ${transactionId} to be in pending(ready)`); + ws.ensureTaskLoopRunning(); + let p: OpenedPromise<void> | undefined = undefined; + const cancelNotifs = ws.addNotificationListener((notif) => { + if (!p) { + return; + } + if (notif.type === NotificationType.TransactionStateTransition) { + p.resolve(); + } + }); + while (1) { + p = openPromise(); + const tx = await getTransactionById(ws, { + transactionId, + }); + if ( + tx.txState.major == TransactionMajorState.Pending && + tx.txState.minor === TransactionMinorState.Ready + ) { + break; + } + // Wait until transaction state changed + await p.promise; + } + logger.info(`done waiting for ${transactionId} to be in pending(ready)`); + cancelNotifs(); +} + +/** + * Wait until a transaction is in a particular state. + */ +export async function waitTransactionState( + ws: InternalWalletState, + transactionId: string, + txState: TransactionState, +): Promise<void> { + logger.info( + `starting waiting for ${transactionId} to be in ${JSON.stringify( + txState, + )})`, + ); + ws.ensureTaskLoopRunning(); + let p: OpenedPromise<void> | undefined = undefined; + const cancelNotifs = ws.addNotificationListener((notif) => { + if (!p) { + return; + } + if (notif.type === NotificationType.TransactionStateTransition) { + p.resolve(); + } + }); + while (1) { + p = openPromise(); + const tx = await getTransactionById(ws, { + transactionId, + }); + if ( + tx.txState.major === txState.major && + tx.txState.minor === txState.minor + ) { + break; + } + // Wait until transaction state changed + await p.promise; + } + logger.info( + `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`, + ); + cancelNotifs(); +} + +export async function waitUntilTransactionWithAssociatedRefreshesFinal( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + await waitUntilGivenTransactionsFinal(ws, [transactionId]); + await waitUntilGivenTransactionsFinal( + ws, + await getRefreshesForTransaction(ws, transactionId), + ); +} + +export async function waitUntilTransactionFinal( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + await waitUntilGivenTransactionsFinal(ws, [transactionId]); +} + +export async function runIntegrationTest2( + ws: InternalWalletState, + args: IntegrationTestV2Args, +): Promise<void> { + ws.ensureTaskLoopRunning(); + logger.info("running test with arguments", args); + + const exchangeInfo = await fetchFreshExchange(ws, 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(ws, { + amount: Amounts.stringify(amountToWithdraw), + corebankApiBaseUrl: args.corebankApiBaseUrl, + exchangeBaseUrl: args.exchangeBaseUrl, + }); + await waitUntilTransactionFinal(ws, withdrawalRes.transactionId); + 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, + }; + + const makePaymentRes = await makePayment( + ws, + myMerchant, + Amounts.stringify(amountToSpend), + "hello world", + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + 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(ws, { + amount: Amounts.stringify(withdrawAmountTwo), + corebankApiBaseUrl: args.corebankApiBaseUrl, + exchangeBaseUrl: args.exchangeBaseUrl, + }); + + // Wait until the withdraw is done + await waitUntilTransactionFinal(ws, withdrawalRes2.transactionId); + + 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); + + const refundResp = await startRefundQueryForUri(ws, refundUri); + + logger.trace("integration test: applied refund"); + + // Wait until the refund is done + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + refundResp.transactionId, + ); + + logger.trace("integration test: making payment after refund"); + + const makePaymentRes2 = await makePayment( + ws, + myMerchant, + Amounts.stringify(spendAmountThree), + "payment after refund", + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + makePaymentRes2.paymentTransactionId, + ); + + logger.trace("integration test: make payment done"); + + const peerPushInit = await initiatePeerPushDebit(ws, { + 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(ws, peerPushInit.transactionId); + const txDetails = await getTransactionById(ws, { + 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(ws, { + talerUri: txDetails.talerUri, + }); + + await confirmPeerPushCredit(ws, { + transactionId: peerPushCredit.transactionId, + }); + + const peerPullInit = await initiatePeerPullPayment(ws, { + 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(ws, peerPullInit.transactionId); + + const peerPullInc = await preparePeerPullDebit(ws, { + talerUri: peerPullInit.talerUri, + }); + + await confirmPeerPullDebit(ws, { + peerPullDebitId: peerPullInc.peerPullDebitId, + }); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + peerPullInc.transactionId, + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + peerPullInit.transactionId, + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + peerPushCredit.transactionId, + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + ws, + 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(ws, { + amount: `${currency}:5` as AmountString, + depositPaytoUri: depositPayto, + }); + + logger.trace("integration test: all done!"); +} + +export async function testPay( + ws: InternalWalletState, + args: TestPayArgs, +): Promise<TestPayResult> { + 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); + } + 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}`); + } + const r = await confirmPay( + ws, + result.transactionId, + undefined, + args.forcedCoinSel, + ); + if (r.type != ConfirmPayResultType.Done) { + throw Error("payment not done"); + } + const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => { + return tx.purchases.get(result.proposalId); + }); + checkLogicInvariant(!!purchase); + return { + payCoinSelection: purchase.payInfo?.payCoinSelection!, + }; +} diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -0,0 +1,2007 @@ +/* + 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, + DepositTransactionTrackingState, + j2s, + Logger, + NotificationType, + OrderShortInfo, + PeerContractTerms, + RefundInfoShort, + RefundPaymentInfo, + ScopeType, + stringifyPayPullUri, + stringifyPayPushUri, + TalerErrorCode, + TalerPreciseTimestamp, + Transaction, + TransactionByIdRequest, + TransactionIdStr, + TransactionMajorState, + TransactionRecordFilter, + TransactionsRequest, + TransactionsResponse, + TransactionState, + TransactionType, + TransactionWithdrawal, + WalletContractData, + WithdrawalTransactionByURIRequest, + WithdrawalType, +} from "@gnu-taler/taler-util"; +import { + DepositElementStatus, + DepositGroupRecord, + OperationRetryRecord, + PeerPullCreditRecord, + PeerPullDebitRecordStatus, + PeerPullPaymentIncomingRecord, + PeerPushCreditStatus, + PeerPushDebitRecord, + PeerPushPaymentIncomingRecord, + PurchaseRecord, + PurchaseStatus, + RefreshGroupRecord, + RefreshOperationStatus, + RefundGroupRecord, + RewardRecord, + WithdrawalGroupRecord, + WithdrawalGroupStatus, + WithdrawalRecordType, +} from "./db.js"; +import { + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + PeerPushDebitStatus, + timestampPreciseFromDb, + timestampProtocolFromDb, + WalletDbReadOnlyTransaction, +} from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType, TaskId } from "./pending-types.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; +import { + constructTaskIdentifier, + TaskIdentifiers, + TransactionContext, +} from "./common.js"; +import { + computeDepositTransactionActions, + computeDepositTransactionStatus, + DepositTransactionContext, +} from "./deposits.js"; +import { + 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 { + computeRewardTransactionStatus, + computeTipTransactionActions, + RewardTransactionContext, +} from "./reward.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.Reward]: 2, + [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, +}; + +export async function getTransactionById( + ws: InternalWalletState, + 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 ws.db.runReadWriteTx( + [ + "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.Recoup: + throw new Error("not yet supported"); + + case TransactionType.Payment: { + const proposalId = parsedTx.proposalId; + return await ws.db.runReadWriteTx( + [ + "purchases", + "tombstones", + "operationRetries", + "contractTerms", + "refundGroups", + ], + async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) throw Error("not found"); + const download = await expectProposalDownload(ws, 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 ws.db.runReadOnlyTx( + ["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.Reward: { + const tipId = parsedTx.walletRewardId; + return await ws.db.runReadWriteTx( + ["rewards", "operationRetries"], + async (tx) => { + const tipRecord = await tx.rewards.get(tipId); + if (!tipRecord) throw Error("not found"); + + const retries = await tx.operationRetries.get( + TaskIdentifiers.forTipPickup(tipRecord), + ); + return buildTransactionForTip(tipRecord, retries); + }, + ); + } + + case TransactionType.Deposit: { + const depositGroupId = parsedTx.depositGroupId; + return await ws.db.runReadWriteTx( + ["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 ws.db.runReadOnlyTx( + ["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 ws.db.runReadWriteTx( + ["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 ws.db.runReadWriteTx( + ["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 ws.db.runReadWriteTx( + [ + "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 ws.db.runReadWriteTx( + [ + "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 } : {}), + }; +} + +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 buildTransactionForDeposit( + dg: DepositGroupRecord, + ort?: OperationRetryRecord, +): Transaction { + let deposited = true; + for (const d of dg.statusPerCoin) { + if (d == DepositElementStatus.DepositPending) { + 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, + }); + } + + 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: + (100 * + dg.statusPerCoin.reduce( + (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0), + 0, + )) / + dg.statusPerCoin.length, + depositGroupId: dg.depositGroupId, + trackingState, + deposited, + ...(ort?.lastError ? { error: ort.lastError } : {}), + }; +} + +function buildTransactionForTip( + tipRecord: RewardRecord, + ort?: OperationRetryRecord, +): Transaction { + checkLogicInvariant(!!tipRecord.acceptedTimestamp); + + const txState = computeRewardTransactionStatus(tipRecord); + return { + type: TransactionType.Reward, + txState, + txActions: computeTipTransactionActions(tipRecord), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(tipRecord.rewardAmountEffective)) + : Amounts.stringify(tipRecord.rewardAmountEffective), + amountRaw: Amounts.stringify(tipRecord.rewardAmountRaw), + timestamp: timestampPreciseFromDb(tipRecord.acceptedTimestamp), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Reward, + walletRewardId: tipRecord.walletRewardId, + }), + merchantBaseUrl: tipRecord.merchantBaseUrl, + ...(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: contractData.merchant, + 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( + ws: InternalWalletState, + request: WithdrawalTransactionByURIRequest, +): Promise<TransactionWithdrawal | undefined> { + return await ws.db.runReadWriteTx( + ["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( + ws: InternalWalletState, + transactionsRequest?: TransactionsRequest, +): Promise<TransactionsResponse> { + const transactions: Transaction[] = []; + + const filter: TransactionRecordFilter = {}; + if (transactionsRequest?.filterByState) { + filter.onlyState = transactionsRequest.filterByState; + } + + await ws.db.runReadOnlyTx( + [ + "coins", + "denominations", + "depositGroups", + "exchangeDetails", + "exchanges", + "operationRetries", + "peerPullDebit", + "peerPushDebit", + "peerPushCredit", + "peerPullCredit", + "planchets", + "purchases", + "contractTerms", + "recoupGroups", + "rewards", + "tombstones", + "withdrawalGroups", + "refreshGroups", + "refundGroups", + ], + 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) return; //refund with no payment + + // 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 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, + ), + ); + }); + + //FIXME: remove rewards + await iterRecordsForReward(tx, filter, async (tipRecord) => { + if ( + shouldSkipCurrency( + transactionsRequest, + Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency, + [tipRecord.exchangeBaseUrl], + ) + ) { + return; + } + if (!tipRecord.acceptedTimestamp) { + return; + } + const opId = TaskIdentifiers.forTipPickup(tipRecord); + const retryRecord = await tx.operationRetries.get(opId); + transactions.push(buildTransactionForTip(tipRecord, retryRecord)); + }); + //ends REMOVE REWARDS + }, + ); + + // 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; + + const txPending = transactions.filter((x) => isPending(x)); + const txNotPending = transactions.filter((x) => !isPending(x)); + + 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; + }; + + txPending.sort(txCmp); + txNotPending.sort(txCmp); + + return { transactions: [...txNotPending, ...txPending] }; +} + +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.Reward; walletRewardId: string } + | { tag: TransactionType.Withdrawal; withdrawalGroupId: string } + | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string } + | { tag: TransactionType.Recoup; recoupGroupId: 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.Reward: + return `txn:${pTxId.tag}:${pTxId.walletRewardId}` 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; + 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.Reward: + return { + tag: TransactionType.Reward, + walletRewardId: rest[0], + }; + case TransactionType.Withdrawal: + return { + tag: TransactionType.Withdrawal, + withdrawalGroupId: rest[0], + }; + default: + return undefined; + } +} + +function maybeTaskFromTransaction(transactionId: string): TaskId | 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.Reward: + return constructTaskIdentifier({ + tag: PendingTaskType.RewardPickup, + walletRewardId: parsedTx.walletRewardId, + }); + 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, + }); + default: + assertUnreachable(parsedTx); + } +} + +/** + * Immediately retry the underlying operation + * of a transaction. + */ +export async function retryTransaction( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + logger.info(`resetting retry timeout for ${transactionId}`); + const taskId = maybeTaskFromTransaction(transactionId); + if (taskId) { + ws.taskScheduler.resetTaskRetries(taskId); + } +} + +async function getContextForTransaction( + ws: InternalWalletState, + transactionId: string, +): Promise<TransactionContext> { + const tx = parseTransactionIdentifier(transactionId); + if (!tx) { + throw Error("invalid transaction ID"); + } + switch (tx.tag) { + case TransactionType.Deposit: + return new DepositTransactionContext(ws, tx.depositGroupId); + case TransactionType.Refresh: + return new RefreshTransactionContext(ws, tx.refreshGroupId); + case TransactionType.InternalWithdrawal: + case TransactionType.Withdrawal: + return new WithdrawTransactionContext(ws, tx.withdrawalGroupId); + case TransactionType.Payment: + return new PayMerchantTransactionContext(ws, tx.proposalId); + case TransactionType.PeerPullCredit: + return new PeerPullCreditTransactionContext(ws, tx.pursePub); + case TransactionType.PeerPushDebit: + return new PeerPushDebitTransactionContext(ws, tx.pursePub); + case TransactionType.PeerPullDebit: + return new PeerPullDebitTransactionContext(ws, tx.peerPullDebitId); + case TransactionType.PeerPushCredit: + return new PeerPushCreditTransactionContext(ws, tx.peerPushCreditId); + case TransactionType.Refund: + return new RefundTransactionContext(ws, tx.refundGroupId); + case TransactionType.Reward: + return new RewardTransactionContext(ws, tx.walletRewardId); + case TransactionType.Recoup: + throw new Error("not yet supported"); + //return new RecoupTransactionContext(ws, tx.recoupGroupId); + 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( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(ws, transactionId); + await ctx.suspendTransaction(); +} + +export async function failTransaction( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(ws, transactionId); + await ctx.failTransaction(); +} + +/** + * Resume a suspended transaction. + */ +export async function resumeTransaction( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(ws, transactionId); + await ctx.resumeTransaction(); +} + +/** + * Permanently delete a transaction based on the transaction ID. + */ +export async function deleteTransaction( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(ws, transactionId); + await ctx.deleteTransaction(); +} + +export async function abortTransaction( + ws: InternalWalletState, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(ws, transactionId); + await ctx.abortTransaction(); +} + +export interface TransitionInfo { + oldTxState: TransactionState; + newTxState: TransactionState; +} + +/** + * Notify of a state transition if necessary. + */ +export function notifyTransition( + ws: InternalWalletState, + transactionId: string, + transitionInfo: TransitionInfo | undefined, + experimentalUserData: any = undefined, +): void { + if ( + transitionInfo && + !( + transitionInfo.oldTxState.major === transitionInfo.newTxState.major && + transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor + ) + ) { + 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 iterRecordsForReward( + tx: WalletDbReadOnlyTransaction<["rewards"]>, + filter: TransactionRecordFilter, + f: (r: RewardRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.rewards.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.rewards.indexes.byStatus.iter().forEachAsync(f); + } +} + +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/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -64,8 +64,8 @@ import { InternalWalletState } from "../internal-wallet-state.js"; import { getMerchantPaymentBalanceDetails, getPeerPaymentBalanceDetailsInTx, -} from "../operations/balance.js"; -import { getAutoRefreshExecuteThreshold } from "../operations/common.js"; +} from "../balance.js"; +import { getAutoRefreshExecuteThreshold } from "../common.js"; import { checkDbInvariant, checkLogicInvariant } from "./invariants.js"; const logger = new Logger("coinSelection.ts"); diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -144,8 +144,8 @@ import { BackupInfo, RemoveBackupProviderRequest, RunBackupCycleRequest, -} from "./operations/backup/index.js"; -import { MerchantPaymentBalanceDetails } from "./operations/balance.js"; +} from "./backup/index.js"; +import { MerchantPaymentBalanceDetails } from "./balance.js"; import { PendingOperationsResponse as PendingTasksResponse } from "./pending-types.js"; export enum WalletApiOperation { diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -162,7 +162,7 @@ import { getUserAttentions, getUserAttentionsUnreadCount, markAttentionRequestAsRead, -} from "./operations/attention.js"; +} from "./attention.js"; import { addBackupProvider, codecForAddBackupProviderRequest, @@ -174,14 +174,14 @@ import { removeBackupProvider, runBackupCycle, setWalletDeviceId, -} from "./operations/backup/index.js"; -import { getBalanceDetail, getBalances } from "./operations/balance.js"; +} from "./backup/index.js"; +import { getBalanceDetail, getBalances } from "./balance.js"; import { computeDepositTransactionStatus, createDepositGroup, generateDepositGroupTxId, prepareDepositGroup, -} from "./operations/deposits.js"; +} from "./deposits.js"; import { acceptExchangeTermsOfService, addPresetExchangeEntry, @@ -192,7 +192,7 @@ import { getExchangeTos, listExchanges, lookupExchangeByUri, -} from "./operations/exchanges.js"; +} from "./exchanges.js"; import { computePayMerchantTransactionState, computeRefundTransactionState, @@ -203,33 +203,33 @@ import { sharePayment, startQueryRefund, startRefundQueryForUri, -} from "./operations/pay-merchant.js"; +} from "./pay-merchant.js"; import { checkPeerPullPaymentInitiation, computePeerPullCreditTransactionState, initiatePeerPullPayment, -} from "./operations/pay-peer-pull-credit.js"; +} from "./pay-peer-pull-credit.js"; import { computePeerPullDebitTransactionState, confirmPeerPullDebit, preparePeerPullDebit, -} from "./operations/pay-peer-pull-debit.js"; +} from "./pay-peer-pull-debit.js"; import { computePeerPushCreditTransactionState, confirmPeerPushCredit, preparePeerPushCredit, -} from "./operations/pay-peer-push-credit.js"; +} from "./pay-peer-push-credit.js"; import { checkPeerPushDebit, computePeerPushDebitTransactionState, initiatePeerPushDebit, -} from "./operations/pay-peer-push-debit.js"; -import { createRecoupGroup } from "./operations/recoup.js"; +} from "./pay-peer-push-debit.js"; +import { createRecoupGroup } from "./recoup.js"; import { computeRefreshTransactionState, forceRefresh, -} from "./operations/refresh.js"; -import { computeRewardTransactionStatus } from "./operations/reward.js"; +} from "./refresh.js"; +import { computeRewardTransactionStatus } from "./reward.js"; import { runIntegrationTest, runIntegrationTest2, @@ -238,7 +238,7 @@ import { waitUntilAllTransactionsFinal, waitUntilRefreshesDone, withdrawTestBalance, -} from "./operations/testing.js"; +} from "./testing.js"; import { abortTransaction, constructTransactionIdentifier, @@ -251,14 +251,14 @@ import { resumeTransaction, retryTransaction, suspendTransaction, -} from "./operations/transactions.js"; +} from "./transactions.js"; import { acceptWithdrawalFromUri, computeWithdrawalTransactionStatus, createManualWithdrawal, getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, -} from "./operations/withdraw.js"; +} from "./withdraw.js"; import { PendingOperationsResponse } from "./pending-types.js"; import { TaskScheduler } from "./shepherd.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; @@ -270,7 +270,7 @@ import { getMaxPeerPushAmount, } from "./util/instructedAmountConversion.js"; import { checkDbInvariant } from "./util/invariants.js"; -import { DbAccess } from "./util/query.js"; +import { DbAccess } from "./query.js"; import { TimerAPI, TimerGroup } from "./util/timer.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts @@ -0,0 +1,370 @@ +/* + 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 "./util/coinSelection.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, + listIssueDate: timestampProtocolToDb({ t_s: 0 }), + }, + { + 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", + listIssueDate: timestampProtocolToDb({ t_s: 0 }), + }, + { + 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", + listIssueDate: timestampProtocolToDb({ t_s: 0 }), + }, + { + 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", + listIssueDate: timestampProtocolToDb({ t_s: 0 }), + }, + { + 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", + listIssueDate: timestampProtocolToDb({ t_s: 0 }), + }, + { + 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", + listIssueDate: timestampProtocolToDb({ t_s: 0 }), + }, + ]; + + 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 @@ -0,0 +1,2754 @@ +/* + 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/> + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AcceptManualWithdrawalResult, + AcceptWithdrawalResponse, + AgeRestriction, + AmountJson, + AmountLike, + AmountString, + Amounts, + BankWithdrawDetails, + CancellationToken, + CoinStatus, + CurrencySpecification, + DenomKeyType, + DenomSelectionState, + Duration, + ExchangeBatchWithdrawRequest, + ExchangeUpdateStatus, + ExchangeWireAccount, + ExchangeWithdrawBatchResponse, + ExchangeWithdrawRequest, + ExchangeWithdrawResponse, + ExchangeWithdrawalDetails, + ForcedDenomSel, + HttpStatusCode, + LibtoolVersion, + Logger, + NotificationType, + TalerBankIntegrationHttpClient, + TalerError, + TalerErrorCode, + TalerErrorDetail, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + URL, + UnblindedSignature, + WalletNotification, + WithdrawUriInfoResponse, + WithdrawalExchangeAccountDetails, + addPaytoQueryParams, + canonicalizeBaseUrl, + codecForAny, + codecForCashinConversionResponse, + codecForConversionBankConfig, + codecForExchangeWithdrawBatchResponse, + codecForReserveStatus, + codecForWalletKycUuid, + codecForWithdrawOperationStatusResponse, + encodeCrock, + getErrorDetailFromException, + getRandomBytes, + j2s, + makeErrorDetail, + parseWithdrawUri, +} from "@gnu-taler/taler-util"; +import { + HttpRequestLibrary, + HttpResponse, + readSuccessResponseJsonOrErrorCode, + readSuccessResponseJsonOrThrow, + throwUnexpectedRequestError, +} from "@gnu-taler/taler-util/http"; +import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; +import { + CoinRecord, + CoinSourceType, + DenominationRecord, + DenominationVerificationStatus, + KycPendingInfo, + PlanchetRecord, + PlanchetStatus, + WalletStoresV1, + WgInfo, + WithdrawalGroupRecord, + WithdrawalGroupStatus, + WithdrawalRecordType, +} from "./db.js"; +import { + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, + isWithdrawableDenom, + timestampPreciseToDb, +} from "./index.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, + makeCoinAvailable, + makeCoinsVisible, +} from "./common.js"; +import { PendingTaskType, TaskId } from "./pending-types.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { + selectForcedWithdrawalDenominations, + selectWithdrawalDenominations, +} from "./util/coinSelection.js"; +import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; +import { DbAccess } from "./query.js"; +import { + WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + WALLET_EXCHANGE_PROTOCOL_VERSION, +} from "./versions.js"; +import { + ReadyExchangeSummary, + fetchFreshExchange, + getExchangePaytoUri, + getExchangeWireDetailsInTx, + listExchanges, + markExchangeUsed, +} from "./exchanges.js"; +import { + TransitionInfo, + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; + +/** + * Logger for this file. + */ +const logger = new Logger("operations/withdraw.ts"); + +export class WithdrawTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskId; + + constructor( + public ws: InternalWalletState, + public withdrawalGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId, + }); + } + + async deleteTransaction(): Promise<void> { + const { ws, withdrawalGroupId } = this; + await ws.db.runReadWriteTx( + ["withdrawalGroups", "tombstones"], + 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; + } + }, + ); + } + + async suspendTransaction(): Promise<void> { + const { ws, withdrawalGroupId, transactionId, taskId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + 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}`, + ); + } + if (newStatus != null) { + const oldTxState = computeWithdrawalTransactionStatus(wg); + wg.status = newStatus; + const newTxState = computeWithdrawalTransactionStatus(wg); + await tx.withdrawalGroups.put(wg); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + ws.taskScheduler.stopShepherdTask(taskId); + notifyTransition(ws, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { ws, withdrawalGroupId, transactionId, taskId } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + 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: + // No transition needed, but not an error + break; + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.FailedAbortingBank: + // Not allowed + throw Error("abort not allowed in current state"); + default: + assertUnreachable(wg.status); + } + if (newStatus != null) { + const oldTxState = computeWithdrawalTransactionStatus(wg); + wg.status = newStatus; + const newTxState = computeWithdrawalTransactionStatus(wg); + await tx.withdrawalGroups.put(wg); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + ws.taskScheduler.stopShepherdTask(taskId); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(taskId); + } + + async resumeTransaction(): Promise<void> { + const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this; + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + 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}`, + ); + } + if (newStatus != null) { + const oldTxState = computeWithdrawalTransactionStatus(wg); + wg.status = newStatus; + const newTxState = computeWithdrawalTransactionStatus(wg); + await tx.withdrawalGroups.put(wg); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + ws.taskScheduler.startShepherdTask(retryTag); + } + + async failTransaction(): Promise<void> { + const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this; + const stateUpdate = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.AbortingBank: + newStatus = WithdrawalGroupStatus.FailedAbortingBank; + break; + default: + break; + } + if (newStatus != null) { + const oldTxState = computeWithdrawalTransactionStatus(wg); + wg.status = newStatus; + const newTxState = computeWithdrawalTransactionStatus(wg); + await tx.withdrawalGroups.put(wg); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + ws.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(ws, transactionId, stateUpdate); + ws.taskScheduler.startShepherdTask(retryTag); + } +} + +/** + * 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, + }; + } +} + +/** + * 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: + return [TransactionAction.Delete]; + case WithdrawalGroupStatus.AbortedExchange: + return [TransactionAction.Delete]; + case WithdrawalGroupStatus.AbortedBank: + return [TransactionAction.Delete]; + } +} + +/** + * 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; + + logger.info(`bank withdrawal operation status: ${j2s(status)}`); + + 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( + ws: InternalWalletState, + exchangeBaseUrl: string, + currency: string, +): Promise<DenominationRecord[]> { + return await ws.db.runReadOnlyTx(["denominations"], async (tx) => { + return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency); + }); +} + +export async function getCandidateWithdrawalDenomsTx( + ws: InternalWalletState, + tx: WalletDbReadOnlyTransaction<["denominations"]>, + exchangeBaseUrl: string, + currency: string, +): Promise<DenominationRecord[]> { + // FIXME: 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, 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( + ws: InternalWalletState, + withdrawalGroup: WithdrawalGroupRecord, + coinIdx: number, +): Promise<void> { + let planchet = await ws.db.runReadOnlyTx(["planchets"], async (tx) => { + return tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + }); + if (planchet) { + return; + } + let ci = 0; + 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; + break; + } + ci += d.count; + } + if (!maybeDenomPubHash) { + throw Error("invariant violated"); + } + const denomPubHash = maybeDenomPubHash; + + const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => { + return ws.getDenomInfo( + ws, + tx, + withdrawalGroup.exchangeBaseUrl, + denomPubHash, + ); + }); + checkDbInvariant(!!denom); + const r = await ws.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 ws.db.runReadWriteTx(["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; +} + +enum AmlStatus { + normal = 0, + pending = 1, + fronzen = 2, +} + +/** + * Transition a withdrawal transaction with a (new) KYC URL. + * + * Emit a notification for the (self-)transition. + */ +async function transitionKycUrlUpdate( + ws: InternalWalletState, + withdrawalGroupId: string, + kycUrl: string, +): Promise<void> { + let notificationKycUrl: string | undefined = undefined; + const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId); + const transactionId = ctx.transactionId; + + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg2) { + return; + } + const oldTxState = computeWithdrawalTransactionStatus(wg2); + switch (wg2.status) { + case WithdrawalGroupStatus.PendingReady: { + wg2.kycUrl = kycUrl; + notificationKycUrl = kycUrl; + await tx.withdrawalGroups.put(wg2); + const newTxState = computeWithdrawalTransactionStatus(wg2); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + if (transitionInfo) { + // Always notify, even on self-transition, as the KYC URL might have changed. + ws.notify({ + type: NotificationType.TransactionStateTransition, + oldTxState: transitionInfo.oldTxState, + newTxState: transitionInfo.newTxState, + transactionId, + experimentalUserData: notificationKycUrl, + }); + } + ws.taskScheduler.startShepherdTask(ctx.taskId); +} + +async function handleKycRequired( + ws: InternalWalletState, + 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 transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + 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 ws.http.fetch(url.href, { + method: "GET", + }); + let kycUrl: string; + let amlStatus: AmlStatus | 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})`); + } + + let notificationKycUrl: string | undefined = undefined; + + const transitionInfo = await ws.db.runReadWriteTx( + ["planchets", "withdrawalGroups"], + async (tx) => { + 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); + } + const wg2 = await tx.withdrawalGroups.get( + withdrawalGroup.withdrawalGroupId, + ); + if (!wg2) { + return; + } + const oldTxState = computeWithdrawalTransactionStatus(wg2); + switch (wg2.status) { + case WithdrawalGroupStatus.PendingReady: { + wg2.kycPending = { + paytoHash: uuidResp.h_payto, + requirementRow: uuidResp.requirement_row, + }; + wg2.kycUrl = kycUrl; + wg2.status = + amlStatus === AmlStatus.normal || amlStatus === undefined + ? WithdrawalGroupStatus.PendingKyc + : amlStatus === AmlStatus.pending + ? WithdrawalGroupStatus.PendingAml + : amlStatus === AmlStatus.fronzen + ? WithdrawalGroupStatus.SuspendedAml + : assertUnreachable(amlStatus); + + notificationKycUrl = kycUrl; + + await tx.withdrawalGroups.put(wg2); + const newTxState = computeWithdrawalTransactionStatus(wg2); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + notifyTransition(ws, transactionId, transitionInfo, notificationKycUrl); +} + +/** + * 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( + ws: InternalWalletState, + wgContext: WithdrawalGroupContext, + 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 ws.db.runReadOnlyTx(["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; + } + const denom = await ws.getDenomInfo( + ws, + 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(e: any, coinIdx: number): Promise<void> { + const errDetail = getErrorDetailFromException(e); + logger.trace("withdrawal request failed", e); + logger.trace(String(e)); + await ws.db.runReadWriteTx(["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 ws.http.fetch(reqUrl, { + method: "POST", + body: batchReq, + }); + if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { + await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + const r = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeWithdrawBatchResponse(), + ); + return { + coinIdxs: requestCoinIdxs, + batchResp: r, + }; + } catch (e) { + await storeCoinError(e, requestCoinIdxs[0]); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } +} + +async function processPlanchetVerifyAndStoreCoin( + ws: InternalWalletState, + wgContext: WithdrawalGroupContext, + coinIdx: number, + resp: ExchangeWithdrawResponse, +): Promise<void> { + const withdrawalGroup = wgContext.wgRecord; + logger.trace(`checking and storing planchet idx=${coinIdx}`); + const d = await ws.db.runReadOnlyTx( + ["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 ws.getDenomInfo( + ws, + 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 ws.cryptoApi.rsaUnblind({ + bk: planchet.blindingKey, + blindedSig: evSig.blinded_rsa_signature, + pk: planchetDenomPub.rsa_public_key, + }); + + const isValid = await ws.cryptoApi.rsaVerify({ + hm: planchet.coinPub, + pk: planchetDenomPub.rsa_public_key, + sig: denomSigRsa.sig, + }); + + if (!isValid) { + await ws.db.runReadWriteTx(["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 ws.db.runReadWriteTx( + ["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(ws, 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. + */ +async function updateWithdrawalDenoms( + ws: InternalWalletState, + exchangeBaseUrl: string, +): Promise<void> { + logger.trace( + `updating denominations used for withdrawal for ${exchangeBaseUrl}`, + ); + const exchangeDetails = await ws.db.runReadOnlyTx( + ["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( + ws, + 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 (ws.config.testing.insecureTrustExchange) { + valid = true; + } else { + const res = await ws.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 ws.db.runReadWriteTx(["denominations"], 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"); + } + } +} + +/** + * 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 queryReserve( + ws: InternalWalletState, + withdrawalGroupId: string, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { + withdrawalGroupId, + }); + checkDbInvariant(!!withdrawalGroup); + 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 ws.http.fetch(reserveUrl.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + 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.backoff(); + } else { + throwUnexpectedRequestError(resp, result.talerErrorResponse); + } + } + + logger.trace(`got reserve status ${j2s(result.response)}`); + + const transitionResult = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return undefined; + } + const txStateOld = computeWithdrawalTransactionStatus(wg); + wg.status = WithdrawalGroupStatus.PendingReady; + const txStateNew = computeWithdrawalTransactionStatus(wg); + wg.reserveBalanceAmount = Amounts.stringify(result.response.balance); + await tx.withdrawalGroups.put(wg); + return { + oldTxState: txStateOld, + newTxState: txStateNew, + }; + }, + ); + + notifyTransition(ws, transactionId, transitionResult); + + return TaskRunResult.backoff(); +} + +/** + * Withdrawal context that is kept in-memory. + * + * Used to store some cached info during a withdrawal operation. + */ +export interface WithdrawalGroupContext { + numPlanchets: number; + planchetsFinished: Set<string>; + + /** + * Cached withdrawal group record from the database. + */ + wgRecord: WithdrawalGroupRecord; +} + +async function processWithdrawalGroupAbortingBank( + ws: InternalWalletState, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const { withdrawalGroupId } = withdrawalGroup; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + 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 ws.http.fetch(abortUrl, { + method: "POST", + body: {}, + }); + logger.info(`abort response status: ${abortResp.status}`); + + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + return undefined; + } + const txStatusOld = computeWithdrawalTransactionStatus(wg); + wg.status = WithdrawalGroupStatus.AbortedBank; + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + const txStatusNew = computeWithdrawalTransactionStatus(wg); + await tx.withdrawalGroups.put(wg); + return { + oldTxState: txStatusOld, + newTxState: txStatusNew, + }; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + return TaskRunResult.finished(); +} + +/** + * Store in the database that the KYC for a withdrawal is now + * satisfied. + */ +async function transitionKycSatisfied( + ws: InternalWalletState, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + }); + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg2 = await tx.withdrawalGroups.get( + withdrawalGroup.withdrawalGroupId, + ); + if (!wg2) { + return; + } + const oldTxState = computeWithdrawalTransactionStatus(wg2); + switch (wg2.status) { + case WithdrawalGroupStatus.PendingKyc: { + delete wg2.kycPending; + delete wg2.kycUrl; + wg2.status = WithdrawalGroupStatus.PendingReady; + await tx.withdrawalGroups.put(wg2); + const newTxState = computeWithdrawalTransactionStatus(wg2); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + notifyTransition(ws, transactionId, transitionInfo); +} + +async function processWithdrawalGroupPendingKyc( + ws: InternalWalletState, + withdrawalGroup: WithdrawalGroupRecord, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + 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"); + + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + + logger.info(`long-polling for withdrawal KYC status via ${url.href}`); + const kycStatusRes = await ws.http.fetch(url.href, { + method: "GET", + 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 transitionKycSatisfied(ws, withdrawalGroup); + } 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 transitionKycUrlUpdate(ws, withdrawalGroupId, kycUrl); + } + } 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(); +} + +async function processWithdrawalGroupPendingReady( + ws: InternalWalletState, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const { withdrawalGroupId } = withdrawalGroup; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + + await fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl); + + if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { + logger.warn("Finishing empty withdrawal group (no denoms)"); + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + return undefined; + } + const txStatusOld = computeWithdrawalTransactionStatus(wg); + wg.status = WithdrawalGroupStatus.Done; + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + const txStatusNew = computeWithdrawalTransactionStatus(wg); + await tx.withdrawalGroups.put(wg); + return { + oldTxState: txStatusOld, + newTxState: txStatusNew, + }; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + return TaskRunResult.finished(); + } + + const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms + .map((x) => x.count) + .reduce((a, b) => a + b); + + const wgContext: WithdrawalGroupContext = { + numPlanchets: numTotalCoins, + planchetsFinished: new Set<string>(), + wgRecord: withdrawalGroup, + }; + + await ws.db.runReadOnlyTx(["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(ws, withdrawalGroup, i); + } + + const maxBatchSize = 100; + + for (let i = 0; i < numTotalCoins; i += maxBatchSize) { + const resp = await processPlanchetExchangeBatchRequest(ws, 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( + ws, + wgContext, + resp.coinIdxs[j], + resp.batchResp.ev_sigs[j], + ), + ); + } + await Promise.all(work); + } + + let numFinished = 0; + const errorsPerCoin: Record<number, TalerErrorDetail> = {}; + let numPlanchetErrors = 0; + const maxReportedErrors = 5; + + const res = await ws.db.runReadWriteTx( + ["coins", "coinAvailability", "withdrawalGroups", "planchets"], + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + return; + } + + await tx.planchets.indexes.byGroup + .iter(withdrawalGroupId) + .forEach((x) => { + if (x.planchetStatus === PlanchetStatus.WithdrawalDone) { + numFinished++; + } + if (x.lastError) { + numPlanchetErrors++; + if (numPlanchetErrors < maxReportedErrors) { + errorsPerCoin[x.coinIdx] = x.lastError; + } + } + }); + const oldTxState = computeWithdrawalTransactionStatus(wg); + logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`); + if (wg.timestampFinish === undefined && numFinished === numTotalCoins) { + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + wg.status = WithdrawalGroupStatus.Done; + await makeCoinsVisible(ws, tx, transactionId); + } + + const newTxState = computeWithdrawalTransactionStatus(wg); + await tx.withdrawalGroups.put(wg); + + return { + kycInfo: wg.kycPending, + transitionInfo: { + oldTxState, + newTxState, + }, + }; + }, + ); + + if (!res) { + throw Error("withdrawal group does not exist anymore"); + } + + notifyTransition(ws, transactionId, res.transitionInfo); + ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: 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( + ws: InternalWalletState, + withdrawalGroupId: string, + cancellationToken: CancellationToken, +): Promise<TaskRunResult> { + logger.trace("processing withdrawal group", withdrawalGroupId); + const withdrawalGroup = await ws.db.runReadOnlyTx( + ["withdrawalGroups"], + async (tx) => { + return tx.withdrawalGroups.get(withdrawalGroupId); + }, + ); + + if (!withdrawalGroup) { + throw Error(`withdrawal group ${withdrawalGroupId} not found`); + } + + switch (withdrawalGroup.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + await processReserveBankStatus(ws, withdrawalGroupId); + // FIXME: This will get called by the main task loop, why call it here?! + return await processWithdrawalGroup( + ws, + withdrawalGroupId, + cancellationToken, + ); + case WithdrawalGroupStatus.PendingQueryingStatus: { + return queryReserve(ws, withdrawalGroupId, cancellationToken); + } + case WithdrawalGroupStatus.PendingWaitConfirmBank: { + return await processReserveBankStatus(ws, withdrawalGroupId); + } + case WithdrawalGroupStatus.PendingAml: + // FIXME: Handle this case, withdrawal doesn't support AML yet. + return TaskRunResult.backoff(); + case WithdrawalGroupStatus.PendingKyc: + return processWithdrawalGroupPendingKyc( + ws, + withdrawalGroup, + cancellationToken, + ); + case WithdrawalGroupStatus.PendingReady: + // Continue with the actual withdrawal! + return await processWithdrawalGroupPendingReady(ws, withdrawalGroup); + case WithdrawalGroupStatus.AbortingBank: + return await processWithdrawalGroupAbortingBank(ws, 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: + // 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( + ws: InternalWalletState, + exchangeBaseUrl: string, + instructedAmount: AmountJson, + ageRestricted: number | undefined, +): Promise<ExchangeWithdrawalDetails> { + logger.trace("updating exchange"); + const exchange = await fetchFreshExchange(ws, exchangeBaseUrl); + + 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(ws, { + exchange, + instructedAmount, + }); + + logger.trace("updating withdrawal denoms"); + await updateWithdrawalDenoms(ws, exchangeBaseUrl); + + logger.trace("getting candidate denoms"); + const denoms = await getCandidateWithdrawalDenoms( + ws, + exchangeBaseUrl, + instructedAmount.currency, + ); + logger.trace("selecting withdrawal denoms"); + const selectedDenoms = selectWithdrawalDenominations( + instructedAmount, + denoms, + 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 hasDenomWithAgeRestriction = false; + + logger.trace("computing earliest deposit expiration"); + + let earliestDepositExpiration: TalerProtocolTimestamp | undefined; + for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) { + const ds = selectedDenoms.selectedDenoms[i]; + // FIXME: Do in one transaction! + const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => { + return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash); + }); + checkDbInvariant(!!denom); + hasDenomWithAgeRestriction = + hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0; + const expireDeposit = denom.stampExpireDeposit; + if (!earliestDepositExpiration) { + earliestDepositExpiration = expireDeposit; + continue; + } + if ( + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp(expireDeposit), + AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration), + ) < 0 + ) { + earliestDepositExpiration = expireDeposit; + } + } + + checkLogicInvariant(!!earliestDepositExpiration); + + const possibleDenoms = await getCandidateWithdrawalDenoms( + ws, + exchangeBaseUrl, + instructedAmount.currency, + ); + + 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, + exchangePaytoUris: paytoUris, + exchangeWireAccounts, + exchangeCreditAccountDetails: withdrawalAccountsList, + exchangeVersion: exchange.protocolVersionRange || "unknown", + numOfferedDenoms: possibleDenoms.length, + selectedDenoms, + // FIXME: delete this field / replace by something we can display to the user + trustedAuditorPubs: [], + 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: hasDenomWithAgeRestriction + ? AGE_MASK_GROUPS + : undefined, + scopeInfo: exchange.scopeInfo, + }; + return ret; +} + +export interface GetWithdrawalDetailsForUriOpts { + restrictAge?: number; + notifyChangeFromPendingTimeoutMs?: number; +} + +type WithdrawalOperationMemoryMap = { + [uri: string]: boolean | undefined; +}; +const ongoingChecks: WithdrawalOperationMemoryMap = {}; +/** + * 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( + ws: InternalWalletState, + talerWithdrawUri: string, + opts: GetWithdrawalDetailsForUriOpts = {}, +): Promise<WithdrawUriInfoResponse> { + logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); + const info = await getBankWithdrawalInfo(ws.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(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 currency = Amounts.currencyOf(info.amount); + + const listExchangesResp = await listExchanges(ws); + const possibleExchanges = listExchangesResp.exchanges.filter((x) => { + return ( + x.currency === currency && + (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || + x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) + ); + }); + + // FIXME: this should be removed after the extended version of + // withdrawal state machine. issue #8099 + if ( + info.status === "pending" && + opts.notifyChangeFromPendingTimeoutMs !== undefined && + !ongoingChecks[talerWithdrawUri] + ) { + ongoingChecks[talerWithdrawUri] = true; + const bankApi = new TalerBankIntegrationHttpClient( + info.apiBaseUrl, + ws.http, + ); + console.log( + `waiting operation (${info.operationId}) to change from pending`, + ); + bankApi + .getWithdrawalOperationById(info.operationId, { + old_state: "pending", + timeoutMs: opts.notifyChangeFromPendingTimeoutMs, + }) + .then((resp) => { + console.log( + `operation (${info.operationId}) to change to ${JSON.stringify( + resp, + undefined, + 2, + )}`, + ); + ws.notify({ + type: NotificationType.WithdrawalOperationTransition, + operationId: info.operationId, + state: resp.type === "fail" ? info.status : resp.body.status, + }); + ongoingChecks[talerWithdrawUri] = false; + }); + } + + 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 Withdrawal ${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(["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( + ws: InternalWalletState, + withdrawalGroupId: string, +): Promise<void> { + const withdrawalGroup = await ws.db.runReadOnlyTx( + ["withdrawalGroups"], + async (tx) => { + return await tx.withdrawalGroups.get(withdrawalGroupId); + }, + ); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + switch (withdrawalGroup?.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: + return; + } + if ( + withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated + ) { + throw Error("expecting withdrarwal 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 ws.http.fetch(bankStatusUrl, { + method: "POST", + body: reqBody, + timeout: getReserveRequestTimeout(withdrawalGroup), + }); + // FIXME: libeufin-bank currently doesn't return a response in the right format, so we don't validate at all. + await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const r = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!r) { + return undefined; + } + switch (r.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return; + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()), + ); + const oldTxState = computeWithdrawalTransactionStatus(r); + r.status = WithdrawalGroupStatus.PendingWaitConfirmBank; + const newTxState = computeWithdrawalTransactionStatus(r); + await tx.withdrawalGroups.put(r); + return { + oldTxState, + newTxState, + }; + }, + ); + + notifyTransition(ws, transactionId, transitionInfo); +} + +async function processReserveBankStatus( + ws: InternalWalletState, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { + withdrawalGroupId, + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + switch (withdrawalGroup?.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: + return TaskRunResult.backoff(); + } + + if ( + withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated + ) { + throw Error("wrong withdrawal record type"); + } + const bankInfo = withdrawalGroup.wgInfo.bankInfo; + if (!bankInfo) { + return TaskRunResult.backoff(); + } + + const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri); + + const statusResp = await ws.http.fetch(bankStatusUrl, { + timeout: getReserveRequestTimeout(withdrawalGroup), + }); + const status = await readSuccessResponseJsonOrThrow( + statusResp, + codecForWithdrawOperationStatusResponse(), + ); + + if (status.aborted) { + logger.info("bank aborted the withdrawal"); + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const r = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!r) { + return; + } + switch (r.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return; + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + const oldTxState = computeWithdrawalTransactionStatus(r); + r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); + r.status = WithdrawalGroupStatus.FailedBankAborted; + const newTxState = computeWithdrawalTransactionStatus(r); + await tx.withdrawalGroups.put(r); + return { + oldTxState, + newTxState, + }; + }, + ); + notifyTransition(ws, transactionId, transitionInfo); + return TaskRunResult.finished(); + } + + // Bank still needs to know our reserve info + if (!status.selection_done) { + await registerReserveWithBank(ws, withdrawalGroupId); + return await processReserveBankStatus(ws, withdrawalGroupId); + } + + // FIXME: Why do we do this?! + if (withdrawalGroup.status === WithdrawalGroupStatus.PendingRegisteringBank) { + await registerReserveWithBank(ws, withdrawalGroupId); + return await processReserveBankStatus(ws, withdrawalGroupId); + } + + const transitionInfo = await ws.db.runReadWriteTx( + ["withdrawalGroups"], + async (tx) => { + const r = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!r) { + return undefined; + } + // Re-check reserve status within transaction + switch (r.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return undefined; + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + const oldTxState = computeWithdrawalTransactionStatus(r); + 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; + } else { + logger.trace("withdrawal: transfer not yet confirmed by bank"); + r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url; + r.senderWire = status.sender_wire; + } + const newTxState = computeWithdrawalTransactionStatus(r); + await tx.withdrawalGroups.put(r); + return { + oldTxState, + newTxState, + }; + }, + ); + + notifyTransition(ws, transactionId, transitionInfo); + + 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( + ws: InternalWalletState, + args: { + reserveStatus: WithdrawalGroupStatus; + amount: AmountJson; + exchangeBaseUrl: string; + forcedWithdrawalGroupId?: string; + forcedDenomSel?: ForcedDenomSel; + reserveKeyPair?: EddsaKeypair; + restrictAge?: number; + wgInfo: WgInfo; + }, +): Promise<PrepareCreateWithdrawalGroupResult> { + const reserveKeyPair = + args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({})); + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + const secretSeed = encodeCrock(getRandomBytes(32)); + const canonExchange = canonicalizeBaseUrl(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 ws.db.runReadOnlyTx( + ["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(ws, canonExchange); + const denoms = await getCandidateWithdrawalDenoms( + ws, + canonExchange, + currency, + ); + + let initialDenomSel: DenomSelectionState; + const denomSelUid = encodeCrock(getRandomBytes(16)); + if (args.forcedDenomSel) { + logger.warn("using forced denom selection"); + initialDenomSel = selectForcedWithdrawalDenominations( + amount, + denoms, + args.forcedDenomSel, + ws.config.testing.denomselAllowLate, + ); + } else { + initialDenomSel = selectWithdrawalDenominations( + amount, + denoms, + ws.config.testing.denomselAllowLate, + ); + } + + const withdrawalGroup: WithdrawalGroupRecord = { + denomSelUid, + denomsSel: initialDenomSel, + exchangeBaseUrl: canonExchange, + 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(ws, canonExchange); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + }); + + return { + withdrawalGroup, + transactionId, + creationInfo: { + canonExchange, + 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( + ws: InternalWalletState, + 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( + ws, + tx, + prep.withdrawalGroup.exchangeBaseUrl, + ); + + const ctx = new WithdrawTransactionContext( + ws, + withdrawalGroup.withdrawalGroupId, + ); + + ws.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( + ws: InternalWalletState, + args: { + reserveStatus: WithdrawalGroupStatus; + amount: AmountJson; + exchangeBaseUrl: string; + forcedWithdrawalGroupId?: string; + forcedDenomSel?: ForcedDenomSel; + reserveKeyPair?: EddsaKeypair; + restrictAge?: number; + wgInfo: WgInfo; + }, +): Promise<WithdrawalGroupRecord> { + const prep = await internalPrepareCreateWithdrawalGroup(ws, args); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId, + }); + const res = await ws.db.runReadWriteTx( + ["withdrawalGroups", "reserves", "exchanges", "exchangeDetails"], + async (tx) => { + return await internalPerformCreateWithdrawalGroup(ws, tx, prep); + }, + ); + if (res.exchangeNotif) { + ws.notify(res.exchangeNotif); + } + notifyTransition(ws, transactionId, res.transitionInfo); + return res.withdrawalGroup; +} + +export async function acceptWithdrawalFromUri( + ws: InternalWalletState, + req: { + talerWithdrawUri: string; + selectedExchange: string; + forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; + }, +): Promise<AcceptWithdrawalResponse> { + const selectedExchange = canonicalizeBaseUrl(req.selectedExchange); + logger.info( + `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, + ); + const existingWithdrawalGroup = await ws.db.runReadOnlyTx( + ["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, + }), + }; + } + + await fetchFreshExchange(ws, selectedExchange); + const withdrawInfo = await getBankWithdrawalInfo( + ws.http, + req.talerWithdrawUri, + ); + const exchangePaytoUri = await getExchangePaytoUri( + ws, + selectedExchange, + withdrawInfo.wireTypes, + ); + + const exchange = await fetchFreshExchange(ws, selectedExchange); + + const withdrawalAccountList = await fetchWithdrawalAccountInfo(ws, { + exchange, + instructedAmount: withdrawInfo.amount, + }); + + const withdrawalGroup = await internalCreateWithdrawalGroup(ws, { + 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(ws, withdrawalGroupId); + + const transactionId = ctx.transactionId; + + // 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, withdrawalGroupId); + const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { + withdrawalGroupId, + }); + if ( + processedWithdrawalGroup?.status === WithdrawalGroupStatus.FailedBankAborted + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + } + + ws.taskScheduler.startShepherdTask(ctx.taskId); + + return { + reservePub: withdrawalGroup.reservePub, + confirmTransferUrl: withdrawInfo.confirmTransferUrl, + transactionId, + }; +} + +async function fetchAccount( + ws: InternalWalletState, + instructedAmount: AmountJson, + acct: ExchangeWireAccount, + reservePub?: string, +): 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 ws.http.fetch(reqUrl.href); + 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 ws.http.fetch(configUrl.href); + 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 Withdrawal ${reservePub}`, + }); + } + const acctInfo: WithdrawalExchangeAccountDetails = { + status: "ok", + paytoUri, + transferAmount, + 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( + ws: InternalWalletState, + req: { + exchange: ReadyExchangeSummary; + instructedAmount: AmountJson; + reservePub?: string; + }, +): Promise<WithdrawalExchangeAccountDetails[]> { + const { exchange, instructedAmount } = req; + const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = []; + for (let acct of exchange.wireInfo.accounts) { + const acctInfo = await fetchAccount( + ws, + req.instructedAmount, + acct, + req.reservePub, + ); + withdrawalAccounts.push(acctInfo); + } + 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( + ws: InternalWalletState, + req: { + exchangeBaseUrl: string; + amount: AmountLike; + restrictAge?: number; + forcedDenomSel?: ForcedDenomSel; + }, +): Promise<AcceptManualWithdrawalResult> { + const { exchangeBaseUrl } = req; + const amount = Amounts.parseOrThrow(req.amount); + const exchange = await fetchFreshExchange(ws, exchangeBaseUrl); + + if (exchange.currency != amount.currency) { + throw Error( + "manual withdrawal with conversion from foreign currency is not yet supported", + ); + } + const reserveKeyPair: EddsaKeypair = await ws.cryptoApi.createEddsaKeypair( + {}, + ); + + const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, { + exchange, + instructedAmount: amount, + reservePub: reserveKeyPair.pub, + }); + + const withdrawalGroup = await internalCreateWithdrawalGroup(ws, { + 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 withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId); + + const transactionId = ctx.transactionId; + + const exchangePaytoUris = await ws.db.runReadOnlyTx( + ["withdrawalGroups", "exchanges", "exchangeDetails"], + async (tx) => { + return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId); + }, + ); + + ws.taskScheduler.startShepherdTask(ctx.taskId); + + return { + reservePub: withdrawalGroup.reservePub, + exchangePaytoUris: exchangePaytoUris, + withdrawalAccountsList: withdrawalAccountsList, + transactionId, + }; +} diff --git a/packages/taler-wallet-core/tsconfig.json b/packages/taler-wallet-core/tsconfig.json @@ -33,5 +33,5 @@ "path": "../taler-util/" } ], - "include": ["src/**/*", "src/*.json", "../taler-util/src/bank-api-client.ts"] + "include": ["src/**/*", "src/*.json"] }