diff options
author | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-02-19 18:05:48 +0100 |
commit | e951075d2ef52fa8e9e7489c62031777c3a7e66b (patch) | |
tree | 64208c09a9162f3a99adccf30edc36de1ef884ef /packages/taler-wallet-core/src/operations | |
parent | e975740ac4e9ba4bc531226784d640a018c00833 (diff) | |
download | wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.gz wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.bz2 wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.zip |
wallet-core: flatten directory structure
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
21 files changed, 0 insertions, 22309 deletions
diff --git a/packages/taler-wallet-core/src/operations/attention.ts b/packages/taler-wallet-core/src/operations/attention.ts deleted file mode 100644 index 7d8b11e79..000000000 --- a/packages/taler-wallet-core/src/operations/attention.ts +++ /dev/null @@ -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 deleted file mode 100644 index 948b8eb85..000000000 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ /dev/null @@ -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/backup/state.ts b/packages/taler-wallet-core/src/operations/backup/state.ts deleted file mode 100644 index 72f850b25..000000000 --- a/packages/taler-wallet-core/src/operations/backup/state.ts +++ /dev/null @@ -1,15 +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/> - */ diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts deleted file mode 100644 index 12f9795b2..000000000 --- a/packages/taler-wallet-core/src/operations/balance.ts +++ /dev/null @@ -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 deleted file mode 100644 index 6bafa632e..000000000 --- a/packages/taler-wallet-core/src/operations/common.ts +++ /dev/null @@ -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 deleted file mode 100644 index 415f3cd72..000000000 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ /dev/null @@ -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 deleted file mode 100644 index 678e48fb9..000000000 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ /dev/null @@ -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 deleted file mode 100644 index a148953f0..000000000 --- a/packages/taler-wallet-core/src/operations/merchants.ts +++ /dev/null @@ -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 deleted file mode 100644 index 260fc815a..000000000 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ /dev/null @@ -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 deleted file mode 100644 index ae6f98ccd..000000000 --- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts +++ /dev/null @@ -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 deleted file mode 100644 index e97466084..000000000 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts +++ /dev/null @@ -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 deleted file mode 100644 index 1504f3d83..000000000 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts +++ /dev/null @@ -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 deleted file mode 100644 index 412631356..000000000 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts +++ /dev/null @@ -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 deleted file mode 100644 index 91c5430be..000000000 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts +++ /dev/null @@ -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 deleted file mode 100644 index b88115d8e..000000000 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ /dev/null @@ -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 deleted file mode 100644 index ad9fdedb4..000000000 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ /dev/null @@ -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 deleted file mode 100644 index 7d826e630..000000000 --- a/packages/taler-wallet-core/src/operations/reward.ts +++ /dev/null @@ -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 deleted file mode 100644 index 3c7845813..000000000 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ /dev/null @@ -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 deleted file mode 100644 index 1d3ea3d5a..000000000 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ /dev/null @@ -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 deleted file mode 100644 index 97a80ec26..000000000 --- a/packages/taler-wallet-core/src/operations/withdraw.test.ts +++ /dev/null @@ -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 deleted file mode 100644 index 542868de0..000000000 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ /dev/null @@ -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, - }; -} |