diff options
Diffstat (limited to 'packages/taler-wallet-core')
61 files changed, 21671 insertions, 17993 deletions
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index 4825de2c9..46b3cef4e 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-core", - "version": "0.9.3-dev.34", + "version": "0.10.7", "description": "", "engines": { "node": ">=0.18.0" @@ -39,6 +39,9 @@ }, "./remote": { "default": "./lib/remote.js" + }, + "./dbless": { + "default": "./lib/dbless.js" } }, "imports": { diff --git a/packages/taler-wallet-core/src/operations/attention.ts b/packages/taler-wallet-core/src/attention.ts index 92d69e93e..7a52ceaa3 100644 --- a/packages/taler-wallet-core/src/operations/attention.ts +++ b/packages/taler-wallet-core/src/attention.ts @@ -18,30 +18,28 @@ * Imports. */ import { - AbsoluteTime, AttentionInfo, Logger, - TalerProtocolTimestamp, TalerPreciseTimestamp, UserAttentionByIdRequest, UserAttentionPriority, + UserAttentionUnreadList, UserAttentionsCountResponse, UserAttentionsRequest, UserAttentionsResponse, - UserAttentionUnreadList, } from "@gnu-taler/taler-util"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { timestampPreciseFromDb, timestampPreciseToDb } from "../index.js"; +import { timestampPreciseFromDb, timestampPreciseToDb } from "./db.js"; +import { WalletExecutionContext } from "./wallet.js"; const logger = new Logger("operations/attention.ts"); export async function getUserAttentionsUnreadCount( - ws: InternalWalletState, + wex: WalletExecutionContext, req: UserAttentionsRequest, ): Promise<UserAttentionsCountResponse> { - const total = await ws.db - .mktx((x) => [x.userAttention]) - .runReadOnly(async (tx) => { + const total = await wex.db.runReadOnlyTx( + { storeNames: ["userAttention"] }, + async (tx) => { let count = 0; await tx.userAttention.iter().forEach((x) => { if ( @@ -54,18 +52,19 @@ export async function getUserAttentionsUnreadCount( }); return count; - }); + }, + ); return { total }; } export async function getUserAttentions( - ws: InternalWalletState, + wex: WalletExecutionContext, req: UserAttentionsRequest, ): Promise<UserAttentionsResponse> { - return await ws.db - .mktx((x) => [x.userAttention]) - .runReadOnly(async (tx) => { + return await wex.db.runReadOnlyTx( + { storeNames: ["userAttention"] }, + async (tx) => { const pending: UserAttentionUnreadList = []; await tx.userAttention.iter().forEach((x) => { if ( @@ -81,65 +80,60 @@ export async function getUserAttentions( }); return { pending }; - }); + }, + ); } export async function markAttentionRequestAsRead( - ws: InternalWalletState, + wex: WalletExecutionContext, req: UserAttentionByIdRequest, ): Promise<void> { - await ws.db - .mktx((x) => [x.userAttention]) - .runReadWrite(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()), - }); + await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => { + const ua = await tx.userAttention.get([req.entityId, req.type]); + if (!ua) throw Error("attention request not found"); + tx.userAttention.put({ + ...ua, + read: timestampPreciseToDb(TalerPreciseTimestamp.now()), }); + }); } /** * the wallet need the user attention to complete a task * internal API * - * @param ws + * @param wex * @param info */ export async function addAttentionRequest( - ws: InternalWalletState, + wex: WalletExecutionContext, info: AttentionInfo, entityId: string, ): Promise<void> { - await ws.db - .mktx((x) => [x.userAttention]) - .runReadWrite(async (tx) => { - await tx.userAttention.put({ - info, - entityId, - created: timestampPreciseToDb(TalerPreciseTimestamp.now()), - read: undefined, - }); + await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => { + await tx.userAttention.put({ + info, + entityId, + created: timestampPreciseToDb(TalerPreciseTimestamp.now()), + read: undefined, }); + }); } /** * user completed the task, attention request is not needed * internal API * - * @param ws + * @param wex * @param created */ export async function removeAttentionRequest( - ws: InternalWalletState, + wex: WalletExecutionContext, req: UserAttentionByIdRequest, ): Promise<void> { - await ws.db - .mktx((x) => [x.userAttention]) - .runReadWrite(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]); - }); + await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => { + const ua = await tx.userAttention.get([req.entityId, req.type]); + if (!ua) throw Error("attention request not found"); + await tx.userAttention.delete([req.entityId, req.type]); + }); } diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts index 7a2771c57..15904b470 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/backup/index.ts @@ -26,46 +26,42 @@ */ import { AbsoluteTime, - AmountString, AttentionType, BackupRecovery, Codec, - DenomKeyType, + Duration, EddsaKeyPair, HttpStatusCode, Logger, PreparePayResult, - PreparePayResultType, + ProviderInfo, + ProviderPaymentStatus, RecoveryLoadRequest, RecoveryMergeStrategy, TalerError, TalerErrorCode, - TalerErrorDetail, TalerPreciseTimestamp, URL, buildCodecForObject, buildCodecForUnion, bytesToString, canonicalJson, - canonicalizeBaseUrl, - codecForAmountString, + checkDbInvariant, + checkLogicInvariant, codecForBoolean, codecForConstString, codecForList, - codecForNumber, codecForString, + codecForSyncTermsOfServiceResponse, codecOptional, decodeCrock, - durationFromSpec, eddsaGetPublic, encodeCrock, getRandomBytes, hash, - hashDenomPub, j2s, kdf, notEmpty, - rsaBlind, secretbox, secretbox_open, stringToBytes, @@ -75,34 +71,25 @@ import { readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; import { gunzipSync, gzipSync } from "fflate"; -import { TalerCryptoInterface } from "../../crypto/cryptoImplementation.js"; +import { addAttentionRequest, removeAttentionRequest } from "../attention.js"; +import { + TaskIdentifiers, + TaskRunResult, + TaskRunResultType, +} from "../common.js"; import { BackupProviderRecord, BackupProviderState, BackupProviderStateTag, - BackupProviderTerms, ConfigRecord, ConfigRecordKey, WalletBackupConfState, + WalletDbReadOnlyTransaction, timestampOptionalPreciseFromDb, - timestampPreciseFromDb, timestampPreciseToDb, -} from "../../db.js"; -import { InternalWalletState } from "../../internal-wallet-state.js"; -import { assertUnreachable } from "../../util/assertUnreachable.js"; -import { - checkDbInvariant, - checkLogicInvariant, -} from "../../util/invariants.js"; -import { addAttentionRequest, removeAttentionRequest } from "../attention.js"; -import { - TaskRunResult, - TaskRunResultType, - TaskIdentifiers, -} from "../common.js"; -import { checkPaymentByProposalId, preparePayForUri } from "../pay-merchant.js"; -import { WalletStoresV1 } from "../../db.js"; -import { GetReadOnlyAccess } from "../../util/query.js"; +} from "../db.js"; +import { preparePayForUri } from "../pay-merchant.js"; +import { InternalWalletState, WalletExecutionContext } from "../wallet.js"; const logger = new Logger("operations/backup.ts"); @@ -185,20 +172,21 @@ function getNextBackupTimestamp(): TalerPreciseTimestamp { return AbsoluteTime.toPreciseTimestamp( AbsoluteTime.addDuration( AbsoluteTime.now(), - durationFromSpec({ minutes: 5 }), + Duration.fromSpec({ minutes: 5 }), ), ); } async function runBackupCycleForProvider( - ws: InternalWalletState, + wex: WalletExecutionContext, args: BackupForProviderArgs, ): Promise<TaskRunResult> { - const provider = await ws.db - .mktx((x) => [x.backupProviders]) - .runReadOnly(async (tx) => { + const provider = await wex.db.runReadOnlyTx( + { storeNames: ["backupProviders"] }, + async (tx) => { return tx.backupProviders.get(args.backupProviderBaseUrl); - }); + }, + ); if (!provider) { logger.warn("provider disappeared"); @@ -208,7 +196,7 @@ async function runBackupCycleForProvider( //const backupJson = await exportBackup(ws); // FIXME: re-implement backup const backupJson = {}; - const backupConfig = await provideBackupState(ws); + const backupConfig = await provideBackupState(wex); const encBackup = await encryptBackup(backupConfig, backupJson); const currentBackupHash = hash(encBackup); @@ -220,7 +208,7 @@ async function runBackupCycleForProvider( logger.trace(`trying to upload backup to ${provider.baseUrl}`); logger.trace(`old hash ${oldHash}, new hash ${newHash}`); - const syncSigResp = await ws.cryptoApi.makeSyncSignature({ + const syncSigResp = await wex.cryptoApi.makeSyncSignature({ newHash: encodeCrock(currentBackupHash), oldHash: provider.lastBackupHash, accountPriv: encodeCrock(accountKeyPair.eddsaPriv), @@ -237,16 +225,16 @@ async function runBackupCycleForProvider( accountBackupUrl.searchParams.set("fresh", "yes"); } - const resp = await ws.http.fetch(accountBackupUrl.href, { + const resp = await wex.http.fetch(accountBackupUrl.href, { method: "POST", body: encBackup, headers: { "content-type": "application/octet-stream", "sync-signature": syncSigResp.sig, - "if-none-match": newHash, + "if-none-match": JSON.stringify(newHash), ...(provider.lastBackupHash ? { - "if-match": provider.lastBackupHash, + "if-match": JSON.stringify(provider.lastBackupHash), } : {}), }, @@ -255,9 +243,9 @@ async function runBackupCycleForProvider( logger.trace(`sync response status: ${resp.status}`); if (resp.status === HttpStatusCode.NotModified) { - await ws.db - .mktx((x) => [x.backupProviders]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders"] }, + async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { return; @@ -270,9 +258,10 @@ async function runBackupCycleForProvider( nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()), }; await tx.backupProviders.put(prov); - }); + }, + ); - removeAttentionRequest(ws, { + removeAttentionRequest(wex, { entityId: provider.baseUrl, type: AttentionType.BackupUnpaid, }); @@ -292,7 +281,7 @@ async function runBackupCycleForProvider( //FIXME: check download errors let res: PreparePayResult | undefined = undefined; try { - res = await preparePayForUri(ws, talerUri); + res = await preparePayForUri(wex, talerUri); } catch (e) { const error = TalerError.fromException(e); if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) { @@ -303,9 +292,9 @@ async function runBackupCycleForProvider( if (res === undefined) { //claimed - await ws.db - .mktx((x) => [x.backupProviders, x.operationRetries]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders"] }, + async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { logger.warn("backup provider not found anymore"); @@ -316,34 +305,37 @@ async function runBackupCycleForProvider( tag: BackupProviderStateTag.Retrying, }; await tx.backupProviders.put(prov); - }); + }, + ); - return { - type: TaskRunResultType.Pending, - }; + throw Error("not implemented"); + // return { + // type: TaskRunResultType.Pending, + // }; } const result = res; - await ws.db - .mktx((x) => [x.backupProviders, x.operationRetries]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders"] }, + async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { logger.warn("backup provider not found anymore"); return; } - const opId = TaskIdentifiers.forBackup(prov); - //await scheduleRetryInTx(ws, tx, opId); + // 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, + wex, { type: AttentionType.BackupUnpaid, provider_base_url: provider.baseUrl, @@ -352,15 +344,16 @@ async function runBackupCycleForProvider( provider.baseUrl, ); - return { - type: TaskRunResultType.Pending, - }; + throw Error("not implemented"); + // return { + // type: TaskRunResultType.Pending, + // }; } if (resp.status === HttpStatusCode.NoContent) { - await ws.db - .mktx((x) => [x.backupProviders]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders"] }, + async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { return; @@ -374,9 +367,10 @@ async function runBackupCycleForProvider( nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()), }; await tx.backupProviders.put(prov); - }); + }, + ); - removeAttentionRequest(ws, { + removeAttentionRequest(wex, { entityId: provider.baseUrl, type: AttentionType.BackupUnpaid, }); @@ -389,13 +383,13 @@ async function runBackupCycleForProvider( if (resp.status === HttpStatusCode.Conflict) { logger.info("conflicting backup found"); const backupEnc = new Uint8Array(await resp.bytes()); - const backupConfig = await provideBackupState(ws); + const backupConfig = await provideBackupState(wex); // const blob = await decryptBackup(backupConfig, backupEnc); // FIXME: Re-implement backup import with merging // await importBackup(ws, blob, cryptoData); - await ws.db - .mktx((x) => [x.backupProviders, x.operationRetries]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders"] }, + async (tx) => { const prov = await tx.backupProviders.get(provider.baseUrl); if (!prov) { logger.warn("backup provider not found anymore"); @@ -410,10 +404,11 @@ async function runBackupCycleForProvider( tag: BackupProviderStateTag.Retrying, }; await tx.backupProviders.put(prov); - }); + }, + ); logger.info("processed existing backup"); // Now upload our own, merged backup. - return await runBackupCycleForProvider(ws, args); + return await runBackupCycleForProvider(wex, args); } // Some other response that we did not expect! @@ -429,21 +424,22 @@ async function runBackupCycleForProvider( } export async function processBackupForProvider( - ws: InternalWalletState, + wex: WalletExecutionContext, backupProviderBaseUrl: string, ): Promise<TaskRunResult> { - const provider = await ws.db - .mktx((x) => [x.backupProviders]) - .runReadOnly(async (tx) => { + const provider = await wex.db.runReadOnlyTx( + { storeNames: ["backupProviders"] }, + async (tx) => { return await tx.backupProviders.get(backupProviderBaseUrl); - }); + }, + ); if (!provider) { throw Error("unknown backup provider"); } logger.info(`running backup for provider ${backupProviderBaseUrl}`); - return await runBackupCycleForProvider(ws, { + return await runBackupCycleForProvider(wex, { backupProviderBaseUrl: provider.baseUrl, }); } @@ -459,14 +455,15 @@ export const codecForRemoveBackupProvider = .build("RemoveBackupProviderRequest"); export async function removeBackupProvider( - ws: InternalWalletState, + wex: WalletExecutionContext, req: RemoveBackupProviderRequest, ): Promise<void> { - await ws.db - .mktx((x) => [x.backupProviders]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders"] }, + async (tx) => { await tx.backupProviders.delete(req.provider); - }); + }, + ); } export interface RunBackupCycleRequest { @@ -489,12 +486,12 @@ export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> => * 3. Upload the updated backup blob. */ export async function runBackupCycle( - ws: InternalWalletState, + wex: WalletExecutionContext, req: RunBackupCycleRequest, ): Promise<void> { - const providers = await ws.db - .mktx((x) => [x.backupProviders]) - .runReadOnly(async (tx) => { + const providers = await wex.db.runReadOnlyTx( + { storeNames: ["backupProviders"] }, + async (tx) => { if (req.providers) { const rs = await Promise.all( req.providers.map((id) => tx.backupProviders.get(id)), @@ -502,35 +499,16 @@ export async function runBackupCycle( return rs.filter(notEmpty); } return await tx.backupProviders.iter().toArray(); - }); + }, + ); for (const provider of providers) { - await runBackupCycleForProvider(ws, { + await runBackupCycleForProvider(wex, { 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; @@ -586,15 +564,15 @@ export const codecForAddBackupProviderResponse = .build("AddBackupProviderResponse"); export async function addBackupProvider( - ws: InternalWalletState, + wex: WalletExecutionContext, req: AddBackupProviderRequest, ): Promise<AddBackupProviderResponse> { logger.info(`adding backup provider ${j2s(req)}`); - await provideBackupState(ws); - const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); - await ws.db - .mktx((x) => [x.backupProviders]) - .runReadWrite(async (tx) => { + await provideBackupState(wex); + const canonUrl = req.backupProviderBaseUrl; + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders"] }, + async (tx) => { const oldProv = await tx.backupProviders.get(canonUrl); if (oldProv) { logger.info("old backup provider found"); @@ -610,16 +588,17 @@ export async function addBackupProvider( } return; } - }); + }, + ); const termsUrl = new URL("config", canonUrl); - const resp = await ws.http.fetch(termsUrl.href); + const resp = await wex.http.fetch(termsUrl.href); const terms = await readSuccessResponseJsonOrThrow( resp, codecForSyncTermsOfServiceResponse(), ); - await ws.db - .mktx((x) => [x.backupProviders]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders"] }, + async (tx) => { let state: BackupProviderState; //FIXME: what is the difference provisional and ready? if (req.activate) { @@ -647,196 +626,113 @@ export async function addBackupProvider( baseUrl: canonUrl, uids: [encodeCrock(getRandomBytes(32))], }); - }); + }, + ); - return await runFirstBackupCycleForProvider(ws, { + return await runFirstBackupCycleForProvider(wex, { backupProviderBaseUrl: canonUrl, }); } async function runFirstBackupCycleForProvider( - ws: InternalWalletState, + wex: WalletExecutionContext, args: BackupForProviderArgs, ): Promise<AddBackupProviderResponse> { - 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.Longpoll: - throw Error( - "unexpected runFirstBackupCycleForProvider result (longpoll)", - ); - case TaskRunResultType.Pending: - return { - status: "payment-required", - talerUri: "FIXME", - //talerUri: resp.result.talerUri, - }; - default: - assertUnreachable(resp); - } + 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, + wex: WalletExecutionContext, provider: BackupProviderRecord, ): Promise<ProviderPaymentStatus> { - 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); - } + 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, + wex: WalletExecutionContext, ): Promise<BackupInfo> { - const backupConfig = await provideBackupState(ws); - const providerRecords = await ws.db - .mktx((x) => [x.backupProviders, x.operationRetries]) - .runReadOnly(async (tx) => { + const backupConfig = await provideBackupState(wex); + const providerRecords = await wex.db.runReadOnlyTx( + { storeNames: ["backupProviders", "operationRetries"] }, + async (tx) => { return await tx.backupProviders.iter().mapAsync(async (bp) => { const opId = TaskIdentifiers.forBackup(bp); const retryRecord = await tx.operationRetries.get(opId); @@ -845,7 +741,8 @@ export async function getBackupInfo( retryRecord, }; }); - }); + }, + ); const providers: ProviderInfo[] = []; for (const x of providerRecords) { providers.push({ @@ -859,7 +756,7 @@ export async function getBackupInfo( x.provider.state.tag === BackupProviderStateTag.Retrying ? x.retryRecord?.lastError : undefined, - paymentStatus: await getProviderPaymentInfo(ws, x.provider), + paymentStatus: await getProviderPaymentInfo(wex, x.provider), terms: x.provider.terms, name: x.provider.name, }); @@ -876,14 +773,15 @@ export async function getBackupInfo( * private key. */ export async function getBackupRecovery( - ws: InternalWalletState, + wex: WalletExecutionContext, ): Promise<BackupRecovery> { - const bs = await provideBackupState(ws); - const providers = await ws.db - .mktx((x) => [x.backupProviders]) - .runReadOnly(async (tx) => { + const bs = await provideBackupState(wex); + const providers = await wex.db.runReadOnlyTx( + { storeNames: ["backupProviders"] }, + async (tx) => { return await tx.backupProviders.iter().toArray(); - }); + }, + ); return { providers: providers .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional) @@ -898,12 +796,12 @@ export async function getBackupRecovery( } async function backupRecoveryTheirs( - ws: InternalWalletState, + wex: WalletExecutionContext, br: BackupRecovery, ) { - await ws.db - .mktx((x) => [x.config, x.backupProviders]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["backupProviders", "config"] }, + async (tx) => { let backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); @@ -944,23 +842,28 @@ async function backupRecoveryTheirs( prov.lastBackupHash = undefined; await tx.backupProviders.put(prov); } - }); + }, + ); } -async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) { +async function backupRecoveryOurs( + wex: WalletExecutionContext, + br: BackupRecovery, +) { throw Error("not implemented"); } export async function loadBackupRecovery( - ws: InternalWalletState, + wex: WalletExecutionContext, br: RecoveryLoadRequest, ): Promise<void> { - const bs = await provideBackupState(ws); - const providers = await ws.db - .mktx((x) => [x.backupProviders]) - .runReadOnly(async (tx) => { + const bs = await provideBackupState(wex); + const providers = await wex.db.runReadOnlyTx( + { storeNames: ["backupProviders"] }, + async (tx) => { return await tx.backupProviders.iter().toArray(); - }); + }, + ); let strategy = br.strategy; if ( br.recovery.walletRootPriv != bs.walletRootPriv && @@ -975,9 +878,9 @@ export async function loadBackupRecovery( strategy = RecoveryMergeStrategy.Theirs; } if (strategy === RecoveryMergeStrategy.Theirs) { - return backupRecoveryTheirs(ws, br.recovery); + return backupRecoveryTheirs(wex, br.recovery); } else { - return backupRecoveryOurs(ws, br.recovery); + return backupRecoveryOurs(wex, br.recovery); } } @@ -1001,52 +904,51 @@ export async function decryptBackup( } export async function provideBackupState( - ws: InternalWalletState, + wex: WalletExecutionContext, ): Promise<WalletBackupConfState> { - const bs: ConfigRecord | undefined = await ws.db - .mktx((stores) => [stores.config]) - .runReadOnly(async (tx) => { + const bs: ConfigRecord | undefined = await wex.db.runReadOnlyTx( + { storeNames: ["config"] }, + async (tx) => { return await tx.config.get(ConfigRecordKey.WalletBackupState); - }); + }, + ); if (bs) { checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); return bs.value; } // We need to generate the key outside of the transaction // due to how IndexedDB works. - const k = await ws.cryptoApi.createEddsaKeypair({}); + const k = await wex.cryptoApi.createEddsaKeypair({}); const d = getRandomBytes(5); // FIXME: device ID should be configured when wallet is initialized // and be based on hostname const deviceId = `wallet-core-${encodeCrock(d)}`; - return await ws.db - .mktx((x) => [x.config]) - .runReadWrite(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; - }); + return await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { + let backupStateEntry: ConfigRecord | undefined = await tx.config.get( + ConfigRecordKey.WalletBackupState, + ); + if (!backupStateEntry) { + backupStateEntry = { + key: ConfigRecordKey.WalletBackupState, + value: { + deviceId, + walletRootPub: k.pub, + walletRootPriv: k.priv, + lastBackupPlainHash: undefined, + }, + }; + await tx.config.put(backupStateEntry); + } + checkDbInvariant( + backupStateEntry.key === ConfigRecordKey.WalletBackupState, + ); + return backupStateEntry.value; + }); } export async function getWalletBackupState( ws: InternalWalletState, - tx: GetReadOnlyAccess<{ config: typeof WalletStoresV1.config }>, + tx: WalletDbReadOnlyTransaction<["config"]>, ): Promise<WalletBackupConfState> { const bs = await tx.config.get(ConfigRecordKey.WalletBackupState); checkDbInvariant(!!bs, "wallet backup state should be in DB"); @@ -1055,30 +957,28 @@ export async function getWalletBackupState( } export async function setWalletDeviceId( - ws: InternalWalletState, + wex: WalletExecutionContext, deviceId: string, ): Promise<void> { - await provideBackupState(ws); - await ws.db - .mktx((x) => [x.config]) - .runReadWrite(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); - }); + await provideBackupState(wex); + await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { + let backupStateEntry: ConfigRecord | undefined = await tx.config.get( + ConfigRecordKey.WalletBackupState, + ); + if ( + !backupStateEntry || + backupStateEntry.key !== ConfigRecordKey.WalletBackupState + ) { + return; + } + backupStateEntry.value.deviceId = deviceId; + await tx.config.put(backupStateEntry); + }); } export async function getWalletDeviceId( - ws: InternalWalletState, + wex: WalletExecutionContext, ): Promise<string> { - const bs = await provideBackupState(ws); + const bs = await provideBackupState(wex); return bs.deviceId; } diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts b/packages/taler-wallet-core/src/backup/state.ts index d02ead783..72f850b25 100644 --- a/packages/taler-wallet-core/src/operations/backup/state.ts +++ b/packages/taler-wallet-core/src/backup/state.ts @@ -13,5 +13,3 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - - diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts new file mode 100644 index 000000000..76e604324 --- /dev/null +++ b/packages/taler-wallet-core/src/balance.ts @@ -0,0 +1,797 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Functions to compute the wallet's balance. + * + * There are multiple definition of the wallet's balance. + * We use the following terminology: + * + * - "available": Balance that is available + * for spending from transactions in their final state and + * expected to be available from pending refreshes. + * + * - "pending-incoming": Expected (positive!) delta + * to the available balance that we expect to have + * after pending operations reach the "done" state. + * + * - "pending-outgoing": Amount that is currently allocated + * to be spent, but the spend operation could still be aborted + * and part of the pending-outgoing amount could be recovered. + * + * - "material": Balance that the wallet believes it could spend *right now*, + * without waiting for any operations to complete. + * This balance type is important when showing "insufficient balance" error messages. + * + * - "age-acceptable": Subset of the material balance that can be spent + * with age restrictions applied. + * + * - "merchant-acceptable": Subset of the material balance that can be spent with a particular + * merchant (restricted via min age, exchange, auditor, wire_method). + * + * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant + * can accept via their supported wire methods. + */ + +/** + * Imports. + */ +import { GlobalIDB } from "@gnu-taler/idb-bridge"; +import { + AmountJson, + AmountLike, + Amounts, + assertUnreachable, + BalanceFlag, + BalancesResponse, + checkDbInvariant, + GetBalanceDetailRequest, + j2s, + Logger, + parsePaytoUri, + ScopeInfo, + ScopeType, +} from "@gnu-taler/taler-util"; +import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js"; +import { + DepositOperationStatus, + ExchangeEntryDbRecordStatus, + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + PeerPushDebitStatus, + RefreshGroupRecord, + RefreshOperationStatus, + WalletDbReadOnlyTransaction, + WithdrawalGroupStatus, +} from "./db.js"; +import { + getExchangeScopeInfo, + getExchangeWireDetailsInTx, +} from "./exchanges.js"; +import { getDenomInfo, WalletExecutionContext } from "./wallet.js"; + +/** + * Logger. + */ +const logger = new Logger("operations/balance.ts"); + +interface WalletBalance { + scopeInfo: ScopeInfo; + available: AmountJson; + pendingIncoming: AmountJson; + pendingOutgoing: AmountJson; + flagIncomingKyc: boolean; + flagIncomingAml: boolean; + flagIncomingConfirmation: boolean; + flagOutgoingKyc: boolean; +} + +/** + * Compute the available amount that the wallet expects to get + * out of a refresh group. + */ +function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson { + // Don't count finished refreshes, since the refresh already resulted + // in coins being added to the wallet. + let available = Amounts.zeroOfCurrency(r.currency); + if (r.timestampFinished) { + return available; + } + for (let i = 0; i < r.oldCoinPubs.length; i++) { + available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount; + } + return available; +} + +function getBalanceKey(scopeInfo: ScopeInfo): string { + switch (scopeInfo.type) { + case ScopeType.Auditor: + return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`; + case ScopeType.Exchange: + return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`; + case ScopeType.Global: + return `${scopeInfo.type};${scopeInfo.currency}`; + } +} + +class BalancesStore { + private exchangeScopeCache: Record<string, ScopeInfo> = {}; + private balanceStore: Record<string, WalletBalance> = {}; + + constructor( + private wex: WalletExecutionContext, + private tx: WalletDbReadOnlyTransaction< + [ + "globalCurrencyAuditors", + "globalCurrencyExchanges", + "exchanges", + "exchangeDetails", + ] + >, + ) {} + + /** + * Add amount to a balance field, both for + * the slicing by exchange and currency. + */ + private async initBalance( + currency: string, + exchangeBaseUrl: string, + ): Promise<WalletBalance> { + let scopeInfo: ScopeInfo | undefined = + this.exchangeScopeCache[exchangeBaseUrl]; + if (!scopeInfo) { + scopeInfo = await getExchangeScopeInfo( + this.tx, + exchangeBaseUrl, + currency, + ); + this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo; + } + const balanceKey = getBalanceKey(scopeInfo); + const b = this.balanceStore[balanceKey]; + if (!b) { + const zero = Amounts.zeroOfCurrency(currency); + this.balanceStore[balanceKey] = { + scopeInfo, + available: zero, + pendingIncoming: zero, + pendingOutgoing: zero, + flagIncomingAml: false, + flagIncomingConfirmation: false, + flagIncomingKyc: false, + flagOutgoingKyc: false, + }; + } + return this.balanceStore[balanceKey]; + } + + async addZero(currency: string, exchangeBaseUrl: string): Promise<void> { + await this.initBalance(currency, exchangeBaseUrl); + } + + async addAvailable( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.available = Amounts.add(b.available, amount).amount; + } + + async addPendingIncoming( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.pendingIncoming = Amounts.add(b.pendingIncoming, amount).amount; + } + + async addPendingOutgoing( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.pendingOutgoing = Amounts.add(b.pendingOutgoing, amount).amount; + } + + async setFlagIncomingAml( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingAml = true; + } + + async setFlagIncomingKyc( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingKyc = true; + } + + async setFlagIncomingConfirmation( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingConfirmation = true; + } + + async setFlagOutgoingKyc( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagOutgoingKyc = true; + } + + toBalancesResponse(): BalancesResponse { + const balancesResponse: BalancesResponse = { + balances: [], + }; + + const balanceStore = this.balanceStore; + + Object.keys(balanceStore) + .sort() + .forEach((c) => { + const v = balanceStore[c]; + const flags: BalanceFlag[] = []; + if (v.flagIncomingAml) { + flags.push(BalanceFlag.IncomingAml); + } + if (v.flagIncomingKyc) { + flags.push(BalanceFlag.IncomingKyc); + } + if (v.flagIncomingConfirmation) { + flags.push(BalanceFlag.IncomingConfirmation); + } + if (v.flagOutgoingKyc) { + flags.push(BalanceFlag.OutgoingKyc); + } + balancesResponse.balances.push({ + scopeInfo: v.scopeInfo, + available: Amounts.stringify(v.available), + pendingIncoming: Amounts.stringify(v.pendingIncoming), + pendingOutgoing: Amounts.stringify(v.pendingOutgoing), + // FIXME: This field is basically not implemented, do we even need it? + hasPendingTransactions: false, + // FIXME: This field is basically not implemented, do we even need it? + requiresUserInput: false, + flags, + }); + }); + return balancesResponse; + } +} + +/** + * Get balance information. + */ +export async function getBalancesInsideTransaction( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "exchanges", + "exchangeDetails", + "coinAvailability", + "refreshGroups", + "depositGroups", + "withdrawalGroups", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + "peerPushDebit", + ] + >, +): Promise<BalancesResponse> { + const balanceStore: BalancesStore = new BalancesStore(wex, tx); + + const keyRangeActive = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + + await tx.exchanges.iter().forEachAsync(async (ex) => { + if ( + ex.entryStatus === ExchangeEntryDbRecordStatus.Used || + ex.tosAcceptedTimestamp != null + ) { + const det = await getExchangeWireDetailsInTx(tx, ex.baseUrl); + if (det) { + await balanceStore.addZero(det.currency, ex.baseUrl); + } + } + }); + + await tx.coinAvailability.iter().forEachAsync(async (ca) => { + const count = ca.visibleCoinCount ?? 0; + await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl); + for (let i = 0; i < count; i++) { + await balanceStore.addAvailable( + ca.currency, + ca.exchangeBaseUrl, + ca.value, + ); + } + }); + + await tx.refreshGroups.iter().forEachAsync(async (r) => { + switch (r.operationStatus) { + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: + break; + default: + return; + } + const perExchange = r.infoPerExchange; + if (!perExchange) { + return; + } + for (const [e, x] of Object.entries(perExchange)) { + await balanceStore.addAvailable(r.currency, e, x.outputEffective); + } + }); + + await tx.withdrawalGroups.indexes.byStatus + .iter(keyRangeActive) + .forEachAsync(async (wg) => { + switch (wg.status) { + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedOtherWallet: + case WithdrawalGroupStatus.AbortedUserRefused: + case WithdrawalGroupStatus.DialogProposed: + case WithdrawalGroupStatus.Done: + // Does not count as pendingIncoming + return; + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.AbortingBank: + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.SuspendedReady: + case WithdrawalGroupStatus.SuspendedRegisteringBank: + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.SuspendedQueryingStatus: + // Pending, but no special flag. + break; + case WithdrawalGroupStatus.SuspendedKyc: + case WithdrawalGroupStatus.PendingKyc: { + checkDbInvariant( + wg.denomsSel !== undefined, + "wg in kyc state should have been initialized", + ); + const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); + await balanceStore.setFlagIncomingKyc(currency, wg.exchangeBaseUrl); + break; + } + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.SuspendedAml: { + checkDbInvariant( + wg.denomsSel !== undefined, + "wg in aml state should have been initialized", + ); + const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); + await balanceStore.setFlagIncomingAml(currency, wg.exchangeBaseUrl); + break; + } + case WithdrawalGroupStatus.PendingRegisteringBank: { + if (wg.denomsSel && wg.exchangeBaseUrl) { + const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); + await balanceStore.setFlagIncomingConfirmation( + currency, + wg.exchangeBaseUrl, + ); + } + break; + } + case WithdrawalGroupStatus.PendingWaitConfirmBank: { + checkDbInvariant( + wg.denomsSel !== undefined, + "wg in confirmed state should have been initialized", + ); + const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); + await balanceStore.setFlagIncomingConfirmation( + currency, + wg.exchangeBaseUrl, + ); + break; + } + default: + assertUnreachable(wg.status); + } + if (wg.denomsSel && wg.exchangeBaseUrl) { + // only inform pending incoming if amount and exchange has been selected + const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); + await balanceStore.addPendingIncoming( + currency, + wg.exchangeBaseUrl, + wg.denomsSel.totalCoinValue, + ); + } + }); + + await tx.peerPushDebit.indexes.byStatus + .iter(keyRangeActive) + .forEachAsync(async (ppdRecord) => { + switch (ppdRecord.status) { + case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.PendingReady: + case PeerPushDebitStatus.SuspendedReady: + case PeerPushDebitStatus.PendingCreatePurse: + case PeerPushDebitStatus.SuspendedCreatePurse: { + const currency = Amounts.currencyOf(ppdRecord.amount); + await balanceStore.addPendingOutgoing( + currency, + ppdRecord.exchangeBaseUrl, + ppdRecord.totalCost, + ); + break; + } + } + }); + + await tx.depositGroups.indexes.byStatus + .iter(keyRangeActive) + .forEachAsync(async (dgRecord) => { + const perExchange = dgRecord.infoPerExchange; + if (!perExchange) { + return; + } + for (const [e, x] of Object.entries(perExchange)) { + const currency = Amounts.currencyOf(dgRecord.amount); + switch (dgRecord.operationStatus) { + case DepositOperationStatus.SuspendedKyc: + case DepositOperationStatus.PendingKyc: + await balanceStore.setFlagOutgoingKyc(currency, e); + } + + switch (dgRecord.operationStatus) { + case DepositOperationStatus.SuspendedKyc: + case DepositOperationStatus.PendingKyc: + case DepositOperationStatus.PendingTrack: + case DepositOperationStatus.SuspendedAborting: + case DepositOperationStatus.SuspendedDeposit: + case DepositOperationStatus.SuspendedTrack: + case DepositOperationStatus.PendingDeposit: { + const perExchange = dgRecord.infoPerExchange; + if (perExchange) { + for (const [e, v] of Object.entries(perExchange)) { + await balanceStore.addPendingOutgoing( + currency, + e, + v.amountEffective, + ); + } + } + } + } + } + }); + + return balanceStore.toBalancesResponse(); +} + +/** + * Get detailed balance information, sliced by exchange and by currency. + */ +export async function getBalances( + wex: WalletExecutionContext, +): Promise<BalancesResponse> { + logger.trace("starting to compute balance"); + + const wbal = await wex.db.runReadWriteTx( + { + storeNames: [ + "coinAvailability", + "coins", + "depositGroups", + "exchangeDetails", + "exchanges", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + "purchases", + "refreshGroups", + "withdrawalGroups", + "peerPushDebit", + ], + }, + async (tx) => { + return getBalancesInsideTransaction(wex, tx); + }, + ); + + logger.trace("finished computing wallet balance"); + + return wbal; +} + +export interface PaymentRestrictionsForBalance { + currency: string; + minAge: number; + restrictExchanges: ExchangeRestrictionSpec | undefined; + restrictWireMethods: string[] | undefined; + depositPaytoUri: string | undefined; +} + +export interface AcceptableExchanges { + /** + * Exchanges accepted by the merchant, but wire method might not match. + */ + acceptableExchanges: string[]; + + /** + * Exchanges accepted by the merchant, including a matching + * wire method, i.e. the merchant can deposit coins there. + */ + depositableExchanges: string[]; +} + +export interface PaymentBalanceDetails { + /** + * Balance of type "available" (see balance.ts for definition). + */ + balanceAvailable: AmountJson; + + /** + * Balance of type "material" (see balance.ts for definition). + */ + balanceMaterial: AmountJson; + + /** + * Balance of type "age-acceptable" (see balance.ts for definition). + */ + balanceAgeAcceptable: AmountJson; + + /** + * Balance of type "merchant-acceptable" (see balance.ts for definition). + */ + balanceReceiverAcceptable: AmountJson; + + /** + * Balance of type "merchant-depositable" (see balance.ts for definition). + */ + balanceReceiverDepositable: AmountJson; + + /** + * Balance that's depositable with the exchange. + * This balance is reduced by the exchange's debit restrictions + * and wire fee configuration. + */ + balanceExchangeDepositable: AmountJson; + + maxEffectiveSpendAmount: AmountJson; +} + +export async function getPaymentBalanceDetails( + wex: WalletExecutionContext, + req: PaymentRestrictionsForBalance, +): Promise<PaymentBalanceDetails> { + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "coinAvailability", + "refreshGroups", + "exchanges", + "exchangeDetails", + "denominations", + ], + }, + async (tx) => { + return getPaymentBalanceDetailsInTx(wex, tx, req); + }, + ); +} + +export async function getPaymentBalanceDetailsInTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "coinAvailability", + "refreshGroups", + "exchanges", + "exchangeDetails", + "denominations", + ] + >, + req: PaymentRestrictionsForBalance, +): Promise<PaymentBalanceDetails> { + const d: PaymentBalanceDetails = { + balanceAvailable: Amounts.zeroOfCurrency(req.currency), + balanceMaterial: Amounts.zeroOfCurrency(req.currency), + balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency), + balanceReceiverAcceptable: Amounts.zeroOfCurrency(req.currency), + balanceReceiverDepositable: Amounts.zeroOfCurrency(req.currency), + maxEffectiveSpendAmount: Amounts.zeroOfCurrency(req.currency), + balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency), + }; + + logger.info(`computing balance details for ${j2s(req)}`); + + const availableCoins = await tx.coinAvailability.getAll(); + + for (const ca of availableCoins) { + if (ca.currency != req.currency) { + continue; + } + + const denom = await getDenomInfo( + wex, + tx, + ca.exchangeBaseUrl, + ca.denomPubHash, + ); + if (!denom) { + continue; + } + + const wireDetails = await getExchangeWireDetailsInTx( + tx, + ca.exchangeBaseUrl, + ); + if (!wireDetails) { + continue; + } + + const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value); + const coinAmount: AmountJson = Amounts.mult( + singleCoinAmount, + ca.freshCoinCount, + ).amount; + + let wireOkay = false; + if (req.restrictWireMethods == null) { + wireOkay = true; + } else { + for (const wm of req.restrictWireMethods) { + const wmf = findMatchingWire(wm, req.depositPaytoUri, wireDetails); + if (wmf) { + wireOkay = true; + break; + } + } + } + + if (wireOkay) { + d.balanceExchangeDepositable = Amounts.add( + d.balanceExchangeDepositable, + coinAmount, + ).amount; + } + + let ageOkay = ca.maxAge === 0 || ca.maxAge > req.minAge; + + let merchantExchangeAcceptable = false; + + if (!req.restrictExchanges) { + merchantExchangeAcceptable = true; + } else { + for (const ex of req.restrictExchanges.exchanges) { + if (ex.exchangeBaseUrl === ca.exchangeBaseUrl) { + merchantExchangeAcceptable = true; + break; + } + } + for (const acceptedAuditor of req.restrictExchanges.auditors) { + for (const exchangeAuditor of wireDetails.auditors) { + if (acceptedAuditor.auditorBaseUrl === exchangeAuditor.auditor_url) { + merchantExchangeAcceptable = true; + break; + } + } + } + } + + const merchantExchangeDepositable = merchantExchangeAcceptable && wireOkay; + + d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount; + d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount; + if (ageOkay) { + d.balanceAgeAcceptable = Amounts.add( + d.balanceAgeAcceptable, + coinAmount, + ).amount; + if (merchantExchangeAcceptable) { + d.balanceReceiverAcceptable = Amounts.add( + d.balanceReceiverAcceptable, + coinAmount, + ).amount; + if (merchantExchangeDepositable) { + d.balanceReceiverDepositable = Amounts.add( + d.balanceReceiverDepositable, + coinAmount, + ).amount; + } + } + } + + if ( + ageOkay && + wireOkay && + merchantExchangeAcceptable && + merchantExchangeDepositable + ) { + d.maxEffectiveSpendAmount = Amounts.add( + d.maxEffectiveSpendAmount, + Amounts.mult(ca.value, ca.freshCoinCount).amount, + ).amount; + + d.maxEffectiveSpendAmount = Amounts.sub( + d.maxEffectiveSpendAmount, + Amounts.mult(denom.feeDeposit, ca.freshCoinCount).amount, + ).amount; + } + } + + await tx.refreshGroups.iter().forEach((r) => { + if (r.currency != req.currency) { + return; + } + d.balanceAvailable = Amounts.add( + d.balanceAvailable, + computeRefreshGroupAvailableAmount(r), + ).amount; + }); + + return d; +} + +export async function getBalanceDetail( + wex: WalletExecutionContext, + req: GetBalanceDetailRequest, +): Promise<PaymentBalanceDetails> { + const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = []; + const wires = new Array<string>(); + await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "exchangeDetails"] }, + async (tx) => { + const allExchanges = await tx.exchanges.iter().toArray(); + for (const e of allExchanges) { + const details = await getExchangeWireDetailsInTx(tx, e.baseUrl); + if (!details || req.currency !== details.currency) { + continue; + } + details.wireInfo.accounts.forEach((a) => { + const payto = parsePaytoUri(a.payto_uri); + if (payto && !wires.includes(payto.targetType)) { + wires.push(payto.targetType); + } + }); + exchanges.push({ + exchangePub: details.masterPublicKey, + exchangeBaseUrl: e.baseUrl, + }); + } + }, + ); + + return await getPaymentBalanceDetails(wex, { + currency: req.currency, + restrictExchanges: { + auditors: [], + exchanges, + }, + restrictWireMethods: wires, + minAge: 0, + depositPaytoUri: undefined, + }); +} diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts index 0715c999f..c7cb2857e 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -19,13 +19,13 @@ import { Amounts, DenomKeyType, Duration, - TalerProtocolTimestamp, j2s, } from "@gnu-taler/taler-util"; import test from "ava"; import { AvailableDenom, - testing_greedySelectPeer, + CoinSelectionTally, + emptyTallyForPeerPayment, testing_selectGreedy, } from "./coinSelection.js"; @@ -42,12 +42,12 @@ const inThePast = AbsoluteTime.toProtocolTimestamp( test("p2p: should select the coin", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:2"); - const tally = { - amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - }; - const coins = testing_greedySelectPeer( + const tally = emptyTallyForPeerPayment(instructedAmount); + t.log(`tally before: ${j2s(tally)}`); + const coins = testing_selectGreedy( + { + wireFeesPerExchange: {}, + }, createCandidates([ { amount: "LOCAL:10" as AmountString, @@ -56,11 +56,11 @@ test("p2p: should select the coin", (t) => { fromExchange: "http://exchange.localhost/", }, ]), - instructedAmount, tally, ); - t.log(j2s(coins)); + t.log(`coins: ${j2s(coins)}`); + t.log(`tally: ${j2s(tally)}`); t.assert(coins != null); @@ -70,26 +70,17 @@ test("p2p: should select the coin", (t) => { denomPubHash: "hash0", maxAge: 32, contributions: [Amounts.parseOrThrow("LOCAL:2.1")], - expireDeposit: inTheDistantFuture, - expireWithdraw: inTheDistantFuture, }, }); - - t.deepEqual(tally, { - amountAcc: Amounts.parseOrThrow("LOCAL:2"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.1"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); }); test("p2p: should select 3 coins", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:20"); - const tally = { - amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - }; - const coins = testing_greedySelectPeer( + const tally = emptyTallyForPeerPayment(instructedAmount); + const coins = testing_selectGreedy( + { + wireFeesPerExchange: {}, + }, createCandidates([ { amount: "LOCAL:10" as AmountString, @@ -98,7 +89,6 @@ test("p2p: should select 3 coins", (t) => { fromExchange: "http://exchange.localhost/", }, ]), - instructedAmount, tally, ); @@ -108,30 +98,21 @@ test("p2p: should select 3 coins", (t) => { denomPubHash: "hash0", maxAge: 32, contributions: [ - Amounts.parseOrThrow("LOCAL:9.9"), - Amounts.parseOrThrow("LOCAL:9.9"), - Amounts.parseOrThrow("LOCAL:0.5"), + Amounts.parseOrThrow("LOCAL:10"), + Amounts.parseOrThrow("LOCAL:10"), + Amounts.parseOrThrow("LOCAL:0.3"), ], - expireDeposit: inTheDistantFuture, - expireWithdraw: inTheDistantFuture, }, }); - - t.deepEqual(tally, { - amountAcc: Amounts.parseOrThrow("LOCAL:20"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.3"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); }); test("p2p: can't select since the instructed amount is too high", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:60"); - const tally = { - amountAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - depositFeesAcc: Amounts.zeroOfCurrency(instructedAmount.currency), - lastDepositFee: Amounts.zeroOfCurrency(instructedAmount.currency), - }; - const coins = testing_greedySelectPeer( + const tally = emptyTallyForPeerPayment(instructedAmount); + const coins = testing_selectGreedy( + { + wireFeesPerExchange: {}, + }, createCandidates([ { amount: "LOCAL:10" as AmountString, @@ -140,17 +121,10 @@ test("p2p: can't select since the instructed amount is too high", (t) => { fromExchange: "http://exchange.localhost/", }, ]), - instructedAmount, tally, ); t.is(coins, undefined); - - t.deepEqual(tally, { - amountAcc: Amounts.parseOrThrow("LOCAL:49"), - depositFeesAcc: Amounts.parseOrThrow("LOCAL:0.5"), - lastDepositFee: Amounts.parseOrThrow("LOCAL:0.1"), - }); }); test("pay: select one coin to pay with fee", (t) => { @@ -159,28 +133,15 @@ test("pay: select one coin to pay with fee", (t) => { const zero = Amounts.zeroOfCurrency(payment.currency); const tally = { amountPayRemaining: payment, - amountWireFeeLimitRemaining: zero, amountDepositFeeLimitRemaining: zero, customerDepositFees: zero, customerWireFees: zero, wireFeeCoveredForExchange: new Set<string>(), lastDepositFee: zero, - }; + } satisfies CoinSelectionTally; const coins = testing_selectGreedy( { - auditors: [], - exchanges: [ - { - exchangeBaseUrl: "http://exchange.localhost/", - exchangePub: "E5M8CGRDHXF1RCVP3B8TQCTDYNQ7T4XHWR5SVEQRGVVMVME41VJ0", - }, - ], - contractTermsAmount: payment, - depositFeeLimit: zero, - wireFeeAmortization: 1, - wireFeeLimit: zero, - prevPayCoins: [], - wireMethod: "x-taler-bank", + wireFeesPerExchange: { "http://exchange.localhost/": exchangeWireFee }, }, createCandidates([ { @@ -190,7 +151,6 @@ test("pay: select one coin to pay with fee", (t) => { fromExchange: "http://exchange.localhost/", }, ]), - { "http://exchange.localhost/": exchangeWireFee }, tally, ); @@ -200,19 +160,16 @@ test("pay: select one coin to pay with fee", (t) => { denomPubHash: "hash0", maxAge: 32, contributions: [Amounts.parseOrThrow("LOCAL:2.2")], - expireDeposit: inTheDistantFuture, - expireWithdraw: inTheDistantFuture, }, }); t.deepEqual(tally, { - amountPayRemaining: Amounts.parseOrThrow("LOCAL:2"), - amountWireFeeLimitRemaining: zero, + amountPayRemaining: Amounts.parseOrThrow("LOCAL:0"), amountDepositFeeLimitRemaining: zero, - customerDepositFees: zero, - customerWireFees: zero, - wireFeeCoveredForExchange: new Set(), - lastDepositFee: zero, + customerDepositFees: Amounts.parse("LOCAL:0.1"), + customerWireFees: Amounts.parse("LOCAL:0.1"), + wireFeeCoveredForExchange: new Set(["http://exchange.localhost/"]), + lastDepositFee: Amounts.parse("LOCAL:0.1"), }); }); @@ -247,3 +204,78 @@ function createCandidates( }; }); } + +test("p2p: regression STATER", (t) => { + const candidates = [ + { + denomPub: { + age_mask: 349441, + cipher: "RSA", + rsa_public_key: + "040000WTR9ERP6FYDM4581C1WY4DX6EA6ZP0RKDEY1VCEG1HGZQDB1E1MT0HSPWKVWYY8GN99YG8JV2BQHCV608V3AP00HZ44M4R2RDK3MEG1HY3H5VP2YESFDXC8C2J0BT6E662JJYN4MCFR8Q8ZFD7ZCA8HGBNVG4JMTS5MBDTF9CX3JC25H702K1FG2C54HR48767D18F2H11HMVK7EEF51QRGE08T704VRCNZ6WTM3Z73Z5DW4W26GBEWTDZZ4HX94HRJEH8YENXAW5T5E39TQQN7MZ7HEPB59BQWB0DDMM8MAE274BV3HC2AJVCSXFJSKBAK1B9HKERPWF7Z5556VJG6YJ9236G5SFM3RC22PJM2SXHYBWFV1WBAYF1F2026C0CM5Q3RPQETHCWZTEX8KJ2J1K904002", + }, + denomPubHash: + "TF5S4VJ8P3NN0SM5R1KW5MP665KEFMGAT2RPR70BMG0WQ5A72J53GDDE0YSCTWEXHRW8FMMX3X27RQK4D1VH69GVJBYR5RSJY3X5FS8", + feeDeposit: "STATER:1", + feeRefresh: "STATER:0", + feeRefund: "STATER:0", + feeWithdraw: "STATER:0", + stampExpireDeposit: { + t_s: 1772722025, + }, + stampExpireLegal: { + t_s: 1961938025, + }, + stampExpireWithdraw: { + t_s: 1709650025, + }, + stampStart: { + t_s: 1709045225, + }, + value: "STATER:2", + exchangeBaseUrl: "https://exchange.taler.grothoff.org/", + numAvailable: 6, + maxAge: 32, + }, + { + denomPub: { + age_mask: 349441, + cipher: "RSA", + rsa_public_key: + "040000Y84BTTQCZ28AS2KZ867V05WES3YPN34X51DNF14ADGW2HNG9YFXCCNVQ2JA9ZT3KSBD17ZN9Y71KGWAWEFYMHE0S61DW63WN58VWRXQ92440V1JSZDD7FDTYEVNGG8ZVARVZ4GGF1RCDM93R28M067S5CPRZFCCQBRFFM9YDK2W06WDXE96BDCB8MZEYPHSGK5CTDY6XJE18EMRWYRBAG0H8P6QGQS73REXX66PTJ3MRX3AK3ARZF8417QKMZZPNS1JV5EYPAC7X8R1F9G1GWAQXVVQ2XTA5NMVMNJDJ0KEM93AXD4W2C7XMVJFSQN8RVB9KZ8JXWGN1YJQK7P6476HV896THKQ05QK4F0C65P4HA7QDX84C91F42PZVMH8AMYMA2NBXEYXS0EV8NXZHMZ30JF04002", + }, + denomPubHash: + "WCMKBGR8ZKJ62YZXCRNT3EHPFQQ2M0B5CGZXW0PYA76G8PPXJMXZ7Q3WBP2DA3Z4BF21K3X9AG769RYCC39C3PT0R1DCTJA2PRTSHSR", + feeDeposit: "STATER:1", + feeRefresh: "STATER:0", + feeRefund: "STATER:0", + feeWithdraw: "STATER:0", + stampExpireDeposit: { + t_s: 1772722025, + }, + stampExpireLegal: { + t_s: 1961938025, + }, + stampExpireWithdraw: { + t_s: 1709650025, + }, + stampStart: { + t_s: 1709045225, + }, + value: "STATER:1", + exchangeBaseUrl: "https://exchange.taler.grothoff.org/", + numAvailable: 1, + maxAge: 32, + }, + ]; + const instructedAmount = Amounts.parseOrThrow("STATER:1"); + const tally = emptyTallyForPeerPayment(instructedAmount); + const res = testing_selectGreedy( + { + wireFeesPerExchange: {}, + }, + candidates as any, + tally, + ); + t.assert(!!res); +}); diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts new file mode 100644 index 000000000..a60e41ecd --- /dev/null +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -0,0 +1,1258 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Selection of coins for payments. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { GlobalIDB } from "@gnu-taler/idb-bridge"; +import { + AbsoluteTime, + AccountRestriction, + AgeRestriction, + AllowedAuditorInfo, + AllowedExchangeInfo, + AmountJson, + Amounts, + checkDbInvariant, + checkLogicInvariant, + CoinStatus, + DenominationInfo, + ExchangeGlobalFees, + ForcedCoinSel, + InternationalizedString, + j2s, + Logger, + parsePaytoUri, + PayCoinSelection, + PaymentInsufficientBalanceDetails, + ProspectivePayCoinSelection, + SelectedCoin, + SelectedProspectiveCoin, + strcmp, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { getPaymentBalanceDetailsInTx } from "./balance.js"; +import { getAutoRefreshExecuteThreshold } from "./common.js"; +import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js"; +import { + ExchangeWireDetails, + getExchangeWireDetailsInTx, +} from "./exchanges.js"; +import { getDenomInfo, WalletExecutionContext } from "./wallet.js"; + +const logger = new Logger("coinSelection.ts"); + +export type PreviousPayCoins = { + coinPub: string; + contribution: AmountJson; +}[]; + +export interface ExchangeRestrictionSpec { + exchanges: AllowedExchangeInfo[]; + auditors: AllowedAuditorInfo[]; +} + +export interface CoinSelectionTally { + /** + * Amount that still needs to be paid. + * May increase during the computation when fees need to be covered. + */ + amountPayRemaining: AmountJson; + + /** + * Allowance given by the merchant towards deposit fees + * (and wire fees after wire fee limit is exhausted) + */ + amountDepositFeeLimitRemaining: AmountJson; + + customerDepositFees: AmountJson; + + customerWireFees: AmountJson; + + wireFeeCoveredForExchange: Set<string>; + + lastDepositFee: AmountJson; +} + +/** + * Account for the fees of spending a coin. + */ +function tallyFees( + tally: CoinSelectionTally, + wireFeesPerExchange: Record<string, AmountJson>, + exchangeBaseUrl: string, + feeDeposit: AmountJson, +): void { + const currency = tally.amountPayRemaining.currency; + + if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) { + const wf = + wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency); + // The remaining, amortized amount needs to be paid by the + // wallet or covered by the deposit fee allowance. + let wfRemaining = wf; + // This is the amount forgiven via the deposit fee allowance. + const wfDepositForgiven = Amounts.min( + tally.amountDepositFeeLimitRemaining, + wfRemaining, + ); + tally.amountDepositFeeLimitRemaining = Amounts.sub( + tally.amountDepositFeeLimitRemaining, + wfDepositForgiven, + ).amount; + wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount; + tally.customerWireFees = Amounts.add( + tally.customerWireFees, + wfRemaining, + ).amount; + tally.amountPayRemaining = Amounts.add( + tally.amountPayRemaining, + wfRemaining, + ).amount; + tally.wireFeeCoveredForExchange.add(exchangeBaseUrl); + } + + const dfForgiven = Amounts.min( + feeDeposit, + tally.amountDepositFeeLimitRemaining, + ); + + tally.amountDepositFeeLimitRemaining = Amounts.sub( + tally.amountDepositFeeLimitRemaining, + dfForgiven, + ).amount; + + // How much does the user spend on deposit fees for this coin? + const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount; + tally.customerDepositFees = Amounts.add( + tally.customerDepositFees, + dfRemaining, + ).amount; + tally.amountPayRemaining = Amounts.add( + tally.amountPayRemaining, + dfRemaining, + ).amount; + tally.lastDepositFee = feeDeposit; +} + +export type SelectPayCoinsResult = + | { + type: "failure"; + insufficientBalanceDetails: PaymentInsufficientBalanceDetails; + } + | { type: "prospective"; result: ProspectivePayCoinSelection } + | { type: "success"; coinSel: PayCoinSelection }; + +async function internalSelectPayCoins( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "coinAvailability", + "denominations", + "refreshGroups", + "exchanges", + "exchangeDetails", + "coins", + ] + >, + req: SelectPayCoinRequestNg, + includePendingCoins: boolean, +): Promise< + | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally } + | undefined +> { + const { contractTermsAmount, depositFeeLimit } = req; + const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates( + wex, + tx, + { + restrictExchanges: req.restrictExchanges, + instructedAmount: req.contractTermsAmount, + restrictWireMethod: req.restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + requiredMinimumAge: req.requiredMinimumAge, + includePendingCoins, + }, + ); + + if (logger.shouldLogTrace()) { + logger.trace( + `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`, + ); + logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`); + logger.trace(`candidates: ${j2s(candidateDenoms)}`); + } + + const coinRes: SelectedCoin[] = []; + const currency = contractTermsAmount.currency; + + let tally: CoinSelectionTally = { + amountPayRemaining: contractTermsAmount, + amountDepositFeeLimitRemaining: depositFeeLimit, + customerDepositFees: Amounts.zeroOfCurrency(currency), + customerWireFees: Amounts.zeroOfCurrency(currency), + wireFeeCoveredForExchange: new Set(), + lastDepositFee: Amounts.zeroOfCurrency(currency), + }; + + await maybeRepairCoinSelection( + wex, + tx, + req.prevPayCoins ?? [], + coinRes, + tally, + { + wireFeesPerExchange: wireFeesPerExchange, + }, + ); + + let selectedDenom: SelResult | undefined; + if (req.forcedSelection) { + selectedDenom = selectForced(req, candidateDenoms); + } else { + // FIXME: Here, we should select coins in a smarter way. + // Instead of always spending the next-largest coin, + // we should try to find the smallest coin that covers the + // amount. + selectedDenom = selectGreedy( + { + wireFeesPerExchange: wireFeesPerExchange, + }, + candidateDenoms, + tally, + ); + } + + if (!selectedDenom) { + return undefined; + } + return { + sel: selectedDenom, + coinRes, + tally, + }; +} + +/** + * Select coins to spend under the merchant's constraints. + * + * The prevPayCoins can be specified to "repair" a coin selection + * by adding additional coins, after a broken (e.g. double-spent) coin + * has been removed from the selection. + */ +export async function selectPayCoins( + wex: WalletExecutionContext, + req: SelectPayCoinRequestNg, +): Promise<SelectPayCoinsResult> { + if (logger.shouldLogTrace()) { + logger.trace(`selecting coins for ${j2s(req)}`); + } + + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "coinAvailability", + "denominations", + "refreshGroups", + "exchanges", + "exchangeDetails", + "coins", + ], + }, + async (tx) => { + const materialAvSel = await internalSelectPayCoins(wex, tx, req, false); + + if (!materialAvSel) { + const prospectiveAvSel = await internalSelectPayCoins( + wex, + tx, + req, + true, + ); + + if (prospectiveAvSel) { + const prospectiveCoins: SelectedProspectiveCoin[] = []; + for (const avKey of Object.keys(prospectiveAvSel.sel)) { + const mySel = prospectiveAvSel.sel[avKey]; + for (const contrib of mySel.contributions) { + prospectiveCoins.push({ + denomPubHash: mySel.denomPubHash, + contribution: Amounts.stringify(contrib), + exchangeBaseUrl: mySel.exchangeBaseUrl, + }); + } + } + return { + type: "prospective", + result: { + prospectiveCoins, + customerDepositFees: Amounts.stringify( + prospectiveAvSel.tally.customerDepositFees, + ), + customerWireFees: Amounts.stringify( + prospectiveAvSel.tally.customerWireFees, + ), + }, + } satisfies SelectPayCoinsResult; + } + + return { + type: "failure", + insufficientBalanceDetails: await reportInsufficientBalanceDetails( + wex, + tx, + { + restrictExchanges: req.restrictExchanges, + instructedAmount: req.contractTermsAmount, + requiredMinimumAge: req.requiredMinimumAge, + wireMethod: req.restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + }, + ), + } satisfies SelectPayCoinsResult; + } + + const coinSel = await assembleSelectPayCoinsSuccessResult( + tx, + materialAvSel.sel, + materialAvSel.coinRes, + materialAvSel.tally, + ); + + if (logger.shouldLogTrace()) { + logger.trace(`coin selection: ${j2s(coinSel)}`); + } + + return { + type: "success", + coinSel, + }; + }, + ); +} + +async function maybeRepairCoinSelection( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, + prevPayCoins: PreviousPayCoins, + coinRes: SelectedCoin[], + tally: CoinSelectionTally, + feeInfo: { + wireFeesPerExchange: Record<string, AmountJson>; + }, +): Promise<void> { + // Look at existing pay coin selection and tally up + for (const prev of prevPayCoins) { + const coin = await tx.coins.get(prev.coinPub); + if (!coin) { + continue; + } + const denom = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denom) { + continue; + } + tallyFees( + tally, + feeInfo.wireFeesPerExchange, + coin.exchangeBaseUrl, + Amounts.parseOrThrow(denom.feeDeposit), + ); + tally.amountPayRemaining = Amounts.sub( + tally.amountPayRemaining, + prev.contribution, + ).amount; + + coinRes.push({ + exchangeBaseUrl: coin.exchangeBaseUrl, + denomPubHash: coin.denomPubHash, + coinPub: prev.coinPub, + contribution: Amounts.stringify(prev.contribution), + }); + } +} + +/** + * Returns undefined if the success response could not be assembled, + * as not enough coins are actually available. + */ +async function assembleSelectPayCoinsSuccessResult( + tx: WalletDbReadOnlyTransaction<["coins"]>, + finalSel: SelResult, + coinRes: SelectedCoin[], + tally: CoinSelectionTally, +): Promise<PayCoinSelection> { + for (const dph of Object.keys(finalSel)) { + const selInfo = finalSel[dph]; + const numRequested = selInfo.contributions.length; + const query = [ + selInfo.exchangeBaseUrl, + selInfo.denomPubHash, + selInfo.maxAge, + CoinStatus.Fresh, + ]; + logger.trace(`query: ${j2s(query)}`); + const coins = + await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( + query, + numRequested, + ); + if (coins.length != numRequested) { + throw Error( + `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, + ); + } + + for (let i = 0; i < selInfo.contributions.length; i++) { + coinRes.push({ + denomPubHash: coins[i].denomPubHash, + coinPub: coins[i].coinPub, + contribution: Amounts.stringify(selInfo.contributions[i]), + exchangeBaseUrl: coins[i].exchangeBaseUrl, + }); + } + } + + return { + coins: coinRes, + customerDepositFees: Amounts.stringify(tally.customerDepositFees), + customerWireFees: Amounts.stringify(tally.customerWireFees), + }; +} + +interface ReportInsufficientBalanceRequest { + instructedAmount: AmountJson; + requiredMinimumAge: number | undefined; + restrictExchanges: ExchangeRestrictionSpec | undefined; + wireMethod: string | undefined; + depositPaytoUri: string | undefined; +} + +export async function reportInsufficientBalanceDetails( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "coinAvailability", + "exchanges", + "exchangeDetails", + "refreshGroups", + "denominations", + ] + >, + req: ReportInsufficientBalanceRequest, +): Promise<PaymentInsufficientBalanceDetails> { + const details = await getPaymentBalanceDetailsInTx(wex, tx, { + restrictExchanges: req.restrictExchanges, + restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined, + currency: Amounts.currencyOf(req.instructedAmount), + minAge: req.requiredMinimumAge ?? 0, + depositPaytoUri: req.depositPaytoUri, + }); + const perExchange: PaymentInsufficientBalanceDetails["perExchange"] = {}; + const exchanges = await tx.exchanges.getAll(); + + for (const exch of exchanges) { + if (!exch.detailsPointer) { + continue; + } + let missingGlobalFees = false; + const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); + if (!exchWire) { + missingGlobalFees = true; + } else { + const globalFees = getGlobalFees(exchWire); + if (!globalFees) { + missingGlobalFees = true; + } + } + const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, { + restrictExchanges: { + exchanges: [ + { + exchangeBaseUrl: exch.baseUrl, + exchangePub: exch.detailsPointer?.masterPublicKey, + }, + ], + auditors: [], + }, + restrictWireMethods: req.wireMethod ? [req.wireMethod] : [], + currency: Amounts.currencyOf(req.instructedAmount), + minAge: req.requiredMinimumAge ?? 0, + depositPaytoUri: req.depositPaytoUri, + }); + perExchange[exch.baseUrl] = { + balanceAvailable: Amounts.stringify(exchDet.balanceAvailable), + balanceMaterial: Amounts.stringify(exchDet.balanceMaterial), + balanceExchangeDepositable: Amounts.stringify( + exchDet.balanceExchangeDepositable, + ), + balanceAgeAcceptable: Amounts.stringify(exchDet.balanceAgeAcceptable), + balanceReceiverAcceptable: Amounts.stringify( + exchDet.balanceReceiverAcceptable, + ), + balanceReceiverDepositable: Amounts.stringify( + exchDet.balanceReceiverDepositable, + ), + maxEffectiveSpendAmount: Amounts.stringify( + exchDet.maxEffectiveSpendAmount, + ), + missingGlobalFees, + }; + } + + return { + amountRequested: Amounts.stringify(req.instructedAmount), + balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable), + balanceAvailable: Amounts.stringify(details.balanceAvailable), + balanceMaterial: Amounts.stringify(details.balanceMaterial), + balanceReceiverAcceptable: Amounts.stringify( + details.balanceReceiverAcceptable, + ), + balanceExchangeDepositable: Amounts.stringify( + details.balanceExchangeDepositable, + ), + balanceReceiverDepositable: Amounts.stringify( + details.balanceReceiverDepositable, + ), + maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount), + perExchange, + }; +} + +function makeAvailabilityKey( + exchangeBaseUrl: string, + denomPubHash: string, + maxAge: number, +): string { + return `${denomPubHash};${maxAge};${exchangeBaseUrl}`; +} + +/** + * Selection result. + */ +interface SelResult { + /** + * Map from an availability key + * to an array of contributions. + */ + [avKey: string]: { + exchangeBaseUrl: string; + denomPubHash: string; + maxAge: number; + contributions: AmountJson[]; + }; +} + +export function testing_selectGreedy( + ...args: Parameters<typeof selectGreedy> +): ReturnType<typeof selectGreedy> { + return selectGreedy(...args); +} + +export interface SelectGreedyRequest { + wireFeesPerExchange: Record<string, AmountJson>; +} + +function selectGreedy( + req: SelectGreedyRequest, + candidateDenoms: AvailableDenom[], + tally: CoinSelectionTally, +): SelResult | undefined { + const selectedDenom: SelResult = {}; + for (const denom of candidateDenoms) { + const contributions: AmountJson[] = []; + + // Don't use this coin if depositing it is more expensive than + // the amount it would give the merchant. + if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) { + tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit); + continue; + } + + for ( + let i = 0; + i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining); + i++ + ) { + tallyFees( + tally, + req.wireFeesPerExchange, + denom.exchangeBaseUrl, + Amounts.parseOrThrow(denom.feeDeposit), + ); + + const coinSpend = Amounts.max( + Amounts.min(tally.amountPayRemaining, denom.value), + denom.feeDeposit, + ); + + tally.amountPayRemaining = Amounts.sub( + tally.amountPayRemaining, + coinSpend, + ).amount; + + contributions.push(coinSpend); + } + + if (contributions.length) { + const avKey = makeAvailabilityKey( + denom.exchangeBaseUrl, + denom.denomPubHash, + denom.maxAge, + ); + let sd = selectedDenom[avKey]; + if (!sd) { + sd = { + contributions: [], + denomPubHash: denom.denomPubHash, + exchangeBaseUrl: denom.exchangeBaseUrl, + maxAge: denom.maxAge, + }; + } + sd.contributions.push(...contributions); + selectedDenom[avKey] = sd; + } + } + return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined; +} + +function selectForced( + req: SelectPayCoinRequestNg, + candidateDenoms: AvailableDenom[], +): SelResult | undefined { + const selectedDenom: SelResult = {}; + + const forcedSelection = req.forcedSelection; + checkLogicInvariant(!!forcedSelection); + + for (const forcedCoin of forcedSelection.coins) { + let found = false; + for (const aci of candidateDenoms) { + if (aci.numAvailable <= 0) { + continue; + } + if (Amounts.cmp(aci.value, forcedCoin.value) === 0) { + aci.numAvailable--; + const avKey = makeAvailabilityKey( + aci.exchangeBaseUrl, + aci.denomPubHash, + aci.maxAge, + ); + let sd = selectedDenom[avKey]; + if (!sd) { + sd = { + contributions: [], + denomPubHash: aci.denomPubHash, + exchangeBaseUrl: aci.exchangeBaseUrl, + maxAge: aci.maxAge, + }; + } + sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value)); + selectedDenom[avKey] = sd; + found = true; + break; + } + } + if (!found) { + throw Error("can't find coin for forced coin selection"); + } + } + return selectedDenom; +} + +export function checkAccountRestriction( + paytoUri: string, + restrictions: AccountRestriction[], +): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } { + for (const myRestriction of restrictions) { + switch (myRestriction.type) { + case "deny": + return { ok: false }; + case "regex": + const regex = new RegExp(myRestriction.payto_regex); + if (!regex.test(paytoUri)) { + return { + ok: false, + hint: myRestriction.human_hint, + hintI18n: myRestriction.human_hint_i18n, + }; + } + } + } + return { + ok: true, + }; +} + +export interface SelectPayCoinRequestNg { + restrictExchanges: ExchangeRestrictionSpec | undefined; + restrictWireMethod: string; + contractTermsAmount: AmountJson; + depositFeeLimit: AmountJson; + prevPayCoins?: PreviousPayCoins; + requiredMinimumAge?: number; + forcedSelection?: ForcedCoinSel; + + /** + * Deposit payto URI, in case we already know the account that + * will be deposited into. + * + * That is typically the case when the wallet does a deposit to + * return funds to the user's own bank account. + */ + depositPaytoUri?: string; +} + +export type AvailableDenom = DenominationInfo & { + maxAge: number; + numAvailable: number; +}; + +export function findMatchingWire( + wireMethod: string, + depositPaytoUri: string | undefined, + exchangeWireDetails: ExchangeWireDetails, +): { wireFee: AmountJson } | undefined { + for (const acc of exchangeWireDetails.wireInfo.accounts) { + const pp = parsePaytoUri(acc.payto_uri); + checkLogicInvariant(!!pp); + if (pp.targetType !== wireMethod) { + continue; + } + const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[ + wireMethod + ]?.find((x) => { + return AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(x.startStamp), + AbsoluteTime.fromProtocolTimestamp(x.endStamp), + ); + })?.wireFee; + + if (!wireFeeStr) { + continue; + } + + let debitAccountCheckOk = false; + if (depositPaytoUri) { + // FIXME: We should somehow propagate the hint here! + const checkResult = checkAccountRestriction( + depositPaytoUri, + acc.debit_restrictions, + ); + if (checkResult.ok) { + debitAccountCheckOk = true; + } + } else { + debitAccountCheckOk = true; + } + + if (!debitAccountCheckOk) { + continue; + } + + return { + wireFee: Amounts.parseOrThrow(wireFeeStr), + }; + } + return undefined; +} + +function checkExchangeAccepted( + exchangeDetails: ExchangeWireDetails, + exchangeRestrictions: ExchangeRestrictionSpec | undefined, +): boolean { + if (!exchangeRestrictions) { + return true; + } + let accepted = false; + for (const allowedExchange of exchangeRestrictions.exchanges) { + if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { + accepted = true; + break; + } + } + for (const allowedAuditor of exchangeRestrictions.auditors) { + for (const providedAuditor of exchangeDetails.auditors) { + if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { + accepted = true; + break; + } + } + } + return accepted; +} + +interface SelectPayCandidatesRequest { + instructedAmount: AmountJson; + restrictWireMethod: string | undefined; + depositPaytoUri?: string; + restrictExchanges: ExchangeRestrictionSpec | undefined; + requiredMinimumAge?: number; + + /** + * If set to true, the coin selection will also use coins that are not + * materially available yet, but that are expected to become available + * as the output of a refresh operation. + */ + includePendingCoins: boolean; +} + +async function selectPayCandidates( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + ["exchanges", "coinAvailability", "exchangeDetails", "denominations"] + >, + req: SelectPayCandidatesRequest, +): Promise<[AvailableDenom[], Record<string, AmountJson>]> { + // FIXME: Use the existing helper (from balance.ts) to + // get acceptable exchanges. + logger.shouldLogTrace() && + logger.trace(`selecting available coin candidates for ${j2s(req)}`); + const denoms: AvailableDenom[] = []; + const exchanges = await tx.exchanges.iter().toArray(); + const wfPerExchange: Record<string, AmountJson> = {}; + for (const exchange of exchanges) { + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + exchange.baseUrl, + ); + // 1. exchange has same currency + if (exchangeDetails?.currency !== req.instructedAmount.currency) { + logger.shouldLogTrace() && + logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`); + continue; + } + + // 2. Exchange supports wire method (only for pay/deposit) + if (req.restrictWireMethod) { + const wire = findMatchingWire( + req.restrictWireMethod, + req.depositPaytoUri, + exchangeDetails, + ); + if (!wire) { + if (logger.shouldLogTrace()) { + logger.trace( + `skipping ${exchange.baseUrl} due to missing wire info mismatch`, + ); + } + continue; + } + wfPerExchange[exchange.baseUrl] = wire.wireFee; + } + + // 3. exchange is trusted in the exchange list or auditor list + let accepted = checkExchangeAccepted( + exchangeDetails, + req.restrictExchanges, + ); + if (!accepted) { + if (logger.shouldLogTrace()) { + logger.trace(`skipping ${exchange.baseUrl} due to unacceptability`); + } + continue; + } + + // 4. filter coins restricted by age + let ageLower = 0; + let ageUpper = AgeRestriction.AGE_UNRESTRICTED; + if (req.requiredMinimumAge) { + ageLower = req.requiredMinimumAge; + } + + const myExchangeCoins = + await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( + GlobalIDB.KeyRange.bound( + [exchangeDetails.exchangeBaseUrl, ageLower, 1], + [exchangeDetails.exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER], + ), + ); + + if (logger.shouldLogTrace()) { + logger.trace( + `exchange ${exchange.baseUrl} has ${myExchangeCoins.length} candidate records`, + ); + } + + let numUsable = 0; + + // 5. save denoms with how many coins are available + // FIXME: Check that the individual denomination is audited! + // FIXME: Should we exclude denominations that are + // not spendable anymore? + for (const coinAvail of myExchangeCoins) { + const denom = await tx.denominations.get([ + coinAvail.exchangeBaseUrl, + coinAvail.denomPubHash, + ]); + checkDbInvariant(!!denom); + if (denom.isRevoked) { + logger.trace("denom is revoked"); + continue; + } + if (!denom.isOffered) { + logger.trace("denom is unoffered"); + continue; + } + numUsable++; + let numAvailable = coinAvail.freshCoinCount ?? 0; + if (req.includePendingCoins) { + numAvailable += coinAvail.pendingRefreshOutputCount ?? 0; + } + denoms.push({ + ...DenominationRecord.toDenomInfo(denom), + numAvailable, + maxAge: coinAvail.maxAge, + }); + } + + if (logger.shouldLogTrace()) { + logger.trace( + `exchange ${exchange.baseUrl} has ${numUsable} candidate records with usable denominations`, + ); + } + } + // Sort by available amount (descending), deposit fee (ascending) and + // denomPub (ascending) if deposit fee is the same + // (to guarantee deterministic results) + denoms.sort( + (o1, o2) => + -Amounts.cmp(o1.value, o2.value) || + Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || + strcmp(o1.denomPubHash, o2.denomPubHash), + ); + return [denoms, wfPerExchange]; +} + +export interface PeerCoinSelectionDetails { + exchangeBaseUrl: string; + + /** + * Info of Coins that were selected. + */ + coins: SelectedCoin[]; + + /** + * How much of the deposit fees is the customer paying? + */ + depositFees: AmountJson; + + maxExpirationDate: TalerProtocolTimestamp; +} + +export interface ProspectivePeerCoinSelectionDetails { + exchangeBaseUrl: string; + + prospectiveCoins: SelectedProspectiveCoin[]; + + /** + * How much of the deposit fees is the customer paying? + */ + depositFees: AmountJson; + + maxExpirationDate: TalerProtocolTimestamp; +} + +export type SelectPeerCoinsResult = + | { type: "success"; result: PeerCoinSelectionDetails } + // Successful, but using coins that are not materially available yet. + | { type: "prospective"; result: ProspectivePeerCoinSelectionDetails } + | { + type: "failure"; + insufficientBalanceDetails: PaymentInsufficientBalanceDetails; + }; + +export interface PeerCoinSelectionRequest { + instructedAmount: AmountJson; + + /** + * Instruct the coin selection to repair this coin + * selection instead of selecting completely new coins. + */ + repair?: PreviousPayCoins; +} + +export async function computeCoinSelMaxExpirationDate( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>, + selectedDenom: SelResult, +): Promise<TalerProtocolTimestamp> { + let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never(); + for (const dph of Object.keys(selectedDenom)) { + const selInfo = selectedDenom[dph]; + const denom = await getDenomInfo( + wex, + tx, + selInfo.exchangeBaseUrl, + selInfo.denomPubHash, + ); + if (!denom) { + continue; + } + // Compute earliest time that a selected denom + // would have its coins auto-refreshed. + minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min( + minAutorefreshExecuteThreshold, + AbsoluteTime.toProtocolTimestamp( + getAutoRefreshExecuteThreshold({ + stampExpireDeposit: denom.stampExpireDeposit, + stampExpireWithdraw: denom.stampExpireWithdraw, + }), + ), + ); + } + return minAutorefreshExecuteThreshold; +} + +export function emptyTallyForPeerPayment( + instructedAmount: AmountJson, +): CoinSelectionTally { + const currency = instructedAmount.currency; + const zero = Amounts.zeroOfCurrency(currency); + return { + amountPayRemaining: instructedAmount, + customerDepositFees: zero, + lastDepositFee: zero, + amountDepositFeeLimitRemaining: zero, + customerWireFees: zero, + wireFeeCoveredForExchange: new Set(), + }; +} + +function getGlobalFees( + wireDetails: ExchangeWireDetails, +): ExchangeGlobalFees | undefined { + const now = AbsoluteTime.now(); + for (let gf of wireDetails.globalFees) { + const isActive = AbsoluteTime.isBetween( + now, + AbsoluteTime.fromProtocolTimestamp(gf.startDate), + AbsoluteTime.fromProtocolTimestamp(gf.endDate), + ); + if (!isActive) { + continue; + } + return gf; + } + return undefined; +} + +async function internalSelectPeerCoins( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "exchanges", + "contractTerms", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "exchangeDetails", + ] + >, + req: PeerCoinSelectionRequest, + exch: ExchangeWireDetails, + includePendingCoins: boolean, +): Promise< + | { sel: SelResult; tally: CoinSelectionTally; resCoins: SelectedCoin[] } + | undefined +> { + const candidatesRes = await selectPayCandidates(wex, tx, { + instructedAmount: req.instructedAmount, + restrictExchanges: { + auditors: [], + exchanges: [ + { + exchangeBaseUrl: exch.exchangeBaseUrl, + exchangePub: exch.masterPublicKey, + }, + ], + }, + restrictWireMethod: undefined, + includePendingCoins, + }); + const candidates = candidatesRes[0]; + if (logger.shouldLogTrace()) { + logger.trace(`peer payment candidate coins: ${j2s(candidates)}`); + } + const tally = emptyTallyForPeerPayment(req.instructedAmount); + const resCoins: SelectedCoin[] = []; + + await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, { + wireFeesPerExchange: {}, + }); + + if (logger.shouldLogTrace()) { + logger.trace(`candidates: ${j2s(candidates)}`); + logger.trace(`instructedAmount: ${j2s(req.instructedAmount)}`); + logger.trace(`tally: ${j2s(tally)}`); + } + + const selRes = selectGreedy( + { + wireFeesPerExchange: {}, + }, + candidates, + tally, + ); + if (!selRes) { + return undefined; + } + + return { + sel: selRes, + tally, + resCoins, + }; +} + +export async function selectPeerCoins( + wex: WalletExecutionContext, + req: PeerCoinSelectionRequest, +): Promise<SelectPeerCoinsResult> { + const instructedAmount = req.instructedAmount; + if (Amounts.isZero(instructedAmount)) { + // Other parts of the code assume that we have at least + // one coin to spend. + throw new Error("amount of zero not allowed"); + } + + return await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "contractTerms", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "exchangeDetails", + ], + }, + async (tx): Promise<SelectPeerCoinsResult> => { + const exchanges = await tx.exchanges.iter().toArray(); + const currency = Amounts.currencyOf(instructedAmount); + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; + } + const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); + if (!exchWire) { + continue; + } + const globalFees = getGlobalFees(exchWire); + if (!globalFees) { + continue; + } + + const avRes = await internalSelectPeerCoins( + wex, + tx, + req, + exchWire, + false, + ); + + if (!avRes) { + // Try to see if we can do a prospective selection + const prospectiveAvRes = await internalSelectPeerCoins( + wex, + tx, + req, + exchWire, + true, + ); + if (prospectiveAvRes) { + const prospectiveCoins: SelectedProspectiveCoin[] = []; + for (const avKey of Object.keys(prospectiveAvRes.sel)) { + const mySel = prospectiveAvRes.sel[avKey]; + for (const contrib of mySel.contributions) { + prospectiveCoins.push({ + denomPubHash: mySel.denomPubHash, + contribution: Amounts.stringify(contrib), + exchangeBaseUrl: mySel.exchangeBaseUrl, + }); + } + } + const maxExpirationDate = await computeCoinSelMaxExpirationDate( + wex, + tx, + prospectiveAvRes.sel, + ); + return { + type: "prospective", + result: { + prospectiveCoins, + depositFees: prospectiveAvRes.tally.customerDepositFees, + exchangeBaseUrl: exch.baseUrl, + maxExpirationDate, + }, + }; + } + } else if (avRes) { + const r = await assembleSelectPayCoinsSuccessResult( + tx, + avRes.sel, + avRes.resCoins, + avRes.tally, + ); + + const maxExpirationDate = await computeCoinSelMaxExpirationDate( + wex, + tx, + avRes.sel, + ); + + return { + type: "success", + result: { + coins: r.coins, + depositFees: Amounts.parseOrThrow(r.customerDepositFees), + exchangeBaseUrl: exch.baseUrl, + maxExpirationDate, + }, + }; + } + } + const insufficientBalanceDetails = await reportInsufficientBalanceDetails( + wex, + tx, + { + restrictExchanges: undefined, + instructedAmount: req.instructedAmount, + requiredMinimumAge: undefined, + wireMethod: undefined, + depositPaytoUri: undefined, + }, + ); + return { + type: "failure", + insufficientBalanceDetails, + }; + }, + ); +} diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/common.ts index abba3f7a7..edaba5ba4 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/common.ts @@ -19,43 +19,34 @@ */ import { AbsoluteTime, - AgeRestriction, AmountJson, Amounts, - CancellationToken, + AsyncFlag, CoinRefreshRequest, CoinStatus, Duration, ExchangeEntryState, ExchangeEntryStatus, - ExchangeListItem, ExchangeTosStatus, ExchangeUpdateStatus, - getErrorDetailFromException, - j2s, Logger, - makeErrorDetail, - NotificationType, - OperationErrorInfo, RefreshReason, - ScopeInfo, - ScopeType, - TalerError, - TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, + TalerProtocolTimestamp, TombstoneIdStr, TransactionIdStr, - TransactionType, WalletNotification, + assertUnreachable, + checkDbInvariant, + checkLogicInvariant, + durationMul, } from "@gnu-taler/taler-util"; -import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js"; import { BackupProviderRecord, CoinRecord, DbPreciseTimestamp, DepositGroupRecord, - ExchangeDetailsRecord, ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, ExchangeEntryRecord, @@ -67,19 +58,12 @@ import { RecoupGroupRecord, RefreshGroupRecord, RewardRecord, - timestampPreciseToDb, - WalletStoresV1, + WalletDbReadWriteTransaction, WithdrawalGroupRecord, -} 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 { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; -import { - constructTransactionIdentifier, - parseTransactionIdentifier, -} from "./transactions.js"; + timestampPreciseToDb, +} from "./db.js"; +import { createRefreshGroup } from "./refresh.js"; +import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; const logger = new Logger("operations/common.ts"); @@ -94,16 +78,12 @@ export interface CoinsSpendInfo { } export async function makeCoinsVisible( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - coinAvailability: typeof WalletStoresV1.coinAvailability; - }>, + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>, transactionId: string, ): Promise<void> { - const coins = await tx.coins.indexes.bySourceTransactionId.getAll( - transactionId, - ); + const coins = + await tx.coins.indexes.bySourceTransactionId.getAll(transactionId); for (const coinRecord of coins) { if (!coinRecord.visible) { coinRecord.visible = 1; @@ -126,12 +106,10 @@ export async function makeCoinsVisible( } export async function makeCoinAvailable( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - coinAvailability: typeof WalletStoresV1.coinAvailability; - denominations: typeof WalletStoresV1.denominations; - }>, + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["coins", "coinAvailability", "denominations"] + >, coinRecord: CoinRecord, ): Promise<void> { checkLogicInvariant(coinRecord.status === CoinStatus.Fresh); @@ -167,13 +145,16 @@ export async function makeCoinAvailable( } export async function spendCoins( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - coinAvailability: typeof WalletStoresV1.coinAvailability; - refreshGroups: typeof WalletStoresV1.refreshGroups; - denominations: typeof WalletStoresV1.denominations; - }>, + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + [ + "coins", + "coinAvailability", + "refreshGroups", + "refreshSessions", + "denominations", + ] + >, csi: CoinsSpendInfo, ): Promise<void> { if (csi.coinPubs.length != csi.contributions.length) { @@ -188,8 +169,8 @@ export async function spendCoins( if (!coin) { throw Error("coin allocated for payment doesn't exist anymore"); } - const denom = await ws.getDenomInfo( - ws, + const denom = await getDenomInfo( + wex, tx, coin.exchangeBaseUrl, coin.denomPubHash, @@ -250,343 +231,16 @@ export async function spendCoins( await tx.coinAvailability.put(coinAvailability); } - await ws.refreshOps.createRefreshGroup( - ws, + await createRefreshGroup( + wex, tx, Amounts.currencyOf(csi.contributions[0]), refreshCoinPubs, csi.refreshReason, - { - originatingTransactionId: csi.allocationId, - }, + csi.allocationId, ); } -/** - * Convert the task ID for a task that processes a transaction int - * the ID for the transaction. - */ -function convertTaskToTransactionId( - taskId: string, -): TransactionIdStr | undefined { - const parsedTaskId = parseTaskIdentifier(taskId); - switch (parsedTaskId.tag) { - case PendingTaskType.PeerPullCredit: - return constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub: parsedTaskId.pursePub, - }); - case PendingTaskType.PeerPullDebit: - return constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId: parsedTaskId.peerPullDebitId, - }); - // FIXME: This doesn't distinguish internal-withdrawal. - // Maybe we should have a different task type for that as well? - // Or maybe transaction IDs should be valid task identifiers? - case PendingTaskType.Withdraw: - return constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId: parsedTaskId.withdrawalGroupId, - }); - case PendingTaskType.PeerPushCredit: - return constructTransactionIdentifier({ - tag: TransactionType.PeerPushCredit, - peerPushCreditId: parsedTaskId.peerPushCreditId, - }); - case PendingTaskType.Deposit: - return constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId: parsedTaskId.depositGroupId, - }); - case PendingTaskType.Refresh: - return constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId: parsedTaskId.refreshGroupId, - }); - case PendingTaskType.RewardPickup: - return constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: parsedTaskId.walletRewardId, - }); - case PendingTaskType.PeerPushDebit: - return constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub: parsedTaskId.pursePub, - }); - case PendingTaskType.Purchase: - return constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: parsedTaskId.proposalId, - }); - default: - return undefined; - } -} - -async function makeTransactionRetryNotification( - ws: InternalWalletState, - tx: GetReadOnlyAccess<typeof WalletStoresV1>, - pendingTaskId: string, - e: TalerErrorDetail | undefined, -): Promise<WalletNotification | undefined> { - const txId = convertTaskToTransactionId(pendingTaskId); - if (!txId) { - return undefined; - } - const txState = await ws.getTransactionState(ws, tx, txId); - if (!txState) { - return undefined; - } - const notif: WalletNotification = { - type: NotificationType.TransactionStateTransition, - transactionId: txId, - oldTxState: txState, - newTxState: txState, - }; - if (e) { - notif.errorInfo = { - code: e.code as number, - hint: e.hint, - }; - } - return notif; -} - -async function makeExchangeRetryNotification( - ws: InternalWalletState, - tx: GetReadOnlyAccess<typeof WalletStoresV1>, - pendingTaskId: string, - e: TalerErrorDetail | undefined, -): Promise<WalletNotification | undefined> { - logger.info("making exchange retry notification"); - const parsedTaskId = parseTaskIdentifier(pendingTaskId); - if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) { - throw Error("invalid task identifier"); - } - const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl); - - if (!rec) { - logger.info(`exchange ${parsedTaskId.exchangeBaseUrl} not found`); - return undefined; - } - - const notif: WalletNotification = { - type: NotificationType.ExchangeStateTransition, - exchangeBaseUrl: parsedTaskId.exchangeBaseUrl, - oldExchangeState: getExchangeState(rec), - newExchangeState: getExchangeState(rec), - }; - if (e) { - notif.errorInfo = { - code: e.code as number, - hint: e.hint, - }; - } - return notif; -} - -/** - * Generate an appropriate error transition notification - * for applicable tasks. - * - * Namely, transition notifications are generated for: - * - exchange update errors - * - transactions - */ -async function taskToRetryNotification( - ws: InternalWalletState, - tx: GetReadOnlyAccess<typeof WalletStoresV1>, - pendingTaskId: string, - e: TalerErrorDetail | undefined, -): Promise<WalletNotification | undefined> { - const parsedTaskId = parseTaskIdentifier(pendingTaskId); - - switch (parsedTaskId.tag) { - case PendingTaskType.ExchangeUpdate: - return makeExchangeRetryNotification(ws, tx, pendingTaskId, e); - case PendingTaskType.PeerPullCredit: - case PendingTaskType.PeerPullDebit: - case PendingTaskType.Withdraw: - case PendingTaskType.PeerPushCredit: - case PendingTaskType.Deposit: - case PendingTaskType.Refresh: - case PendingTaskType.RewardPickup: - case PendingTaskType.PeerPushDebit: - case PendingTaskType.Purchase: - return makeTransactionRetryNotification(ws, tx, pendingTaskId, e); - case PendingTaskType.Backup: - case PendingTaskType.ExchangeCheckRefresh: - case PendingTaskType.Recoup: - return undefined; - } -} - -async function storePendingTaskError( - ws: InternalWalletState, - pendingTaskId: string, - e: TalerErrorDetail, -): Promise<void> { - logger.info(`storing pending task error for ${pendingTaskId}`); - const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => { - let retryRecord = await tx.operationRetries.get(pendingTaskId); - if (!retryRecord) { - retryRecord = { - id: pendingTaskId, - lastError: e, - retryInfo: DbRetryInfo.reset(), - }; - } else { - retryRecord.lastError = e; - retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); - } - await tx.operationRetries.put(retryRecord); - return taskToRetryNotification(ws, tx, pendingTaskId, e); - }); - if (maybeNotification) { - ws.notify(maybeNotification); - } -} - -export async function resetPendingTaskTimeout( - ws: InternalWalletState, - pendingTaskId: string, -): Promise<void> { - const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => { - let retryRecord = await tx.operationRetries.get(pendingTaskId); - if (retryRecord) { - // Note that we don't reset the lastError, it should still be visible - // while the retry runs. - retryRecord.retryInfo = DbRetryInfo.reset(); - await tx.operationRetries.put(retryRecord); - } - return taskToRetryNotification(ws, tx, pendingTaskId, undefined); - }); - if (maybeNotification) { - ws.notify(maybeNotification); - } -} - -async function storePendingTaskPending( - ws: InternalWalletState, - pendingTaskId: string, -): Promise<void> { - const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => { - let retryRecord = await tx.operationRetries.get(pendingTaskId); - let hadError = false; - if (!retryRecord) { - retryRecord = { - id: pendingTaskId, - retryInfo: DbRetryInfo.reset(), - }; - } else { - if (retryRecord.lastError) { - hadError = true; - } - delete retryRecord.lastError; - retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); - } - await tx.operationRetries.put(retryRecord); - if (hadError) { - return taskToRetryNotification(ws, tx, pendingTaskId, undefined); - } else { - return undefined; - } - }); - if (maybeNotification) { - ws.notify(maybeNotification); - } -} - -async function storePendingTaskFinished( - ws: InternalWalletState, - pendingTaskId: string, -): Promise<void> { - await ws.db - .mktx((x) => [x.operationRetries]) - .runReadWrite(async (tx) => { - await tx.operationRetries.delete(pendingTaskId); - }); -} - -export async function runTaskWithErrorReporting( - ws: InternalWalletState, - opId: TaskId, - f: () => Promise<TaskRunResult>, -): Promise<TaskRunResult> { - let maybeError: TalerErrorDetail | undefined; - try { - const resp = await f(); - switch (resp.type) { - case TaskRunResultType.Error: - await storePendingTaskError(ws, opId, resp.errorDetail); - return resp; - case TaskRunResultType.Finished: - await storePendingTaskFinished(ws, opId); - return resp; - case TaskRunResultType.Pending: - await storePendingTaskPending(ws, opId); - return resp; - case TaskRunResultType.Longpoll: - return resp; - } - } catch (e) { - if (e instanceof CryptoApiStoppedError) { - if (ws.stopped) { - logger.warn("crypto API stopped during shutdown, ignoring error"); - return { - type: TaskRunResultType.Error, - errorDetail: makeErrorDetail( - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - {}, - "Crypto API stopped during shutdown", - ), - }; - } - } - if (e instanceof TalerError) { - logger.warn("operation processed resulted in error"); - logger.warn(`error was: ${j2s(e.errorDetail)}`); - maybeError = e.errorDetail; - await storePendingTaskError(ws, opId, maybeError!); - return { - type: TaskRunResultType.Error, - errorDetail: e.errorDetail, - }; - } else if (e instanceof Error) { - // This is a bug, as we expect pending operations to always - // do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED - // or return something. - logger.error(`Uncaught exception: ${e.message}`); - logger.error(`Stack: ${e.stack}`); - maybeError = makeErrorDetail( - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - { - stack: e.stack, - }, - `unexpected exception (message: ${e.message})`, - ); - await storePendingTaskError(ws, opId, maybeError); - return { - type: TaskRunResultType.Error, - errorDetail: maybeError, - }; - } else { - logger.error("Uncaught exception, value is not even an error."); - maybeError = makeErrorDetail( - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - {}, - `unexpected exception (not even an error)`, - ); - await storePendingTaskError(ws, opId, maybeError); - return { - type: TaskRunResultType.Error, - errorDetail: maybeError, - }; - } - } -} - export enum TombstoneTag { DeleteWithdrawalGroup = "delete-withdrawal-group", DeleteReserve = "delete-reserve", @@ -629,6 +283,8 @@ export function getExchangeUpdateStatusFromRecord( return ExchangeUpdateStatus.ReadyUpdate; case ExchangeEntryDbUpdateStatus.Suspended: return ExchangeUpdateStatus.Suspended; + default: + assertUnreachable(r.updateStatus); } } @@ -642,6 +298,8 @@ export function getExchangeEntryStatusFromRecord( return ExchangeEntryStatus.Preset; case ExchangeEntryDbRecordStatus.Used: return ExchangeEntryStatus.Used; + default: + assertUnreachable(r.entryStatus); } } @@ -657,83 +315,6 @@ export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState { }; } -export function makeExchangeListItem( - r: ExchangeEntryRecord, - exchangeDetails: ExchangeDetailsRecord | undefined, - lastError: TalerErrorDetail | undefined, -): ExchangeListItem { - const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError - ? { - error: lastError, - } - : undefined; - - let scopeInfo: ScopeInfo | undefined = undefined; - if (exchangeDetails) { - // FIXME: Look up actual scope info. - scopeInfo = { - currency: exchangeDetails.currency, - type: ScopeType.Exchange, - url: r.baseUrl, - }; - } - - 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 LongpollResult { - ready: boolean; -} - -export function runLongpollAsync( - ws: InternalWalletState, - retryTag: string, - reqFn: (ct: CancellationToken) => Promise<LongpollResult>, -): void { - const asyncFn = async () => { - if (ws.stopped) { - logger.trace("not long-polling reserve, wallet already stopped"); - await storePendingTaskPending(ws, retryTag); - return; - } - const cts = CancellationToken.create(); - let res: { ready: boolean } | undefined = undefined; - try { - ws.activeLongpoll[retryTag] = { - cancel: () => { - logger.trace("cancel of reserve longpoll requested"); - cts.cancel(); - }, - }; - res = await reqFn(cts.token); - } catch (e) { - const errDetail = getErrorDetailFromException(e); - logger.warn(`got error during long-polling: ${j2s(errDetail)}`); - await storePendingTaskError(ws, retryTag, errDetail); - return; - } finally { - delete ws.activeLongpoll[retryTag]; - } - if (!res.ready) { - await storePendingTaskPending(ws, retryTag); - } - ws.workAvailable.trigger(); - }; - asyncFn(); -} - export type ParsedTombstone = | { tag: TombstoneTag.DeleteWithdrawalGroup; @@ -768,7 +349,7 @@ export function constructTombstone(p: ParsedTombstone): TombstoneIdStr { * Uniform interface for a particular wallet transaction. */ export interface TransactionManager { - get taskId(): TaskId; + get taskId(): TaskIdStr; get transactionId(): TransactionIdStr; fail(): Promise<void>; abort(): Promise<void>; @@ -779,31 +360,64 @@ export interface TransactionManager { export enum TaskRunResultType { Finished = "finished", - Pending = "pending", + Backoff = "backoff", + Progress = "progress", Error = "error", - Longpoll = "longpoll", + LongpollReturnedPending = "longpoll-returned-pending", + ScheduleLater = "schedule-later", } export type TaskRunResult = | TaskRunFinishedResult | TaskRunErrorResult - | TaskRunLongpollResult - | TaskRunPendingResult; + | TaskRunBackoffResult + | TaskRunProgressResult + | TaskRunLongpollReturnedPendingResult + | TaskRunScheduleLaterResult; export namespace TaskRunResult { + /** + * Task is finished and does not need to be processed again. + */ export function finished(): TaskRunResult { return { type: TaskRunResultType.Finished, }; } - export function pending(): TaskRunResult { + /** + * 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.Pending, + type: TaskRunResultType.Backoff, }; } - export function longpoll(): TaskRunResult { + /** + * Task made progress and should be processed again. + */ + export function progress(): TaskRunResult { return { - type: TaskRunResultType.Longpoll, + type: TaskRunResultType.Progress, + }; + } + /** + * Run the task again at a fixed time in the future. + */ + export function runAgainAt(runAt: AbsoluteTime): TaskRunResult { + return { + type: TaskRunResultType.ScheduleLater, + runAt, + }; + } + /** + * Longpolling returned, but what we're waiting for + * is still pending on the other side. + */ + export function longpollReturnedPending(): TaskRunLongpollReturnedPendingResult { + return { + type: TaskRunResultType.LongpollReturnedPending, }; } } @@ -812,8 +426,21 @@ export interface TaskRunFinishedResult { type: TaskRunResultType.Finished; } -export interface TaskRunPendingResult { - type: TaskRunResultType.Pending; +export interface TaskRunBackoffResult { + type: TaskRunResultType.Backoff; +} + +export interface TaskRunProgressResult { + type: TaskRunResultType.Progress; +} + +export interface TaskRunScheduleLaterResult { + type: TaskRunResultType.ScheduleLater; + runAt: AbsoluteTime; +} + +export interface TaskRunLongpollReturnedPendingResult { + type: TaskRunResultType.LongpollReturnedPending; } export interface TaskRunErrorResult { @@ -821,10 +448,6 @@ export interface TaskRunErrorResult { errorDetail: TalerErrorDetail; } -export interface TaskRunLongpollResult { - type: TaskRunResultType.Longpoll; -} - export interface DbRetryInfo { firstTry: DbPreciseTimestamp; nextRetry: DbPreciseTimestamp; @@ -869,25 +492,28 @@ function updateTimeout( 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 computeDbBackoff(retryCounter: number): DbPreciseTimestamp { + const now = AbsoluteTime.now(); + if (now.t_ms === "never") { + throw Error("assertion failed"); + } + const p = defaultRetryPolicy; + if (p.backoffDelta.d_ms === "forever") { + throw Error("assertion failed"); } + const nextIncrement = + p.backoffDelta.d_ms * Math.pow(p.backoffBase, retryCounter); + + const t = + now.t_ms + + (p.maxTimeout.d_ms === "forever" + ? nextIncrement + : Math.min(p.maxTimeout.d_ms, nextIncrement)); + return timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t)); +} + +export namespace DbRetryInfo { export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo { const now = TalerPreciseTimestamp.now(); const info: DbRetryInfo = { @@ -914,6 +540,24 @@ export namespace DbRetryInfo { } /** + * 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 = @@ -924,7 +568,6 @@ export type ParsedTaskIdentifier = | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string } | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string } | { tag: PendingTaskType.Deposit; depositGroupId: string } - | { tag: PendingTaskType.ExchangeCheckRefresh; exchangeBaseUrl: string } | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string } | { tag: PendingTaskType.PeerPullCredit; pursePub: string } | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string } @@ -947,8 +590,6 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) }; case PendingTaskType.Deposit: return { tag: type, depositGroupId: rest[0] }; - case PendingTaskType.ExchangeCheckRefresh: - return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) }; case PendingTaskType.ExchangeUpdate: return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) }; case PendingTaskType.PeerPullCredit: @@ -974,96 +615,209 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { } } -export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskId { +export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr { switch (p.tag) { case PendingTaskType.Backup: - return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId; + return `${p.tag}:${p.backupProviderBaseUrl}` as TaskIdStr; case PendingTaskType.Deposit: - return `${p.tag}:${p.depositGroupId}` as TaskId; - case PendingTaskType.ExchangeCheckRefresh: - return `${p.tag}:${p.exchangeBaseUrl}` as TaskId; + return `${p.tag}:${p.depositGroupId}` as TaskIdStr; case PendingTaskType.ExchangeUpdate: - return `${p.tag}:${p.exchangeBaseUrl}` as TaskId; + return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr; case PendingTaskType.PeerPullDebit: - return `${p.tag}:${p.peerPullDebitId}` as TaskId; + return `${p.tag}:${p.peerPullDebitId}` as TaskIdStr; case PendingTaskType.PeerPushCredit: - return `${p.tag}:${p.peerPushCreditId}` as TaskId; + return `${p.tag}:${p.peerPushCreditId}` as TaskIdStr; case PendingTaskType.PeerPullCredit: - return `${p.tag}:${p.pursePub}` as TaskId; + return `${p.tag}:${p.pursePub}` as TaskIdStr; case PendingTaskType.PeerPushDebit: - return `${p.tag}:${p.pursePub}` as TaskId; + return `${p.tag}:${p.pursePub}` as TaskIdStr; case PendingTaskType.Purchase: - return `${p.tag}:${p.proposalId}` as TaskId; + return `${p.tag}:${p.proposalId}` as TaskIdStr; case PendingTaskType.Recoup: - return `${p.tag}:${p.recoupGroupId}` as TaskId; + return `${p.tag}:${p.recoupGroupId}` as TaskIdStr; case PendingTaskType.Refresh: - return `${p.tag}:${p.refreshGroupId}` as TaskId; + return `${p.tag}:${p.refreshGroupId}` as TaskIdStr; case PendingTaskType.RewardPickup: - return `${p.tag}:${p.walletRewardId}` as TaskId; + return `${p.tag}:${p.walletRewardId}` as TaskIdStr; case PendingTaskType.Withdraw: - return `${p.tag}:${p.withdrawalGroupId}` as TaskId; + return `${p.tag}:${p.withdrawalGroupId}` as TaskIdStr; default: assertUnreachable(p); } } export namespace TaskIdentifiers { - export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId { - return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId; + export function forWithdrawal(wg: WithdrawalGroupRecord): TaskIdStr { + return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskIdStr; } - export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId { + export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskIdStr { return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( exch.baseUrl, - )}` as TaskId; + )}` as TaskIdStr; } - export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId { + export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskIdStr { return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( exchBaseUrl, - )}` as TaskId; - } - export function forExchangeCheckRefresh(exch: ExchangeEntryRecord): TaskId { - return `${PendingTaskType.ExchangeCheckRefresh}:${encodeURIComponent( - exch.baseUrl, - )}` as TaskId; + )}` as TaskIdStr; } - export function forTipPickup(tipRecord: RewardRecord): TaskId { - return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId; + export function forTipPickup(tipRecord: RewardRecord): TaskIdStr { + return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskIdStr; } - export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId { - return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId; + export function forRefresh( + refreshGroupRecord: RefreshGroupRecord, + ): TaskIdStr { + return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskIdStr; } - export function forPay(purchaseRecord: PurchaseRecord): TaskId { - return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId; + export function forPay(purchaseRecord: PurchaseRecord): TaskIdStr { + return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskIdStr; } - export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId { - return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId; + export function forRecoup(recoupRecord: RecoupGroupRecord): TaskIdStr { + return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskIdStr; } - export function forDeposit(depositRecord: DepositGroupRecord): TaskId { - return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId; + export function forDeposit(depositRecord: DepositGroupRecord): TaskIdStr { + return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskIdStr; } - export function forBackup(backupRecord: BackupProviderRecord): TaskId { + export function forBackup(backupRecord: BackupProviderRecord): TaskIdStr { return `${PendingTaskType.Backup}:${encodeURIComponent( backupRecord.baseUrl, - )}` as TaskId; + )}` as TaskIdStr; } export function forPeerPushPaymentInitiation( ppi: PeerPushDebitRecord, - ): TaskId { - return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId; + ): TaskIdStr { + return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskIdStr; } export function forPeerPullPaymentInitiation( ppi: PeerPullCreditRecord, - ): TaskId { - return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId; + ): TaskIdStr { + return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskIdStr; } export function forPeerPullPaymentDebit( ppi: PeerPullPaymentIncomingRecord, - ): TaskId { - return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskId; + ): TaskIdStr { + return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskIdStr; } export function forPeerPushCredit( ppi: PeerPushPaymentIncomingRecord, - ): TaskId { - return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId; + ): TaskIdStr { + return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskIdStr; + } +} + +/** + * Result of a transaction transition. + */ +export enum TransitionResultType { + Transition = 1, + Stay = 2, + Delete = 3, +} + +export type TransitionResult<R> = + | { type: TransitionResultType.Stay } + | { type: TransitionResultType.Transition; rec: R } + | { type: TransitionResultType.Delete }; + +export const TransitionResult = { + stay<T>(): TransitionResult<T> { + return { type: TransitionResultType.Stay }; + }, + delete<T>(): TransitionResult<T> { + return { type: TransitionResultType.Delete }; + }, + transition<T>(rec: T): TransitionResult<T> { + return { + type: TransitionResultType.Transition, + rec, + }; + }, +}; + +/** + * Transaction context. + * Uniform interface to all transactions. + */ +export interface TransactionContext { + get taskId(): TaskIdStr | undefined; + get transactionId(): TransactionIdStr; + abortTransaction(): Promise<void>; + suspendTransaction(): Promise<void>; + resumeTransaction(): Promise<void>; + failTransaction(): Promise<void>; + deleteTransaction(): Promise<void>; +} + +/** + * Type and schema definitions for pending tasks in the wallet. + * + * These are only used internally, and are not part of the stable public + * interface to the wallet. + */ + +export enum PendingTaskType { + ExchangeUpdate = "exchange-update", + Purchase = "purchase", + Refresh = "refresh", + Recoup = "recoup", + RewardPickup = "reward-pickup", + Withdraw = "withdraw", + Deposit = "deposit", + Backup = "backup", + PeerPushDebit = "peer-push-debit", + PeerPullCredit = "peer-pull-credit", + PeerPushCredit = "peer-push-credit", + PeerPullDebit = "peer-pull-debit", +} + +declare const __taskIdStr: unique symbol; +export type TaskIdStr = string & { [__taskIdStr]: true }; + +/** + * Wait until the wallet is in a particular state. + * + * Two functions must be provided: + * 1. checkState, which checks if the wallet is in the + * desired state. + * 2. filterNotification, which checks whether a notification + * might have lead to a state change. + */ +export async function genericWaitForState( + wex: WalletExecutionContext, + args: { + checkState: () => Promise<boolean>; + filterNotification: (notif: WalletNotification) => boolean; + }, +): Promise<void> { + await wex.taskScheduler.ensureRunning(); + + // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. + const flag = new AsyncFlag(); + // Raise purchaseNotifFlag whenever we get a notification + // about our refresh. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if (args.filterNotification(notif)) { + flag.raise(); + } + }); + const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { + cancelNotif(); + flag.raise(); + }); + + try { + while (true) { + if (wex.cancellationToken.isCancelled) { + throw Error("cancelled"); + } + if (await args.checkState()) { + return; + } + // Wait for the next transition + await flag.wait(); + flag.reset(); + } + } catch (e) { + unregisterOnCancelled(); + cancelNotif(); } } diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts index 7c6b142fb..2a2958a71 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -214,6 +214,10 @@ export interface TalerCryptoInterface { signPurseCreation(req: SignPurseCreationRequest): Promise<EddsaSigningResult>; + signReserveHistoryReq( + req: SignReserveHistoryReqRequest, + ): Promise<SignReserveHistoryReqResponse>; + signPurseDeposits( req: SignPurseDepositsRequest, ): Promise<SignPurseDepositsResponse>; @@ -438,6 +442,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignCoinHistoryResponse> { throw new Error("Function not implemented."); }, + signReserveHistoryReq: function ( + req: SignReserveHistoryReqRequest, + ): Promise<SignReserveHistoryReqResponse> { + throw new Error("Function not implemented."); + }, }; export type WithArg<X> = X extends (req: infer T) => infer R @@ -475,6 +484,15 @@ export interface SignPurseCreationRequest { minAge: number; } +export interface SignReserveHistoryReqRequest { + reservePriv: string; + startOffset: number; +} + +export interface SignReserveHistoryReqResponse { + sig: string; +} + export interface SpendCoinDetails { coinPub: string; coinPriv: string; @@ -1134,7 +1152,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { depositInfo.ageCommitmentProof.commitment, ); hAgeCommitment = decodeCrock(ach); - if (depositInfo.requiredMinimumAge != null) { + if (depositInfo.requiredMinimumAge) { minimumAgeSig = encodeCrock( AgeRestriction.commitmentAttest( depositInfo.ageCommitmentProof, @@ -1184,7 +1202,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { }, }; - if (depositInfo.requiredMinimumAge != null) { + if (depositInfo.requiredMinimumAge) { // These are only required by the merchant s.minimum_age_sig = minimumAgeSig; s.age_commitment = @@ -1468,15 +1486,12 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { const hExchangeBaseUrl = hash(stringToBytes(req.exchangeBaseUrl + "\0")); const deposits: PurseDeposit[] = []; for (const c of req.coins) { - let haveAch: boolean; let maybeAch: Uint8Array; if (c.ageCommitmentProof) { - haveAch = true; maybeAch = decodeCrock( AgeRestriction.hashCommitment(c.ageCommitmentProof.commitment), ); } else { - haveAch = false; maybeAch = new Uint8Array(32); } const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT) @@ -1733,6 +1748,23 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { sig: sigResp.sig, }; }, + async signReserveHistoryReq( + tci: TalerCryptoInterfaceR, + req: SignReserveHistoryReqRequest, + ): Promise<SignReserveHistoryReqResponse> { + const reserveHistoryBlob = buildSigPS( + TalerSignaturePurpose.WALLET_RESERVE_HISTORY, + ) + .put(bufferForUint64(req.startOffset)) + .build(); + const sigResp = await tci.eddsaSign(tci, { + msg: encodeCrock(reserveHistoryBlob), + priv: req.reservePriv, + }); + return { + sig: sigResp.sig, + }; + }, }; export interface EddsaSignRequest { diff --git a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts index 192e9cda1..f86163723 100644 --- a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts +++ b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts @@ -23,10 +23,16 @@ /** * Imports. */ -import { j2s, Logger, TalerErrorCode } from "@gnu-taler/taler-util"; -import { TalerError } from "@gnu-taler/taler-util"; -import { openPromise } from "../../util/promiseUtils.js"; -import { timer, performanceNow, TimerHandle } from "../../util/timer.js"; +import { + j2s, + Logger, + openPromise, + performanceNow, + TalerError, + TalerErrorCode, + timer, + TimerHandle, +} from "@gnu-taler/taler-util"; import { nullCrypto, TalerCryptoInterface } from "../cryptoImplementation.js"; import { CryptoWorker } from "./cryptoWorkerInterface.js"; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 6f6aad256..44c241aed 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2022 Taler Systems S.A. + (C) 2021-2024 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -19,7 +19,6 @@ */ import { Event, - GlobalIDB, IDBDatabase, IDBFactory, IDBObjectStore, @@ -34,11 +33,14 @@ import { AmountString, Amounts, AttentionInfo, + BackupProviderTerms, + CancellationToken, Codec, CoinEnvelope, CoinPublicKeyString, CoinRefreshRequest, CoinStatus, + DenomLossEventType, DenomSelectionState, DenominationInfo, DenominationPubKey, @@ -48,24 +50,24 @@ import { ExchangeGlobalFees, HashCodeString, Logger, - PayCoinSelection, RefreshReason, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolDuration, TalerProtocolTimestamp, + Transaction, TransactionIdStr, UnblindedSignature, WireInfo, WithdrawalExchangeAccountDetails, codecForAny, } from "@gnu-taler/taler-util"; -import { DbRetryInfo, TaskIdentifiers } from "./operations/common.js"; +import { DbRetryInfo, TaskIdentifiers } from "./common.js"; import { DbAccess, + DbAccessImpl, DbReadOnlyTransaction, DbReadWriteTransaction, - GetReadWriteAccess, IndexDescriptor, StoreDescriptor, StoreNames, @@ -73,8 +75,9 @@ import { describeContents, describeIndex, describeStore, + describeStoreV2, openDatabase, -} from "./util/query.js"; +} from "./query.js"; /** * This file contains the database schema of the Taler wallet together @@ -148,7 +151,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 1; +export const WALLET_DB_MINOR_VERSION = 10; declare const symDbProtocolTimestamp: unique symbol; @@ -255,6 +258,16 @@ export function timestampOptionalAbsoluteFromDb( */ /** + * First possible operation status in the active range (inclusive). + */ +export const OPERATION_STATUS_ACTIVE_FIRST = 0x0100_0000; + +/** + * LAST possible operation status in the active range (inclusive). + */ +export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_ffff; + +/** * Status of a withdrawal. */ export enum WithdrawalGroupStatus { @@ -285,6 +298,11 @@ export enum WithdrawalGroupStatus { SuspendedReady = 0x0110_0004, /** + * Proposed to the user, has can choose to accept/refuse. + */ + DialogProposed = 0x0101_0000, + + /** * We are telling the bank that we don't want to complete * the withdrawal! */ @@ -325,15 +343,22 @@ export enum WithdrawalGroupStatus { AbortedExchange = 0x0503_0001, AbortedBank = 0x0503_0002, -} -/** - * Status range of nonfinal withdrawal groups. - */ -export const withdrawalGroupNonfinalRange = GlobalIDB.KeyRange.bound( - WithdrawalGroupStatus.PendingRegisteringBank, - WithdrawalGroupStatus.PendingAml, -); + /** + * User didn't refused the withdrawal. + */ + AbortedUserRefused = 0x0503_0003, + + /** + * Another wallet confirmed the withdrawal + * (by POSTing the reserve pub to the bank) + * before we had the chance. + * + * In this situation, we'll let the other wallet continue + * and give up ourselves. + */ + AbortedOtherWallet = 0x0503_0004, +} /** * Extra info about a withdrawal that is used @@ -351,7 +376,7 @@ export interface ReserveBankInfo { /** * Exchange payto URI that the bank will use to fund the reserve. */ - exchangePaytoUri: string; + exchangePaytoUri?: string; /** * Time when the information about this reserve was posted to the bank. @@ -368,6 +393,8 @@ export interface ReserveBankInfo { * Set to undefined if not confirmed yet. */ timestampBankConfirmed: DbPreciseTimestamp | undefined; + + wireTypes: string[] | undefined; } /** @@ -484,6 +511,13 @@ export interface DenominationRecord { isRevoked: boolean; /** + * If set to true, the exchange announced that the private key for this + * denomination is lost. Thus it can't be used to sign new coins + * during withdrawal/refresh/..., but the coins can still be spent. + */ + isLost?: boolean; + + /** * Base URL of the exchange. */ exchangeBaseUrl: string; @@ -493,12 +527,6 @@ export interface DenominationRecord { * on the denomination. */ exchangeMasterPub: string; - - /** - * Latest list issue date of the "/keys" response - * that includes this denomination. - */ - listIssueDate: DbProtocolTimestamp; } export namespace DenominationRecord { @@ -645,6 +673,12 @@ export interface ExchangeEntryRecord { updateStatus: ExchangeEntryDbUpdateStatus; /** + * If set to true, the next update to the exchange + * status will request /keys with no-cache headers set. + */ + cachebreakNextUpdate?: boolean; + + /** * Etag of the current ToS of the exchange. */ tosCurrentEtag: string | undefined; @@ -663,6 +697,8 @@ export interface ExchangeEntryRecord { */ nextUpdateStamp: DbPreciseTimestamp; + updateRetryCounter?: number; + lastKeysEtag: string | undefined; /** @@ -678,12 +714,23 @@ export interface ExchangeEntryRecord { * receiving P2P payments. */ currentMergeReserveRowId?: number; + + /** + * Defaults to false. + */ + peerPaymentsDisabled?: boolean; + + /** + * Defaults to false. + */ + noFees?: boolean; } export enum PlanchetStatus { Pending = 0x0100_0000, KycRequired = 0x0100_0001, WithdrawalDone = 0x0500_000, + AbortedReplaced = 0x0503_0001, } /** @@ -954,6 +1001,7 @@ export enum RewardRecordStatus { DialogAccept = 0x0101_0000, Done = 0x0500_0000, Aborted = 0x0500_0000, + Failed = 0x0501_000, } export enum RefreshCoinStatus { @@ -990,12 +1038,11 @@ export enum DepositElementStatus { RefundFailed = 0x0501_0000, } -/** - * Additional information about the reason of a refresh. - */ -export interface RefreshReasonDetails { - originatingTransactionId?: string; - proposalId?: string; +export interface RefreshGroupPerExchangeInfo { + /** + * (Expected) output once the refresh group succeeded. + */ + outputEffective: AmountString; } /** @@ -1022,10 +1069,7 @@ export interface RefreshGroupRecord { */ reason: RefreshReason; - /** - * Extra information depending on the reason. - */ - reasonDetails?: RefreshReasonDetails; + originatingTransactionId?: string; oldCoinPubs: string[]; @@ -1033,6 +1077,8 @@ export interface RefreshGroupRecord { expectedOutputPerCoin: AmountString[]; + infoPerExchange?: Record<string, RefreshGroupPerExchangeInfo>; + /** * Flag for each coin whether refreshing finished. * If a coin can't be refreshed (remaining value too small), @@ -1137,7 +1183,7 @@ export enum PurchaseStatus { SuspendedQueryingAutoRefund = 0x0110_0004, PendingAcceptRefund = 0x0100_0005, - SuspendedPendingAcceptRefund = 0x0100_0005, + SuspendedPendingAcceptRefund = 0x0110_0005, /** * Proposal downloaded, but the user needs to accept/reject it. @@ -1161,6 +1207,13 @@ export enum PurchaseStatus { FailedClaim = 0x0501_0000, /** + * Tried to abort, but aborting failed or was cancelled. + */ + FailedAbort = 0x0501_0001, + + FailedPaidByOther = 0x0501_0002, + + /** * Payment was successful. */ Done = 0x0500_0000, @@ -1175,12 +1228,9 @@ export enum PurchaseStatus { */ AbortedIncompletePayment = 0x0503_0000, - /** - * Tried to abort, but aborting failed or was cancelled. - */ - FailedAbort = 0x0501_0001, + AbortedRefunded = 0x0503_0001, - AbortedRefunded = 0x0503_0000, + AbortedOrderDeleted = 0x0503_0002, } /** @@ -1195,10 +1245,21 @@ export interface ProposalDownloadInfo { contractTermsMerchantSig: string; } +export interface DbCoinSelection { + coinPubs: string[]; + coinContributions: AmountString[]; +} + export interface PurchasePayInfo { - payCoinSelection: PayCoinSelection; + /** + * Undefined if payment is blocked by a pending refund. + */ + payCoinSelection?: DbCoinSelection; + /** + * Undefined if payment is blocked by a pending refund. + */ + payCoinSelectionUid?: string; totalPayCost: AmountString; - payCoinSelectionUid: string; } /** @@ -1278,8 +1339,9 @@ export interface PurchaseRecord { posConfirmation: string | undefined; /** - * This purchase was created by sharing nonce or - * did the wallet made the nonce public + * This purchase was created by reading + * a payment share or the wallet + * the nonce public by a payment share */ shared: boolean; @@ -1321,6 +1383,9 @@ export enum ConfigRecordKey { WalletBackupState = "walletBackupState", CurrencyDefaultsApplied = "currencyDefaultsApplied", DevMode = "devMode", + // Only for testing, do not use! + TestLoopTx = "testTxLoop", + LastInitInfo = "lastInitInfo", } /** @@ -1333,7 +1398,8 @@ export type ConfigRecord = value: WalletBackupConfState; } | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean } - | { key: ConfigRecordKey.DevMode; value: boolean }; + | { key: ConfigRecordKey.TestLoopTx; value: number } + | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp }; export interface WalletBackupConfState { deviceId: string; @@ -1498,7 +1564,7 @@ export interface WithdrawalGroupRecord { /** * Amount that was sent by the user to fund the reserve. */ - instructedAmount: AmountString; + instructedAmount?: AmountString; /** * Amount that was observed when querying the reserve that @@ -1515,7 +1581,7 @@ export interface WithdrawalGroupRecord { * (Initial amount confirmed by the user, might differ with denomSel * on reselection.) */ - rawWithdrawalAmount: AmountString; + rawWithdrawalAmount?: AmountString; /** * Amount that will be added to the balance when the withdrawal succeeds. @@ -1523,12 +1589,12 @@ export interface WithdrawalGroupRecord { * (Initial amount confirmed by the user, might differ with denomSel * on reselection.) */ - effectiveWithdrawalAmount: AmountString; + effectiveWithdrawalAmount?: AmountString; /** * Denominations selected for withdrawal. */ - denomsSel: DenomSelectionState; + denomsSel?: DenomSelectionState; /** * UID of the denomination selection. @@ -1552,6 +1618,14 @@ export interface BankWithdrawUriRecord { reservePub: string; } +export enum RecoupOperationStatus { + Pending = 0x0100_0000, + Suspended = 0x0110_0000, + + Finished = 0x0500_000, + Failed = 0x0501_000, +} + /** * Status of recoup operations that were grouped together. * @@ -1566,6 +1640,8 @@ export interface RecoupGroupRecord { exchangeBaseUrl: string; + operationStatus: RecoupOperationStatus; + timestampStarted: DbPreciseTimestamp; timestampFinished: DbPreciseTimestamp | undefined; @@ -1608,12 +1684,6 @@ export type BackupProviderState = tag: BackupProviderStateTag.Retrying; }; -export interface BackupProviderTerms { - supportedProtocolVersion: string; - annualFee: AmountString; - storageLimitInMegabytes: number; -} - export interface BackupProviderRecord { /** * Base URL of the provider. @@ -1694,11 +1764,6 @@ export enum DepositOperationStatus { Aborted = 0x0503_0000, } -export const depositOperationNonfinalStatusRange = GlobalIDB.KeyRange.bound( - DepositOperationStatus.PendingDeposit, - DepositOperationStatus.PendingKyc, -); - export interface DepositTrackingInfo { // Raw wire transfer identifier of the deposit. wireTransferId: string; @@ -1712,6 +1777,14 @@ export interface DepositTrackingInfo { exchangePub: string; } +export interface DepositInfoPerExchange { + /** + * Expected effective amount that will be deposited + * from coins of this exchange. + */ + amountEffective: AmountString; +} + /** * Group of deposits made by the wallet. */ @@ -1744,9 +1817,9 @@ export interface DepositGroupRecord { contractTermsHash: string; - payCoinSelection: PayCoinSelection; + payCoinSelection?: DbCoinSelection; - payCoinSelectionUid: string; + payCoinSelectionUid?: string; totalPayCost: AmountString; @@ -1761,7 +1834,9 @@ export interface DepositGroupRecord { operationStatus: DepositOperationStatus; - statusPerCoin: DepositElementStatus[]; + statusPerCoin?: DepositElementStatus[]; + + infoPerExchange?: Record<string, DepositInfoPerExchange>; /** * When the deposit transaction was aborted and @@ -1820,7 +1895,7 @@ export enum PeerPushDebitStatus { Expired = 0x0502_0000, } -export interface PeerPushPaymentCoinSelection { +export interface DbPeerPushPaymentCoinSelection { contributions: AmountString[]; coinPubs: CoinPublicKeyString[]; } @@ -1841,7 +1916,7 @@ export interface PeerPushDebitRecord { totalCost: AmountString; - coinSel: PeerPushPaymentCoinSelection; + coinSel?: DbPeerPushPaymentCoinSelection; contractTermsHash: HashCodeString; @@ -2153,6 +2228,11 @@ export interface CoinAvailabilityRecord { * a final state. */ visibleCoinCount: number; + + /** + * Number of coins that we expect to obtain via a pending refresh. + */ + pendingRefreshOutputCount?: number; } export interface ContractTermsRecord { @@ -2193,26 +2273,12 @@ export interface DbAuditorHandle { auditorPub: string; } -// Work in progress for regional currencies -export interface CurrencySettingsRecord { - currency: string; - - globalScopeExchanges: DbExchangeHandle[]; - - globalScopeAuditors: DbAuditorHandle[]; - - // Used to decide which auditor to show the currency under - // when multiple auditors apply. - auditorPriority: string[]; - - // Later, we might add stuff related to how the currency is rendered. -} - export enum RefundGroupStatus { Pending = 0x0100_0000, Done = 0x0500_0000, Failed = 0x0501_0000, Aborted = 0x0503_0000, + Expired = 0x0502_0000, } /** @@ -2294,18 +2360,128 @@ export function passthroughCodec<T>(): Codec<T> { return codecForAny(); } +export interface GlobalCurrencyAuditorRecord { + id?: number; + currency: string; + auditorBaseUrl: string; + auditorPub: string; +} + +export interface GlobalCurrencyExchangeRecord { + id?: number; + currency: string; + exchangeBaseUrl: string; + exchangeMasterPub: string; +} + +/** + * Primary key: transactionItem.transactionId + */ +export interface TransactionRecord { + /** + * Transaction item returned to the client. + */ + transactionItem: Transaction; + + /** + * Exchanges involved in the transaction. + */ + exchanges: string[]; + + currency: string; +} + +export enum DenomLossStatus { + /** + * Done indicates that the loss happened. + */ + Done = 0x0500_0000, + + /** + * Aborted in the sense that the loss was reversed. + */ + Aborted = 0x0503_0001, +} + +export interface DenomLossEventRecord { + denomLossEventId: string; + currency: string; + denomPubHashes: string[]; + status: DenomLossStatus; + timestampCreated: DbPreciseTimestamp; + amount: string; + eventType: DenomLossEventType; + exchangeBaseUrl: string; +} + /** * Schema definition for the IndexedDB * wallet database. */ export const WalletStoresV1 = { - currencySettings: describeStore( - "currencySettings", - describeContents<CurrencySettingsRecord>({ - keyPath: ["currency"], - }), - {}, - ), + denomLossEvents: describeStoreV2({ + recordCodec: passthroughCodec<DenomLossEventRecord>(), + storeName: "denomLossEvents", + keyPath: "denomLossEventId", + versionAdded: 9, + indexes: { + byCurrency: describeIndex("byCurrency", "currency", { + versionAdded: 9, + }), + byStatus: describeIndex("byStatus", "status", { + versionAdded: 10, + }), + }, + }), + transactions: describeStoreV2({ + recordCodec: passthroughCodec<TransactionRecord>(), + storeName: "transactions", + keyPath: "transactionItem.transactionId", + versionAdded: 7, + indexes: { + byCurrency: describeIndex("byCurrency", "currency", { + versionAdded: 7, + }), + byExchange: describeIndex("byExchange", "exchanges", { + versionAdded: 7, + multiEntry: true, + }), + }, + }), + globalCurrencyAuditors: describeStoreV2({ + recordCodec: passthroughCodec<GlobalCurrencyAuditorRecord>(), + storeName: "globalCurrencyAuditors", + keyPath: "id", + autoIncrement: true, + versionAdded: 3, + indexes: { + byCurrencyAndUrlAndPub: describeIndex( + "byCurrencyAndUrlAndPub", + ["currency", "auditorBaseUrl", "auditorPub"], + { + unique: true, + versionAdded: 4, + }, + ), + }, + }), + globalCurrencyExchanges: describeStoreV2({ + recordCodec: passthroughCodec<GlobalCurrencyExchangeRecord>(), + storeName: "globalCurrencyExchanges", + keyPath: "id", + autoIncrement: true, + versionAdded: 3, + indexes: { + byCurrencyAndUrlAndPub: describeIndex( + "byCurrencyAndUrlAndPub", + ["currency", "exchangeBaseUrl", "exchangeMasterPub"], + { + unique: true, + versionAdded: 4, + }, + ), + }, + }), coinAvailability: describeStore( "coinAvailability", describeContents<CoinAvailabilityRecord>({ @@ -2317,6 +2493,9 @@ export const WalletStoresV1 = { "maxAge", "freshCoinCount", ]), + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 8, + }), }, ), coins: describeStore( @@ -2379,6 +2558,9 @@ export const WalletStoresV1 = { autoIncrement: true, }), { + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 2, + }), byPointer: describeIndex( "byDetailsPointer", ["exchangeBaseUrl", "currency", "masterPublicKey"], @@ -2406,6 +2588,13 @@ export const WalletStoresV1 = { }), { byStatus: describeIndex("byStatus", "operationStatus"), + byOriginatingTransactionId: describeIndex( + "byOriginatingTransactionId", + "originatingTransactionId", + { + versionAdded: 5, + }, + ), }, ), refreshSessions: describeStore( @@ -2420,7 +2609,11 @@ export const WalletStoresV1 = { describeContents<RecoupGroupRecord>({ keyPath: "recoupGroupId", }), - {}, + { + byStatus: describeIndex("byStatus", "operationStatus", { + versionAdded: 6, + }), + }, ), purchases: describeStore( "purchases", @@ -2457,6 +2650,9 @@ export const WalletStoresV1 = { }), { byStatus: describeIndex("byStatus", "status"), + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 2, + }), byTalerWithdrawUri: describeIndex( "byTalerWithdrawUri", "wgInfo.bankInfo.talerWithdrawUri", @@ -2483,7 +2679,9 @@ export const WalletStoresV1 = { describeContents<BankWithdrawUriRecord>({ keyPath: "talerWithdrawUri", }), - {}, + { + byGroup: describeIndex("byGroup", "withdrawalGroupId"), + }, ), backupProviders: describeStore( "backupProviders", @@ -2631,6 +2829,7 @@ export const WalletStoresV1 = { "coinPub", "rtxid", ]), + // FIXME: Why is this a list of index keys? Confusing! byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]), }, ), @@ -2643,13 +2842,23 @@ export const WalletStoresV1 = { ), }; -export type WalletDbReadOnlyTransaction< - Stores extends StoreNames<typeof WalletStoresV1> & string, -> = DbReadOnlyTransaction<typeof WalletStoresV1, Stores>; +export type WalletDbStoresArr = Array<StoreNames<typeof WalletStoresV1>>; -export type WalletDbReadWriteTransaction< - Stores extends StoreNames<typeof WalletStoresV1> & string, -> = DbReadWriteTransaction<typeof WalletStoresV1, Stores>; +export type WalletDbReadWriteTransaction<StoresArr extends WalletDbStoresArr> = + DbReadWriteTransaction<typeof WalletStoresV1, StoresArr>; + +export type WalletDbReadOnlyTransaction<StoresArr extends WalletDbStoresArr> = + DbReadOnlyTransaction<typeof WalletStoresV1, StoresArr>; + +export type WalletDbAllStoresReadOnlyTransaction<> = DbReadOnlyTransaction< + typeof WalletStoresV1, + WalletDbStoresArr +>; + +export type WalletDbAllStoresReadWriteTransaction<> = DbReadWriteTransaction< + typeof WalletStoresV1, + WalletDbStoresArr +>; /** * An applied migration. @@ -2859,7 +3068,12 @@ export async function importDb(db: IDBDatabase, dumpJson: any): Promise<void> { export interface FixupDescription { name: string; - fn(tx: GetReadWriteAccess<typeof WalletStoresV1>): Promise<void>; + fn( + tx: DbReadWriteTransaction< + typeof WalletStoresV1, + Array<StoreNames<typeof WalletStoresV1>> + >, + ): Promise<void>; } /** @@ -2873,7 +3087,7 @@ export async function applyFixups( db: DbAccess<typeof WalletStoresV1>, ): Promise<void> { logger.trace("applying fixups"); - await db.mktxAll().runReadWrite(async (tx) => { + await db.runAllStoresReadWriteTx({}, async (tx) => { for (const fixupInstruction of walletDbFixups) { logger.trace(`checking fixup ${fixupInstruction.name}`); const fixupRecord = await tx.fixups.get(fixupInstruction.name); @@ -2947,8 +3161,10 @@ function upgradeFromStoreMap( }); } catch (e) { const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : ""; - throw Error( + throw new Error( `Migration failed. Could not create store ${swi.storeName}.${moreInfo}`, + // @ts-expect-error no support for options.cause yet + { cause: e }, ); } } @@ -2975,6 +3191,8 @@ function upgradeFromStoreMap( const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : ""; throw Error( `Migration failed. Could not create index ${indexDesc.name}/${indexDesc.keyPath}. ${moreInfo}`, + // @ts-expect-error no support for options.cause yet + { cause: e }, ); } } @@ -3076,7 +3294,12 @@ export async function openStoredBackupsDatabase( onStoredBackupsDbUpgradeNeeded, ); - const handle = new DbAccess(backupsDbHandle, StoredBackupStores); + const handle = new DbAccessImpl( + backupsDbHandle, + StoredBackupStores, + {}, + CancellationToken.CONTINUE, + ); return handle; } @@ -3090,7 +3313,7 @@ export async function openStoredBackupsDatabase( export async function openTalerDatabase( idbFactory: IDBFactory, onVersionChange: () => void, -): Promise<DbAccess<typeof WalletStoresV1>> { +): Promise<IDBDatabase> { const metaDbHandle = await openDatabase( idbFactory, TALER_WALLET_META_DB_NAME, @@ -3099,22 +3322,25 @@ export async function openTalerDatabase( onMetaDbUpgradeNeeded, ); - const metaDb = new DbAccess(metaDbHandle, walletMetadataStore); + const metaDb = new DbAccessImpl( + metaDbHandle, + walletMetadataStore, + {}, + CancellationToken.CONTINUE, + ); let currentMainVersion: string | undefined; - await metaDb - .mktx((stores) => [stores.metaConfig]) - .runReadWrite(async (tx) => { - const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); - if (!dbVersionRecord) { - currentMainVersion = TALER_WALLET_MAIN_DB_NAME; - await tx.metaConfig.put({ - key: CURRENT_DB_CONFIG_KEY, - value: TALER_WALLET_MAIN_DB_NAME, - }); - } else { - currentMainVersion = dbVersionRecord.value; - } - }); + await metaDb.runReadWriteTx({ storeNames: ["metaConfig"] }, async (tx) => { + const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); + if (!dbVersionRecord) { + currentMainVersion = TALER_WALLET_MAIN_DB_NAME; + await tx.metaConfig.put({ + key: CURRENT_DB_CONFIG_KEY, + value: TALER_WALLET_MAIN_DB_NAME, + }); + } else { + currentMainVersion = dbVersionRecord.value; + } + }); if (currentMainVersion !== TALER_WALLET_MAIN_DB_NAME) { switch (currentMainVersion) { @@ -3128,14 +3354,15 @@ export async function openTalerDatabase( case "taler-wallet-main-v9": // We consider this a pre-release // development version, no migration is done. - await metaDb - .mktx((stores) => [stores.metaConfig]) - .runReadWrite(async (tx) => { + await metaDb.runReadWriteTx( + { storeNames: ["metaConfig"] }, + async (tx) => { await tx.metaConfig.put({ key: CURRENT_DB_CONFIG_KEY, value: TALER_WALLET_MAIN_DB_NAME, }); - }); + }, + ); break; default: throw Error( @@ -3152,11 +3379,15 @@ export async function openTalerDatabase( onTalerDbUpgradeNeeded, ); - const handle = new DbAccess(mainDbHandle, WalletStoresV1); - - await applyFixups(handle); + const mainDbAccess = new DbAccessImpl( + mainDbHandle, + WalletStoresV1, + {}, + CancellationToken.CONTINUE, + ); + await applyFixups(mainDbAccess); - return handle; + return mainDbHandle; } export async function deleteTalerDatabase( diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index e841d1d20..d3085ecb4 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -29,29 +29,26 @@ import { AbsoluteTime, AgeRestriction, AmountJson, - Amounts, AmountString, + Amounts, + DenominationPubKey, + ExchangeBatchDepositRequest, + ExchangeBatchWithdrawRequest, + ExchangeMeltRequest, + ExchangeProtocolVersion, + Logger, TalerCorebankApiClient, + UnblindedSignature, codecForAny, codecForBankWithdrawalOperationPostResponse, codecForBatchDepositSuccess, codecForExchangeMeltResponse, codecForExchangeRevealResponse, - codecForWithdrawResponse, - DenominationPubKey, + codecForExchangeWithdrawBatchResponse, encodeCrock, - ExchangeBatchDepositRequest, - ExchangeMeltRequest, - ExchangeProtocolVersion, - ExchangeWithdrawRequest, getRandomBytes, hashWire, - Logger, parsePaytoUri, - UnblindedSignature, - ExchangeBatchWithdrawRequest, - ExchangeWithdrawBatchResponse, - codecForExchangeWithdrawBatchResponse, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, @@ -59,16 +56,12 @@ import { } from "@gnu-taler/taler-util/http"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { DenominationRecord } from "./db.js"; -import { - ExchangeInfo, - ExchangeKeysDownloadResult, - isWithdrawableDenom, -} from "./index.js"; -import { assembleRefreshRevealRequest } from "./operations/refresh.js"; -import { - getBankStatusUrl, - getBankWithdrawalInfo, -} from "./operations/withdraw.js"; +import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js"; +import { assembleRefreshRevealRequest } from "./refresh.js"; +import { isWithdrawableDenom } from "./denominations.js"; +import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js"; + +export { downloadExchangeInfo }; const logger = new Logger("dbless.ts"); @@ -106,13 +99,13 @@ export async function checkReserve( if (longpollTimeoutMs) { reqUrl.searchParams.set("timeout_ms", `${longpollTimeoutMs}`); } - const resp = await http.get(reqUrl.href); + const resp = await http.fetch(reqUrl.href, { method: "GET" }); if (resp.status !== 200) { throw new Error("reserve not okay"); } } -export interface TopupReserveWithDemobankArgs { +export interface TopupReserveWithBankArgs { http: HttpRequestLibrary; reservePub: string; corebankApiBaseUrl: string; @@ -120,9 +113,7 @@ export interface TopupReserveWithDemobankArgs { amount: AmountString; } -export async function topupReserveWithDemobank( - args: TopupReserveWithDemobankArgs, -) { +export async function topupReserveWithBank(args: TopupReserveWithBankArgs) { const { http, corebankApiBaseUrl, amount, exchangeInfo, reservePub } = args; const bankClient = new TalerCorebankApiClient(corebankApiBaseUrl); const bankUser = await bankClient.createRandomBankUser(); @@ -140,9 +131,12 @@ export async function topupReserveWithDemobank( if (plainPaytoUris.length <= 0) { throw new Error(); } - const httpResp = await http.postJson(bankStatusUrl, { - reserve_pub: reservePub, - selected_exchange: plainPaytoUris[0], + const httpResp = await http.fetch(bankStatusUrl, { + method: "POST", + body: { + reserve_pub: reservePub, + selected_exchange: plainPaytoUris[0], + }, }); await readSuccessResponseJsonOrThrow( httpResp, @@ -245,7 +239,7 @@ export async function depositCoin(args: { }): Promise<void> { const { coin, http, cryptoApi } = args; const depositPayto = - args.depositPayto ?? "payto://x-taler-bank/localhost/foo"; + args.depositPayto ?? "payto://x-taler-bank/localhost/foo?receiver-name=foo"; const wireSalt = args.wireSalt ?? encodeCrock(getRandomBytes(16)); const timestampNow = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now()); const contractTermsHash = @@ -369,7 +363,10 @@ export async function refreshCoin(req: { oldCoin.exchangeBaseUrl, ); - const revealResp = await http.postJson(reqUrl.href, revealRequest); + const revealResp = await http.fetch(reqUrl.href, { + method: "POST", + body: revealRequest, + }); logger.info("requesting reveal done"); diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts new file mode 100644 index 000000000..ecc1fa881 --- /dev/null +++ b/packages/taler-wallet-core/src/denomSelection.ts @@ -0,0 +1,199 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Selection of denominations for withdrawals. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AmountJson, + Amounts, + DenomSelectionState, + ForcedDenomSel, + Logger, +} from "@gnu-taler/taler-util"; +import { DenominationRecord, timestampAbsoluteFromDb } from "./db.js"; +import { isWithdrawableDenom } from "./denominations.js"; + +const logger = new Logger("denomSelection.ts"); + +/** + * Get a list of denominations (with repetitions possible) + * whose total value is as close as possible to the available + * amount, but never larger. + */ +export function selectWithdrawalDenominations( + amountAvailable: AmountJson, + denoms: DenominationRecord[], + denomselAllowLate: boolean = false, +): DenomSelectionState { + let remaining = Amounts.copy(amountAvailable); + + const selectedDenoms: { + count: number; + denomPubHash: string; + }[] = []; + + let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency); + let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency); + let earliestDepositExpiration: AbsoluteTime | undefined; + let hasDenomWithAgeRestriction = false; + + denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate)); + denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); + + if (logger.shouldLogTrace()) { + logger.trace( + `selecting withdrawal denoms for ${Amounts.stringify(amountAvailable)}`, + ); + } + + for (const d of denoms) { + const cost = Amounts.add(d.value, d.fees.feeWithdraw).amount; + const res = Amounts.divmod(remaining, cost); + const count = res.quotient; + remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount; + if (count > 0) { + totalCoinValue = Amounts.add( + totalCoinValue, + Amounts.mult(d.value, count).amount, + ).amount; + totalWithdrawCost = Amounts.add( + totalWithdrawCost, + Amounts.mult(cost, count).amount, + ).amount; + selectedDenoms.push({ + count, + denomPubHash: d.denomPubHash, + }); + hasDenomWithAgeRestriction = + hasDenomWithAgeRestriction || d.denomPub.age_mask > 0; + const expireDeposit = timestampAbsoluteFromDb(d.stampExpireDeposit); + if (!earliestDepositExpiration) { + earliestDepositExpiration = expireDeposit; + } else { + earliestDepositExpiration = AbsoluteTime.min( + expireDeposit, + earliestDepositExpiration, + ); + } + } + + if (logger.shouldLogTrace()) { + logger.trace( + `denom_pub_hash=${ + d.denomPubHash + }, count=${count}, val=${Amounts.stringify( + d.value, + )}, wdfee=${Amounts.stringify(d.fees.feeWithdraw)}`, + ); + } + + if (Amounts.isZero(remaining)) { + break; + } + } + + if (logger.shouldLogTrace()) { + logger.trace("(end of denom selection)"); + } + + earliestDepositExpiration ??= AbsoluteTime.never(); + + return { + selectedDenoms, + totalCoinValue: Amounts.stringify(totalCoinValue), + totalWithdrawCost: Amounts.stringify(totalWithdrawCost), + earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp( + earliestDepositExpiration, + ), + hasDenomWithAgeRestriction, + }; +} + +export function selectForcedWithdrawalDenominations( + amountAvailable: AmountJson, + denoms: DenominationRecord[], + forcedDenomSel: ForcedDenomSel, + denomselAllowLate: boolean, +): DenomSelectionState { + const selectedDenoms: { + count: number; + denomPubHash: string; + }[] = []; + + let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency); + let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency); + let earliestDepositExpiration: AbsoluteTime | undefined; + let hasDenomWithAgeRestriction = false; + + denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate)); + denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); + + for (const fds of forcedDenomSel.denoms) { + const count = fds.count; + const denom = denoms.find((x) => { + return Amounts.cmp(x.value, fds.value) == 0; + }); + if (!denom) { + throw Error( + `unable to find denom for forced selection (value ${fds.value})`, + ); + } + const cost = Amounts.add(denom.value, denom.fees.feeWithdraw).amount; + totalCoinValue = Amounts.add( + totalCoinValue, + Amounts.mult(denom.value, count).amount, + ).amount; + totalWithdrawCost = Amounts.add( + totalWithdrawCost, + Amounts.mult(cost, count).amount, + ).amount; + selectedDenoms.push({ + count, + denomPubHash: denom.denomPubHash, + }); + hasDenomWithAgeRestriction = + hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0; + const expireDeposit = timestampAbsoluteFromDb(denom.stampExpireDeposit); + if (!earliestDepositExpiration) { + earliestDepositExpiration = expireDeposit; + } else { + earliestDepositExpiration = AbsoluteTime.min( + expireDeposit, + earliestDepositExpiration, + ); + } + } + + earliestDepositExpiration ??= AbsoluteTime.never(); + + return { + selectedDenoms, + totalCoinValue: Amounts.stringify(totalCoinValue), + totalWithdrawCost: Amounts.stringify(totalWithdrawCost), + earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp( + earliestDepositExpiration, + ), + hasDenomWithAgeRestriction, + }; +} diff --git a/packages/taler-wallet-core/src/util/denominations.test.ts b/packages/taler-wallet-core/src/denominations.test.ts index 98af5d1a4..98af5d1a4 100644 --- a/packages/taler-wallet-core/src/util/denominations.test.ts +++ b/packages/taler-wallet-core/src/denominations.test.ts diff --git a/packages/taler-wallet-core/src/util/denominations.ts b/packages/taler-wallet-core/src/denominations.ts index db6e69956..d41307d5d 100644 --- a/packages/taler-wallet-core/src/util/denominations.ts +++ b/packages/taler-wallet-core/src/denominations.ts @@ -14,6 +14,9 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +/** + * Imports. + */ import { AbsoluteTime, AmountJson, @@ -21,14 +24,12 @@ import { AmountString, DenominationInfo, Duration, - durationFromSpec, FeeDescription, FeeDescriptionPair, TalerProtocolTimestamp, TimePoint, } from "@gnu-taler/taler-util"; -import { DenominationRecord } from "../db.js"; -import { timestampProtocolFromDb } from "../index.js"; +import { DenominationRecord, timestampProtocolFromDb } from "./db.js"; /** * Given a list of denominations with the same value and same period of time: @@ -469,10 +470,10 @@ export function isWithdrawableDenom( } else { lastPossibleWithdraw = AbsoluteTime.subtractDuraction( withdrawExpire, - durationFromSpec({ minutes: 5 }), + Duration.fromSpec({ minutes: 5 }), ); } const remaining = Duration.getRemaining(lastPossibleWithdraw, now); const stillOkay = remaining.d_ms !== 0; - return started && stillOkay && !d.isRevoked && d.isOffered; + return started && stillOkay && !d.isRevoked && d.isOffered && !d.isLost; } diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index 8205b7583..c4cd98d73 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -15,6 +15,10 @@ */ /** + * Implementation of the deposit transaction. + */ + +/** * Imports. */ import { @@ -29,32 +33,36 @@ import { DepositGroupFees, Duration, ExchangeBatchDepositRequest, + ExchangeHandle, ExchangeRefundRequest, HttpStatusCode, Logger, MerchantContractTerms, NotificationType, - PayCoinSelection, PrepareDepositRequest, PrepareDepositResponse, RefreshReason, + SelectedProspectiveCoin, TalerError, TalerErrorCode, TalerPreciseTimestamp, TalerProtocolTimestamp, TrackTransaction, TransactionAction, + TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, URL, WireFee, + assertUnreachable, canonicalJson, + checkDbInvariant, + checkLogicInvariant, codecForBatchDepositSuccess, codecForTackTransactionAccepted, codecForTackTransactionWired, - durationFromSpec, encodeCrock, getRandomBytes, hashTruncate32, @@ -64,49 +72,248 @@ import { stringToBytes, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { DepositElementStatus, DepositGroupRecord } from "../db.js"; +import { selectPayCoins } from "./coinSelection.js"; import { - DepositOperationStatus, - DepositTrackingInfo, - KycPendingInfo, PendingTaskType, - RefreshOperationStatus, - createRefreshGroup, - getCandidateWithdrawalDenomsTx, - getTotalRefreshCost, - timestampPreciseToDb, - timestampProtocolFromDb, - 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 { + TaskIdStr, TaskRunResult, TombstoneTag, + TransactionContext, constructTaskIdentifier, - runLongpollAsync, spendCoins, } from "./common.js"; -import { getExchangeDetails } from "./exchanges.js"; +import { + DepositElementStatus, + DepositGroupRecord, + DepositInfoPerExchange, + DepositOperationStatus, + DepositTrackingInfo, + KycPendingInfo, + RefreshOperationStatus, + timestampPreciseToDb, + timestampProtocolToDb, +} from "./db.js"; +import { getExchangeWireDetailsInTx } from "./exchanges.js"; import { extractContractData, generateDepositPermissions, getTotalPaymentCost, } from "./pay-merchant.js"; import { + CreateRefreshGroupResult, + createRefreshGroup, + getTotalRefreshCost, +} from "./refresh.js"; +import { constructTransactionIdentifier, notifyTransition, parseTransactionIdentifier, - stopLongpolling, } from "./transactions.js"; +import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; /** * Logger. */ const logger = new Logger("deposits.ts"); +export class DepositTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + public depositGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Deposit, + depositGroupId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.Deposit, + depositGroupId, + }); + } + + async deleteTransaction(): Promise<void> { + const depositGroupId = this.depositGroupId; + const ws = this.wex; + // FIXME: We should check first if we are in a final state + // where deletion is allowed. + await ws.db.runReadWriteTx( + { storeNames: ["depositGroups", "tombstones"] }, + async (tx) => { + const tipRecord = await tx.depositGroups.get(depositGroupId); + if (tipRecord) { + await tx.depositGroups.delete(depositGroupId); + await tx.tombstones.put({ + id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId, + }); + } + }, + ); + return; + } + + async suspendTransaction(): Promise<void> { + const { wex, depositGroupId, transactionId, taskId: retryTag } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + logger.warn( + `can't suspend deposit group, depositGroupId=${depositGroupId} not found`, + ); + return undefined; + } + const oldState = computeDepositTransactionStatus(dg); + let newOpStatus: DepositOperationStatus | undefined; + switch (dg.operationStatus) { + case DepositOperationStatus.PendingDeposit: + newOpStatus = DepositOperationStatus.SuspendedDeposit; + break; + case DepositOperationStatus.PendingKyc: + newOpStatus = DepositOperationStatus.SuspendedKyc; + break; + case DepositOperationStatus.PendingTrack: + newOpStatus = DepositOperationStatus.SuspendedTrack; + break; + case DepositOperationStatus.Aborting: + newOpStatus = DepositOperationStatus.SuspendedAborting; + break; + } + if (!newOpStatus) { + return undefined; + } + dg.operationStatus = newOpStatus; + await tx.depositGroups.put(dg); + return { + oldTxState: oldState, + newTxState: computeDepositTransactionStatus(dg), + }; + }, + ); + wex.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { wex, depositGroupId, transactionId, taskId: retryTag } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + logger.warn( + `can't suspend deposit group, depositGroupId=${depositGroupId} not found`, + ); + return undefined; + } + const oldState = computeDepositTransactionStatus(dg); + switch (dg.operationStatus) { + case DepositOperationStatus.Finished: + return undefined; + case DepositOperationStatus.PendingDeposit: + case DepositOperationStatus.SuspendedDeposit: { + dg.operationStatus = DepositOperationStatus.Aborting; + await tx.depositGroups.put(dg); + return { + oldTxState: oldState, + newTxState: computeDepositTransactionStatus(dg), + }; + } + } + return undefined; + }, + ); + wex.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + } + + async resumeTransaction(): Promise<void> { + const { wex, depositGroupId, transactionId, taskId: retryTag } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + logger.warn( + `can't resume deposit group, depositGroupId=${depositGroupId} not found`, + ); + return; + } + const oldState = computeDepositTransactionStatus(dg); + let newOpStatus: DepositOperationStatus | undefined; + switch (dg.operationStatus) { + case DepositOperationStatus.SuspendedDeposit: + newOpStatus = DepositOperationStatus.PendingDeposit; + break; + case DepositOperationStatus.SuspendedAborting: + newOpStatus = DepositOperationStatus.Aborting; + break; + case DepositOperationStatus.SuspendedKyc: + newOpStatus = DepositOperationStatus.PendingKyc; + break; + case DepositOperationStatus.SuspendedTrack: + newOpStatus = DepositOperationStatus.PendingTrack; + break; + } + if (!newOpStatus) { + return undefined; + } + dg.operationStatus = newOpStatus; + await tx.depositGroups.put(dg); + return { + oldTxState: oldState, + newTxState: computeDepositTransactionStatus(dg), + }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + } + + async failTransaction(): Promise<void> { + const { wex, depositGroupId, transactionId, taskId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + logger.warn( + `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`, + ); + return undefined; + } + const oldState = computeDepositTransactionStatus(dg); + switch (dg.operationStatus) { + case DepositOperationStatus.SuspendedAborting: + case DepositOperationStatus.Aborting: { + dg.operationStatus = DepositOperationStatus.Failed; + await tx.depositGroups.put(dg); + return { + oldTxState: oldState, + newTxState: computeDepositTransactionStatus(dg), + }; + } + } + return undefined; + }, + ); + wex.taskScheduler.stopShepherdTask(taskId); + notifyTransition(wex, transactionId, transitionInfo); + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + } +} + /** * Get the (DD37-style) transaction status based on the * database record of a deposit group. @@ -204,303 +411,45 @@ export function computeDepositTransactionActions( } } -/** - * Put a deposit group in a suspended state. - * While the deposit group is suspended, no network requests - * will be made to advance the transaction status. - */ -export async function suspendDepositGroup( - ws: InternalWalletState, - depositGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId, - }); - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.Deposit, - depositGroupId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.depositGroups]) - .runReadWrite(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), - }; - }); - stopLongpolling(ws, retryTag); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function resumeDepositGroup( - ws: InternalWalletState, - depositGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.depositGroups]) - .runReadWrite(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), - }; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function abortDepositGroup( - ws: InternalWalletState, - depositGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId, - }); - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.Deposit, - depositGroupId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.depositGroups]) - .runReadWrite(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; - }); - stopLongpolling(ws, retryTag); - // Need to process the operation again. - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function failDepositTransaction( - ws: InternalWalletState, - depositGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId, - }); - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.Deposit, - depositGroupId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.depositGroups]) - .runReadWrite(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) - stopLongpolling(ws, retryTag); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function deleteDepositGroup( - ws: InternalWalletState, - depositGroupId: string, -) { - // FIXME: We should check first if we are in a final state - // where deletion is allowed. - await ws.db - .mktx((x) => [x.depositGroups, x.tombstones]) - .runReadWrite(async (tx) => { - const tipRecord = await tx.depositGroups.get(depositGroupId); - if (tipRecord) { - await tx.depositGroups.delete(depositGroupId); - await tx.tombstones.put({ - id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId, - }); - } - }); -} - -/** - * 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 - .mktx((x) => [x.refreshGroups, x.depositGroups]) - .runReadWrite(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.pending(); -} - async function refundDepositGroup( - ws: InternalWalletState, + wex: WalletExecutionContext, depositGroup: DepositGroupRecord, ): Promise<TaskRunResult> { - const newTxPerCoin = [...depositGroup.statusPerCoin]; + const statusPerCoin = depositGroup.statusPerCoin; + const payCoinSelection = depositGroup.payCoinSelection; + if (!statusPerCoin) { + throw Error( + "unable to refund deposit group without coin selection (status missing)", + ); + } + if (!payCoinSelection) { + throw Error( + "unable to refund deposit group without coin selection (selection missing)", + ); + } + const newTxPerCoin = [...statusPerCoin]; logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`); - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - const st = depositGroup.statusPerCoin[i]; + for (let i = 0; i < statusPerCoin.length; i++) { + const st = statusPerCoin[i]; switch (st) { case DepositElementStatus.RefundFailed: case DepositElementStatus.RefundSuccess: break; default: { - const coinPub = depositGroup.payCoinSelection.coinPubs[i]; - const coinExchange = await ws.db - .mktx((x) => [x.coins]) - .runReadOnly(async (tx) => { + const coinPub = payCoinSelection.coinPubs[i]; + const coinExchange = await wex.db.runReadOnlyTx( + { storeNames: ["coins"] }, + async (tx) => { const coinRecord = await tx.coins.get(coinPub); checkDbInvariant(!!coinRecord); return coinRecord.exchangeBaseUrl; - }); - const refundAmount = depositGroup.payCoinSelection.coinContributions[i]; + }, + ); + const refundAmount = payCoinSelection.coinContributions[i]; // We use a constant refund transaction ID, since there can // only be one refund. const rtid = 1; - const sig = await ws.cryptoApi.signRefund({ + const sig = await wex.cryptoApi.signRefund({ coinPub, contractTermsHash: depositGroup.contractTermsHash, merchantPriv: depositGroup.merchantPriv, @@ -516,9 +465,10 @@ async function refundDepositGroup( rtransaction_id: rtid, }; const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange); - const httpResp = await ws.http.fetch(refundUrl.href, { + const httpResp = await wex.http.fetch(refundUrl.href, { method: "POST", body: refundReq, + cancellationToken: wex.cancellationToken, }); logger.info( `coin ${i} refund HTTP status for coin: ${httpResp.status}`, @@ -549,15 +499,18 @@ async function refundDepositGroup( const currency = Amounts.currencyOf(depositGroup.totalPayCost); - await ws.db - .mktx((x) => [ - x.depositGroups, - x.refreshGroups, - x.coins, - x.denominations, - x.coinAvailability, - ]) - .runReadWrite(async (tx) => { + const res = await wex.db.runReadWriteTx( + { + storeNames: [ + "depositGroups", + "refreshGroups", + "refreshSessions", + "coins", + "denominations", + "coinAvailability", + ], + }, + async (tx) => { const newDg = await tx.depositGroups.get(depositGroup.depositGroupId); if (!newDg) { return; @@ -566,42 +519,120 @@ async function refundDepositGroup( const refreshCoins: CoinRefreshRequest[] = []; for (let i = 0; i < newTxPerCoin.length; i++) { refreshCoins.push({ - amount: depositGroup.payCoinSelection.coinContributions[i], - coinPub: depositGroup.payCoinSelection.coinPubs[i], + amount: payCoinSelection.coinContributions[i], + coinPub: payCoinSelection.coinPubs[i], }); } + let refreshRes: CreateRefreshGroupResult | undefined = undefined; if (isDone) { - const rgid = await createRefreshGroup( - ws, + refreshRes = await createRefreshGroup( + wex, tx, currency, refreshCoins, RefreshReason.AbortDeposit, + constructTransactionIdentifier({ + tag: TransactionType.Deposit, + depositGroupId: newDg.depositGroupId, + }), ); - newDg.abortRefreshGroupId = rgid.refreshGroupId; + newDg.abortRefreshGroupId = refreshRes.refreshGroupId; } await tx.depositGroups.put(newDg); - }); + return { refreshRes }; + }, + ); + + if (res?.refreshRes) { + for (const notif of res.refreshRes.notifications) { + wex.ws.notify(notif); + } + } - return TaskRunResult.pending(); + return TaskRunResult.backoff(); +} + +/** + * Check whether the refresh associated with the + * aborting deposit group is done. + * + * If done, mark the deposit transaction as aborted. + * + * Otherwise continue waiting. + * + * FIXME: Wait for the refresh group notifications instead of periodically + * checking the refresh group status. + * FIXME: This is just one transaction, can't we do this in the initial + * transaction of processDepositGroup? + */ +async function waitForRefreshOnDepositGroup( + wex: WalletExecutionContext, + depositGroup: DepositGroupRecord, +): Promise<TaskRunResult> { + const abortRefreshGroupId = depositGroup.abortRefreshGroupId; + checkLogicInvariant(!!abortRefreshGroupId); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Deposit, + depositGroupId: depositGroup.depositGroupId, + }); + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups", "refreshGroups"] }, + async (tx) => { + const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId); + let newOpState: DepositOperationStatus | undefined; + if (!refreshGroup) { + // Maybe it got manually deleted? Means that we should + // just go into aborted. + logger.warn("no aborting refresh group found for deposit group"); + newOpState = DepositOperationStatus.Aborted; + } else { + if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) { + newOpState = DepositOperationStatus.Aborted; + } else if ( + refreshGroup.operationStatus === RefreshOperationStatus.Failed + ) { + newOpState = DepositOperationStatus.Aborted; + } + } + if (newOpState) { + const newDg = await tx.depositGroups.get(depositGroup.depositGroupId); + if (!newDg) { + return; + } + const oldTxState = computeDepositTransactionStatus(newDg); + newDg.operationStatus = newOpState; + const newTxState = computeDepositTransactionStatus(newDg); + await tx.depositGroups.put(newDg); + return { oldTxState, newTxState }; + } + return undefined; + }, + ); + + notifyTransition(wex, transactionId, transitionInfo); + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + return TaskRunResult.backoff(); } async function processDepositGroupAborting( - ws: InternalWalletState, + wex: WalletExecutionContext, depositGroup: DepositGroupRecord, ): Promise<TaskRunResult> { logger.info("processing deposit tx in 'aborting'"); const abortRefreshGroupId = depositGroup.abortRefreshGroupId; if (!abortRefreshGroupId) { logger.info("refunding deposit group"); - return refundDepositGroup(ws, depositGroup); + return refundDepositGroup(wex, depositGroup); } logger.info("waiting for refresh"); - return waitForRefreshOnDepositGroup(ws, depositGroup); + return waitForRefreshOnDepositGroup(wex, depositGroup); } async function processDepositGroupPendingKyc( - ws: InternalWalletState, + wex: WalletExecutionContext, depositGroup: DepositGroupRecord, ): Promise<TaskRunResult> { const { depositGroupId } = depositGroup; @@ -609,10 +640,6 @@ async function processDepositGroupPendingKyc( tag: TransactionType.Deposit, depositGroupId, }); - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.Deposit, - depositGroupId, - }); const kycInfo = depositGroup.kycInfo; const userType = "individual"; @@ -621,51 +648,46 @@ async function processDepositGroupPendingKyc( throw Error("invalid DB state, in pending(kyc), but no kycInfo present"); } - runLongpollAsync(ws, retryTag, async (ct) => { - 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: ct, - }); - 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 - .mktx((x) => [x.depositGroups]) - .runReadWrite(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); - return { ready: true }; - } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - // FIXME: Do we have to update the URL here? - return { ready: false }; - } else { - throw Error( - `unexpected response from kyc-check (${kycStatusRes.status})`, - ); - } + const url = new URL( + `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, + kycInfo.exchangeBaseUrl, + ); + url.searchParams.set("timeout_ms", "10000"); + logger.info(`kyc url ${url.href}`); + const kycStatusRes = await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, }); - return TaskRunResult.longpoll(); + if ( + kycStatusRes.status === HttpStatusCode.Ok || + //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge + // remove after the exchange is fixed or clarified + kycStatusRes.status === HttpStatusCode.NoContent + ) { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { + const newDg = await tx.depositGroups.get(depositGroupId); + if (!newDg) { + return; + } + if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) { + return; + } + const oldTxState = computeDepositTransactionStatus(newDg); + newDg.operationStatus = DepositOperationStatus.PendingTrack; + const newTxState = computeDepositTransactionStatus(newDg); + await tx.depositGroups.put(newDg); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + // FIXME: Do we have to update the URL here? + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } + return TaskRunResult.backoff(); } /** @@ -674,7 +696,7 @@ async function processDepositGroupPendingKyc( * and transition the transaction to the KYC required state. */ async function transitionToKycRequired( - ws: InternalWalletState, + wex: WalletExecutionContext, depositGroup: DepositGroupRecord, kycInfo: KycPendingInfo, exchangeUrl: string, @@ -692,18 +714,18 @@ async function transitionToKycRequired( exchangeUrl, ); logger.info(`kyc url ${url.href}`); - const kycStatusReq = await ws.http.fetch(url.href, { + const kycStatusReq = await wex.http.fetch(url.href, { method: "GET", }); if (kycStatusReq.status === HttpStatusCode.Ok) { logger.warn("kyc requested, but already fulfilled"); - return TaskRunResult.finished(); + 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 - .mktx((x) => [x.depositGroups]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return undefined; @@ -721,8 +743,9 @@ async function transitionToKycRequired( await tx.depositGroups.put(dg); const newTxState = computeDepositTransactionStatus(dg); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); return TaskRunResult.finished(); } else { throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`); @@ -730,21 +753,33 @@ async function transitionToKycRequired( } async function processDepositGroupPendingTrack( - ws: InternalWalletState, + wex: WalletExecutionContext, depositGroup: DepositGroupRecord, - cancellationToken?: CancellationToken, ): Promise<TaskRunResult> { + const statusPerCoin = depositGroup.statusPerCoin; + const payCoinSelection = depositGroup.payCoinSelection; + if (!statusPerCoin) { + throw Error( + "unable to refund deposit group without coin selection (status missing)", + ); + } + if (!payCoinSelection) { + throw Error( + "unable to refund deposit group without coin selection (selection missing)", + ); + } const { depositGroupId } = depositGroup; - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - const coinPub = depositGroup.payCoinSelection.coinPubs[i]; + for (let i = 0; i < statusPerCoin.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; // FIXME: Make the URL part of the coin selection? - const exchangeBaseUrl = await ws.db - .mktx((x) => [x.coins]) - .runReadWrite(async (tx) => { + const exchangeBaseUrl = await wex.db.runReadWriteTx( + { storeNames: ["coins"] }, + async (tx) => { const coinRecord = await tx.coins.get(coinPub); - checkDbInvariant(!!coinRecord); + checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`); return coinRecord.exchangeBaseUrl; - }); + }, + ); let updatedTxStatus: DepositElementStatus | undefined = undefined; let newWiredCoin: @@ -754,9 +789,9 @@ async function processDepositGroupPendingTrack( } | undefined; - if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) { + if (statusPerCoin[i] !== DepositElementStatus.Wired) { const track = await trackDeposit( - ws, + wex, depositGroup, coinPub, exchangeBaseUrl, @@ -773,7 +808,7 @@ async function processDepositGroupPendingTrack( requirementRow, }; return transitionToKycRequired( - ws, + wex, depositGroup, kycInfo, exchangeBaseUrl, @@ -790,7 +825,7 @@ async function processDepositGroupPendingTrack( } const fee = await getExchangeWireFee( - ws, + wex, payto.targetType, exchangeBaseUrl, track.execution_time, @@ -814,13 +849,16 @@ async function processDepositGroupPendingTrack( } if (updatedTxStatus !== undefined) { - await ws.db - .mktx((x) => [x.depositGroups]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return; } + if (!dg.statusPerCoin) { + return; + } if (updatedTxStatus !== undefined) { dg.statusPerCoin[i] = updatedTxStatus; } @@ -840,22 +878,26 @@ async function processDepositGroupPendingTrack( dg.trackingState[newWiredCoin.id] = newWiredCoin.value; } await tx.depositGroups.put(dg); - }); + }, + ); } } let allWired = true; - const transitionInfo = await ws.db - .mktx((x) => [x.depositGroups]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return undefined; } + if (!dg.statusPerCoin) { + return undefined; + } const oldTxState = computeDepositTransactionStatus(dg); - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) { + for (let i = 0; i < dg.statusPerCoin.length; i++) { + if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) { allWired = false; break; } @@ -869,32 +911,37 @@ async function processDepositGroupPendingTrack( } const newTxState = computeDepositTransactionStatus(dg); return { oldTxState, newTxState }; - }); + }, + ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId, }); - notifyTransition(ws, transactionId, transitionInfo); + notifyTransition(wex, transactionId, transitionInfo); if (allWired) { + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); return TaskRunResult.finished(); } else { - // FIXME: Use long-polling. - return TaskRunResult.pending(); + return TaskRunResult.longpollReturnedPending(); } } async function processDepositGroupPendingDeposit( - ws: InternalWalletState, + wex: WalletExecutionContext, depositGroup: DepositGroupRecord, cancellationToken?: CancellationToken, ): Promise<TaskRunResult> { logger.info("processing deposit group in pending(deposit)"); const depositGroupId = depositGroup.depositGroupId; - const contractTermsRec = await ws.db - .mktx((x) => [x.contractTerms]) - .runReadOnly(async (tx) => { + const contractTermsRec = await wex.db.runReadOnlyTx( + { storeNames: ["contractTerms"] }, + async (tx) => { return tx.contractTerms.get(depositGroup.contractTermsHash); - }); + }, + ); if (!contractTermsRec) { throw Error("contract terms for deposit not found in database"); } @@ -914,9 +961,91 @@ async function processDepositGroupPendingDeposit( // Check for cancellation before expensive operations. cancellationToken?.throwIfCancelled(); + if (!depositGroup.payCoinSelection) { + logger.info("missing coin selection for deposit group, selecting now"); + // FIXME: Consider doing the coin selection inside the txn + const payCoinSel = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins: [], + }); + + switch (payCoinSel.type) { + case "success": + logger.info("coin selection success"); + break; + case "failure": + logger.info("coin selection failure"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + logger.info("coin selection prospective"); + throw Error("insufficient balance (waiting on pending refresh)"); + default: + assertUnreachable(payCoinSel); + } + + const transitionDone = await wex.db.runReadWriteTx( + { + storeNames: [ + "depositGroups", + "coins", + "coinAvailability", + "refreshGroups", + "refreshSessions", + "denominations", + ], + }, + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + return false; + } + if (dg.statusPerCoin) { + return false; + } + dg.payCoinSelection = { + coinContributions: payCoinSel.coinSel.coins.map( + (x) => x.contribution, + ), + coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub), + }; + dg.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); + dg.statusPerCoin = payCoinSel.coinSel.coins.map( + () => DepositElementStatus.DepositPending, + ); + await tx.depositGroups.put(dg); + await spendCoins(wex, tx, { + allocationId: transactionId, + coinPubs: dg.payCoinSelection.coinPubs, + contributions: dg.payCoinSelection.coinContributions.map((x) => + Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayDeposit, + }); + return true; + }, + ); + + if (transitionDone) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } + } + // FIXME: Cache these! const depositPermissions = await generateDepositPermissions( - ws, + wex, depositGroup.payCoinSelection, contractData, ); @@ -963,8 +1092,9 @@ async function processDepositGroupPendingDeposit( // Check for cancellation before making network request. cancellationToken?.throwIfCancelled(); const url = new URL(`batch-deposit`, exchangeUrl); - logger.info(`depositing to ${url}`); - const httpResp = await ws.http.fetch(url.href, { + logger.info(`depositing to ${url.href}`); + logger.trace(`deposit request: ${j2s(batchReq)}`); + const httpResp = await wex.http.fetch(url.href, { method: "POST", body: batchReq, cancellationToken: cancellationToken, @@ -974,13 +1104,16 @@ async function processDepositGroupPendingDeposit( codecForBatchDepositSuccess(), ); - await ws.db - .mktx((x) => [x.depositGroups]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return; } + if (!dg.statusPerCoin) { + return; + } for (const batchIndex of batchIndexes) { const coinStatus = dg.statusPerCoin[batchIndex]; switch (coinStatus) { @@ -989,12 +1122,13 @@ async function processDepositGroupPendingDeposit( await tx.depositGroups.put(dg); } } - }); + }, + ); } - const transitionInfo = await ws.db - .mktx((x) => [x.depositGroups]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups"] }, + async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return undefined; @@ -1004,27 +1138,26 @@ async function processDepositGroupPendingDeposit( await tx.depositGroups.put(dg); const newTxState = computeDepositTransactionStatus(dg); return { oldTxState, newTxState }; - }); + }, + ); - notifyTransition(ws, transactionId, transitionInfo); - return TaskRunResult.finished(); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.progress(); } /** * Process a deposit group that is not in its final state yet. */ export async function processDepositGroup( - ws: InternalWalletState, + wex: WalletExecutionContext, depositGroupId: string, - options: { - cancellationToken?: CancellationToken; - } = {}, ): Promise<TaskRunResult> { - const depositGroup = await ws.db - .mktx((x) => [x.depositGroups]) - .runReadOnly(async (tx) => { + const depositGroup = await wex.db.runReadOnlyTx( + { storeNames: ["depositGroups"] }, + async (tx) => { return tx.depositGroups.get(depositGroupId); - }); + }, + ); if (!depositGroup) { logger.warn(`deposit group ${depositGroupId} not found`); return TaskRunResult.finished(); @@ -1032,35 +1165,30 @@ export async function processDepositGroup( switch (depositGroup.operationStatus) { case DepositOperationStatus.PendingTrack: - return processDepositGroupPendingTrack( - ws, - depositGroup, - options.cancellationToken, - ); + return processDepositGroupPendingTrack(wex, depositGroup); case DepositOperationStatus.PendingKyc: - return processDepositGroupPendingKyc(ws, depositGroup); + return processDepositGroupPendingKyc(wex, depositGroup); case DepositOperationStatus.PendingDeposit: - return processDepositGroupPendingDeposit( - ws, - depositGroup, - options.cancellationToken, - ); + return processDepositGroupPendingDeposit(wex, depositGroup); case DepositOperationStatus.Aborting: - return processDepositGroupAborting(ws, depositGroup); + return processDepositGroupAborting(wex, depositGroup); } return TaskRunResult.finished(); } +/** + * FIXME: Consider moving this to exchanges.ts. + */ async function getExchangeWireFee( - ws: InternalWalletState, + wex: WalletExecutionContext, wireType: string, baseUrl: string, time: TalerProtocolTimestamp, ): Promise<WireFee> { - const exchangeDetails = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { + const exchangeDetails = await wex.db.runReadOnlyTx( + { storeNames: ["exchangeDetails", "exchanges"] }, + async (tx) => { const ex = await tx.exchanges.get(baseUrl); if (!ex || !ex.detailsPointer) return undefined; return await tx.exchangeDetails.indexes.byPointer.get([ @@ -1068,7 +1196,8 @@ async function getExchangeWireFee( ex.detailsPointer.currency, ex.detailsPointer.masterPublicKey, ]); - }); + }, + ); if (!exchangeDetails) { throw Error(`exchange missing: ${baseUrl}`); @@ -1097,7 +1226,7 @@ async function getExchangeWireFee( } async function trackDeposit( - ws: InternalWalletState, + wex: WalletExecutionContext, depositGroup: DepositGroupRecord, coinPub: string, exchangeUrl: string, @@ -1111,7 +1240,7 @@ async function trackDeposit( `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`, exchangeUrl, ); - const sigResp = await ws.cryptoApi.signTrackTransaction({ + const sigResp = await wex.cryptoApi.signTrackTransaction({ coinPub, contractTermsHash: depositGroup.contractTermsHash, merchantPriv: depositGroup.merchantPriv, @@ -1119,7 +1248,11 @@ async function trackDeposit( wireHash, }); url.searchParams.set("merchant_sig", sigResp.sig); - const httpResp = await ws.http.fetch(url.href, { method: "GET" }); + url.searchParams.set("timeout_ms", "30000"); + const httpResp = await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); logger.trace(`deposits response status: ${httpResp.status}`); switch (httpResp.status) { case HttpStatusCode.Accepted: { @@ -1147,12 +1280,9 @@ async function trackDeposit( /** * 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, +export async function checkDepositGroup( + wex: WalletExecutionContext, req: PrepareDepositRequest, ): Promise<PrepareDepositResponse> { const p = parsePaytoUri(req.depositPaytoUri); @@ -1160,15 +1290,16 @@ export async function prepareDepositGroup( throw Error("invalid payto URI"); } const amount = Amounts.parseOrThrow(req.amount); + const currency = Amounts.currencyOf(amount); - const exchangeInfos: { url: string; master_pub: string }[] = []; + const exchangeInfos: ExchangeHandle[] = []; - await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { + await wex.db.runReadOnlyTx( + { storeNames: ["exchangeDetails", "exchanges"] }, + async (tx) => { const allExchanges = await tx.exchanges.iter().toArray(); for (const e of allExchanges) { - const details = await getExchangeDetails(tx, e.baseUrl); + const details = await getExchangeWireDetailsInTx(tx, e.baseUrl); if (!details || amount.currency !== details.currency) { continue; } @@ -1177,7 +1308,8 @@ export async function prepareDepositGroup( url: e.baseUrl, }); } - }); + }, + ); const now = AbsoluteTime.now(); const nowRounded = AbsoluteTime.toProtocolTimestamp(now); @@ -1185,7 +1317,6 @@ export async function prepareDepositGroup( 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: "", @@ -1195,7 +1326,7 @@ export async function prepareDepositGroup( order_id: "", h_wire: "", pay_deadline: AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })), + AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })), ), merchant: { name: "(wallet)", @@ -1204,7 +1335,7 @@ export async function prepareDepositGroup( refund_deadline: TalerProtocolTimestamp.zero(), }; - const { h: contractTermsHash } = await ws.cryptoApi.hashString({ + const { h: contractTermsHash } = await wex.cryptoApi.hashString({ str: canonicalJson(contractTerms), }); @@ -1214,39 +1345,50 @@ export async function prepareDepositGroup( "", ); - const payCoinSel = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, + const payCoinSel = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: 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, - }, - ); + let selCoins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (payCoinSel.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + selCoins = payCoinSel.result.prospectiveCoins; + break; + case "success": + selCoins = payCoinSel.coinSel.coins; + break; + default: + assertUnreachable(payCoinSel); } - const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel); + const totalDepositCost = await getTotalPaymentCost(wex, currency, selCoins); const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount( - ws, + wex, p.targetType, - payCoinSel.coinSel, + selCoins, ); const fees = await getTotalFeesForDepositAmount( - ws, + wex, p.targetType, amount, - payCoinSel.coinSel, + selCoins, ); return { @@ -1265,7 +1407,7 @@ export function generateDepositGroupTxId(): string { } export async function createDepositGroup( - ws: InternalWalletState, + wex: WalletExecutionContext, req: CreateDepositGroupRequest, ): Promise<CreateDepositGroupResponse> { const p = parsePaytoUri(req.depositPaytoUri); @@ -1274,15 +1416,16 @@ export async function createDepositGroup( } const amount = Amounts.parseOrThrow(req.amount); + const currency = amount.currency; const exchangeInfos: { url: string; master_pub: string }[] = []; - await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { + await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "exchangeDetails"] }, + async (tx) => { const allExchanges = await tx.exchanges.iter().toArray(); for (const e of allExchanges) { - const details = await getExchangeDetails(tx, e.baseUrl); + const details = await getExchangeWireDetailsInTx(tx, e.baseUrl); if (!details || amount.currency !== details.currency) { continue; } @@ -1291,22 +1434,22 @@ export async function createDepositGroup( 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 noncePair = await wex.cryptoApi.createEddsaKeypair({}); + const merchantPair = await wex.cryptoApi.createEddsaKeypair({}); const wireSalt = encodeCrock(getRandomBytes(16)); const wireHash = hashWire(req.depositPaytoUri, wireSalt); const contractTerms: MerchantContractTerms = { exchanges: exchangeInfos, amount: req.amount, max_fee: Amounts.stringify(amount), - max_wire_fee: Amounts.stringify(amount), wire_method: p.targetType, timestamp: nowRounded, merchant_base_url: "", @@ -1316,7 +1459,7 @@ export async function createDepositGroup( order_id: "", h_wire: wireHash, pay_deadline: AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })), + AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })), ), merchant: { name: "(wallet)", @@ -1325,7 +1468,7 @@ export async function createDepositGroup( refund_deadline: TalerProtocolTimestamp.zero(), }; - const { h: contractTermsHash } = await ws.cryptoApi.hashString({ + const { h: contractTermsHash } = await wex.cryptoApi.hashString({ str: canonicalJson(contractTerms), }); @@ -1335,27 +1478,38 @@ export async function createDepositGroup( "", ); - const payCoinSel = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, + const payCoinSel = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: 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, - }, - ); + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (payCoinSel.type) { + case "success": + coins = payCoinSel.coinSel.coins; + break; + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = payCoinSel.result.prospectiveCoins; + break; + default: + assertUnreachable(payCoinSel); } - const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel); + const totalDepositCost = await getTotalPaymentCost(wex, currency, coins); let depositGroupId: string; if (req.transactionId) { @@ -1368,12 +1522,25 @@ export async function createDepositGroup( depositGroupId = encodeCrock(getRandomBytes(32)); } - const counterpartyEffectiveDepositAmount = - await getCounterpartyEffectiveDepositAmount( - ws, - p.targetType, - payCoinSel.coinSel, + const infoPerExchange: Record<string, DepositInfoPerExchange> = {}; + + for (let i = 0; i < coins.length; i++) { + let depPerExchange = infoPerExchange[coins[i].exchangeBaseUrl]; + if (!depPerExchange) { + infoPerExchange[coins[i].exchangeBaseUrl] = depPerExchange = { + amountEffective: Amounts.stringify( + Amounts.zeroOfAmount(totalDepositCost), + ), + }; + } + const contrib = coins[i].contribution; + depPerExchange.amountEffective = Amounts.stringify( + Amounts.add(depPerExchange.amountEffective, contrib).amount, ); + } + + const counterpartyEffectiveDepositAmount = + await getCounterpartyEffectiveDepositAmount(wex, p.targetType, coins); const depositGroup: DepositGroupRecord = { contractTermsHash, @@ -1386,11 +1553,9 @@ export async function createDepositGroup( AbsoluteTime.toPreciseTimestamp(now), ), timestampFinished: undefined, - statusPerCoin: payCoinSel.coinSel.coinPubs.map( - () => DepositElementStatus.DepositPending, - ), - payCoinSelection: payCoinSel.coinSel, - payCoinSelectionUid: encodeCrock(getRandomBytes(32)), + statusPerCoin: undefined, + payCoinSelection: undefined, + payCoinSelectionUid: undefined, merchantPriv: merchantPair.priv, merchantPub: merchantPair.pub, totalPayCost: Amounts.stringify(totalDepositCost), @@ -1405,41 +1570,57 @@ export async function createDepositGroup( salt: wireSalt, }, operationStatus: DepositOperationStatus.PendingDeposit, + infoPerExchange, }; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId, - }); + if (payCoinSel.type === "success") { + depositGroup.payCoinSelection = { + coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution), + coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub), + }; + depositGroup.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); + depositGroup.statusPerCoin = payCoinSel.coinSel.coins.map( + () => DepositElementStatus.DepositPending, + ); + } - const newTxState = await ws.db - .mktx((x) => [ - x.depositGroups, - x.coins, - x.recoupGroups, - x.denominations, - x.refreshGroups, - x.coinAvailability, - x.contractTerms, - ]) - .runReadWrite(async (tx) => { - await spendCoins(ws, tx, { - allocationId: transactionId, - coinPubs: payCoinSel.coinSel.coinPubs, - contributions: payCoinSel.coinSel.coinContributions.map((x) => - Amounts.parseOrThrow(x), - ), - refreshReason: RefreshReason.PayDeposit, - }); + const ctx = new DepositTransactionContext(wex, depositGroupId); + const transactionId = ctx.transactionId; + + const newTxState = await wex.db.runReadWriteTx( + { + storeNames: [ + "depositGroups", + "coins", + "recoupGroups", + "denominations", + "refreshGroups", + "refreshSessions", + "coinAvailability", + "contractTerms", + ], + }, + async (tx) => { + if (depositGroup.payCoinSelection) { + await spendCoins(wex, tx, { + allocationId: transactionId, + coinPubs: depositGroup.payCoinSelection.coinPubs, + contributions: depositGroup.payCoinSelection.coinContributions.map( + (x) => Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayDeposit, + }); + } await tx.depositGroups.put(depositGroup); await tx.contractTerms.put({ contractTermsRaw: contractTerms, h: contractTermsHash, }); return computeDepositTransactionStatus(depositGroup); - }); + }, + ); - ws.notify({ + wex.ws.notify({ type: NotificationType.TransactionStateTransition, transactionId, oldTxState: { @@ -1448,11 +1629,13 @@ export async function createDepositGroup( newTxState, }); - ws.notify({ + wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, }); + wex.taskScheduler.startShepherdTask(ctx.taskId); + return { depositGroupId, transactionId, @@ -1464,38 +1647,37 @@ export async function createDepositGroup( * account after depositing, not considering aggregation. */ export async function getCounterpartyEffectiveDepositAmount( - ws: InternalWalletState, + wex: WalletExecutionContext, wireType: string, - pcs: PayCoinSelection, + pcs: SelectedProspectiveCoin[], ): Promise<AmountJson> { const amt: AmountJson[] = []; const fees: AmountJson[] = []; const exchangeSet: Set<string> = new Set(); - await ws.db - .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { - for (let i = 0; i < pcs.coinPubs.length; i++) { - const coin = await tx.coins.get(pcs.coinPubs[i]); - if (!coin) { - throw Error("can't calculate deposit amount, coin not found"); - } - const denom = await ws.getDenomInfo( - ws, + await wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations", "exchangeDetails", "exchanges"] }, + async (tx) => { + for (let i = 0; i < pcs.length; i++) { + const denom = await getDenomInfo( + wex, tx, - coin.exchangeBaseUrl, - coin.denomPubHash, + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, ); if (!denom) { throw Error("can't find denomination to calculate deposit amount"); } - amt.push(Amounts.parseOrThrow(pcs.coinContributions[i])); + amt.push(Amounts.parseOrThrow(pcs[i].contribution)); fees.push(Amounts.parseOrThrow(denom.feeDeposit)); - exchangeSet.add(coin.exchangeBaseUrl); + exchangeSet.add(pcs[i].exchangeBaseUrl); } for (const exchangeUrl of exchangeSet.values()) { - const exchangeDetails = await getExchangeDetails(tx, exchangeUrl); + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + exchangeUrl, + ); if (!exchangeDetails) { continue; } @@ -1514,7 +1696,8 @@ export async function getCounterpartyEffectiveDepositAmount( fees.push(Amounts.parseOrThrow(fee)); } } - }); + }, + ); return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount; } @@ -1523,58 +1706,46 @@ export async function getCounterpartyEffectiveDepositAmount( * specified amount using the selected coins and the wire method. */ async function getTotalFeesForDepositAmount( - ws: InternalWalletState, + wex: WalletExecutionContext, wireType: string, total: AmountJson, - pcs: PayCoinSelection, + pcs: SelectedProspectiveCoin[], ): 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 - .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { - for (let i = 0; i < pcs.coinPubs.length; i++) { - const coin = await tx.coins.get(pcs.coinPubs[i]); - if (!coin) { - throw Error("can't calculate deposit amount, coin not found"); - } - const denom = await ws.getDenomInfo( - ws, + + await wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations", "exchanges", "exchangeDetails"] }, + async (tx) => { + for (let i = 0; i < pcs.length; i++) { + const denom = await getDenomInfo( + wex, tx, - coin.exchangeBaseUrl, - coin.denomPubHash, + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, ); if (!denom) { throw Error("can't find denomination to calculate deposit amount"); } coinFee.push(Amounts.parseOrThrow(denom.feeDeposit)); - exchangeSet.add(coin.exchangeBaseUrl); - - const allDenoms = await getCandidateWithdrawalDenomsTx( - ws, + exchangeSet.add(pcs[i].exchangeBaseUrl); + const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; + const refreshCost = await getTotalRefreshCost( + wex, 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 getExchangeDetails(tx, exchangeUrl); + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + exchangeUrl, + ); if (!exchangeDetails) { continue; } @@ -1591,7 +1762,8 @@ async function getTotalFeesForDepositAmount( wireFee.push(Amounts.parseOrThrow(fee)); } } - }); + }, + ); return { coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount), diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts index 176ed09d9..5cb9400be 100644 --- a/packages/taler-wallet-core/src/dev-experiments.ts +++ b/packages/taler-wallet-core/src/dev-experiments.ts @@ -25,14 +25,29 @@ * Imports. */ -import { Logger, parseDevExperimentUri } from "@gnu-taler/taler-util"; -import { ConfigRecordKey } from "./db.js"; -import { InternalWalletState } from "./internal-wallet-state.js"; +import { + DenomLossEventType, + Logger, + RefreshReason, + TalerPreciseTimestamp, + encodeCrock, + getRandomBytes, + parseDevExperimentUri, +} from "@gnu-taler/taler-util"; import { HttpRequestLibrary, HttpRequestOptions, HttpResponse, } from "@gnu-taler/taler-util/http"; +import { PendingTaskType, constructTaskIdentifier } from "./common.js"; +import { + DenomLossEventRecord, + DenomLossStatus, + RefreshGroupRecord, + RefreshOperationStatus, + timestampPreciseToDb, +} from "./db.js"; +import { WalletExecutionContext } from "./wallet.js"; const logger = new Logger("dev-experiments.ts"); @@ -40,7 +55,7 @@ const logger = new Logger("dev-experiments.ts"); * Apply a dev experiment to the wallet database / state. */ export async function applyDevExperiment( - ws: InternalWalletState, + wex: WalletExecutionContext, uri: string, ): Promise<void> { logger.info(`applying dev experiment ${uri}`); @@ -49,11 +64,74 @@ export async function applyDevExperiment( logger.info("unable to parse dev experiment URI"); return; } - if (!ws.config.testing.devModeActive) { - throw Error( - "can't handle devmode URI (other than enable-devmode) unless devmode is active", - ); + if (!wex.ws.config.testing.devModeActive) { + throw Error("can't handle devmode URI unless devmode is active"); } + + switch (parsedUri.devExperimentId) { + case "start-block-refresh": { + wex.ws.devExperimentState.blockRefreshes = true; + return; + } + case "stop-block-refresh": { + wex.ws.devExperimentState.blockRefreshes = false; + return; + } + case "insert-pending-refresh": { + const refreshGroupId = encodeCrock(getRandomBytes(32)); + await wex.db.runReadWriteTx( + { storeNames: ["refreshGroups"] }, + async (tx) => { + const newRg: RefreshGroupRecord = { + currency: "TESTKUDOS", + expectedOutputPerCoin: [], + inputPerCoin: [], + oldCoinPubs: [], + operationStatus: RefreshOperationStatus.Pending, + reason: RefreshReason.Manual, + refreshGroupId, + statusPerCoin: [], + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + timestampFinished: undefined, + originatingTransactionId: undefined, + infoPerExchange: {}, + }; + await tx.refreshGroups.put(newRg); + }, + ); + wex.taskScheduler.startShepherdTask( + constructTaskIdentifier({ + tag: PendingTaskType.Refresh, + refreshGroupId, + }), + ); + return; + } + case "insert-denom-loss": { + await wex.db.runReadWriteTx( + { storeNames: ["denomLossEvents"] }, + async (tx) => { + const eventId = encodeCrock(getRandomBytes(32)); + const newRg: DenomLossEventRecord = { + amount: "TESTKUDOS:42", + currency: "TESTKUDOS", + exchangeBaseUrl: "https://exchange.test.taler.net/", + denomLossEventId: eventId, + denomPubHashes: [ + encodeCrock(getRandomBytes(64)), + encodeCrock(getRandomBytes(64)), + ], + eventType: DenomLossEventType.DenomExpired, + status: DenomLossStatus.Done, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + }; + await tx.denomLossEvents.put(newRg); + }, + ); + return; + } + } + throw Error(`dev-experiment id not understood ${parsedUri.devExperimentId}`); } @@ -65,23 +143,6 @@ export class DevExperimentHttpLib implements HttpRequestLibrary { this.underlyingLib = lib; } - get( - url: string, - opt?: HttpRequestOptions | undefined, - ): Promise<HttpResponse> { - logger.trace(`devexperiment httplib ${url}`); - return this.underlyingLib.fetch(url, opt); - } - - postJson( - url: string, - body: any, - opt?: HttpRequestOptions | undefined, - ): Promise<HttpResponse> { - logger.trace(`devexperiment httplib ${url}`); - return this.underlyingLib.postJson(url, body, opt); - } - fetch( url: string, opt?: HttpRequestOptions | undefined, diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts new file mode 100644 index 000000000..d8063d561 --- /dev/null +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -0,0 +1,2581 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * @fileoverview + * Implementation of exchange entry management in wallet-core. + * The details of exchange entry management are specified in DD48. + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AgeRestriction, + Amount, + Amounts, + AsyncFlag, + CancellationToken, + CoinRefreshRequest, + CoinStatus, + DeleteExchangeRequest, + DenomKeyType, + DenomLossEventType, + DenomOperationMap, + DenominationInfo, + DenominationPubKey, + Duration, + EddsaPublicKeyString, + ExchangeAuditor, + ExchangeDetailedResponse, + ExchangeGlobalFees, + ExchangeListItem, + ExchangeSignKeyJson, + ExchangeTosStatus, + ExchangeWireAccount, + ExchangesListResponse, + FeeDescription, + GetExchangeEntryByUrlRequest, + GetExchangeResourcesResponse, + GetExchangeTosResult, + GlobalFees, + LibtoolVersion, + Logger, + NotificationType, + OperationErrorInfo, + Recoup, + RefreshReason, + ScopeInfo, + ScopeType, + TalerError, + TalerErrorCode, + TalerErrorDetail, + TalerPreciseTimestamp, + TalerProtocolDuration, + TalerProtocolTimestamp, + TransactionIdStr, + TransactionMajorState, + TransactionState, + TransactionType, + URL, + WalletNotification, + WireFee, + WireFeeMap, + WireFeesJson, + WireInfo, + assertUnreachable, + checkDbInvariant, + codecForExchangeKeysJson, + durationMul, + encodeCrock, + getRandomBytes, + hashDenomPub, + j2s, + makeErrorDetail, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + HttpRequestLibrary, + getExpiry, + readSuccessResponseJsonOrThrow, + readSuccessResponseTextOrThrow, +} from "@gnu-taler/taler-util/http"; +import { + PendingTaskType, + TaskIdStr, + TaskIdentifiers, + TaskRunResult, + TaskRunResultType, + TransactionContext, + computeDbBackoff, + constructTaskIdentifier, + getAutoRefreshExecuteThreshold, + getExchangeEntryStatusFromRecord, + getExchangeState, + getExchangeTosStatusFromRecord, + getExchangeUpdateStatusFromRecord, +} from "./common.js"; +import { + DenomLossEventRecord, + DenomLossStatus, + DenominationRecord, + DenominationVerificationStatus, + ExchangeDetailsRecord, + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + ExchangeEntryRecord, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, + WalletStoresV1, + timestampAbsoluteFromDb, + timestampOptionalPreciseFromDb, + timestampPreciseFromDb, + timestampPreciseToDb, + timestampProtocolFromDb, + timestampProtocolToDb, +} from "./db.js"; +import { + createTimeline, + isWithdrawableDenom, + selectBestForOverlappingDenominations, + selectMinimumFee, +} from "./denominations.js"; +import { DbReadOnlyTransaction } from "./query.js"; +import { createRecoupGroup } from "./recoup.js"; +import { createRefreshGroup } from "./refresh.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; +import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; +import { InternalWalletState, WalletExecutionContext } from "./wallet.js"; + +const logger = new Logger("exchanges.ts"); + +function getExchangeRequestTimeout(): Duration { + return Duration.fromSpec({ + seconds: 15, + }); +} + +interface ExchangeTosDownloadResult { + tosText: string; + tosEtag: string; + tosContentType: string; + tosContentLanguage: string | undefined; + tosAvailableLanguages: string[]; +} + +async function downloadExchangeWithTermsOfService( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + http: HttpRequestLibrary, + timeout: Duration, + acceptFormat: string, + acceptLanguage: string | undefined, +): Promise<ExchangeTosDownloadResult> { + logger.trace(`downloading exchange tos (type ${acceptFormat})`); + const reqUrl = new URL("terms", exchangeBaseUrl); + const headers: { + Accept: string; + "Accept-Language"?: string; + } = { + Accept: acceptFormat, + }; + + if (acceptLanguage) { + headers["Accept-Language"] = acceptLanguage; + } + + const resp = await http.fetch(reqUrl.href, { + headers, + timeout, + cancellationToken: wex.cancellationToken, + }); + const tosText = await readSuccessResponseTextOrThrow(resp); + const tosEtag = resp.headers.get("etag") || "unknown"; + const tosContentLanguage = resp.headers.get("content-language") || undefined; + const tosContentType = resp.headers.get("content-type") || "text/plain"; + const availLangStr = resp.headers.get("avail-languages") || ""; + // Work around exchange bug that reports the same language multiple times. + const availLangSet = new Set<string>( + availLangStr.split(",").map((x) => x.trim()), + ); + const tosAvailableLanguages = [...availLangSet]; + + return { + tosText, + tosEtag, + tosContentType, + tosContentLanguage, + tosAvailableLanguages, + }; +} + +/** + * Get exchange details from the database. + */ +async function getExchangeRecordsInternal( + tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>, + exchangeBaseUrl: string, +): Promise<ExchangeDetailsRecord | undefined> { + const r = await tx.exchanges.get(exchangeBaseUrl); + if (!r) { + logger.warn(`no exchange found for ${exchangeBaseUrl}`); + return; + } + const dp = r.detailsPointer; + if (!dp) { + logger.warn(`no exchange details pointer for ${exchangeBaseUrl}`); + return; + } + const { currency, masterPublicKey } = dp; + const details = await tx.exchangeDetails.indexes.byPointer.get([ + r.baseUrl, + currency, + masterPublicKey, + ]); + if (!details) { + logger.warn( + `no exchange details with pointer ${j2s(dp)} for ${exchangeBaseUrl}`, + ); + } + return details; +} + +export async function getExchangeScopeInfo( + tx: WalletDbReadOnlyTransaction< + [ + "exchanges", + "exchangeDetails", + "globalCurrencyExchanges", + "globalCurrencyAuditors", + ] + >, + exchangeBaseUrl: string, + currency: string, +): Promise<ScopeInfo> { + const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl); + if (!det) { + return { + type: ScopeType.Exchange, + currency: currency, + url: exchangeBaseUrl, + }; + } + return internalGetExchangeScopeInfo(tx, det); +} + +async function internalGetExchangeScopeInfo( + tx: WalletDbReadOnlyTransaction< + ["globalCurrencyExchanges", "globalCurrencyAuditors"] + >, + exchangeDetails: ExchangeDetailsRecord, +): Promise<ScopeInfo> { + const globalExchangeRec = + await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([ + exchangeDetails.currency, + exchangeDetails.exchangeBaseUrl, + exchangeDetails.masterPublicKey, + ]); + if (globalExchangeRec) { + return { + currency: exchangeDetails.currency, + type: ScopeType.Global, + }; + } else { + for (const aud of exchangeDetails.auditors) { + const globalAuditorRec = + await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([ + exchangeDetails.currency, + aud.auditor_url, + aud.auditor_pub, + ]); + if (globalAuditorRec) { + return { + currency: exchangeDetails.currency, + type: ScopeType.Auditor, + url: aud.auditor_url, + }; + } + } + } + return { + currency: exchangeDetails.currency, + type: ScopeType.Exchange, + url: exchangeDetails.exchangeBaseUrl, + }; +} + +async function makeExchangeListItem( + tx: WalletDbReadOnlyTransaction< + ["globalCurrencyExchanges", "globalCurrencyAuditors"] + >, + r: ExchangeEntryRecord, + exchangeDetails: ExchangeDetailsRecord | undefined, + lastError: TalerErrorDetail | undefined, +): Promise<ExchangeListItem> { + const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError + ? { + error: lastError, + } + : undefined; + + let scopeInfo: ScopeInfo | undefined = undefined; + + if (exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); + } + + return { + exchangeBaseUrl: r.baseUrl, + masterPub: exchangeDetails?.masterPublicKey, + noFees: r.noFees ?? false, + peerPaymentsDisabled: r.peerPaymentsDisabled ?? false, + currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN", + exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r), + exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), + tosStatus: getExchangeTosStatusFromRecord(r), + ageRestrictionOptions: exchangeDetails?.ageMask + ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask) + : [], + paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [], + lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate), + lastUpdateErrorInfo, + scopeInfo: scopeInfo ?? { + type: ScopeType.Exchange, + currency: "UNKNOWN", + url: r.baseUrl, + }, + }; +} + +export interface ExchangeWireDetails { + currency: string; + masterPublicKey: EddsaPublicKeyString; + wireInfo: WireInfo; + exchangeBaseUrl: string; + auditors: ExchangeAuditor[]; + globalFees: ExchangeGlobalFees[]; +} + +export async function getExchangeWireDetailsInTx( + tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>, + exchangeBaseUrl: string, +): Promise<ExchangeWireDetails | undefined> { + const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl); + if (!det) { + return undefined; + } + return { + currency: det.currency, + masterPublicKey: det.masterPublicKey, + wireInfo: det.wireInfo, + exchangeBaseUrl: det.exchangeBaseUrl, + auditors: det.auditors, + globalFees: det.globalFees, + }; +} + +export async function lookupExchangeByUri( + wex: WalletExecutionContext, + req: GetExchangeEntryByUrlRequest, +): Promise<ExchangeListItem> { + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "operationRetries", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + }, + async (tx) => { + const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl); + if (!exchangeRec) { + throw Error("exchange not found"); + } + const exchangeDetails = await getExchangeRecordsInternal( + tx, + exchangeRec.baseUrl, + ); + const opRetryRecord = await tx.operationRetries.get( + TaskIdentifiers.forExchangeUpdate(exchangeRec), + ); + return await makeExchangeListItem( + tx, + exchangeRec, + exchangeDetails, + opRetryRecord?.lastError, + ); + }, + ); +} + +/** + * Mark the current ToS version as accepted by the user. + */ +export async function acceptExchangeTermsOfService( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<void> { + const notif = await wex.db.runReadWriteTx( + { storeNames: ["exchangeDetails", "exchanges"] }, + async (tx) => { + const exch = await tx.exchanges.get(exchangeBaseUrl); + if (exch && exch.tosCurrentEtag) { + const oldExchangeState = getExchangeState(exch); + exch.tosAcceptedEtag = exch.tosCurrentEtag; + exch.tosAcceptedTimestamp = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + await tx.exchanges.put(exch); + const newExchangeState = getExchangeState(exch); + wex.ws.exchangeCache.clear(); + return { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl, + newExchangeState: newExchangeState, + oldExchangeState: oldExchangeState, + } satisfies WalletNotification; + } + return undefined; + }, + ); + if (notif) { + wex.ws.notify(notif); + } +} + +/** + * Mark the current ToS version as accepted by the user. + */ +export async function forgetExchangeTermsOfService( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<void> { + const notif = await wex.db.runReadWriteTx( + { storeNames: ["exchangeDetails", "exchanges"] }, + async (tx) => { + const exch = await tx.exchanges.get(exchangeBaseUrl); + if (exch) { + const oldExchangeState = getExchangeState(exch); + exch.tosAcceptedEtag = undefined; + exch.tosAcceptedTimestamp = undefined; + await tx.exchanges.put(exch); + const newExchangeState = getExchangeState(exch); + wex.ws.exchangeCache.clear(); + return { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl, + newExchangeState: newExchangeState, + oldExchangeState: oldExchangeState, + } satisfies WalletNotification; + } + return undefined; + }, + ); + if (notif) { + wex.ws.notify(notif); + } +} + +/** + * Validate wire fees and wire accounts. + * + * Throw an exception if they are invalid. + */ +async function validateWireInfo( + wex: WalletExecutionContext, + versionCurrent: number, + wireInfo: ExchangeKeysDownloadResult, + masterPublicKey: string, +): Promise<WireInfo> { + for (const a of wireInfo.accounts) { + logger.trace("validating exchange acct"); + let isValid = false; + if (wex.ws.config.testing.insecureTrustExchange) { + isValid = true; + } else { + const { valid: v } = await wex.ws.cryptoApi.isValidWireAccount({ + masterPub: masterPublicKey, + paytoUri: a.payto_uri, + sig: a.master_sig, + versionCurrent, + conversionUrl: a.conversion_url, + creditRestrictions: a.credit_restrictions, + debitRestrictions: a.debit_restrictions, + }); + isValid = v; + } + if (!isValid) { + throw Error("exchange acct signature invalid"); + } + } + logger.trace("account validation done"); + const feesForType: WireFeeMap = {}; + for (const wireMethod of Object.keys(wireInfo.wireFees)) { + const feeList: WireFee[] = []; + for (const x of wireInfo.wireFees[wireMethod]) { + const startStamp = x.start_date; + const endStamp = x.end_date; + const fee: WireFee = { + closingFee: Amounts.stringify(x.closing_fee), + endStamp, + sig: x.sig, + startStamp, + wireFee: Amounts.stringify(x.wire_fee), + }; + let isValid = false; + if (wex.ws.config.testing.insecureTrustExchange) { + isValid = true; + } else { + const { valid: v } = await wex.ws.cryptoApi.isValidWireFee({ + masterPub: masterPublicKey, + type: wireMethod, + wf: fee, + }); + isValid = v; + } + if (!isValid) { + throw Error("exchange wire fee signature invalid"); + } + feeList.push(fee); + } + feesForType[wireMethod] = feeList; + } + + return { + accounts: wireInfo.accounts, + feesForType, + }; +} + +/** + * Validate global fees. + * + * Throw an exception if they are invalid. + */ +async function validateGlobalFees( + wex: WalletExecutionContext, + fees: GlobalFees[], + masterPub: string, +): Promise<ExchangeGlobalFees[]> { + const egf: ExchangeGlobalFees[] = []; + for (const gf of fees) { + logger.trace("validating exchange global fees"); + let isValid = false; + if (wex.ws.config.testing.insecureTrustExchange) { + isValid = true; + } else { + const { valid: v } = await wex.cryptoApi.isValidGlobalFees({ + masterPub, + gf, + }); + isValid = v; + } + + if (!isValid) { + throw Error("exchange global fees signature invalid: " + gf.master_sig); + } + egf.push({ + accountFee: Amounts.stringify(gf.account_fee), + historyFee: Amounts.stringify(gf.history_fee), + purseFee: Amounts.stringify(gf.purse_fee), + startDate: gf.start_date, + endDate: gf.end_date, + signature: gf.master_sig, + historyTimeout: gf.history_expiration, + purseLimit: gf.purse_account_limit, + purseTimeout: gf.purse_timeout, + }); + } + + return egf; +} + +/** + * Add an exchange entry to the wallet database in the + * entry state "preset". + * + * Returns the notification to the caller that should be emitted + * if the DB transaction succeeds. + */ +export async function addPresetExchangeEntry( + tx: WalletDbReadWriteTransaction<["exchanges"]>, + exchangeBaseUrl: string, + currencyHint?: string, +): Promise<{ notification?: WalletNotification }> { + let exchange = await tx.exchanges.get(exchangeBaseUrl); + if (!exchange) { + const r: ExchangeEntryRecord = { + entryStatus: ExchangeEntryDbRecordStatus.Preset, + updateStatus: ExchangeEntryDbUpdateStatus.Initial, + baseUrl: exchangeBaseUrl, + presetCurrencyHint: currencyHint, + detailsPointer: undefined, + lastUpdate: undefined, + lastKeysEtag: undefined, + nextRefreshCheckStamp: timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), + ), + nextUpdateStamp: timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), + ), + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + tosCurrentEtag: undefined, + }; + await tx.exchanges.put(r); + return { + notification: { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: exchangeBaseUrl, + // Exchange did not exist yet + oldExchangeState: undefined, + newExchangeState: getExchangeState(r), + }, + }; + } + return {}; +} + +async function provideExchangeRecordInTx( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>, + baseUrl: string, +): Promise<{ + exchange: ExchangeEntryRecord; + exchangeDetails: ExchangeDetailsRecord | undefined; + notification?: WalletNotification; +}> { + let notification: WalletNotification | undefined = undefined; + let exchange = await tx.exchanges.get(baseUrl); + if (!exchange) { + const r: ExchangeEntryRecord = { + entryStatus: ExchangeEntryDbRecordStatus.Ephemeral, + updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate, + baseUrl: baseUrl, + detailsPointer: undefined, + lastUpdate: undefined, + nextUpdateStamp: timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), + ), + nextRefreshCheckStamp: timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), + ), + // The first update should always be done in a way that ignores the cache, + // so that removing and re-adding an exchange works properly, even + // if /keys is cached in the browser. + cachebreakNextUpdate: true, + lastKeysEtag: undefined, + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + tosCurrentEtag: undefined, + }; + await tx.exchanges.put(r); + exchange = r; + notification = { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: r.baseUrl, + oldExchangeState: undefined, + newExchangeState: getExchangeState(r), + }; + } + const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl); + return { exchange, exchangeDetails, notification }; +} + +export interface ExchangeKeysDownloadResult { + baseUrl: string; + masterPublicKey: string; + currency: string; + auditors: ExchangeAuditor[]; + currentDenominations: DenominationRecord[]; + protocolVersion: string; + signingKeys: ExchangeSignKeyJson[]; + reserveClosingDelay: TalerProtocolDuration; + expiry: TalerProtocolTimestamp; + recoup: Recoup[]; + listIssueDate: TalerProtocolTimestamp; + globalFees: GlobalFees[]; + accounts: ExchangeWireAccount[]; + wireFees: { [methodName: string]: WireFeesJson[] }; +} + +/** + * Download and validate an exchange's /keys data. + */ +async function downloadExchangeKeysInfo( + baseUrl: string, + http: HttpRequestLibrary, + timeout: Duration, + cancellationToken: CancellationToken, + noCache: boolean, +): Promise<ExchangeKeysDownloadResult> { + const keysUrl = new URL("keys", baseUrl); + + const headers: Record<string, string> = {}; + if (noCache) { + headers["cache-control"] = "no-cache"; + } + const resp = await http.fetch(keysUrl.href, { + timeout, + cancellationToken, + headers, + }); + + logger.info("got response to /keys request"); + + // We must make sure to parse out the protocol version + // before we validate the body. + // Otherwise the parser might complain with a hard to understand + // message about some other field, when it is just a version + // incompatibility. + + const keysJson = await resp.json(); + + const protocolVersion = keysJson.version; + if (typeof protocolVersion !== "string") { + throw Error("bad exchange, does not even specify protocol version"); + } + + const versionRes = LibtoolVersion.compare( + WALLET_EXCHANGE_PROTOCOL_VERSION, + protocolVersion, + ); + if (!versionRes) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: resp.requestUrl, + httpStatusCode: resp.status, + requestMethod: resp.requestMethod, + }, + "exchange protocol version malformed", + ); + } + if (!versionRes.compatible) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE, + { + exchangeProtocolVersion: protocolVersion, + walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, + }, + "exchange protocol version not compatible with wallet", + ); + } + + const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeKeysJson(), + ); + + if (exchangeKeysJsonUnchecked.denominations.length === 0) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, + { + exchangeBaseUrl: baseUrl, + }, + "exchange doesn't offer any denominations", + ); + } + + const currency = exchangeKeysJsonUnchecked.currency; + + const currentDenominations: DenominationRecord[] = []; + + for (const denomGroup of exchangeKeysJsonUnchecked.denominations) { + switch (denomGroup.cipher) { + case "RSA": + case "RSA+age_restricted": { + let ageMask = 0; + if (denomGroup.cipher === "RSA+age_restricted") { + ageMask = denomGroup.age_mask; + } + for (const denomIn of denomGroup.denoms) { + const denomPub: DenominationPubKey = { + age_mask: ageMask, + cipher: DenomKeyType.Rsa, + rsa_public_key: denomIn.rsa_pub, + }; + const denomPubHash = encodeCrock(hashDenomPub(denomPub)); + const value = Amounts.parseOrThrow(denomGroup.value); + const rec: DenominationRecord = { + denomPub, + denomPubHash, + exchangeBaseUrl: baseUrl, + exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key, + isOffered: true, + isRevoked: false, + isLost: denomIn.lost ?? false, + value: Amounts.stringify(value), + currency: value.currency, + stampExpireDeposit: timestampProtocolToDb( + denomIn.stamp_expire_deposit, + ), + stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal), + stampExpireWithdraw: timestampProtocolToDb( + denomIn.stamp_expire_withdraw, + ), + stampStart: timestampProtocolToDb(denomIn.stamp_start), + verificationStatus: DenominationVerificationStatus.Unverified, + masterSig: denomIn.master_sig, + fees: { + feeDeposit: Amounts.stringify(denomGroup.fee_deposit), + feeRefresh: Amounts.stringify(denomGroup.fee_refresh), + feeRefund: Amounts.stringify(denomGroup.fee_refund), + feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw), + }, + }; + currentDenominations.push(rec); + } + break; + } + case "CS+age_restricted": + case "CS": + logger.warn("Clause-Schnorr denominations not supported"); + continue; + default: + logger.warn( + `denomination type ${(denomGroup as any).cipher} not supported`, + ); + continue; + } + } + + return { + masterPublicKey: exchangeKeysJsonUnchecked.master_public_key, + currency, + baseUrl: exchangeKeysJsonUnchecked.base_url, + auditors: exchangeKeysJsonUnchecked.auditors, + currentDenominations, + protocolVersion: exchangeKeysJsonUnchecked.version, + signingKeys: exchangeKeysJsonUnchecked.signkeys, + reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay, + expiry: AbsoluteTime.toProtocolTimestamp( + getExpiry(resp, { + minDuration: Duration.fromSpec({ hours: 1 }), + }), + ), + recoup: exchangeKeysJsonUnchecked.recoup ?? [], + listIssueDate: exchangeKeysJsonUnchecked.list_issue_date, + globalFees: exchangeKeysJsonUnchecked.global_fees, + accounts: exchangeKeysJsonUnchecked.accounts, + wireFees: exchangeKeysJsonUnchecked.wire_fees, + }; +} + +async function downloadTosFromAcceptedFormat( + wex: WalletExecutionContext, + baseUrl: string, + timeout: Duration, + acceptedFormat?: string[], + acceptLanguage?: string, +): Promise<ExchangeTosDownloadResult> { + let tosFound: ExchangeTosDownloadResult | undefined; + // Remove this when exchange supports multiple content-type in accept header + if (acceptedFormat) + for (const format of acceptedFormat) { + const resp = await downloadExchangeWithTermsOfService( + wex, + baseUrl, + wex.http, + timeout, + format, + acceptLanguage, + ); + if (resp.tosContentType === format) { + tosFound = resp; + break; + } + } + if (tosFound !== undefined) { + return tosFound; + } + // If none of the specified format was found try text/plain + return await downloadExchangeWithTermsOfService( + wex, + baseUrl, + wex.http, + timeout, + "text/plain", + acceptLanguage, + ); +} + +/** + * Transition an exchange into an updating state. + * + * If the update is forced, the exchange is put into an updating state + * even if the old information should still be up to date. + * + * If the exchange entry doesn't exist, + * a new ephemeral entry is created. + */ +async function startUpdateExchangeEntry( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + options: { forceUpdate?: boolean } = {}, +): Promise<void> { + logger.info( + `starting update of exchange entry ${exchangeBaseUrl}, forced=${ + options.forceUpdate ?? false + }`, + ); + + const { notification } = await wex.db.runReadWriteTx( + { storeNames: ["exchanges", "exchangeDetails"] }, + async (tx) => { + wex.ws.exchangeCache.clear(); + return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl); + }, + ); + + logger.trace("created exchange record"); + + if (notification) { + wex.ws.notify(notification); + } + + const { oldExchangeState, newExchangeState, taskId } = + await wex.db.runReadWriteTx( + { storeNames: ["exchanges", "operationRetries"] }, + async (tx) => { + const r = await tx.exchanges.get(exchangeBaseUrl); + if (!r) { + throw Error("exchange not found"); + } + const oldExchangeState = getExchangeState(r); + switch (r.updateStatus) { + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + r.cachebreakNextUpdate = options.forceUpdate; + break; + case ExchangeEntryDbUpdateStatus.Suspended: + r.cachebreakNextUpdate = options.forceUpdate; + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + r.cachebreakNextUpdate = options.forceUpdate; + break; + case ExchangeEntryDbUpdateStatus.Ready: { + const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp( + timestampPreciseFromDb(r.nextUpdateStamp), + ); + // Only update if entry is outdated or update is forced. + if ( + options.forceUpdate || + AbsoluteTime.isExpired(nextUpdateTimestamp) + ) { + r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate; + r.cachebreakNextUpdate = options.forceUpdate; + } + break; + } + case ExchangeEntryDbUpdateStatus.Initial: + r.cachebreakNextUpdate = options.forceUpdate; + r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate; + break; + case ExchangeEntryDbUpdateStatus.InitialUpdate: + r.cachebreakNextUpdate = options.forceUpdate; + break; + } + wex.ws.exchangeCache.clear(); + await tx.exchanges.put(r); + const newExchangeState = getExchangeState(r); + // Reset retries for updating the exchange entry. + const taskId = TaskIdentifiers.forExchangeUpdate(r); + await tx.operationRetries.delete(taskId); + return { oldExchangeState, newExchangeState, taskId }; + }, + ); + wex.ws.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl, + newExchangeState: newExchangeState, + oldExchangeState: oldExchangeState, + }); + await wex.taskScheduler.resetTaskRetries(taskId); +} + +/** + * Basic information about an exchange in a ready state. + */ +export interface ReadyExchangeSummary { + exchangeBaseUrl: string; + currency: string; + masterPub: string; + tosStatus: ExchangeTosStatus; + tosAcceptedEtag: string | undefined; + tosCurrentEtag: string | undefined; + wireInfo: WireInfo; + protocolVersionRange: string; + tosAcceptedTimestamp: TalerPreciseTimestamp | undefined; + scopeInfo: ScopeInfo; +} + +async function internalWaitReadyExchange( + wex: WalletExecutionContext, + canonUrl: string, + exchangeNotifFlag: AsyncFlag, + options: { + cancellationToken?: CancellationToken; + forceUpdate?: boolean; + expectedMasterPub?: string; + } = {}, +): Promise<ReadyExchangeSummary> { + const operationId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: canonUrl, + }); + while (true) { + if (wex.cancellationToken.isCancelled) { + throw Error("cancelled"); + } + logger.info(`waiting for ready exchange ${canonUrl}`); + const { exchange, exchangeDetails, retryInfo, scopeInfo } = + await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "operationRetries", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + }, + async (tx) => { + const exchange = await tx.exchanges.get(canonUrl); + const exchangeDetails = await getExchangeRecordsInternal( + tx, + canonUrl, + ); + const retryInfo = await tx.operationRetries.get(operationId); + let scopeInfo: ScopeInfo | undefined = undefined; + if (exchange && exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); + } + return { exchange, exchangeDetails, retryInfo, scopeInfo }; + }, + ); + + if (!exchange) { + throw Error("exchange entry does not exist anymore"); + } + + let ready = false; + + switch (exchange.updateStatus) { + case ExchangeEntryDbUpdateStatus.Ready: + ready = true; + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + // If the update is forced, + // we wait until we're in a full "ready" state, + // as we're not happy with the stale information. + if (!options.forceUpdate) { + ready = true; + } + break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + default: { + if (retryInfo) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + } + } + } + + if (!ready) { + logger.info("waiting for exchange update notification"); + await exchangeNotifFlag.wait(); + logger.info("done waiting for exchange update notification"); + exchangeNotifFlag.reset(); + continue; + } + + if (!exchangeDetails) { + throw Error("invariant failed"); + } + + if (!scopeInfo) { + throw Error("invariant failed"); + } + + const res: ReadyExchangeSummary = { + currency: exchangeDetails.currency, + exchangeBaseUrl: canonUrl, + masterPub: exchangeDetails.masterPublicKey, + tosStatus: getExchangeTosStatusFromRecord(exchange), + tosAcceptedEtag: exchange.tosAcceptedEtag, + wireInfo: exchangeDetails.wireInfo, + protocolVersionRange: exchangeDetails.protocolVersionRange, + tosCurrentEtag: exchange.tosCurrentEtag, + tosAcceptedTimestamp: timestampOptionalPreciseFromDb( + exchange.tosAcceptedTimestamp, + ), + scopeInfo, + }; + + if (options.expectedMasterPub) { + if (res.masterPub !== options.expectedMasterPub) { + throw Error( + "public key of the exchange does not match expected public key", + ); + } + } + return res; + } +} + +/** + * Ensure that a fresh exchange entry exists for the given + * exchange base URL. + * + * The cancellation token can be used to abort waiting for the + * updated exchange entry. + * + * If an exchange entry for the database doesn't exist in the + * DB, it will be added ephemerally. + * + * If the expectedMasterPub is given and does not match the actual + * master pub, an exception will be thrown. However, the exchange + * will still have been added as an ephemeral exchange entry. + */ +export async function fetchFreshExchange( + wex: WalletExecutionContext, + baseUrl: string, + options: { + forceUpdate?: boolean; + } = {}, +): Promise<ReadyExchangeSummary> { + if (!options.forceUpdate) { + const cachedResp = wex.ws.exchangeCache.get(baseUrl); + if (cachedResp) { + return cachedResp; + } + } else { + wex.ws.exchangeCache.clear(); + } + + await wex.taskScheduler.ensureRunning(); + + await startUpdateExchangeEntry(wex, baseUrl, { + forceUpdate: options.forceUpdate, + }); + + const resp = await waitReadyExchange(wex, baseUrl, options); + wex.ws.exchangeCache.put(baseUrl, resp); + return resp; +} + +async function waitReadyExchange( + wex: WalletExecutionContext, + canonUrl: string, + options: { + forceUpdate?: boolean; + expectedMasterPub?: string; + } = {}, +): Promise<ReadyExchangeSummary> { + logger.trace(`waiting for exchange ${canonUrl} to become ready`); + // FIXME: We should use Symbol.dispose magic here for cleanup! + + const exchangeNotifFlag = new AsyncFlag(); + // Raise exchangeNotifFlag whenever we get a notification + // about our exchange. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === canonUrl + ) { + logger.info(`raising update notification: ${j2s(notif)}`); + exchangeNotifFlag.raise(); + } + }); + + const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { + cancelNotif(); + exchangeNotifFlag.raise(); + }); + + try { + const res = await internalWaitReadyExchange( + wex, + canonUrl, + exchangeNotifFlag, + options, + ); + logger.info("done waiting for ready exchange"); + return res; + } finally { + unregisterOnCancelled(); + cancelNotif(); + } +} + +function checkPeerPaymentsDisabled( + keysInfo: ExchangeKeysDownloadResult, +): boolean { + const now = AbsoluteTime.now(); + for (let gf of keysInfo.globalFees) { + const isActive = AbsoluteTime.isBetween( + now, + AbsoluteTime.fromProtocolTimestamp(gf.start_date), + AbsoluteTime.fromProtocolTimestamp(gf.end_date), + ); + if (!isActive) { + continue; + } + return false; + } + // No global fees, we can't do p2p payments! + return true; +} + +function checkNoFees(keysInfo: ExchangeKeysDownloadResult): boolean { + for (const gf of keysInfo.globalFees) { + if (!Amounts.isZero(gf.account_fee)) { + return false; + } + if (!Amounts.isZero(gf.history_fee)) { + return false; + } + if (!Amounts.isZero(gf.purse_fee)) { + return false; + } + } + for (const denom of keysInfo.currentDenominations) { + if (!Amounts.isZero(denom.fees.feeWithdraw)) { + return false; + } + if (!Amounts.isZero(denom.fees.feeDeposit)) { + return false; + } + if (!Amounts.isZero(denom.fees.feeRefund)) { + return false; + } + if (!Amounts.isZero(denom.fees.feeRefresh)) { + return false; + } + } + for (const wft of Object.values(keysInfo.wireFees)) { + for (const wf of wft) { + if (!Amounts.isZero(wf.wire_fee)) { + return false; + } + } + } + return true; +} + +/** + * Update an exchange entry in the wallet's database + * by fetching the /keys and /wire information. + * Optionally link the reserve entry to the new or existing + * exchange entry in then DB. + */ +export async function updateExchangeFromUrlHandler( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<TaskRunResult> { + logger.trace(`updating exchange info for ${exchangeBaseUrl}`); + + const oldExchangeRec = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges"] }, + async (tx) => { + return tx.exchanges.get(exchangeBaseUrl); + }, + ); + + if (!oldExchangeRec) { + logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`); + return TaskRunResult.finished(); + } + + let updateRequestedExplicitly = false; + + switch (oldExchangeRec.updateStatus) { + case ExchangeEntryDbUpdateStatus.Suspended: + logger.info(`not updating exchange in status "suspended"`); + return TaskRunResult.finished(); + case ExchangeEntryDbUpdateStatus.Initial: + logger.info(`not updating exchange in status "initial"`); + return TaskRunResult.finished(); + case ExchangeEntryDbUpdateStatus.InitialUpdate: + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + updateRequestedExplicitly = true; + break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + // Only retry when scheduled to respect backoff + break; + case ExchangeEntryDbUpdateStatus.Ready: + break; + default: + assertUnreachable(oldExchangeRec.updateStatus); + } + + let refreshCheckNecessary = true; + + if (!updateRequestedExplicitly) { + // If the update wasn't requested explicitly, + // check if we really need to update. + + let nextUpdateStamp = timestampAbsoluteFromDb( + oldExchangeRec.nextUpdateStamp, + ); + + let nextRefreshCheckStamp = timestampAbsoluteFromDb( + oldExchangeRec.nextRefreshCheckStamp, + ); + + let updateNecessary = true; + + if ( + !AbsoluteTime.isNever(nextUpdateStamp) && + !AbsoluteTime.isExpired(nextUpdateStamp) + ) { + logger.info( + `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString( + nextUpdateStamp, + )}`, + ); + updateNecessary = false; + } + + if ( + !AbsoluteTime.isNever(nextRefreshCheckStamp) && + !AbsoluteTime.isExpired(nextRefreshCheckStamp) + ) { + logger.info( + `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString( + nextRefreshCheckStamp, + )}`, + ); + refreshCheckNecessary = false; + } + + if (!(updateNecessary || refreshCheckNecessary)) { + logger.trace("update not necessary, running again later"); + return TaskRunResult.runAgainAt( + AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp), + ); + } + } + + // When doing the auto-refresh check, we always update + // the key info before that. + + logger.trace("updating exchange /keys info"); + + const timeout = getExchangeRequestTimeout(); + + const keysInfo = await downloadExchangeKeysInfo( + exchangeBaseUrl, + wex.http, + timeout, + wex.cancellationToken, + oldExchangeRec.cachebreakNextUpdate ?? false, + ); + + logger.trace("validating exchange wire info"); + + const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion); + if (!version) { + // Should have been validated earlier. + throw Error("unexpected invalid version"); + } + + const wireInfo = await validateWireInfo( + wex, + version.current, + keysInfo, + keysInfo.masterPublicKey, + ); + + const globalFees = await validateGlobalFees( + wex, + keysInfo.globalFees, + keysInfo.masterPublicKey, + ); + + if (keysInfo.baseUrl != exchangeBaseUrl) { + logger.warn("exchange base URL mismatch"); + const errorDetail: TalerErrorDetail = makeErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH, + { + urlWallet: exchangeBaseUrl, + urlExchange: keysInfo.baseUrl, + }, + ); + return { + type: TaskRunResultType.Error, + errorDetail, + }; + } + + logger.trace("finished validating exchange /wire info"); + + // We download the text/plain version here, + // because that one needs to exist, and we + // will get the current etag from the response. + const tosDownload = await downloadTosFromAcceptedFormat( + wex, + exchangeBaseUrl, + timeout, + ["text/plain"], + ); + + logger.trace("updating exchange info in database"); + + let ageMask = 0; + for (const x of keysInfo.currentDenominations) { + if ( + isWithdrawableDenom(x, wex.ws.config.testing.denomselAllowLate) && + x.denomPub.age_mask != 0 + ) { + ageMask = x.denomPub.age_mask; + break; + } + } + let noFees = checkNoFees(keysInfo); + let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo); + + const updated = await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "exchangeSignKeys", + "denominations", + "coins", + "refreshGroups", + "recoupGroups", + "coinAvailability", + "denomLossEvents", + ], + }, + async (tx) => { + const r = await tx.exchanges.get(exchangeBaseUrl); + if (!r) { + logger.warn(`exchange ${exchangeBaseUrl} no longer present`); + return; + } + + wex.ws.refreshCostCache.clear(); + wex.ws.exchangeCache.clear(); + wex.ws.denomInfoCache.clear(); + + const oldExchangeState = getExchangeState(r); + const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl); + let detailsPointerChanged = false; + if (!existingDetails) { + detailsPointerChanged = true; + } + let detailsIncompatible = false; + if (existingDetails) { + if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) { + detailsIncompatible = true; + detailsPointerChanged = true; + } + if (existingDetails.currency !== keysInfo.currency) { + detailsIncompatible = true; + detailsPointerChanged = true; + } + // FIXME: We need to do some consistency checks! + } + if (detailsIncompatible) { + logger.warn( + `exchange ${r.baseUrl} has incompatible data in /keys, not updating`, + ); + // We don't support this gracefully right now. + // See https://bugs.taler.net/n/8576 + r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate; + r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1; + r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter); + r.nextRefreshCheckStamp = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), + ); + r.cachebreakNextUpdate = true; + await tx.exchanges.put(r); + return { + oldExchangeState, + newExchangeState: getExchangeState(r), + }; + } + r.updateRetryCounter = 0; + const newDetails: ExchangeDetailsRecord = { + auditors: keysInfo.auditors, + currency: keysInfo.currency, + masterPublicKey: keysInfo.masterPublicKey, + protocolVersionRange: keysInfo.protocolVersion, + reserveClosingDelay: keysInfo.reserveClosingDelay, + globalFees, + exchangeBaseUrl: r.baseUrl, + wireInfo, + ageMask, + }; + r.noFees = noFees; + r.peerPaymentsDisabled = peerPaymentsDisabled; + r.tosCurrentEtag = tosDownload.tosEtag; + if (existingDetails?.rowId) { + newDetails.rowId = existingDetails.rowId; + } + r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now()); + r.nextUpdateStamp = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp( + AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry), + ), + ); + // New denominations might be available. + r.nextRefreshCheckStamp = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + if (detailsPointerChanged) { + r.detailsPointer = { + currency: newDetails.currency, + masterPublicKey: newDetails.masterPublicKey, + updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()), + }; + } + + r.updateStatus = ExchangeEntryDbUpdateStatus.Ready; + r.cachebreakNextUpdate = false; + await tx.exchanges.put(r); + const drRowId = await tx.exchangeDetails.put(newDetails); + checkDbInvariant(typeof drRowId.key === "number"); + + for (const sk of keysInfo.signingKeys) { + // FIXME: validate signing keys before inserting them + await tx.exchangeSignKeys.put({ + exchangeDetailsRowId: drRowId.key, + masterSig: sk.master_sig, + signkeyPub: sk.key, + stampEnd: timestampProtocolToDb(sk.stamp_end), + stampExpire: timestampProtocolToDb(sk.stamp_expire), + stampStart: timestampProtocolToDb(sk.stamp_start), + }); + } + + // In the future: Filter out old denominations by index + const allOldDenoms = + await tx.denominations.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + const oldDenomByDph = new Map<string, DenominationRecord>(); + for (const denom of allOldDenoms) { + oldDenomByDph.set(denom.denomPubHash, denom); + } + + logger.trace("updating denominations in database"); + const currentDenomSet = new Set<string>( + keysInfo.currentDenominations.map((x) => x.denomPubHash), + ); + + for (const currentDenom of keysInfo.currentDenominations) { + const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash); + if (oldDenom) { + // FIXME: Do consistency check, report to auditor if necessary. + // See https://bugs.taler.net/n/8594 + + // Mark lost denominations as lost. + if (currentDenom.isLost && !oldDenom.isLost) { + logger.warn( + `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`, + ); + oldDenom.isLost = true; + await tx.denominations.put(currentDenom); + } + } else { + await tx.denominations.put(currentDenom); + } + } + + // Update list issue date for all denominations, + // and mark non-offered denominations as such. + for (const x of allOldDenoms) { + if (!currentDenomSet.has(x.denomPubHash)) { + // FIXME: Here, an auditor report should be created, unless + // the denomination is really legally expired. + if (x.isOffered) { + x.isOffered = false; + logger.info( + `setting denomination ${x.denomPubHash} to offered=false`, + ); + } + } else { + if (!x.isOffered) { + x.isOffered = true; + logger.info( + `setting denomination ${x.denomPubHash} to offered=true`, + ); + } + } + await tx.denominations.put(x); + } + + logger.trace("done updating denominations in database"); + + const denomLossResult = await handleDenomLoss( + wex, + tx, + newDetails.currency, + exchangeBaseUrl, + ); + + await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup); + + const newExchangeState = getExchangeState(r); + + return { + exchange: r, + exchangeDetails: newDetails, + oldExchangeState, + newExchangeState, + denomLossResult, + }; + }, + ); + + if (!updated) { + throw Error("something went wrong with updating the exchange"); + } + + if (updated.denomLossResult) { + for (const notif of updated.denomLossResult.notifications) { + wex.ws.notify(notif); + } + } + + logger.trace("done updating exchange info in database"); + + logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`); + + let minCheckThreshold = AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 1 }), + ); + + if (refreshCheckNecessary) { + // Do auto-refresh. + await wex.db.runReadWriteTx( + { + storeNames: [ + "coins", + "denominations", + "coinAvailability", + "refreshGroups", + "refreshSessions", + "exchanges", + ], + }, + async (tx) => { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + if (!exchange || !exchange.detailsPointer) { + return; + } + const coins = await tx.coins.indexes.byBaseUrl + .iter(exchangeBaseUrl) + .toArray(); + const refreshCoins: CoinRefreshRequest[] = []; + for (const coin of coins) { + if (coin.status !== CoinStatus.Fresh) { + continue; + } + const denom = await tx.denominations.get([ + exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + logger.warn("denomination not in database"); + continue; + } + const executeThreshold = + getAutoRefreshExecuteThresholdForDenom(denom); + if (AbsoluteTime.isExpired(executeThreshold)) { + refreshCoins.push({ + coinPub: coin.coinPub, + amount: denom.value, + }); + } else { + const checkThreshold = getAutoRefreshCheckThreshold(denom); + minCheckThreshold = AbsoluteTime.min( + minCheckThreshold, + checkThreshold, + ); + } + } + if (refreshCoins.length > 0) { + const res = await createRefreshGroup( + wex, + tx, + exchange.detailsPointer?.currency, + refreshCoins, + RefreshReason.Scheduled, + undefined, + ); + logger.trace( + `created refresh group for auto-refresh (${res.refreshGroupId})`, + ); + } + logger.trace( + `next refresh check at ${AbsoluteTime.toIsoString( + minCheckThreshold, + )}`, + ); + exchange.nextRefreshCheckStamp = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(minCheckThreshold), + ); + wex.ws.exchangeCache.clear(); + await tx.exchanges.put(exchange); + }, + ); + } + + wex.ws.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl, + newExchangeState: updated.newExchangeState, + oldExchangeState: updated.oldExchangeState, + }); + + // Next invocation will cause the task to be run again + // at the necessary time. + return TaskRunResult.progress(); +} + +interface DenomLossResult { + notifications: WalletNotification[]; +} + +async function handleDenomLoss( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["coinAvailability", "denominations", "denomLossEvents", "coins"] + >, + currency: string, + exchangeBaseUrl: string, +): Promise<DenomLossResult> { + const coinAvailabilityRecs = + await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl); + const denomsVanished: string[] = []; + const denomsUnoffered: string[] = []; + const denomsExpired: string[] = []; + let amountVanished = Amount.zeroOfCurrency(currency); + let amountExpired = Amount.zeroOfCurrency(currency); + let amountUnoffered = Amount.zeroOfCurrency(currency); + + const result: DenomLossResult = { + notifications: [], + }; + + for (const coinAv of coinAvailabilityRecs) { + if (coinAv.freshCoinCount <= 0) { + continue; + } + const n = coinAv.freshCoinCount; + const denom = await tx.denominations.get([ + coinAv.exchangeBaseUrl, + coinAv.denomPubHash, + ]); + const timestampExpireDeposit = !denom + ? undefined + : timestampAbsoluteFromDb(denom.stampExpireDeposit); + if (!denom) { + // Remove availability + coinAv.freshCoinCount = 0; + coinAv.visibleCoinCount = 0; + await tx.coinAvailability.put(coinAv); + denomsVanished.push(coinAv.denomPubHash); + const total = Amount.from(coinAv.value).mult(n); + amountVanished = amountVanished.add(total); + } else if (!denom.isOffered) { + // Remove availability + coinAv.freshCoinCount = 0; + coinAv.visibleCoinCount = 0; + await tx.coinAvailability.put(coinAv); + denomsUnoffered.push(coinAv.denomPubHash); + const total = Amount.from(coinAv.value).mult(n); + amountUnoffered = amountUnoffered.add(total); + } else if ( + timestampExpireDeposit && + AbsoluteTime.isExpired(timestampExpireDeposit) + ) { + // Remove availability + coinAv.freshCoinCount = 0; + coinAv.visibleCoinCount = 0; + await tx.coinAvailability.put(coinAv); + denomsExpired.push(coinAv.denomPubHash); + const total = Amount.from(coinAv.value).mult(n); + amountExpired = amountExpired.add(total); + } else { + // Denomination is still fine! + continue; + } + + logger.warn(`denomination ${coinAv.denomPubHash} is a loss`); + + const coins = await tx.coins.indexes.byDenomPubHash.getAll( + coinAv.denomPubHash, + ); + for (const coin of coins) { + switch (coin.status) { + case CoinStatus.Fresh: + case CoinStatus.FreshSuspended: { + coin.status = CoinStatus.DenomLoss; + await tx.coins.put(coin); + break; + } + } + } + } + + if (denomsVanished.length > 0) { + const denomLossEventId = encodeCrock(getRandomBytes(32)); + await tx.denomLossEvents.add({ + denomLossEventId, + amount: amountVanished.toString(), + currency, + exchangeBaseUrl, + denomPubHashes: denomsVanished, + eventType: DenomLossEventType.DenomVanished, + status: DenomLossStatus.Done, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.DenomLoss, + denomLossEventId, + }); + result.notifications.push({ + type: NotificationType.TransactionStateTransition, + transactionId, + oldTxState: { + major: TransactionMajorState.None, + }, + newTxState: { + major: TransactionMajorState.Done, + }, + }); + result.notifications.push({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + } + + if (denomsUnoffered.length > 0) { + const denomLossEventId = encodeCrock(getRandomBytes(32)); + await tx.denomLossEvents.add({ + denomLossEventId, + amount: amountUnoffered.toString(), + currency, + exchangeBaseUrl, + denomPubHashes: denomsUnoffered, + eventType: DenomLossEventType.DenomUnoffered, + status: DenomLossStatus.Done, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.DenomLoss, + denomLossEventId, + }); + result.notifications.push({ + type: NotificationType.TransactionStateTransition, + transactionId, + oldTxState: { + major: TransactionMajorState.None, + }, + newTxState: { + major: TransactionMajorState.Done, + }, + }); + result.notifications.push({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + } + + if (denomsExpired.length > 0) { + const denomLossEventId = encodeCrock(getRandomBytes(32)); + await tx.denomLossEvents.add({ + denomLossEventId, + amount: amountExpired.toString(), + currency, + exchangeBaseUrl, + denomPubHashes: denomsUnoffered, + eventType: DenomLossEventType.DenomExpired, + status: DenomLossStatus.Done, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.DenomLoss, + denomLossEventId, + }); + result.notifications.push({ + type: NotificationType.TransactionStateTransition, + transactionId, + oldTxState: { + major: TransactionMajorState.None, + }, + newTxState: { + major: TransactionMajorState.Done, + }, + }); + result.notifications.push({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + } + + return result; +} + +export function computeDenomLossTransactionStatus( + rec: DenomLossEventRecord, +): TransactionState { + switch (rec.status) { + case DenomLossStatus.Aborted: + return { + major: TransactionMajorState.Aborted, + }; + case DenomLossStatus.Done: + return { + major: TransactionMajorState.Done, + }; + } +} + +export class DenomLossTransactionContext implements TransactionContext { + get taskId(): TaskIdStr | undefined { + return undefined; + } + transactionId: TransactionIdStr; + + abortTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + suspendTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + resumeTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + failTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + async deleteTransaction(): Promise<void> { + const transitionInfo = await this.wex.db.runReadWriteTx( + { storeNames: ["denomLossEvents"] }, + async (tx) => { + const rec = await tx.denomLossEvents.get(this.denomLossEventId); + if (rec) { + const oldTxState = computeDenomLossTransactionStatus(rec); + await tx.denomLossEvents.delete(this.denomLossEventId); + return { + oldTxState, + newTxState: { + major: TransactionMajorState.Deleted, + }, + }; + } + return undefined; + }, + ); + notifyTransition(this.wex, this.transactionId, transitionInfo); + } + + constructor( + private wex: WalletExecutionContext, + public denomLossEventId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.DenomLoss, + denomLossEventId, + }); + } +} + +async function handleRecoup( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["denominations", "coins", "recoupGroups", "refreshGroups"] + >, + exchangeBaseUrl: string, + recoup: Recoup[], +): Promise<void> { + // Handle recoup + const recoupDenomList = recoup; + const newlyRevokedCoinPubs: string[] = []; + logger.trace("recoup list from exchange", recoupDenomList); + for (const recoupInfo of recoupDenomList) { + const oldDenom = await tx.denominations.get([ + exchangeBaseUrl, + recoupInfo.h_denom_pub, + ]); + if (!oldDenom) { + // We never even knew about the revoked denomination, all good. + continue; + } + if (oldDenom.isRevoked) { + // We already marked the denomination as revoked, + // this implies we revoked all coins + logger.trace("denom already revoked"); + continue; + } + logger.info("revoking denom", recoupInfo.h_denom_pub); + oldDenom.isRevoked = true; + await tx.denominations.put(oldDenom); + const affectedCoins = await tx.coins.indexes.byDenomPubHash.getAll( + recoupInfo.h_denom_pub, + ); + for (const ac of affectedCoins) { + newlyRevokedCoinPubs.push(ac.coinPub); + } + } + if (newlyRevokedCoinPubs.length != 0) { + logger.info("recouping coins", newlyRevokedCoinPubs); + await createRecoupGroup(wex, tx, exchangeBaseUrl, newlyRevokedCoinPubs); + } +} + +function getAutoRefreshExecuteThresholdForDenom( + d: DenominationRecord, +): AbsoluteTime { + return getAutoRefreshExecuteThreshold({ + stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw), + stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit), + }); +} + +/** + * Timestamp after which the wallet would do the next check for an auto-refresh. + */ +function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime { + const expireWithdraw = AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(d.stampExpireWithdraw), + ); + const expireDeposit = AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(d.stampExpireDeposit), + ); + const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit); + const deltaDiv = durationMul(delta, 0.75); + return AbsoluteTime.addDuration(expireWithdraw, deltaDiv); +} + +/** + * Find a payto:// URI of the exchange that is of one + * of the given target types. + * + * Throws if no matching account was found. + */ +export async function getExchangePaytoUri( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + supportedTargetTypes: string[], +): Promise<string> { + // We do the update here, since the exchange might not even exist + // yet in our database. + const details = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "exchangeDetails"] }, + async (tx) => { + return getExchangeRecordsInternal(tx, exchangeBaseUrl); + }, + ); + const accounts = details?.wireInfo.accounts ?? []; + for (const account of accounts) { + const res = parsePaytoUri(account.payto_uri); + if (!res) { + continue; + } + if (supportedTargetTypes.includes(res.targetType)) { + return account.payto_uri; + } + } + throw Error( + `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s( + supportedTargetTypes, + )}`, + ); +} + +/** + * Get the exchange ToS in the requested format. + * Try to download in the accepted format not cached. + */ +export async function getExchangeTos( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + acceptedFormat?: string[], + acceptLanguage?: string, +): Promise<GetExchangeTosResult> { + const exch = await fetchFreshExchange(wex, exchangeBaseUrl); + + const tosDownload = await downloadTosFromAcceptedFormat( + wex, + exchangeBaseUrl, + getExchangeRequestTimeout(), + acceptedFormat, + acceptLanguage, + ); + + await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, async (tx) => { + const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl); + if (updateExchangeEntry) { + updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag; + wex.ws.exchangeCache.clear(); + await tx.exchanges.put(updateExchangeEntry); + } + }); + + return { + acceptedEtag: exch.tosAcceptedEtag, + currentEtag: tosDownload.tosEtag, + content: tosDownload.tosText, + contentType: tosDownload.tosContentType, + contentLanguage: tosDownload.tosContentLanguage, + tosStatus: exch.tosStatus, + tosAvailableLanguages: tosDownload.tosAvailableLanguages, + }; +} + +/** + * Parsed information about an exchange, + * obtained by requesting /keys. + */ +export interface ExchangeInfo { + keys: ExchangeKeysDownloadResult; +} + +/** + * Helper function to download the exchange /keys info. + * + * Only used for testing / dbless wallet. + */ +export async function downloadExchangeInfo( + exchangeBaseUrl: string, + http: HttpRequestLibrary, +): Promise<ExchangeInfo> { + const keysInfo = await downloadExchangeKeysInfo( + exchangeBaseUrl, + http, + Duration.getForever(), + CancellationToken.CONTINUE, + false, + ); + return { + keys: keysInfo, + }; +} + +/** + * List all exchange entries known to the wallet. + */ +export async function listExchanges( + wex: WalletExecutionContext, +): Promise<ExchangesListResponse> { + const exchanges: ExchangeListItem[] = []; + await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "operationRetries", + "exchangeDetails", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + }, + async (tx) => { + const exchangeRecords = await tx.exchanges.iter().toArray(); + for (const r of exchangeRecords) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: r.baseUrl, + }); + const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl); + const opRetryRecord = await tx.operationRetries.get(taskId); + exchanges.push( + await makeExchangeListItem( + tx, + r, + exchangeDetails, + opRetryRecord?.lastError, + ), + ); + } + }, + ); + return { exchanges }; +} + +/** + * Transition an exchange to the "used" entry state if necessary. + * + * Should be called whenever the exchange is actively used by the client (for withdrawals etc.). + * + * The caller should emit the returned notification iff the current transaction + * succeeded. + */ +export async function markExchangeUsed( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction<["exchanges"]>, + exchangeBaseUrl: string, +): Promise<{ notif: WalletNotification | undefined }> { + logger.info(`marking exchange ${exchangeBaseUrl} as used`); + const exch = await tx.exchanges.get(exchangeBaseUrl); + if (!exch) { + return { + notif: undefined, + }; + } + const oldExchangeState = getExchangeState(exch); + switch (exch.entryStatus) { + case ExchangeEntryDbRecordStatus.Ephemeral: + case ExchangeEntryDbRecordStatus.Preset: { + exch.entryStatus = ExchangeEntryDbRecordStatus.Used; + await tx.exchanges.put(exch); + const newExchangeState = getExchangeState(exch); + return { + notif: { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl, + newExchangeState: newExchangeState, + oldExchangeState: oldExchangeState, + } satisfies WalletNotification, + }; + } + default: + return { + notif: undefined, + }; + } +} + +/** + * Get detailed information about the exchange including a timeline + * for the fees charged by the exchange. + */ +export async function getExchangeDetailedInfo( + wex: WalletExecutionContext, + exchangeBaseurl: string, +): Promise<ExchangeDetailedResponse> { + const exchange = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "exchangeDetails", "denominations"] }, + async (tx) => { + const ex = await tx.exchanges.get(exchangeBaseurl); + const dp = ex?.detailsPointer; + if (!dp) { + return; + } + const { currency } = dp; + const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl); + if (!exchangeDetails) { + return; + } + const denominationRecords = + await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl); + + if (!denominationRecords) { + return; + } + + const denominations: DenominationInfo[] = denominationRecords.map((x) => + DenominationRecord.toDenomInfo(x), + ); + + return { + info: { + exchangeBaseUrl: ex.baseUrl, + currency, + paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), + auditors: exchangeDetails.auditors, + wireInfo: exchangeDetails.wireInfo, + globalFees: exchangeDetails.globalFees, + }, + denominations, + }; + }, + ); + + if (!exchange) { + throw Error(`exchange with base url "${exchangeBaseurl}" not found`); + } + + const denoms = exchange.denominations.map((d) => ({ + ...d, + group: Amounts.stringifyValue(d.value), + })); + const denomFees: DenomOperationMap<FeeDescription[]> = { + deposit: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireDeposit", + "feeDeposit", + "group", + selectBestForOverlappingDenominations, + ), + refresh: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeRefresh", + "group", + selectBestForOverlappingDenominations, + ), + refund: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeRefund", + "group", + selectBestForOverlappingDenominations, + ), + withdraw: createTimeline( + denoms, + "denomPubHash", + "stampStart", + "stampExpireWithdraw", + "feeWithdraw", + "group", + selectBestForOverlappingDenominations, + ), + }; + + const transferFees = Object.entries( + exchange.info.wireInfo.feesForType, + ).reduce( + (prev, [wireType, infoForType]) => { + const feesByGroup = [ + ...infoForType.map((w) => ({ + ...w, + fee: Amounts.stringify(w.closingFee), + group: "closing", + })), + ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })), + ]; + prev[wireType] = createTimeline( + feesByGroup, + "sig", + "startStamp", + "endStamp", + "fee", + "group", + selectMinimumFee, + ); + return prev; + }, + {} as Record<string, FeeDescription[]>, + ); + + const globalFeesByGroup = [ + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.accountFee, + group: "account", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.historyFee, + group: "history", + })), + ...exchange.info.globalFees.map((w) => ({ + ...w, + fee: w.purseFee, + group: "purse", + })), + ]; + + const globalFees = createTimeline( + globalFeesByGroup, + "signature", + "startDate", + "endDate", + "fee", + "group", + selectMinimumFee, + ); + + return { + exchange: { + ...exchange.info, + denomFees, + transferFees, + globalFees, + }, + }; +} + +async function internalGetExchangeResources( + wex: WalletExecutionContext, + tx: DbReadOnlyTransaction< + typeof WalletStoresV1, + ["exchanges", "coins", "withdrawalGroups"] + >, + exchangeBaseUrl: string, +): Promise<GetExchangeResourcesResponse> { + let numWithdrawals = 0; + let numCoins = 0; + numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl); + numWithdrawals = + await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl); + const total = numWithdrawals + numCoins; + return { + hasResources: total != 0, + }; +} + +/** + * Purge information in the database associated with the exchange. + * + * Deletes information specific to the exchange and withdrawals, + * but keeps some transactions (payments, p2p, refreshes) around. + */ +async function purgeExchange( + tx: WalletDbReadWriteTransaction< + [ + "exchanges", + "exchangeDetails", + "transactions", + "coinAvailability", + "coins", + "denominations", + "exchangeSignKeys", + "withdrawalGroups", + "planchets", + ] + >, + exchangeBaseUrl: string, +): Promise<void> { + const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll(); + for (const r of detRecs) { + if (r.rowId == null) { + // Should never happen, as rowId is the primary key. + continue; + } + await tx.exchangeDetails.delete(r.rowId); + const signkeyRecs = + await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId); + for (const rec of signkeyRecs) { + await tx.exchangeSignKeys.delete([r.rowId, rec.signkeyPub]); + } + } + // FIXME: Also remove records related to transactions? + await tx.exchanges.delete(exchangeBaseUrl); + + { + const coinAvailabilityRecs = + await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + for (const rec of coinAvailabilityRecs) { + await tx.coinAvailability.delete([ + exchangeBaseUrl, + rec.denomPubHash, + rec.maxAge, + ]); + } + } + + { + const coinRecs = await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl); + for (const rec of coinRecs) { + await tx.coins.delete(rec.coinPub); + } + } + + { + const denomRecs = + await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl); + for (const rec of denomRecs) { + await tx.denominations.delete(rec.denomPubHash); + } + } + + { + const withdrawalGroupRecs = + await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + for (const wg of withdrawalGroupRecs) { + await tx.withdrawalGroups.delete(wg.withdrawalGroupId); + const planchets = await tx.planchets.indexes.byGroup.getAll( + wg.withdrawalGroupId, + ); + for (const p of planchets) { + await tx.planchets.delete(p.coinPub); + } + } + } +} + +export async function deleteExchange( + wex: WalletExecutionContext, + req: DeleteExchangeRequest, +): Promise<void> { + let inUse: boolean = false; + const exchangeBaseUrl = req.exchangeBaseUrl; + await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "transactions", + "coinAvailability", + "coins", + "denominations", + "exchangeSignKeys", + "withdrawalGroups", + "planchets", + ], + }, + async (tx) => { + const exchangeRec = await tx.exchanges.get(exchangeBaseUrl); + if (!exchangeRec) { + // Nothing to delete! + logger.info("no exchange found to delete"); + return; + } + const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl); + if (res.hasResources && !req.purge) { + inUse = true; + return; + } + await purgeExchange(tx, exchangeBaseUrl); + wex.ws.exchangeCache.clear(); + }, + ); + + if (inUse) { + throw TalerError.fromUncheckedDetail({ + code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED, + hint: "Exchange in use.", + }); + } +} + +export async function getExchangeResources( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<GetExchangeResourcesResponse> { + // Withdrawals include internal withdrawals from peer transactions + const res = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "withdrawalGroups", "coins"] }, + async (tx) => { + const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl); + if (!exchangeRecord) { + return undefined; + } + return internalGetExchangeResources(wex, tx, exchangeBaseUrl); + }, + ); + if (!res) { + throw Error("exchange not found"); + } + return res; +} diff --git a/packages/taler-wallet-core/src/host-common.ts b/packages/taler-wallet-core/src/host-common.ts index c56d7ed1c..7651e5a12 100644 --- a/packages/taler-wallet-core/src/host-common.ts +++ b/packages/taler-wallet-core/src/host-common.ts @@ -16,7 +16,6 @@ import { WalletNotification } from "@gnu-taler/taler-util"; import { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; -import { WalletConfigParameter } from "./index.js"; /** * Helpers to initiate a wallet in a host environment. @@ -45,11 +44,6 @@ export interface DefaultNodeWalletArgs { httpLib?: HttpRequestLibrary; cryptoWorkerType?: "sync" | "node-worker-thread"; - - /** - * Config parameters - */ - config?: WalletConfigParameter; } /** diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts index fefee1067..ec026b296 100644 --- a/packages/taler-wallet-core/src/host-impl.node.ts +++ b/packages/taler-wallet-core/src/host-impl.node.ts @@ -25,22 +25,24 @@ import type { IDBFactory } from "@gnu-taler/idb-bridge"; // eslint-disable-next-line no-duplicate-imports import { + AccessStats, BridgeIDBFactory, MemoryBackend, createSqliteBackend, shimIndexedDB, } from "@gnu-taler/idb-bridge"; -import { AccessStats } from "@gnu-taler/idb-bridge"; -import { Logger } from "@gnu-taler/taler-util"; +import { createNodeSqlite3Impl } from "@gnu-taler/idb-bridge/node-sqlite3-bindings"; +import { + Logger, + SetTimeoutTimerAPI, + WalletRunConfig, +} from "@gnu-taler/taler-util"; +import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import * as fs from "fs"; import { NodeThreadCryptoWorkerFactory } from "./crypto/workers/nodeThreadWorker.js"; import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js"; -import { openTalerDatabase } from "./index.js"; -import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; -import { SetTimeoutTimerAPI } from "./util/timer.js"; -import { Wallet } from "./wallet.js"; import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js"; -import { createNodeSqlite3Impl } from "@gnu-taler/idb-bridge/node-sqlite3-bindings"; +import { Wallet } from "./wallet.js"; const logger = new Logger("host-impl.node.ts"); @@ -68,7 +70,11 @@ async function makeFileDb( logger.trace("wallet file doesn't exist yet"); } else { logger.error("could not open wallet database file"); - throw e; + throw Error( + "could not open wallet database file", + // @ts-expect-error no support for options.cause yet + { cause: e }, + ); } } @@ -104,8 +110,10 @@ async function makeSqliteDb( ): Promise<MakeDbResult> { BridgeIDBFactory.enableTracing = false; const imp = await createNodeSqlite3Impl(); + const dbFilename = args.persistentStoragePath ?? ":memory:"; + logger.info(`using database ${dbFilename}`); const myBackend = await createSqliteBackend(imp, { - filename: args.persistentStoragePath ?? ":memory:", + filename: dbFilename, }); myBackend.enableTracing = false; if (process.env.TALER_WALLET_DBSTATS) { @@ -131,15 +139,18 @@ export async function createNativeWalletHost2( wallet: Wallet; getDbStats: () => AccessStats; }> { - let myHttpLib; - if (args.httpLib) { - myHttpLib = args.httpLib; - } else { - myHttpLib = createPlatformHttpLib({ - enableThrottling: true, - requireTls: !args.config?.features?.allowHttp, - }); - } + const myHttpFactory = (config: WalletRunConfig) => { + let myHttpLib; + if (args.httpLib) { + myHttpLib = args.httpLib; + } else { + myHttpLib = createPlatformHttpLib({ + enableThrottling: true, + requireTls: !config.features.allowHttp, + }); + } + return myHttpLib; + }; let dbResp: MakeDbResult; @@ -150,7 +161,7 @@ export async function createNativeWalletHost2( logger.info("using JSON file DB backend (slow, only use for testing)"); dbResp = await makeFileDb(args); } else { - logger.info("using sqlite3 DB backend"); + logger.info(`using sqlite3 DB backend`); dbResp = await makeSqliteDb(args); } @@ -186,10 +197,9 @@ export async function createNativeWalletHost2( const w = await Wallet.create( myIdbFactory, - myHttpLib, + myHttpFactory, timer, workerFactory, - args.config, ); if (args.notifyHandler) { diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts index 0fc346b44..9c985d0c1 100644 --- a/packages/taler-wallet-core/src/host-impl.qtart.ts +++ b/packages/taler-wallet-core/src/host-impl.qtart.ts @@ -36,12 +36,15 @@ import { createSqliteBackend, shimIndexedDB, } from "@gnu-taler/idb-bridge"; -import { Logger } from "@gnu-taler/taler-util"; +import { + Logger, + SetTimeoutTimerAPI, + WalletRunConfig, +} from "@gnu-taler/taler-util"; import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { qjsOs, qjsStd } from "@gnu-taler/taler-util/qtart"; import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js"; import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js"; -import { SetTimeoutTimerAPI } from "./util/timer.js"; import { Wallet } from "./wallet.js"; const logger = new Logger("host-impl.qtart.ts"); @@ -181,15 +184,18 @@ export async function createNativeWalletHost2( shimIndexedDB(dbResp.idbFactory); - let myHttpLib; - if (args.httpLib) { - myHttpLib = args.httpLib; - } else { - myHttpLib = createPlatformHttpLib({ - enableThrottling: true, - requireTls: !args.config?.features?.allowHttp, - }); - } + const myHttpFactory = (config: WalletRunConfig) => { + let myHttpLib; + if (args.httpLib) { + myHttpLib = args.httpLib; + } else { + myHttpLib = createPlatformHttpLib({ + enableThrottling: true, + requireTls: !config.features.allowHttp, + }); + } + return myHttpLib; + }; let workerFactory; workerFactory = new SynchronousCryptoWorkerFactoryPlain(); @@ -198,10 +204,9 @@ export async function createNativeWalletHost2( const w = await Wallet.create( myIdbFactory, - myHttpLib, + myHttpFactory, timer, workerFactory, - args.config, ); if (args.notifyHandler) { diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts index 643d65620..fe2d3af15 100644 --- a/packages/taler-wallet-core/src/index.ts +++ b/packages/taler-wallet-core/src/index.ts @@ -18,43 +18,29 @@ * Module entry point for the wallet when used as a node module. */ -// Util functionality -export * from "./util/promiseUtils.js"; -export * from "./util/query.js"; - -export * from "./versions.js"; - -export * from "./db.js"; - -// Crypto and crypto workers -// export * from "./crypto/workers/nodeThreadWorker.js"; -export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js"; +export * from "./crypto/cryptoImplementation.js"; +export * from "./crypto/cryptoTypes.js"; export { - CryptoWorkerFactory, CryptoDispatcher, + CryptoWorkerFactory, } from "./crypto/workers/crypto-dispatcher.js"; - -export * from "./pending-types.js"; - -export { InternalWalletState } from "./internal-wallet-state.js"; +export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js"; +export { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js"; +export * from "./host-common.js"; +export * from "./host.js"; +export * from "./versions.js"; export * from "./wallet-api-types.js"; export * from "./wallet.js"; -export * from "./operations/backup/index.js"; - -export * from "./operations/exchanges.js"; +export { parseTransactionIdentifier } from "./transactions.js"; -export * from "./operations/withdraw.js"; -export * from "./operations/refresh.js"; +export { createPairTimeline } from "./denominations.js"; -export * from "./dbless.js"; - -export * from "./crypto/cryptoTypes.js"; -export * from "./crypto/cryptoImplementation.js"; - -export * from "./util/timer.js"; -export * from "./util/denominations.js"; - -export { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js"; -export * from "./host-common.js"; -export * from "./host.js"; +// FIXME: Should these really be exported?! +export { + WalletStoresV1, + deleteTalerDatabase, + exportDb, + importDb, +} from "./db.js"; +export { DbAccess } from "./query.js"; diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts index de8515d09..03e702568 100644 --- a/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts +++ b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts @@ -22,8 +22,12 @@ import { TransactionAmountMode, } from "@gnu-taler/taler-util"; import test, { ExecutionContext } from "ava"; -import { CoinInfo } from "./coinSelection.js"; -import { convertDepositAmountForAvailableCoins, getMaxDepositAmountForAvailableCoins, convertWithdrawalAmountFromAvailableCoins } from "./instructedAmountConversion.js"; +import { + CoinInfo, + convertDepositAmountForAvailableCoins, + convertWithdrawalAmountFromAvailableCoins, + getMaxDepositAmountForAvailableCoins, +} from "./instructedAmountConversion.js"; function makeCurrencyHelper(currency: string) { return (sx: TemplateStringsArray, ...vx: any[]) => { diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts index 4365e6d32..1f7d95959 100644 --- a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts +++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -27,17 +27,27 @@ import { GetPlanForOperationRequest, TransactionAmountMode, TransactionType, + checkDbInvariant, parsePaytoUri, strcmp, } from "@gnu-taler/taler-util"; -import { - DenominationRecord, - InternalWalletState, - getExchangeDetails, - timestampProtocolFromDb, -} from "../index.js"; -import { CoinInfo } from "./coinSelection.js"; -import { checkDbInvariant } from "./invariants.js"; +import { DenominationRecord, timestampProtocolFromDb } from "./db.js"; +import { getExchangeWireDetailsInTx } from "./exchanges.js"; +import { WalletExecutionContext } from "./wallet.js"; + +export interface CoinInfo { + id: string; + value: AmountJson; + denomDeposit: AmountJson; + denomWithdraw: AmountJson; + denomRefresh: AmountJson; + totalAvailable: number | undefined; + exchangeWire: AmountJson | undefined; + exchangePurse: AmountJson | undefined; + duration: Duration; + exchangeBaseUrl: string; + maxAge: number; +} /** * If the operation going to be plan subtracts @@ -61,8 +71,8 @@ function getOperationType(txType: TransactionType): OperationType { txType === TransactionType.Withdrawal ? OperationType.Credit : txType === TransactionType.Deposit - ? OperationType.Debit - : undefined; + ? OperationType.Debit + : undefined; if (!operationType) { throw Error(`operation type ${txType} not yet supported`); } @@ -132,21 +142,23 @@ interface AvailableCoins { * of being cached */ async function getAvailableDenoms( - ws: InternalWalletState, + wex: WalletExecutionContext, op: TransactionType, currency: string, filters: CoinsFilter = {}, ): Promise<AvailableCoins> { const operationType = getOperationType(TransactionType.Deposit); - return await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.coinAvailability, - ]) - .runReadOnly(async (tx) => { + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "denominations", + "coinAvailability", + ], + }, + async (tx) => { const list: CoinInfo[] = []; const exchanges: Record<string, ExchangeInfo> = {}; @@ -155,7 +167,10 @@ async function getAvailableDenoms( filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl); for (const exchangeBaseUrl of filteredExchanges) { - const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl); + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + exchangeBaseUrl, + ); // 1.- exchange has same currency if (exchangeDetails?.currency !== currency) { continue; @@ -221,9 +236,10 @@ async function getAvailableDenoms( //4.- filter coins restricted by age if (operationType === OperationType.Credit) { // FIXME: Use denom groups instead of querying all denominations! - const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); + const ds = + await tx.denominations.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); for (const denom of ds) { const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(denom.stampExpireWithdraw), @@ -300,7 +316,8 @@ async function getAvailableDenoms( } return { list, exchanges }; - }); + }, + ); } function buildCoinInfoFromDenom( @@ -331,14 +348,14 @@ function buildCoinInfoFromDenom( } export async function convertDepositAmount( - ws: InternalWalletState, + wex: WalletExecutionContext, req: ConvertAmountRequest, ): Promise<AmountResponse> { const amount = Amounts.parseOrThrow(req.amount); // const filter = getCoinsFilter(req); const denoms = await getAvailableDenoms( - ws, + wex, TransactionType.Deposit, amount.currency, {}, @@ -433,13 +450,13 @@ export function convertDepositAmountForAvailableCoins( } export async function getMaxDepositAmount( - ws: InternalWalletState, + wex: WalletExecutionContext, req: GetAmountRequest, ): Promise<AmountResponse> { // const filter = getCoinsFilter(req); const denoms = await getAvailableDenoms( - ws, + wex, TransactionType.Deposit, req.currency, {}, @@ -476,25 +493,27 @@ export function getMaxDepositAmountForAvailableCoins( } export async function convertPeerPushAmount( - ws: InternalWalletState, + wex: WalletExecutionContext, req: ConvertAmountRequest, ): Promise<AmountResponse> { throw Error("to be implemented after 1.0"); } + export async function getMaxPeerPushAmount( - ws: InternalWalletState, + wex: WalletExecutionContext, req: GetAmountRequest, ): Promise<AmountResponse> { throw Error("to be implemented after 1.0"); } + export async function convertWithdrawalAmount( - ws: InternalWalletState, + wex: WalletExecutionContext, req: ConvertAmountRequest, ): Promise<AmountResponse> { const amount = Amounts.parseOrThrow(req.amount); const denoms = await getAvailableDenoms( - ws, + wex, TransactionType.Withdrawal, amount.currency, {}, diff --git a/packages/taler-wallet-core/src/internal-wallet-state.ts b/packages/taler-wallet-core/src/internal-wallet-state.ts deleted file mode 100644 index b1389a359..000000000 --- a/packages/taler-wallet-core/src/internal-wallet-state.ts +++ /dev/null @@ -1,227 +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/> - */ - -/** - * Common interface of the internal wallet state. This object is passed - * to the various operations (exchange management, withdrawal, refresh, reserve - * management, etc.). - * - * Some operations can be accessed via this state object. This allows mutual - * recursion between operations, without having cyclic dependencies between - * the respective TypeScript files. - * - * (You can think of this as a "header file" for the wallet implementation.) - */ - -/** - * Imports. - */ -import { - CancellationToken, - CoinRefreshRequest, - DenominationInfo, - RefreshGroupId, - RefreshReason, - TransactionState, - WalletNotification, -} from "@gnu-taler/taler-util"; -import { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; -import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; -import { - ExchangeDetailsRecord, - ExchangeEntryRecord, - RefreshReasonDetails, - WalletStoresV1, -} from "./db.js"; -import { AsyncCondition } from "./util/promiseUtils.js"; -import { - DbAccess, - GetReadOnlyAccess, - GetReadWriteAccess, -} from "./util/query.js"; -import { TimerGroup } from "./util/timer.js"; -import { WalletConfig } from "./wallet-api-types.js"; -import { IDBFactory } from "@gnu-taler/idb-bridge"; -import { ReadyExchangeSummary } from "./index.js"; - -export const EXCHANGE_COINS_LOCK = "exchange-coins-lock"; -export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock"; - -export interface TrustInfo { - isTrusted: boolean; - isAudited: boolean; -} - -export interface MerchantInfo { - protocolVersionCurrent: number; -} - -/** - * Interface for merchant-related operations. - */ -export interface MerchantOperations { - getMerchantInfo( - ws: InternalWalletState, - merchantBaseUrl: string, - ): Promise<MerchantInfo>; -} - -export interface RefreshOperations { - createRefreshGroup( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coinAvailability: typeof WalletStoresV1.coinAvailability; - }>, - currency: string, - oldCoinPubs: CoinRefreshRequest[], - reason: RefreshReason, - reasonDetails?: RefreshReasonDetails, - ): Promise<RefreshGroupId>; -} - -/** - * Interface for exchange-related operations. - */ -export interface ExchangeOperations { - // FIXME: Should other operations maybe always use - // updateExchangeFromUrl? - getExchangeDetails( - tx: GetReadOnlyAccess<{ - exchanges: typeof WalletStoresV1.exchanges; - exchangeDetails: typeof WalletStoresV1.exchangeDetails; - }>, - exchangeBaseUrl: string, - ): Promise<ExchangeDetailsRecord | undefined>; - fetchFreshExchange( - ws: InternalWalletState, - baseUrl: string, - options?: { - forceNow?: boolean; - cancellationToken?: CancellationToken; - }, - ): Promise<ReadyExchangeSummary>; -} - -export interface RecoupOperations { - createRecoupGroup( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - recoupGroups: typeof WalletStoresV1.recoupGroups; - denominations: typeof WalletStoresV1.denominations; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coins: typeof WalletStoresV1.coins; - }>, - exchangeBaseUrl: string, - coinPubs: string[], - ): Promise<string>; -} - -export type NotificationListener = (n: WalletNotification) => void; - -export interface ActiveLongpollInfo { - [opId: string]: { - cancel: () => void; - }; -} - -export type CancelFn = () => void; - -/** - * Internal, shared wallet state that is used by the implementation - * of wallet operations. - * - * FIXME: This should not be exported anywhere from the taler-wallet-core package, - * as it's an opaque implementation detail. - */ -export interface InternalWalletState { - /** - * Active longpoll operations. - */ - activeLongpoll: ActiveLongpollInfo; - - cryptoApi: TalerCryptoInterface; - - timerGroup: TimerGroup; - stopped: boolean; - - config: Readonly<WalletConfig>; - - /** - * Asynchronous condition to interrupt the sleep of the - * retry loop. - * - * Used to allow processing of new work faster. - */ - workAvailable: AsyncCondition; - - listeners: NotificationListener[]; - - initCalled: boolean; - - merchantInfoCache: Record<string, MerchantInfo>; - - exchangeOps: ExchangeOperations; - recoupOps: RecoupOperations; - merchantOps: MerchantOperations; - refreshOps: RefreshOperations; - - isTaskLoopRunning: boolean; - - getTransactionState( - ws: InternalWalletState, - tx: GetReadOnlyAccess<typeof WalletStoresV1>, - transactionId: string, - ): Promise<TransactionState | undefined>; - - getDenomInfo( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - denominations: typeof WalletStoresV1.denominations; - }>, - exchangeBaseUrl: string, - denomPubHash: string, - ): Promise<DenominationInfo | undefined>; - - ensureWalletDbOpen(): Promise<void>; - - idb: IDBFactory; - db: DbAccess<typeof WalletStoresV1>; - http: HttpRequestLibrary; - - notify(n: WalletNotification): void; - - addNotificationListener(f: (n: WalletNotification) => void): CancelFn; - - /** - * Stop ongoing processing. - */ - stop(): void; - - /** - * Run an async function after acquiring a list of locks, identified - * by string tokens. - */ - runSequentialized<T>(tokens: string[], f: () => Promise<T>): Promise<T>; - - /** - * Ensure that a task loop is currently running. - * Starts one if no task loop is running. - */ - ensureTaskLoopRunning(): void; -} diff --git a/packages/taler-wallet-core/src/observable-wrappers.ts b/packages/taler-wallet-core/src/observable-wrappers.ts new file mode 100644 index 000000000..717de41ca --- /dev/null +++ b/packages/taler-wallet-core/src/observable-wrappers.ts @@ -0,0 +1,295 @@ +/* + This file is part of GNU Taler + (C) 2024 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * @fileoverview Wrappers/proxies to make various interfaces observable. + */ + +/** + * Imports. + */ +import { IDBDatabase } from "@gnu-taler/idb-bridge"; +import { + ObservabilityContext, + ObservabilityEventType, +} from "@gnu-taler/taler-util"; +import { TaskIdStr } from "./common.js"; +import { TalerCryptoInterface } from "./index.js"; +import { + DbAccess, + DbReadOnlyTransaction, + DbReadWriteTransaction, + StoreNames, +} from "./query.js"; +import { TaskScheduler } from "./shepherd.js"; + +/** + * Task scheduler with extra observability events. + */ +export class ObservableTaskScheduler implements TaskScheduler { + constructor( + private impl: TaskScheduler, + private oc: ObservabilityContext, + ) {} + + private taskDepCache = new Set<string>(); + + private declareDep(taskId: TaskIdStr): void { + if (this.taskDepCache.size > 500) { + this.taskDepCache.clear(); + } + if (!this.taskDepCache.has(taskId)) { + this.taskDepCache.add(taskId); + this.oc.observe({ + type: ObservabilityEventType.DeclareTaskDependency, + taskId, + }); + } + } + + shutdown(): Promise<void> { + return this.impl.shutdown(); + } + + getActiveTasks(): TaskIdStr[] { + return this.impl.getActiveTasks(); + } + + isIdle(): boolean { + return this.impl.isIdle(); + } + + ensureRunning(): Promise<void> { + return this.impl.ensureRunning(); + } + + startShepherdTask(taskId: TaskIdStr): void { + this.declareDep(taskId); + this.oc.observe({ + type: ObservabilityEventType.TaskStart, + taskId, + }); + return this.impl.startShepherdTask(taskId); + } + + stopShepherdTask(taskId: TaskIdStr): void { + this.declareDep(taskId); + this.oc.observe({ + type: ObservabilityEventType.TaskStop, + taskId, + }); + return this.impl.stopShepherdTask(taskId); + } + + resetTaskRetries(taskId: TaskIdStr): Promise<void> { + this.declareDep(taskId); + if (this.taskDepCache.size > 500) { + this.taskDepCache.clear(); + } + this.oc.observe({ + type: ObservabilityEventType.TaskReset, + taskId, + }); + return this.impl.resetTaskRetries(taskId); + } + + async reload(): Promise<void> { + return this.impl.reload(); + } +} + +const locRegex = /\s*at\s*([a-zA-Z0-9_.!]*)\s*/; + +export function getCallerInfo(up: number = 2): string { + const stack = new Error().stack ?? ""; + const identifies: string[] = []; + for (const line of stack.split("\n")) { + let l = line.match(locRegex); + if (l) { + identifies.push(l[1]); + } + } + return identifies.slice(up, up + 2).join("/"); +} + +export class ObservableDbAccess<StoreMap> implements DbAccess<StoreMap> { + constructor( + private impl: DbAccess<StoreMap>, + private oc: ObservabilityContext, + ) {} + idbHandle(): IDBDatabase { + return this.impl.idbHandle(); + } + + async runAllStoresReadWriteTx<T>( + options: { + label?: string; + }, + txf: ( + tx: DbReadWriteTransaction<StoreMap, StoreNames<StoreMap>[]>, + ) => Promise<T>, + ): Promise<T> { + const location = getCallerInfo(); + this.oc.observe({ + type: ObservabilityEventType.DbQueryStart, + name: "<unknown>", + location, + }); + try { + const ret = await this.impl.runAllStoresReadWriteTx(options, txf); + this.oc.observe({ + type: ObservabilityEventType.DbQueryFinishSuccess, + name: "<unknown>", + location, + }); + return ret; + } catch (e) { + this.oc.observe({ + type: ObservabilityEventType.DbQueryFinishError, + name: "<unknown>", + location, + }); + throw e; + } + } + + async runAllStoresReadOnlyTx<T>( + options: { + label?: string; + }, + txf: ( + tx: DbReadOnlyTransaction<StoreMap, StoreNames<StoreMap>[]>, + ) => Promise<T>, + ): Promise<T> { + const location = getCallerInfo(); + this.oc.observe({ + type: ObservabilityEventType.DbQueryStart, + name: options.label ?? "<unknown>", + location, + }); + try { + const ret = await this.impl.runAllStoresReadOnlyTx(options, txf); + this.oc.observe({ + type: ObservabilityEventType.DbQueryFinishSuccess, + name: options.label ?? "<unknown>", + location, + }); + return ret; + } catch (e) { + this.oc.observe({ + type: ObservabilityEventType.DbQueryFinishError, + name: options.label ?? "<unknown>", + location, + }); + throw e; + } + } + + async runReadWriteTx<T, StoreNameArray extends StoreNames<StoreMap>[]>( + opts: { + storeNames: StoreNameArray; + label?: string; + }, + txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>, + ): Promise<T> { + const location = getCallerInfo(); + this.oc.observe({ + type: ObservabilityEventType.DbQueryStart, + name: opts.label ?? "<unknown>", + location, + }); + try { + const ret = await this.impl.runReadWriteTx(opts, txf); + this.oc.observe({ + type: ObservabilityEventType.DbQueryFinishSuccess, + name: opts.label ?? "<unknown>", + location, + }); + return ret; + } catch (e) { + this.oc.observe({ + type: ObservabilityEventType.DbQueryFinishError, + name: opts.label ?? "<unknown>", + location, + }); + throw e; + } + } + + async runReadOnlyTx<T, StoreNameArray extends StoreNames<StoreMap>[]>( + opts: { + storeNames: StoreNameArray; + label?: string; + }, + txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>, + ): Promise<T> { + const location = getCallerInfo(); + try { + this.oc.observe({ + type: ObservabilityEventType.DbQueryStart, + name: opts.label ?? "<unknown>", + location, + }); + const ret = await this.impl.runReadOnlyTx(opts, txf); + this.oc.observe({ + type: ObservabilityEventType.DbQueryFinishSuccess, + name: opts.label ?? "<unknown>", + location, + }); + return ret; + } catch (e) { + this.oc.observe({ + type: ObservabilityEventType.DbQueryFinishError, + name: opts.label ?? "<unknown>", + location, + }); + throw e; + } + } +} + +export function observeTalerCrypto( + impl: TalerCryptoInterface, + oc: ObservabilityContext, +): TalerCryptoInterface { + return Object.fromEntries( + Object.keys(impl).map((name) => { + return [ + name, + async (req: any) => { + oc.observe({ + type: ObservabilityEventType.CryptoStart, + operation: name, + }); + try { + const res = await (impl as any)[name](req); + oc.observe({ + type: ObservabilityEventType.CryptoFinishSuccess, + operation: name, + }); + return res; + } catch (e) { + oc.observe({ + type: ObservabilityEventType.CryptoFinishError, + operation: name, + }); + throw e; + } + }, + ]; + }), + ) as any; +} diff --git a/packages/taler-wallet-core/src/operations/README.md b/packages/taler-wallet-core/src/operations/README.md deleted file mode 100644 index a40349d37..000000000 --- a/packages/taler-wallet-core/src/operations/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Wallet Operations - -This folder contains the implementations for all wallet operations that operate on the wallet state. - -To avoid cyclic dependencies, these files must **not** reference each other. Instead, other operations should only be accessed via injected dependencies. - -Avoiding cyclic dependencies is important for module bundlers. 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 1b6ff7844..000000000 --- a/packages/taler-wallet-core/src/operations/balance.ts +++ /dev/null @@ -1,599 +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 { - AllowedAuditorInfo, - AllowedExchangeInfo, - AmountJson, - Amounts, - BalanceFlag, - BalancesResponse, - canonicalizeBaseUrl, - GetBalanceDetailRequest, - Logger, - parsePaytoUri, - ScopeType, -} from "@gnu-taler/taler-util"; -import { - depositOperationNonfinalStatusRange, - DepositOperationStatus, - RefreshGroupRecord, - WalletStoresV1, - withdrawalGroupNonfinalRange, - WithdrawalGroupStatus, -} from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkLogicInvariant } from "../util/invariants.js"; -import { GetReadOnlyAccess } from "../util/query.js"; -import { getExchangeDetails } from "./exchanges.js"; - -/** - * Logger. - */ -const logger = new Logger("operations/balance.ts"); - -interface WalletBalance { - 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; -} - -/** - * Get balance information. - */ -export async function getBalancesInsideTransaction( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - coinAvailability: typeof WalletStoresV1.coinAvailability; - refreshGroups: typeof WalletStoresV1.refreshGroups; - withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; - depositGroups: typeof WalletStoresV1.depositGroups; - }>, -): Promise<BalancesResponse> { - const balanceStore: Record<string, WalletBalance> = {}; - - /** - * Add amount to a balance field, both for - * the slicing by exchange and currency. - */ - const initBalance = (currency: string): WalletBalance => { - const b = balanceStore[currency]; - if (!b) { - balanceStore[currency] = { - available: Amounts.zeroOfCurrency(currency), - pendingIncoming: Amounts.zeroOfCurrency(currency), - pendingOutgoing: Amounts.zeroOfCurrency(currency), - flagIncomingAml: false, - flagIncomingConfirmation: false, - flagIncomingKyc: false, - flagOutgoingKyc: false, - }; - } - return balanceStore[currency]; - }; - - await tx.coinAvailability.iter().forEach((ca) => { - const b = initBalance(ca.currency); - const count = ca.visibleCoinCount ?? 0; - for (let i = 0; i < count; i++) { - b.available = Amounts.add(b.available, ca.value).amount; - } - }); - - await tx.refreshGroups.iter().forEach((r) => { - const b = initBalance(r.currency); - b.available = Amounts.add( - b.available, - computeRefreshGroupAvailableAmount(r), - ).amount; - }); - - await tx.withdrawalGroups.indexes.byStatus - .iter(withdrawalGroupNonfinalRange) - .forEach((wgRecord) => { - const b = initBalance( - Amounts.currencyOf(wgRecord.denomsSel.totalWithdrawCost), - ); - 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: - b.flagIncomingKyc = true; - break; - case WithdrawalGroupStatus.PendingAml: - case WithdrawalGroupStatus.SuspendedAml: - b.flagIncomingAml = true; - break; - case WithdrawalGroupStatus.PendingRegisteringBank: - case WithdrawalGroupStatus.PendingWaitConfirmBank: - b.flagIncomingConfirmation = true; - break; - default: - assertUnreachable(wgRecord.status); - } - b.pendingIncoming = Amounts.add( - b.pendingIncoming, - wgRecord.denomsSel.totalCoinValue, - ).amount; - }); - - // FIXME: Use indexing to filter out final transactions. - await tx.depositGroups.indexes.byStatus - .iter(depositOperationNonfinalStatusRange) - .forEach((dgRecord) => { - const b = initBalance(Amounts.currencyOf(dgRecord.amount)); - switch (dgRecord.operationStatus) { - case DepositOperationStatus.SuspendedKyc: - case DepositOperationStatus.PendingKyc: - b.flagOutgoingKyc = true; - } - }); - - const balancesResponse: BalancesResponse = { - balances: [], - }; - - 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: { - // FIXME: obtain REAL scopeInfo instead of faking a global currency - type: ScopeType.Global, - currency: Amounts.currencyOf(v.available), - }, - 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 detailed balance information, sliced by exchange and by currency. - */ -export async function getBalances( - ws: InternalWalletState, -): Promise<BalancesResponse> { - logger.trace("starting to compute balance"); - - const wbal = await ws.db - .mktx((x) => [ - x.coins, - x.coinAvailability, - x.refreshGroups, - x.purchases, - x.withdrawalGroups, - x.depositGroups, - ]) - .runReadOnly(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 - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadOnly(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 - .mktx((x) => [ - x.coins, - x.coinAvailability, - x.refreshGroups, - x.purchases, - x.withdrawalGroups, - ]) - .runReadOnly(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 - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { - const allExchanges = await tx.exchanges.iter().toArray(); - for (const e of allExchanges) { - const details = await getExchangeDetails(tx, e.baseUrl); - if (!details || 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: GetReadOnlyAccess<{ - coinAvailability: typeof WalletStoresV1.coinAvailability; - refreshGroups: typeof WalletStoresV1.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/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts deleted file mode 100644 index 8f878ecc0..000000000 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ /dev/null @@ -1,1433 +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, - Amounts, - CancellationToken, - DenomKeyType, - DenomOperationMap, - DenominationInfo, - DenominationPubKey, - Duration, - ExchangeAuditor, - ExchangeDetailedResponse, - ExchangeGlobalFees, - ExchangeListItem, - ExchangeSignKeyJson, - ExchangeTosStatus, - ExchangeWireAccount, - ExchangesListResponse, - FeeDescription, - GetExchangeTosResult, - GlobalFees, - LibtoolVersion, - Logger, - NotificationType, - Recoup, - TalerError, - TalerErrorCode, - TalerErrorDetail, - TalerPreciseTimestamp, - TalerProtocolDuration, - TalerProtocolTimestamp, - URL, - WalletNotification, - WireFee, - WireFeeMap, - WireFeesJson, - WireInfo, - canonicalizeBaseUrl, - codecForExchangeKeysJson, - durationFromSpec, - 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, - OpenedPromise, - PendingTaskType, - WalletDbReadWriteTransaction, - createTimeline, - isWithdrawableDenom, - openPromise, - selectBestForOverlappingDenominations, - selectMinimumFee, - timestampOptionalAbsoluteFromDb, - timestampOptionalPreciseFromDb, - timestampPreciseFromDb, - timestampPreciseToDb, - timestampProtocolToDb, -} from "../index.js"; -import { CancelFn, InternalWalletState } from "../internal-wallet-state.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; -import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js"; -import { - TaskIdentifiers, - TaskRunResult, - TaskRunResultType, - constructTaskIdentifier, - getExchangeState, - getExchangeTosStatusFromRecord, - makeExchangeListItem, - runTaskWithErrorReporting, -} from "./common.js"; - -const logger = new Logger("exchanges.ts"); - -function getExchangeRequestTimeout(): Duration { - return Duration.fromSpec({ - seconds: 5, - }); -} - -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. - * - * FIXME: Should we encapsulate the result better, instead of returning the raw DB records here? - */ -export async function getExchangeDetails( - tx: GetReadOnlyAccess<{ - exchanges: typeof WalletStoresV1.exchanges; - exchangeDetails: typeof WalletStoresV1.exchangeDetails; - }>, - exchangeBaseUrl: string, -): Promise<ExchangeDetailsRecord | undefined> { - const r = await tx.exchanges.get(exchangeBaseUrl); - if (!r) { - return; - } - const dp = r.detailsPointer; - if (!dp) { - return; - } - const { currency, masterPublicKey } = dp; - return await tx.exchangeDetails.indexes.byPointer.get([ - r.baseUrl, - currency, - masterPublicKey, - ]); -} - -/** - * Mark a ToS version as accepted by the user. - * - * @param etag version of the ToS to accept, or current ToS version of not given - */ -export async function acceptExchangeTermsOfService( - ws: InternalWalletState, - exchangeBaseUrl: string, - etag: string | undefined, -): Promise<void> { - await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - const exch = await tx.exchanges.get(exchangeBaseUrl); - if (exch && exch.tosCurrentEtag) { - exch.tosAcceptedEtag = exch.tosCurrentEtag; - exch.tosAcceptedTimestamp = timestampPreciseToDb( - TalerPreciseTimestamp.now(), - ); - await tx.exchanges.put(exch); - } - }); -} - -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, - }; -} - -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: GetReadWriteAccess<{ - exchanges: typeof WalletStoresV1.exchanges; - exchangeDetails: typeof WalletStoresV1.exchangeDetails; - }>, - baseUrl: string, - now: AbsoluteTime, -): 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 getExchangeDetails(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, -): Promise<ExchangeKeysDownloadResult> { - const keysUrl = new URL("keys", baseUrl); - - const resp = await http.fetch(keysUrl.href, { - timeout, - }); - - // 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: durationFromSpec({ 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. - */ -export async function startUpdateExchangeEntry( - ws: InternalWalletState, - exchangeBaseUrl: string, - options: { forceUpdate?: boolean } = {}, -): Promise<void> { - const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - - const now = AbsoluteTime.now(); - - const { notification } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { - return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl, now); - }); - - if (notification) { - ws.notify(notification); - } - - const { oldExchangeState, newExchangeState } = await ws.db - .mktx((x) => [x.exchanges, x.operationRetries]) - .runReadWrite(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 }; - }); - ws.notify({ - type: NotificationType.ExchangeStateTransition, - exchangeBaseUrl: canonBaseUrl, - newExchangeState: newExchangeState, - oldExchangeState: oldExchangeState, - }); - ws.workAvailable.trigger(); -} - -export interface NotificationWaiter { - waitNext(): Promise<void>; - cancel(): void; -} - -export function createNotificationWaiter( - ws: InternalWalletState, - pred: (x: WalletNotification) => boolean, -): NotificationWaiter { - ws.ensureTaskLoopRunning(); - let cancelFn: CancelFn | undefined = undefined; - let p: OpenedPromise<void> | undefined = undefined; - - return { - cancel() { - cancelFn?.(); - }, - waitNext(): Promise<void> { - if (!p) { - p = openPromise(); - cancelFn = ws.addNotificationListener((notif) => { - if (pred(notif)) { - // We got a notification that matches our predicate. - // Resolve promise for existing waiters, - // and create a new promise to wait for the next - // notification occurrence. - const myResolve = p?.resolve; - const myCancel = cancelFn; - p = undefined; - cancelFn = undefined; - myResolve?.(); - myCancel?.(); - } - }); - } - return p.promise; - }, - }; -} - -/** - * 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; -} - -/** - * 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); - const operationId = constructTaskIdentifier({ - tag: PendingTaskType.ExchangeUpdate, - exchangeBaseUrl: canonUrl, - }); - - const oldExchange = await ws.db - .mktx((x) => [x.exchanges]) - .runReadOnly(async (tx) => { - return tx.exchanges.get(canonUrl); - }); - - let needsUpdate = false; - - if (!oldExchange || options.forceUpdate) { - needsUpdate = true; - await startUpdateExchangeEntry(ws, canonUrl, { - forceUpdate: options.forceUpdate, - }); - } else { - const nextUpdate = timestampOptionalAbsoluteFromDb( - oldExchange.nextUpdateStamp, - ); - if ( - nextUpdate == null || - AbsoluteTime.isExpired(nextUpdate) || - oldExchange.updateStatus !== ExchangeEntryDbUpdateStatus.Ready - ) { - needsUpdate = true; - } - } - - if (needsUpdate) { - await runTaskWithErrorReporting(ws, operationId, () => - updateExchangeFromUrlHandler(ws, canonUrl), - ); - } - - const { exchange, exchangeDetails } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { - const exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeDetails(tx, canonUrl); - return { exchange, exchangeDetails }; - }); - - if (!exchange) { - throw Error("exchange entry does not exist anymore"); - } - - switch (exchange.updateStatus) { - case ExchangeEntryDbUpdateStatus.Ready: - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - break; - default: - throw Error("unable to update exchange"); - } - - if (!exchangeDetails) { - 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, - ), - }; - - if (options.expectedMasterPub) { - if (res.masterPub !== options.expectedMasterPub) { - throw Error( - "public key of the exchange does not match expected public key", - ); - } - } - return res; -} - -/** - * 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, - options: { - cancellationToken?: CancellationToken; - } = {}, -): Promise<TaskRunResult> { - logger.trace(`updating exchange info for ${exchangeBaseUrl}`); - exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - - logger.trace("updating exchange /keys info"); - - const timeout = getExchangeRequestTimeout(); - - const keysInfo = await downloadExchangeKeysInfo( - exchangeBaseUrl, - ws.http, - timeout, - ); - - 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 - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.exchangeSignKeys, - x.denominations, - x.coins, - x.refreshGroups, - x.recoupGroups, - ]) - .runReadWrite(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 getExchangeDetails(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) { - // Asynchronously start recoup. This doesn't need to finish - // for the exchange update to be considered finished. - ws.workAvailable.trigger(); - } - - if (!updated) { - throw Error("something went wrong with updating the exchange"); - } - - logger.trace("done updating exchange info in database"); - - ws.notify({ - type: NotificationType.ExchangeStateTransition, - exchangeBaseUrl, - newExchangeState: updated.newExchangeState, - oldExchangeState: updated.oldExchangeState, - }); - - return TaskRunResult.finished(); -} - -/** - * 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 - .mktx((x) => [x.exchangeDetails, x.exchanges]) - .runReadOnly(async (tx) => { - return getExchangeDetails(tx, exchangeBaseUrl); - }); - const accounts = details?.wireInfo.accounts ?? []; - for (const account of accounts) { - const res = parsePaytoUri(account.payto_uri); - if (!res) { - continue; - } - if (supportedTargetTypes.includes(res.targetType)) { - return account.payto_uri; - } - } - throw Error( - `no matching 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> { - // FIXME: download ToS in acceptable format if passed! - const exch = await fetchFreshExchange(ws, exchangeBaseUrl); - - const tosDownload = await downloadTosFromAcceptedFormat( - ws, - exchangeBaseUrl, - getExchangeRequestTimeout(), - acceptedFormat, - acceptLanguage, - ); - - await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadWrite(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, - }; -} - -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(), - ); - return { - keys: keysInfo, - }; -} - -export async function getExchanges( - ws: InternalWalletState, -): Promise<ExchangesListResponse> { - const exchanges: ExchangeListItem[] = []; - await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.operationRetries, - ]) - .runReadOnly(async (tx) => { - const exchangeRecords = await tx.exchanges.iter().toArray(); - for (const r of exchangeRecords) { - const exchangeDetails = await getExchangeDetails(tx, r.baseUrl); - const opRetryRecord = await tx.operationRetries.get( - TaskIdentifiers.forExchangeUpdate(r), - ); - exchanges.push( - makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError), - ); - } - }); - return { exchanges }; -} - -export async function getExchangeDetailedInfo( - ws: InternalWalletState, - exchangeBaseurl: string, -): Promise<ExchangeDetailedResponse> { - //TODO: should we use the forceUpdate parameter? - const exchange = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations]) - .runReadOnly(async (tx) => { - const ex = await tx.exchanges.get(exchangeBaseurl); - const dp = ex?.detailsPointer; - if (!dp) { - return; - } - const { currency } = dp; - const exchangeDetails = await getExchangeDetails(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, - }, - }; -} 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-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts deleted file mode 100644 index 72e9e2e4a..000000000 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts +++ /dev/null @@ -1,927 +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 { - AcceptPeerPullPaymentResponse, - Amounts, - CoinRefreshRequest, - ConfirmPeerPullDebitRequest, - ContractTermsUtil, - ExchangePurseDeposits, - HttpStatusCode, - Logger, - NotificationType, - PeerContractTerms, - PreparePeerPullDebitRequest, - PreparePeerPullDebitResponse, - RefreshReason, - TalerError, - TalerErrorCode, - TalerPreciseTimestamp, - TalerProtocolViolationError, - TransactionAction, - 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 { - InternalWalletState, - PeerPullDebitRecordStatus, - PeerPullPaymentIncomingRecord, - PendingTaskType, - RefreshOperationStatus, - createRefreshGroup, - timestampPreciseToDb, -} from "../index.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkLogicInvariant } from "../util/invariants.js"; -import { - TaskRunResult, - TaskRunResultType, - constructTaskIdentifier, - spendCoins, -} from "./common.js"; -import { - codecForExchangePurseStatus, - getTotalPeerPaymentCost, - queryCoinInfosForSelection, -} from "./pay-peer-common.js"; -import { - constructTransactionIdentifier, - notifyTransition, - parseTransactionIdentifier, - stopLongpolling, -} from "./transactions.js"; -import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js"; - -const logger = new Logger("pay-peer-pull-debit.ts"); - -async function handlePurseCreationConflict( - ws: InternalWalletState, - peerPullInc: PeerPullPaymentIncomingRecord, - resp: HttpResponse, -): Promise<TaskRunResult> { - const pursePub = peerPullInc.pursePub; - const errResp = await readTalerErrorResponse(resp); - if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { - await failPeerPullDebitTransaction(ws, pursePub); - 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 - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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.finished(); -} - -async function processPeerPullDebitPendingDeposit( - ws: InternalWalletState, - peerPullInc: PeerPullPaymentIncomingRecord, -): Promise<TaskRunResult> { - const peerPullDebitId = peerPullInc.peerPullDebitId; - 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 transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - - const httpResp = await ws.http.fetch(purseDepositUrl.href, { - method: "POST", - body: depositPayload, - }); - switch (httpResp.status) { - case HttpStatusCode.Ok: { - const resp = await readSuccessResponseJsonOrThrow( - httpResp, - codecForAny(), - ); - logger.trace(`purse deposit response: ${j2s(resp)}`); - - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(async (tx) => { - const pi = await tx.peerPullDebit.get(peerPullDebitId); - if (!pi) { - throw Error("peer pull payment not found anymore"); - } - if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { - return; - } - const oldTxState = computePeerPullDebitTransactionState(pi); - pi.status = PeerPullDebitRecordStatus.Done; - const newTxState = computePeerPullDebitTransactionState(pi); - await tx.peerPullDebit.put(pi); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - break; - } - case HttpStatusCode.Gone: { - const transitionInfo = await ws.db - .mktx((x) => [ - x.peerPullDebit, - x.refreshGroups, - x.denominations, - x.coinAvailability, - x.coins, - ]) - .runReadWrite(async (tx) => { - const pi = await tx.peerPullDebit.get(peerPullDebitId); - if (!pi) { - throw Error("peer pull payment not found anymore"); - } - if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { - return; - } - const oldTxState = computePeerPullDebitTransactionState(pi); - - 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( - ws, - tx, - currency, - coinPubs, - RefreshReason.AbortPeerPullDebit, - ); - - pi.status = PeerPullDebitRecordStatus.AbortingRefresh; - pi.abortRefreshGroupId = refresh.refreshGroupId; - const newTxState = computePeerPullDebitTransactionState(pi); - await tx.peerPullDebit.put(pi); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - break; - } - case HttpStatusCode.Conflict: { - return handlePurseCreationConflict(ws, peerPullInc, httpResp); - } - default: { - const errResp = await readTalerErrorResponse(httpResp); - return { - type: TaskRunResultType.Error, - errorDetail: errResp, - }; - } - } - return TaskRunResult.finished(); -} - -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 - .mktx((x) => [x.refreshGroups, x.peerPullDebit]) - .runReadWrite(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.pending(); -} - -export async function processPeerPullDebit( - ws: InternalWalletState, - peerPullDebitId: string, -): Promise<TaskRunResult> { - const peerPullInc = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadOnly(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 - .mktx((x) => [x.peerPullDebit]) - .runReadOnly(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, - ); - - const ppi = await ws.db - .mktx((x) => [ - x.exchanges, - x.coins, - x.denominations, - x.refreshGroups, - x.peerPullDebit, - x.coinAvailability, - ]) - .runReadWrite(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); - return pi; - }); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - - 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 - .mktx((x) => [x.peerPullDebit, x.contractTerms]) - .runReadOnly(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 - .mktx((x) => [x.peerPullDebit, x.contractTerms]) - .runReadWrite(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 async function suspendPeerPullDebitTransaction( - ws: InternalWalletState, - peerPullDebitId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullDebit, - peerPullDebitId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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); -} - -export async function abortPeerPullDebitTransaction( - ws: InternalWalletState, - peerPullDebitId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullDebit, - peerPullDebitId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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: - newStatus = PeerPullDebitRecordStatus.Aborted; - break; - case PeerPullDebitRecordStatus.Done: - break; - case PeerPullDebitRecordStatus.PendingDeposit: - newStatus = PeerPullDebitRecordStatus.AbortingRefresh; - break; - case PeerPullDebitRecordStatus.SuspendedDeposit: - break; - case PeerPullDebitRecordStatus.Aborted: - break; - case PeerPullDebitRecordStatus.AbortingRefresh: - 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); -} - -export async function failPeerPullDebitTransaction( - ws: InternalWalletState, - peerPullDebitId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullDebit, - peerPullDebitId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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: - newStatus = PeerPullDebitRecordStatus.Aborted; - break; - case PeerPullDebitRecordStatus.Done: - break; - case PeerPullDebitRecordStatus.PendingDeposit: - break; - case PeerPullDebitRecordStatus.SuspendedDeposit: - break; - case PeerPullDebitRecordStatus.Aborted: - break; - case PeerPullDebitRecordStatus.Failed: - break; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - case PeerPullDebitRecordStatus.AbortingRefresh: - // FIXME: abort underlying refresh! - newStatus = PeerPullDebitRecordStatus.Failed; - 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); -} - -export async function resumePeerPullDebitTransaction( - ws: InternalWalletState, - peerPullDebitId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullDebit, - peerPullDebitId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullDebit]) - .runReadWrite(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: - case PeerPullDebitRecordStatus.Done: - case PeerPullDebitRecordStatus.PendingDeposit: - break; - case PeerPullDebitRecordStatus.SuspendedDeposit: - newStatus = PeerPullDebitRecordStatus.PendingDeposit; - break; - case PeerPullDebitRecordStatus.Aborted: - break; - case PeerPullDebitRecordStatus.AbortingRefresh: - break; - case PeerPullDebitRecordStatus.Failed: - break; - case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: - newStatus = PeerPullDebitRecordStatus.AbortingRefresh; - 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; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - -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-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts deleted file mode 100644 index 4c0292cc6..000000000 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts +++ /dev/null @@ -1,1170 +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, - CheckPeerPushDebitRequest, - CheckPeerPushDebitResponse, - CoinRefreshRequest, - ContractTermsUtil, - HttpStatusCode, - InitiatePeerPushDebitRequest, - InitiatePeerPushDebitResponse, - Logger, - NotificationType, - RefreshReason, - TalerError, - TalerErrorCode, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TalerProtocolViolationError, - TransactionAction, - 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 } 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, - constructTaskIdentifier, - runLongpollAsync, - spendCoins, -} from "./common.js"; -import { - codecForExchangePurseStatus, - getTotalPeerPaymentCost, - queryCoinInfosForSelection, -} from "./pay-peer-common.js"; -import { - constructTransactionIdentifier, - notifyTransition, - stopLongpolling, -} from "./transactions.js"; - -const logger = new Logger("pay-peer-push-debit.ts"); - -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); - if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { - await failPeerPushDebitTransaction(ws, pursePub); - 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 - .mktx((x) => [x.peerPushDebit]) - .runReadWrite(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.finished(); -} - -async function processPeerPushDebitCreateReserve( - ws: InternalWalletState, - peerPushInitiation: PeerPushDebitRecord, -): Promise<TaskRunResult> { - const pursePub = peerPushInitiation.pursePub; - const purseExpiration = peerPushInitiation.purseExpiration; - const hContractTerms = peerPushInitiation.contractTermsHash; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub: pursePub, - }); - - logger.trace(`processing ${transactionId} pending(create-reserve)`); - - const contractTermsRecord = await ws.db - .mktx((x) => [x.contractTerms]) - .runReadOnly(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 failPeerPushDebitTransaction(ws, pursePub); - 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.finished(); -} - -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 - .mktx((x) => [ - x.peerPushDebit, - x.refreshGroups, - x.denominations, - x.coinAvailability, - x.coins, - ]) - .runReadWrite(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, - ); - 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.pending(); -} - -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 - .mktx((x) => [x.peerPushDebit]) - .runReadWrite(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 - .mktx((x) => [x.refreshGroups, x.peerPushDebit]) - .runReadWrite(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.pending(); -} - -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 - .mktx((x) => [x.refreshGroups, x.peerPushDebit]) - .runReadWrite(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.pending(); -} - -/** - * Process the "pending(ready)" state of a peer-push-debit transaction. - */ -async function processPeerPushDebitReady( - ws: InternalWalletState, - peerPushInitiation: PeerPushDebitRecord, -): Promise<TaskRunResult> { - logger.trace("processing peer-push-debit pending(ready)"); - const pursePub = peerPushInitiation.pursePub; - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - const transactionId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - runLongpollAsync(ws, retryTag, async (ct) => { - 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: ct, - }); - 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 { ready: false }; - } else { - await transitionPeerPushDebitTransaction( - ws, - peerPushInitiation.pursePub, - { - stFrom: PeerPushDebitStatus.PendingReady, - stTo: PeerPushDebitStatus.Done, - }, - ); - return { - ready: true, - }; - } - } else if (resp.status === HttpStatusCode.Gone) { - const transitionInfo = await ws.db - .mktx((x) => [ - x.peerPushDebit, - x.refreshGroups, - x.denominations, - x.coinAvailability, - x.coins, - ]) - .runReadWrite(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, - ); - 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 { - ready: true, - }; - } else { - logger.warn(`unexpected HTTP status for purse: ${resp.status}`); - return { - ready: false, - }; - } - }); - logger.trace( - "returning early from peer-push-debit for long-polling in background", - ); - return { - type: TaskRunResultType.Longpoll, - }; -} - -export async function processPeerPushDebit( - ws: InternalWalletState, - pursePub: string, -): Promise<TaskRunResult> { - const peerPushInitiation = await ws.db - .mktx((x) => [x.peerPushDebit]) - .runReadOnly(async (tx) => { - return tx.peerPushDebit.get(pursePub); - }); - if (!peerPushInitiation) { - throw Error("peer push payment not found"); - } - - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - - // We're already running! - if (ws.activeLongpoll[retryTag]) { - logger.info("peer-push-debit task already in long-polling, returning!"); - return { - type: TaskRunResultType.Longpoll, - }; - } - - switch (peerPushInitiation.status) { - case PeerPushDebitStatus.PendingCreatePurse: - return processPeerPushDebitCreateReserve(ws, peerPushInitiation); - case PeerPushDebitStatus.PendingReady: - return processPeerPushDebitReady(ws, peerPushInitiation); - 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 transactionId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - - const contractEncNonce = encodeCrock(getRandomBytes(24)); - - const transitionInfo = await ws.db - .mktx((x) => [ - x.exchanges, - x.contractTerms, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - x.peerPushDebit, - ]) - .runReadWrite(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, - }); - - 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 async function abortPeerPushDebitTransaction( - ws: InternalWalletState, - pursePub: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPushDebit]) - .runReadWrite(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; - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function failPeerPushDebitTransaction( - ws: InternalWalletState, - pursePub: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPushDebit]) - .runReadWrite(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; - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function suspendPeerPushDebitTransaction( - ws: InternalWalletState, - pursePub: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPushDebit]) - .runReadWrite(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; - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function resumePeerPushDebitTransaction( - ws: InternalWalletState, - pursePub: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushDebit, - pursePub, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushDebit, - pursePub, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPushDebit]) - .runReadWrite(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.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - -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/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts deleted file mode 100644 index 76b9fd801..000000000 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ /dev/null @@ -1,803 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Derive pending tasks from the wallet database. - */ - -/** - * Imports. - */ -import { GlobalIDB } from "@gnu-taler/idb-bridge"; -import { - AbsoluteTime, - TalerErrorDetail, - TalerPreciseTimestamp, - TransactionRecordFilter, -} from "@gnu-taler/taler-util"; -import { - BackupProviderStateTag, - DbPreciseTimestamp, - DepositElementStatus, - DepositGroupRecord, - ExchangeEntryDbUpdateStatus, - PeerPullCreditRecord, - PeerPullDebitRecordStatus, - PeerPullPaymentCreditStatus, - PeerPullPaymentIncomingRecord, - PeerPushCreditStatus, - PeerPushDebitRecord, - PeerPushDebitStatus, - PeerPushPaymentIncomingRecord, - PurchaseRecord, - PurchaseStatus, - RefreshCoinStatus, - RefreshGroupRecord, - RefreshOperationStatus, - RefundGroupRecord, - RefundGroupStatus, - RewardRecord, - RewardRecordStatus, - WalletStoresV1, - WithdrawalGroupRecord, - depositOperationNonfinalStatusRange, - timestampAbsoluteFromDb, - timestampOptionalAbsoluteFromDb, - timestampPreciseFromDb, - timestampPreciseToDb, - withdrawalGroupNonfinalRange, -} from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { - PendingOperationsResponse, - PendingTaskType, - TaskId, -} from "../pending-types.js"; -import { GetReadOnlyAccess } from "../util/query.js"; -import { TaskIdentifiers } from "./common.js"; - -function getPendingCommon( - ws: InternalWalletState, - opTag: TaskId, - timestampDue: AbsoluteTime, -): { - id: TaskId; - isDue: boolean; - timestampDue: AbsoluteTime; - isLongpolling: boolean; -} { - const isDue = - AbsoluteTime.isExpired(timestampDue) && !ws.activeLongpoll[opTag]; - return { - id: opTag, - isDue, - timestampDue, - isLongpolling: !!ws.activeLongpoll[opTag], - }; -} - -async function gatherExchangePending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - exchanges: typeof WalletStoresV1.exchanges; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - let timestampDue: DbPreciseTimestamp | undefined = undefined; - await tx.exchanges.iter().forEachAsync(async (exch) => { - switch (exch.updateStatus) { - case ExchangeEntryDbUpdateStatus.Initial: - case ExchangeEntryDbUpdateStatus.Suspended: - return; - } - const opUpdateExchangeTag = TaskIdentifiers.forExchangeUpdate(exch); - let opr = await tx.operationRetries.get(opUpdateExchangeTag); - - switch (exch.updateStatus) { - case ExchangeEntryDbUpdateStatus.Ready: - timestampDue = opr?.retryInfo.nextRetry ?? exch.nextRefreshCheckStamp; - break; - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - case ExchangeEntryDbUpdateStatus.InitialUpdate: - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - timestampDue = - opr?.retryInfo.nextRetry ?? - timestampPreciseToDb(TalerPreciseTimestamp.now()); - break; - } - - resp.pendingOperations.push({ - type: PendingTaskType.ExchangeUpdate, - ...getPendingCommon( - ws, - opUpdateExchangeTag, - AbsoluteTime.fromPreciseTimestamp(timestampPreciseFromDb(timestampDue)), - ), - givesLifeness: false, - exchangeBaseUrl: exch.baseUrl, - lastError: opr?.lastError, - }); - - // We only schedule a check for auto-refresh if the exchange update - // was successful. - if (!opr?.lastError) { - const opCheckRefreshTag = TaskIdentifiers.forExchangeCheckRefresh(exch); - resp.pendingOperations.push({ - type: PendingTaskType.ExchangeCheckRefresh, - ...getPendingCommon( - ws, - opCheckRefreshTag, - AbsoluteTime.fromPreciseTimestamp( - timestampPreciseFromDb(timestampDue), - ), - ), - timestampDue: AbsoluteTime.fromPreciseTimestamp( - timestampPreciseFromDb(exch.nextRefreshCheckStamp), - ), - givesLifeness: false, - exchangeBaseUrl: exch.baseUrl, - }); - } - }); -} - -/** - * Iterate refresh records based on a filter. - */ -export async function iterRecordsForRefresh( - tx: GetReadOnlyAccess<{ - refreshGroups: typeof WalletStoresV1.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 gatherRefreshPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - refreshGroups: typeof WalletStoresV1.refreshGroups; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await iterRecordsForRefresh(tx, { onlyState: "nonfinal" }, async (r) => { - if (r.timestampFinished) { - return; - } - const opId = TaskIdentifiers.forRefresh(r); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.Refresh, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: true, - refreshGroupId: r.refreshGroupId, - finishedPerCoin: r.statusPerCoin.map( - (x) => x === RefreshCoinStatus.Finished, - ), - retryInfo: retryRecord?.retryInfo, - }); - }); -} - -export async function iterRecordsForWithdrawal( - tx: GetReadOnlyAccess<{ - withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; - }>, - filter: TransactionRecordFilter, - f: (r: WithdrawalGroupRecord) => Promise<void>, -): Promise<void> { - let withdrawalGroupRecords: WithdrawalGroupRecord[]; - if (filter.onlyState === "nonfinal") { - withdrawalGroupRecords = await tx.withdrawalGroups.indexes.byStatus.getAll( - withdrawalGroupNonfinalRange, - ); - } else { - withdrawalGroupRecords = - await tx.withdrawalGroups.indexes.byStatus.getAll(); - } - for (const wgr of withdrawalGroupRecords) { - await f(wgr); - } -} - -async function gatherWithdrawalPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; - planchets: typeof WalletStoresV1.planchets; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await iterRecordsForWithdrawal(tx, { onlyState: "nonfinal" }, async (wsr) => { - const opTag = TaskIdentifiers.forWithdrawal(wsr); - let opr = await tx.operationRetries.get(opTag); - /** - * kyc pending operation don't give lifeness - * since the user need to complete kyc procedure - */ - const userNeedToCompleteKYC = wsr.kycUrl !== undefined; - const now = AbsoluteTime.now(); - if (!opr) { - opr = { - id: opTag, - retryInfo: { - firstTry: timestampPreciseToDb(AbsoluteTime.toPreciseTimestamp(now)), - nextRetry: timestampPreciseToDb(AbsoluteTime.toPreciseTimestamp(now)), - retryCounter: 0, - }, - }; - } - resp.pendingOperations.push({ - type: PendingTaskType.Withdraw, - ...getPendingCommon( - ws, - opTag, - timestampOptionalAbsoluteFromDb(opr.retryInfo?.nextRetry) ?? - AbsoluteTime.now(), - ), - givesLifeness: !userNeedToCompleteKYC, - withdrawalGroupId: wsr.withdrawalGroupId, - lastError: opr.lastError, - retryInfo: opr.retryInfo, - }); - }); -} - -export async function iterRecordsForDeposit( - tx: GetReadOnlyAccess<{ - depositGroups: typeof WalletStoresV1.depositGroups; - }>, - filter: TransactionRecordFilter, - f: (r: DepositGroupRecord) => Promise<void>, -): Promise<void> { - let dgs: DepositGroupRecord[]; - if (filter.onlyState === "nonfinal") { - dgs = await tx.depositGroups.indexes.byStatus.getAll( - depositOperationNonfinalStatusRange, - ); - } else { - dgs = await tx.depositGroups.indexes.byStatus.getAll(); - } - - for (const dg of dgs) { - await f(dg); - } -} - -async function gatherDepositPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - depositGroups: typeof WalletStoresV1.depositGroups; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await iterRecordsForDeposit(tx, { onlyState: "nonfinal" }, async (dg) => { - let deposited = true; - for (const d of dg.statusPerCoin) { - if (d === DepositElementStatus.DepositPending) { - deposited = false; - } - } - /** - * kyc pending operation don't give lifeness - * since the user need to complete kyc procedure - */ - const userNeedToCompleteKYC = dg.kycInfo !== undefined; - const opId = TaskIdentifiers.forDeposit(dg); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.Deposit, - ...getPendingCommon(ws, opId, timestampDue), - // Fully deposited operations don't give lifeness, - // because there is no reason to wait on the - // deposit tracking status. - givesLifeness: !deposited && !userNeedToCompleteKYC, - depositGroupId: dg.depositGroupId, - lastError: retryRecord?.lastError, - retryInfo: retryRecord?.retryInfo, - }); - }); -} - -export async function iterRecordsForReward( - tx: GetReadOnlyAccess<{ - rewards: typeof WalletStoresV1.rewards; - }>, - filter: TransactionRecordFilter, - f: (r: RewardRecord) => Promise<void>, -): Promise<void> { - if (filter.onlyState === "nonfinal") { - const range = GlobalIDB.KeyRange.bound( - RewardRecordStatus.PendingPickup, - RewardRecordStatus.PendingPickup, - ); - await tx.rewards.indexes.byStatus.iter(range).forEachAsync(f); - } else { - await tx.rewards.indexes.byStatus.iter().forEachAsync(f); - } -} - -async function gatherRewardPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - rewards: typeof WalletStoresV1.rewards; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await iterRecordsForReward(tx, { onlyState: "nonfinal" }, async (tip) => { - const opId = TaskIdentifiers.forTipPickup(tip); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - - /** - * kyc pending operation don't give lifeness - * since the user need to complete kyc procedure - */ - // const userNeedToCompleteKYC = tip. - - if (tip.acceptedTimestamp) { - resp.pendingOperations.push({ - type: PendingTaskType.RewardPickup, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: true, - timestampDue, - merchantBaseUrl: tip.merchantBaseUrl, - tipId: tip.walletRewardId, - merchantTipId: tip.merchantRewardId, - }); - } - }); -} - -export async function iterRecordsForRefund( - tx: GetReadOnlyAccess<{ - refundGroups: typeof WalletStoresV1.refundGroups; - }>, - filter: TransactionRecordFilter, - f: (r: RefundGroupRecord) => Promise<void>, -): Promise<void> { - if (filter.onlyState === "nonfinal") { - const keyRange = GlobalIDB.KeyRange.only(RefundGroupStatus.Pending); - await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f); - } else { - await tx.refundGroups.iter().forEachAsync(f); - } -} - -export async function iterRecordsForPurchase( - tx: GetReadOnlyAccess<{ - purchases: typeof WalletStoresV1.purchases; - }>, - filter: TransactionRecordFilter, - f: (r: PurchaseRecord) => Promise<void>, -): Promise<void> { - if (filter.onlyState === "nonfinal") { - const keyRange = GlobalIDB.KeyRange.bound( - PurchaseStatus.PendingDownloadingProposal, - PurchaseStatus.PendingAcceptRefund, - ); - await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f); - } else { - await tx.purchases.indexes.byStatus.iter().forEachAsync(f); - } -} - -async function gatherPurchasePending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - purchases: typeof WalletStoresV1.purchases; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await iterRecordsForPurchase(tx, { onlyState: "nonfinal" }, async (pr) => { - const opId = TaskIdentifiers.forPay(pr); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.Purchase, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: true, - statusStr: PurchaseStatus[pr.purchaseStatus], - proposalId: pr.proposalId, - retryInfo: retryRecord?.retryInfo, - lastError: retryRecord?.lastError, - }); - }); -} - -async function gatherRecoupPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - recoupGroups: typeof WalletStoresV1.recoupGroups; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - // FIXME: Have a status field! - await tx.recoupGroups.iter().forEachAsync(async (rg) => { - if (rg.timestampFinished) { - return; - } - const opId = TaskIdentifiers.forRecoup(rg); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.Recoup, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: true, - recoupGroupId: rg.recoupGroupId, - retryInfo: retryRecord?.retryInfo, - lastError: retryRecord?.lastError, - }); - }); -} - -async function gatherBackupPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - backupProviders: typeof WalletStoresV1.backupProviders; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await tx.backupProviders.iter().forEachAsync(async (bp) => { - const opId = TaskIdentifiers.forBackup(bp); - const retryRecord = await tx.operationRetries.get(opId); - if (bp.state.tag === BackupProviderStateTag.Ready) { - const timestampDue = timestampAbsoluteFromDb( - bp.state.nextBackupTimestamp, - ); - resp.pendingOperations.push({ - type: PendingTaskType.Backup, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: false, - backupProviderBaseUrl: bp.baseUrl, - lastError: undefined, - }); - } else if (bp.state.tag === BackupProviderStateTag.Retrying) { - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo?.nextRetry) ?? - AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.Backup, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: false, - backupProviderBaseUrl: bp.baseUrl, - retryInfo: retryRecord?.retryInfo, - lastError: retryRecord?.lastError, - }); - } - }); -} - -export async function iterRecordsForPeerPullInitiation( - tx: GetReadOnlyAccess<{ - peerPullCredit: typeof WalletStoresV1.peerPullCredit; - }>, - filter: TransactionRecordFilter, - f: (r: PeerPullCreditRecord) => Promise<void>, -): Promise<void> { - if (filter.onlyState === "nonfinal") { - const keyRange = GlobalIDB.KeyRange.bound( - PeerPullPaymentCreditStatus.PendingCreatePurse, - PeerPullPaymentCreditStatus.AbortingDeletePurse, - ); - await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); - } else { - await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f); - } -} - -async function gatherPeerPullInitiationPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - peerPullCredit: typeof WalletStoresV1.peerPullCredit; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await iterRecordsForPeerPullInitiation( - tx, - { onlyState: "nonfinal" }, - async (pi) => { - const opId = TaskIdentifiers.forPeerPullPaymentInitiation(pi); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - - /** - * kyc pending operation don't give lifeness - * since the user need to complete kyc procedure - */ - const userNeedToCompleteKYC = pi.kycUrl !== undefined; - - resp.pendingOperations.push({ - type: PendingTaskType.PeerPullCredit, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: !userNeedToCompleteKYC, - retryInfo: retryRecord?.retryInfo, - pursePub: pi.pursePub, - internalOperationStatus: `0x${pi.status.toString(16)}`, - }); - }, - ); -} - -export async function iterRecordsForPeerPullDebit( - tx: GetReadOnlyAccess<{ - peerPullDebit: typeof WalletStoresV1.peerPullDebit; - }>, - filter: TransactionRecordFilter, - f: (r: PeerPullPaymentIncomingRecord) => Promise<void>, -): Promise<void> { - if (filter.onlyState === "nonfinal") { - const keyRange = GlobalIDB.KeyRange.bound( - PeerPullDebitRecordStatus.PendingDeposit, - PeerPullDebitRecordStatus.AbortingRefresh, - ); - await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); - } else { - await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f); - } -} - -async function gatherPeerPullDebitPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - peerPullDebit: typeof WalletStoresV1.peerPullDebit; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await iterRecordsForPeerPullDebit( - tx, - { onlyState: "nonfinal" }, - async (pi) => { - const opId = TaskIdentifiers.forPeerPullPaymentDebit(pi); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - switch (pi.status) { - case PeerPullDebitRecordStatus.DialogProposed: - return; - } - resp.pendingOperations.push({ - type: PendingTaskType.PeerPullDebit, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: true, - retryInfo: retryRecord?.retryInfo, - peerPullDebitId: pi.peerPullDebitId, - internalOperationStatus: `0x${pi.status.toString(16)}`, - }); - }, - ); -} - -export async function iterRecordsForPeerPushInitiation( - tx: GetReadOnlyAccess<{ - peerPushDebit: typeof WalletStoresV1.peerPushDebit; - }>, - filter: TransactionRecordFilter, - f: (r: PeerPushDebitRecord) => Promise<void>, -): Promise<void> { - if (filter.onlyState === "nonfinal") { - const keyRange = GlobalIDB.KeyRange.bound( - PeerPushDebitStatus.PendingCreatePurse, - PeerPushDebitStatus.AbortingRefreshExpired, - ); - await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); - } else { - await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f); - } -} - -async function gatherPeerPushInitiationPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - peerPushDebit: typeof WalletStoresV1.peerPushDebit; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - await iterRecordsForPeerPushInitiation( - tx, - { onlyState: "nonfinal" }, - async (pi) => { - const opId = TaskIdentifiers.forPeerPushPaymentInitiation(pi); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.PeerPushDebit, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: true, - retryInfo: retryRecord?.retryInfo, - pursePub: pi.pursePub, - }); - }, - ); -} - -export async function iterRecordsForPeerPushCredit( - tx: GetReadOnlyAccess<{ - peerPushCredit: typeof WalletStoresV1.peerPushCredit; - }>, - filter: TransactionRecordFilter, - f: (r: PeerPushPaymentIncomingRecord) => Promise<void>, -): Promise<void> { - if (filter.onlyState === "nonfinal") { - const keyRange = GlobalIDB.KeyRange.bound( - PeerPushCreditStatus.PendingMerge, - PeerPushCreditStatus.PendingWithdrawing, - ); - await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); - } else { - await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f); - } -} - -async function gatherPeerPushCreditPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - peerPushCredit: typeof WalletStoresV1.peerPushCredit; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - const keyRange = GlobalIDB.KeyRange.bound( - PeerPushCreditStatus.PendingMerge, - PeerPushCreditStatus.PendingWithdrawing, - ); - await iterRecordsForPeerPushCredit( - tx, - { onlyState: "nonfinal" }, - async (pi) => { - const opId = TaskIdentifiers.forPeerPushCredit(pi); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - timestampOptionalAbsoluteFromDb(retryRecord?.retryInfo.nextRetry) ?? - AbsoluteTime.now(); - - /** - * kyc pending operation don't give lifeness - * since the user need to complete kyc procedure - */ - const userNeedToCompleteKYC = pi.kycUrl !== undefined; - - resp.pendingOperations.push({ - type: PendingTaskType.PeerPushCredit, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: !userNeedToCompleteKYC, - retryInfo: retryRecord?.retryInfo, - peerPushCreditId: pi.peerPushCreditId, - }); - }, - ); -} - -const taskPrio: { [X in PendingTaskType]: number } = { - [PendingTaskType.Deposit]: 2, - [PendingTaskType.ExchangeUpdate]: 1, - [PendingTaskType.PeerPullCredit]: 2, - [PendingTaskType.PeerPullDebit]: 2, - [PendingTaskType.PeerPushCredit]: 2, - [PendingTaskType.Purchase]: 2, - [PendingTaskType.Recoup]: 3, - [PendingTaskType.RewardPickup]: 2, - [PendingTaskType.Refresh]: 3, - [PendingTaskType.Withdraw]: 3, - [PendingTaskType.ExchangeCheckRefresh]: 3, - [PendingTaskType.PeerPushDebit]: 2, - [PendingTaskType.Backup]: 4, -}; - -export async function getPendingOperations( - ws: InternalWalletState, -): Promise<PendingOperationsResponse> { - const now = AbsoluteTime.now(); - const resp = await ws.db - .mktx((x) => [ - x.backupProviders, - x.exchanges, - x.exchangeDetails, - x.refreshGroups, - x.coins, - x.withdrawalGroups, - x.rewards, - x.purchases, - x.planchets, - x.depositGroups, - x.recoupGroups, - x.operationRetries, - x.peerPullCredit, - x.peerPushDebit, - x.peerPullDebit, - x.peerPushCredit, - ]) - .runReadWrite(async (tx) => { - const resp: PendingOperationsResponse = { - pendingOperations: [], - }; - await gatherExchangePending(ws, tx, now, resp); - await gatherRefreshPending(ws, tx, now, resp); - await gatherWithdrawalPending(ws, tx, now, resp); - await gatherDepositPending(ws, tx, now, resp); - await gatherRewardPending(ws, tx, now, resp); - await gatherPurchasePending(ws, tx, now, resp); - await gatherRecoupPending(ws, tx, now, resp); - await gatherBackupPending(ws, tx, now, resp); - await gatherPeerPushInitiationPending(ws, tx, now, resp); - await gatherPeerPullInitiationPending(ws, tx, now, resp); - await gatherPeerPullDebitPending(ws, tx, now, resp); - await gatherPeerPushCreditPending(ws, tx, now, resp); - return resp; - }); - - resp.pendingOperations.sort((a, b) => { - let prioA = taskPrio[a.type]; - let prioB = taskPrio[b.type]; - return Math.sign(prioA - prioB); - }); - - return resp; -} 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 17ac54cfb..000000000 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ /dev/null @@ -1,1449 +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 { - AbsoluteTime, - AgeCommitment, - AgeRestriction, - AmountJson, - Amounts, - amountToPretty, - codecForExchangeMeltResponse, - codecForExchangeRevealResponse, - CoinPublicKeyString, - CoinRefreshRequest, - CoinStatus, - DenominationInfo, - DenomKeyType, - Duration, - durationFromSpec, - durationMul, - encodeCrock, - ExchangeMeltRequest, - ExchangeProtocolVersion, - ExchangeRefreshRevealRequest, - fnutil, - getErrorDetailFromException, - getRandomBytes, - HashCodeString, - HttpStatusCode, - j2s, - Logger, - makeErrorDetail, - NotificationType, - RefreshGroupId, - RefreshReason, - TalerError, - TalerErrorCode, - TalerErrorDetail, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - 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, - RefreshReasonDetails, - WalletStoresV1, -} from "../db.js"; -import { - getCandidateWithdrawalDenomsTx, - isWithdrawableDenom, - PendingTaskType, - RefreshSessionRecord, - timestampPreciseToDb, - timestampProtocolFromDb, -} 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 { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js"; -import { - constructTaskIdentifier, - makeCoinAvailable, - makeCoinsVisible, - TaskRunResult, - TaskRunResultType, -} from "./common.js"; -import { fetchFreshExchange } from "./exchanges.js"; -import { - constructTransactionIdentifier, - notifyTransition, -} from "./transactions.js"; - -const logger = new Logger("refresh.ts"); - -/** - * Get the amount that we lose when refreshing a coin of the given denomination - * with a certain amount left. - * - * If the amount left is zero, then the refresh cost - * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of - * the right denominations), then the cost is the full amount left. - * - * Considers refresh fees, withdrawal fees after refresh and amounts too small - * to refresh. - */ -export function getTotalRefreshCost( - denoms: DenominationRecord[], - refreshedDenom: 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 - .mktx((x) => [x.refreshGroups, x.coins, x.refreshSessions]) - .runReadWrite(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 - .mktx((x) => [x.denominations]) - .runReadOnly(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 - .mktx((x) => [x.coins, x.coinAvailability, x.refreshGroups]) - .runReadWrite(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 - .mktx((x) => [x.refreshGroups, x.coins, x.refreshSessions]) - .runReadWrite(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 - .mktx((x) => [x.refreshGroups, x.refreshSessions, x.coins, x.denominations]) - .runReadWrite(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.postJson(reqUrl.href, 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 - .mktx((x) => [ - x.refreshGroups, - x.refreshSessions, - x.coins, - x.coinAvailability, - ]) - .runReadWrite(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 - .mktx((x) => [x.refreshGroups, x.refreshSessions]) - .runReadWrite(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 - .mktx((x) => [x.refreshGroups, x.refreshSessions, x.coins, x.denominations]) - .runReadOnly(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.postJson(reqUrl.href, req, { - 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 - .mktx((x) => [ - x.coins, - x.denominations, - x.coinAvailability, - x.refreshGroups, - x.refreshSessions, - ]) - .runReadWrite(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, - options: Record<string, never> = {}, -): Promise<TaskRunResult> { - logger.trace(`processing refresh group ${refreshGroupId}`); - - const refreshGroup = await ws.db - .mktx((x) => [x.refreshGroups]) - .runReadOnly(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 $ 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.pending(); - } - 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.pending(); -} - -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 - .mktx((x) => [x.refreshGroups, x.refreshSessions]) - .runReadOnly(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[]; -} - -export async function calculateRefreshOutput( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coinAvailability: typeof WalletStoresV1.coinAvailability; - }>, - currency: string, - oldCoinPubs: CoinRefreshRequest[], -): Promise<RefreshOutputInfo> { - const estimatedOutputPerCoin: AmountJson[] = []; - - const denomsPerExchange: Record<string, DenominationRecord[]> = {}; - - // 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; - estimatedOutputPerCoin.push(output); - } - - return { - outputPerCoin: estimatedOutputPerCoin, - }; -} - -async function applyRefresh( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coinAvailability: typeof WalletStoresV1.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); - } -} - -/** - * 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: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coinAvailability: typeof WalletStoresV1.coinAvailability; - }>, - currency: string, - oldCoinPubs: CoinRefreshRequest[], - reason: RefreshReason, - reasonDetails?: RefreshReasonDetails, -): Promise<RefreshGroupId> { - 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), - reasonDetails, - reason, - 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}`); - - return { - refreshGroupId, - }; -} - -/** - * 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); -} - -/** - * 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); -} - -function getAutoRefreshExecuteThresholdForDenom( - d: DenominationRecord, -): AbsoluteTime { - return getAutoRefreshExecuteThreshold({ - stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw), - stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit), - }); -} - -export async function autoRefresh( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise<TaskRunResult> { - logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`); - - // We must make sure that the exchange is up-to-date so that - // can refresh into new denominations. - await fetchFreshExchange(ws, exchangeBaseUrl); - - let minCheckThreshold = AbsoluteTime.addDuration( - AbsoluteTime.now(), - durationFromSpec({ days: 1 }), - ); - await ws.db - .mktx((x) => [ - x.coins, - x.denominations, - x.coinAvailability, - x.refreshGroups, - x.exchanges, - ]) - .runReadWrite(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, - ); - logger.trace( - `created refresh group for auto-refresh (${res.refreshGroupId})`, - ); - } - // logger.trace( - // `current wallet time: ${AbsoluteTime.toIsoString(AbsoluteTime.now())}`, - // ); - logger.trace( - `next refresh check at ${AbsoluteTime.toIsoString(minCheckThreshold)}`, - ); - exchange.nextRefreshCheckStamp = timestampPreciseToDb( - AbsoluteTime.toPreciseTimestamp(minCheckThreshold), - ); - await tx.exchanges.put(exchange); - }); - return TaskRunResult.finished(); -} - -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.Suspend, TransactionAction.Fail]; - case RefreshOperationStatus.Suspended: - return [TransactionAction.Resume, TransactionAction.Fail]; - } -} - -export async function suspendRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - let res = await ws.db - .mktx((x) => [x.refreshGroups]) - .runReadWrite(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, - }); - } -} - -export async function resumeRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.refreshGroups]) - .runReadWrite(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; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function failRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, -): Promise<void> { - throw Error("action cancel-aborting not allowed on refreshes"); -} - -export async function abortRefreshGroup( - ws: InternalWalletState, - refreshGroupId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Refresh, - refreshGroupId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.refreshGroups]) - .runReadWrite(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.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} 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 79beb6432..000000000 --- a/packages/taler-wallet-core/src/operations/reward.ts +++ /dev/null @@ -1,644 +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, - AgeRestriction, - Amounts, - BlindedDenominationSignature, - codecForMerchantTipResponseV2, - codecForRewardPickupGetResponse, - CoinStatus, - DenomKeyType, - encodeCrock, - getRandomBytes, - j2s, - Logger, - NotificationType, - parseRewardUri, - PrepareTipResult, - TalerErrorCode, - TalerPreciseTimestamp, - TipPlanchetDetail, - TransactionAction, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, - URL, -} from "@gnu-taler/taler-util"; -import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js"; -import { - CoinRecord, - CoinSourceType, - DenominationRecord, - RewardRecord, - RewardRecordStatus, - timestampPreciseFromDb, - timestampPreciseToDb, - timestampProtocolFromDb, - timestampProtocolToDb, -} from "../db.js"; -import { makeErrorDetail } from "@gnu-taler/taler-util"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { - getHttpResponseErrorDetails, - readSuccessResponseJsonOrThrow, -} from "@gnu-taler/taler-util/http"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { - constructTaskIdentifier, - makeCoinAvailable, - makeCoinsVisible, - TaskRunResult, - TaskRunResultType, -} from "./common.js"; -import { fetchFreshExchange } from "./exchanges.js"; -import { - getCandidateWithdrawalDenoms, - getExchangeWithdrawalInfo, - updateWithdrawalDenoms, -} from "./withdraw.js"; -import { selectWithdrawalDenominations } from "../util/coinSelection.js"; -import { - constructTransactionIdentifier, - notifyTransition, - stopLongpolling, -} from "./transactions.js"; -import { PendingTaskType } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; - -const logger = new Logger("operations/tip.ts"); - -/** - * 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, - }; - default: - assertUnreachable(tipRecord.status); - } -} - -export function computeTipTransactionActions( - tipRecord: RewardRecord, -): TransactionAction[] { - switch (tipRecord.status) { - case RewardRecordStatus.Done: - 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 prepareTip( - ws: InternalWalletState, - talerTipUri: string, -): Promise<PrepareTipResult> { - const res = parseRewardUri(talerTipUri); - if (!res) { - throw Error("invalid taler://tip URI"); - } - - let tipRecord = await ws.db - .mktx((x) => [x.rewards]) - .runReadOnly(async (tx) => { - return tx.rewards.indexes.byMerchantTipIdAndBaseUrl.get([ - res.merchantRewardId, - res.merchantBaseUrl, - ]); - }); - - if (!tipRecord) { - const tipStatusUrl = new URL( - `rewards/${res.merchantRewardId}`, - res.merchantBaseUrl, - ); - logger.trace("checking tip status from", tipStatusUrl.href); - const merchantResp = await ws.http.fetch(tipStatusUrl.href); - const tipPickupStatus = await readSuccessResponseJsonOrThrow( - merchantResp, - codecForRewardPickupGetResponse(), - ); - logger.trace(`status ${j2s(tipPickupStatus)}`); - - const amount = Amounts.parseOrThrow(tipPickupStatus.reward_amount); - const currency = amount.currency; - - logger.trace("new tip, creating tip record"); - await fetchFreshExchange(ws, tipPickupStatus.exchange_url); - - //FIXME: is this needed? withdrawDetails is not used - // * if the intention is to update the exchange information in the database - // maybe we can use another name. `get` seems like a pure-function - const withdrawDetails = await getExchangeWithdrawalInfo( - ws, - tipPickupStatus.exchange_url, - amount, - undefined, - ); - - const walletTipId = encodeCrock(getRandomBytes(32)); - await updateWithdrawalDenoms(ws, tipPickupStatus.exchange_url); - const denoms = await getCandidateWithdrawalDenoms( - ws, - tipPickupStatus.exchange_url, - currency, - ); - const selectedDenoms = selectWithdrawalDenominations(amount, denoms); - - const secretSeed = encodeCrock(getRandomBytes(64)); - const denomSelUid = encodeCrock(getRandomBytes(32)); - - const newTipRecord: RewardRecord = { - walletRewardId: walletTipId, - acceptedTimestamp: undefined, - status: RewardRecordStatus.DialogAccept, - rewardAmountRaw: Amounts.stringify(amount), - rewardExpiration: timestampProtocolToDb(tipPickupStatus.expiration), - exchangeBaseUrl: tipPickupStatus.exchange_url, - next_url: tipPickupStatus.next_url, - merchantBaseUrl: res.merchantBaseUrl, - createdTimestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - merchantRewardId: res.merchantRewardId, - rewardAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue), - denomsSel: selectedDenoms, - pickedUpTimestamp: undefined, - secretSeed, - denomSelUid, - }; - await ws.db - .mktx((x) => [x.rewards]) - .runReadWrite(async (tx) => { - await tx.rewards.put(newTipRecord); - }); - tipRecord = newTipRecord; - } - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: tipRecord.walletRewardId, - }); - - const tipStatus: PrepareTipResult = { - accepted: !!tipRecord && !!tipRecord.acceptedTimestamp, - rewardAmountRaw: Amounts.stringify(tipRecord.rewardAmountRaw), - exchangeBaseUrl: tipRecord.exchangeBaseUrl, - merchantBaseUrl: tipRecord.merchantBaseUrl, - expirationTimestamp: timestampProtocolFromDb(tipRecord.rewardExpiration), - rewardAmountEffective: Amounts.stringify(tipRecord.rewardAmountEffective), - walletRewardId: tipRecord.walletRewardId, - transactionId, - }; - - return tipStatus; -} - -export async function processTip( - ws: InternalWalletState, - walletTipId: string, -): Promise<TaskRunResult> { - const tipRecord = await ws.db - .mktx((x) => [x.rewards]) - .runReadOnly(async (tx) => { - return tx.rewards.get(walletTipId); - }); - if (!tipRecord) { - return TaskRunResult.finished(); - } - - switch (tipRecord.status) { - case RewardRecordStatus.Aborted: - case RewardRecordStatus.DialogAccept: - case RewardRecordStatus.Done: - case RewardRecordStatus.SuspendedPickup: - return TaskRunResult.finished(); - } - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: walletTipId, - }); - - const denomsForWithdraw = tipRecord.denomsSel; - - const planchets: DerivedTipPlanchet[] = []; - // Planchets in the form that the merchant expects - const planchetsDetail: TipPlanchetDetail[] = []; - const denomForPlanchet: { [index: number]: DenominationRecord } = []; - - for (const dh of denomsForWithdraw.selectedDenoms) { - const denom = await ws.db - .mktx((x) => [x.denominations]) - .runReadOnly(async (tx) => { - return tx.denominations.get([ - tipRecord.exchangeBaseUrl, - dh.denomPubHash, - ]); - }); - checkDbInvariant(!!denom, "denomination should be in database"); - for (let i = 0; i < dh.count; i++) { - const deriveReq = { - denomPub: denom.denomPub, - planchetIndex: planchets.length, - secretSeed: tipRecord.secretSeed, - }; - logger.trace(`deriving tip planchet: ${j2s(deriveReq)}`); - const p = await ws.cryptoApi.createTipPlanchet(deriveReq); - logger.trace(`derive result: ${j2s(p)}`); - denomForPlanchet[planchets.length] = denom; - planchets.push(p); - planchetsDetail.push({ - coin_ev: p.coinEv, - denom_pub_hash: denom.denomPubHash, - }); - } - } - - const tipStatusUrl = new URL( - `rewards/${tipRecord.merchantRewardId}/pickup`, - tipRecord.merchantBaseUrl, - ); - - const req = { planchets: planchetsDetail }; - logger.trace(`sending tip request: ${j2s(req)}`); - const merchantResp = await ws.http.fetch(tipStatusUrl.href, { - method: "POST", - body: req, - }); - - logger.trace(`got tip response, status ${merchantResp.status}`); - - // FIXME: Why do we do this? - if ( - (merchantResp.status >= 500 && merchantResp.status <= 599) || - merchantResp.status === 424 - ) { - logger.trace(`got transient tip error`); - // FIXME: wrap in another error code that indicates a transient error - return { - type: TaskRunResultType.Error, - errorDetail: makeErrorDetail( - TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, - getHttpResponseErrorDetails(merchantResp), - "tip pickup failed (transient)", - ), - }; - } - let blindedSigs: BlindedDenominationSignature[] = []; - - const response = await readSuccessResponseJsonOrThrow( - merchantResp, - codecForMerchantTipResponseV2(), - ); - blindedSigs = response.blind_sigs.map((x) => x.blind_sig); - - if (blindedSigs.length !== planchets.length) { - throw Error("number of tip responses does not match requested planchets"); - } - - const newCoinRecords: CoinRecord[] = []; - - for (let i = 0; i < blindedSigs.length; i++) { - const blindedSig = blindedSigs[i]; - - const denom = denomForPlanchet[i]; - checkLogicInvariant(!!denom); - const planchet = planchets[i]; - checkLogicInvariant(!!planchet); - - if (denom.denomPub.cipher !== DenomKeyType.Rsa) { - throw Error("unsupported cipher"); - } - - if (blindedSig.cipher !== DenomKeyType.Rsa) { - throw Error("unsupported cipher"); - } - - const denomSigRsa = await ws.cryptoApi.rsaUnblind({ - bk: planchet.blindingKey, - blindedSig: blindedSig.blinded_rsa_signature, - pk: denom.denomPub.rsa_public_key, - }); - - const isValid = await ws.cryptoApi.rsaVerify({ - hm: planchet.coinPub, - pk: denom.denomPub.rsa_public_key, - sig: denomSigRsa.sig, - }); - - if (!isValid) { - return { - type: TaskRunResultType.Error, - errorDetail: makeErrorDetail( - TalerErrorCode.WALLET_REWARD_COIN_SIGNATURE_INVALID, - {}, - "invalid signature from the exchange (via merchant reward) after unblinding", - ), - }; - } - - newCoinRecords.push({ - blindingKey: planchet.blindingKey, - coinPriv: planchet.coinPriv, - coinPub: planchet.coinPub, - coinSource: { - type: CoinSourceType.Reward, - coinIndex: i, - walletRewardId: walletTipId, - }, - sourceTransactionId: transactionId, - denomPubHash: denom.denomPubHash, - denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa.sig }, - exchangeBaseUrl: tipRecord.exchangeBaseUrl, - status: CoinStatus.Fresh, - coinEvHash: planchet.coinEvHash, - maxAge: AgeRestriction.AGE_UNRESTRICTED, - ageCommitmentProof: planchet.ageCommitmentProof, - spendAllocation: undefined, - }); - } - - const transitionInfo = await ws.db - .mktx((x) => [x.coins, x.coinAvailability, x.denominations, x.rewards]) - .runReadWrite(async (tx) => { - const tr = await tx.rewards.get(walletTipId); - if (!tr) { - return; - } - if (tr.status !== RewardRecordStatus.PendingPickup) { - return; - } - const oldTxState = computeRewardTransactionStatus(tr); - tr.pickedUpTimestamp = timestampPreciseToDb(TalerPreciseTimestamp.now()); - tr.status = RewardRecordStatus.Done; - await tx.rewards.put(tr); - const newTxState = computeRewardTransactionStatus(tr); - for (const cr of newCoinRecords) { - await makeCoinAvailable(ws, tx, cr); - } - await makeCoinsVisible(ws, tx, transactionId); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - - return TaskRunResult.finished(); -} - -export async function acceptTip( - ws: InternalWalletState, - walletTipId: string, -): Promise<AcceptTipResponse> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: walletTipId, - }); - const dbRes = await ws.db - .mktx((x) => [x.rewards]) - .runReadWrite(async (tx) => { - const tipRecord = await tx.rewards.get(walletTipId); - if (!tipRecord) { - logger.error("tip not found"); - return; - } - if (tipRecord.status != RewardRecordStatus.DialogAccept) { - logger.warn("Unable to accept tip in the current state"); - return { tipRecord }; - } - const oldTxState = computeRewardTransactionStatus(tipRecord); - tipRecord.acceptedTimestamp = timestampPreciseToDb( - TalerPreciseTimestamp.now(), - ); - tipRecord.status = RewardRecordStatus.PendingPickup; - await tx.rewards.put(tipRecord); - const newTxState = computeRewardTransactionStatus(tipRecord); - return { tipRecord, transitionInfo: { oldTxState, newTxState } }; - }); - - if (!dbRes) { - throw Error("tip not found"); - } - - notifyTransition(ws, transactionId, dbRes.transitionInfo); - - const tipRecord = dbRes.tipRecord; - - return { - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: walletTipId, - }), - next_url: tipRecord.next_url, - }; -} - -export async function suspendRewardTransaction( - ws: InternalWalletState, - walletRewardId: string, -): Promise<void> { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.RewardPickup, - walletRewardId: walletRewardId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: walletRewardId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.rewards]) - .runReadWrite(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: - 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; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function resumeTipTransaction( - ws: InternalWalletState, - walletRewardId: string, -): Promise<void> { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.RewardPickup, - walletRewardId: walletRewardId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: walletRewardId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.rewards]) - .runReadWrite(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: - 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); -} - -export async function failTipTransaction( - ws: InternalWalletState, - walletTipId: string, -): Promise<void> { - // We don't have an "aborting" state, so this should never happen! - throw Error("can't run cance-aborting on tip transaction"); -} - -export async function abortTipTransaction( - ws: InternalWalletState, - walletRewardId: string, -): Promise<void> { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.RewardPickup, - walletRewardId: walletRewardId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Reward, - walletRewardId: walletRewardId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.rewards]) - .runReadWrite(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: - 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); -} 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 392b3753d..000000000 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ /dev/null @@ -1,2766 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019-2021 Taler Systems SA - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { - AbsoluteTime, - AcceptManualWithdrawalResult, - AcceptWithdrawalResponse, - AgeRestriction, - AmountJson, - AmountLike, - AmountString, - Amounts, - BankWithdrawDetails, - CancellationToken, - CoinStatus, - CurrencySpecification, - DenomKeyType, - DenomSelectionState, - Duration, - ExchangeBatchWithdrawRequest, - ExchangeListItem, - ExchangeWireAccount, - ExchangeWithdrawBatchResponse, - ExchangeWithdrawRequest, - ExchangeWithdrawResponse, - ExchangeWithdrawalDetails, - ForcedDenomSel, - HttpStatusCode, - LibtoolVersion, - Logger, - NotificationType, - TalerError, - TalerErrorCode, - TalerErrorDetail, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - TransactionAction, - TransactionMajorState, - TransactionMinorState, - TransactionState, - TransactionType, - URL, - UnblindedSignature, - WithdrawUriInfoResponse, - WithdrawalExchangeAccountDetails, - addPaytoQueryParams, - canonicalizeBaseUrl, - codecForAny, - codecForBankWithdrawalOperationPostResponse, - codecForCashinConversionResponse, - codecForConversionBankConfig, - codecForExchangeWithdrawBatchResponse, - codecForIntegrationBankConfig, - 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 { - ExchangeDetailsRecord, - ExchangeEntryDbRecordStatus, - Wallet, - isWithdrawableDenom, - timestampPreciseToDb, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { - TaskIdentifiers, - TaskRunResult, - TaskRunResultType, - constructTaskIdentifier, - makeCoinAvailable, - makeCoinsVisible, - makeExchangeListItem, - runLongpollAsync, -} from "../operations/common.js"; -import { PendingTaskType } 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, - GetReadOnlyAccess, - GetReadWriteAccess, -} from "../util/query.js"; -import { - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - WALLET_EXCHANGE_PROTOCOL_VERSION, -} from "../versions.js"; -import { - getExchangeDetails, - getExchangePaytoUri, - fetchFreshExchange, - ReadyExchangeSummary, -} from "./exchanges.js"; -import { - TransitionInfo, - constructTransactionIdentifier, - notifyTransition, - stopLongpolling, -} from "./transactions.js"; - -/** - * Logger for this file. - */ -const logger = new Logger("operations/withdraw.ts"); - -export async function suspendWithdrawalTransaction( - ws: InternalWalletState, - withdrawalGroupId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.Withdraw, - withdrawalGroupId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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; - }); - - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function resumeWithdrawalTransaction( - ws: InternalWalletState, - withdrawalGroupId: string, -) { - const transitionInfo = await ws.db - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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; - }); - ws.workAvailable.trigger(); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function abortWithdrawalTransaction( - ws: InternalWalletState, - withdrawalGroupId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.Withdraw, - withdrawalGroupId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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"); - break; - 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.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function failWithdrawalTransaction( - ws: InternalWalletState, - withdrawalGroupId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.Withdraw, - withdrawalGroupId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - stopLongpolling(ws, taskId); - const stateUpdate = await ws.db - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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; - }); - notifyTransition(ws, transactionId, stateUpdate); -} - -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, - }; - } -} - -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 configReqUrl = new URL("config", uriResult.bankIntegrationApiBaseUrl); - - const configResp = await http.fetch(configReqUrl.href); - const config = await readSuccessResponseJsonOrThrow( - configResp, - codecForIntegrationBankConfig(), - ); - - const versionRes = LibtoolVersion.compare( - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - config.version, - ); - if (versionRes?.compatible != true) { - 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 reqUrl = new URL( - `withdrawal-operation/${uriResult.withdrawalOperationId}`, - uriResult.bankIntegrationApiBaseUrl, - ); - - logger.info(`bank withdrawal status URL: ${reqUrl.href}}`); - - const resp = await http.fetch(reqUrl.href); - const status = await readSuccessResponseJsonOrThrow( - resp, - codecForWithdrawOperationStatusResponse(), - ); - - logger.info(`bank withdrawal operation status: ${j2s(status)}`); - - return { - amount: Amounts.parseOrThrow(status.amount), - confirmTransferUrl: status.confirm_transfer_url, - selectionDone: status.selection_done, - senderWire: status.sender_wire, - suggestedExchange: status.suggested_exchange, - transferDone: status.transfer_done, - wireTypes: status.wire_types, - }; -} - -/** - * Return denominations that can potentially used for a withdrawal. - */ -export async function getCandidateWithdrawalDenoms( - ws: InternalWalletState, - exchangeBaseUrl: string, - currency: string, -): Promise<DenominationRecord[]> { - return await ws.db - .mktx((x) => [x.denominations]) - .runReadOnly(async (tx) => { - return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency); - }); -} - -export async function getCandidateWithdrawalDenomsTx( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ denominations: typeof WalletStoresV1.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 - .mktx((x) => [x.planchets]) - .runReadOnly(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 - .mktx((x) => [x.denominations]) - .runReadOnly(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 - .mktx((x) => [x.planchets]) - .runReadWrite(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 transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - - const transitionInfo = await ws.db - .mktx((x) => [x.planchets, x.withdrawalGroups]) - .runReadWrite(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.workAvailable.trigger(); -} - -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 - .mktx((x) => [x.planchets, x.withdrawalGroups]) - .runReadWrite(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 - .mktx((x) => [ - x.withdrawalGroups, - x.planchets, - x.exchanges, - x.denominations, - ]) - .runReadOnly(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 - .mktx((x) => [x.planchets]) - .runReadWrite(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.postJson(reqUrl, 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 - .mktx((x) => [x.withdrawalGroups, x.planchets, x.denominations]) - .runReadOnly(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 - .mktx((x) => [x.planchets]) - .runReadWrite(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); - - // Check if this is the first time that the whole - // withdrawal succeeded. If so, mark the withdrawal - // group as finished. - const success = await ws.db - .mktx((x) => [ - x.coins, - x.denominations, - x.coinAvailability, - x.withdrawalGroups, - x.planchets, - ]) - .runReadWrite(async (tx) => { - const p = await tx.planchets.get(planchetCoinPub); - if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) { - return false; - } - p.planchetStatus = PlanchetStatus.WithdrawalDone; - p.lastError = undefined; - await tx.planchets.put(p); - await makeCoinAvailable(ws, tx, coin); - return true; - }); -} - -/** - * Make sure that denominations that currently can be used for withdrawal - * are validated, and the result of validation is stored in the database. - */ -export async function updateWithdrawalDenoms( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise<void> { - logger.trace( - `updating denominations used for withdrawal for ${exchangeBaseUrl}`, - ); - const exchangeDetails = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { - return ws.exchangeOps.getExchangeDetails(tx, exchangeBaseUrl); - }); - if (!exchangeDetails) { - logger.error("exchange details not available"); - throw Error(`exchange ${exchangeBaseUrl} details not available`); - } - // First do a pass where the validity of candidate denominations - // is checked and the result is stored in the database. - logger.trace("getting candidate denominations"); - const denominations = await getCandidateWithdrawalDenoms( - ws, - exchangeBaseUrl, - 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 - .mktx((x) => [x.denominations]) - .runReadWrite(async (tx) => { - for (let i = 0; i < updatedDenominations.length; i++) { - const denom = updatedDenominations[i]; - await tx.denominations.put(denom); - } - }); - logger.trace("done with DB write"); - } - } -} - -/** - * 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<{ ready: boolean }> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { - withdrawalGroupId, - }); - checkDbInvariant(!!withdrawalGroup); - if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) { - return { ready: true }; - } - 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 { ready: false }; - } else { - throwUnexpectedRequestError(resp, result.talerErrorResponse); - } - } - - logger.trace(`got reserve status ${j2s(result.response)}`); - - const transitionResult = await ws.db - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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 { ready: true }; -} - -enum BankStatusResultCode { - Done = "done", - Waiting = "waiting", - Aborted = "aborted", -} - -/** - * 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 - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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 - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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, -): 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; - - const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup); - runLongpollAsync(ws, retryTag, async (cancellationToken) => { - 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); - return { ready: true }; - } 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); - } - return { ready: false }; - } else if ( - kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons - ) { - const kycStatus = await kycStatusRes.json(); - logger.info(`aml status: ${j2s(kycStatus)}`); - return { ready: false }; - } else { - throw Error( - `unexpected response from kyc-check (${kycStatusRes.status})`, - ); - } - }); - return TaskRunResult.longpoll(); -} - -async function processWithdrawalGroupPendingReady( - ws: InternalWalletState, - withdrawalGroup: WithdrawalGroupRecord, -): Promise<TaskRunResult> { - const { withdrawalGroupId } = withdrawalGroup; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - - await ws.exchangeOps.fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl); - - if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { - logger.warn("Finishing empty withdrawal group (no denoms)"); - const transitionInfo = await ws.db - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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 - .mktx((x) => [x.planchets]) - .runReadOnly(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 - .mktx((x) => [x.coins, x.coinAvailability, x.withdrawalGroups, x.planchets]) - .runReadWrite(async (tx) => { - const wg = await tx.withdrawalGroups.get(withdrawalGroupId); - if (!wg) { - return; - } - - await tx.planchets.indexes.byGroup - .iter(withdrawalGroupId) - .forEach((x) => { - if (x.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.finished(); -} - -export async function processWithdrawalGroup( - ws: InternalWalletState, - withdrawalGroupId: string, -): Promise<TaskRunResult> { - logger.trace("processing withdrawal group", withdrawalGroupId); - const withdrawalGroup = await ws.db - .mktx((x) => [x.withdrawalGroups]) - .runReadOnly(async (tx) => { - return tx.withdrawalGroups.get(withdrawalGroupId); - }); - - if (!withdrawalGroup) { - throw Error(`withdrawal group ${withdrawalGroupId} not found`); - } - - const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup); - - // We're already running! - if (ws.activeLongpoll[retryTag]) { - logger.info("withdrawal group already in long-polling, returning!"); - return { - type: TaskRunResultType.Longpoll, - }; - } - - 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); - case WithdrawalGroupStatus.PendingQueryingStatus: { - runLongpollAsync(ws, retryTag, (ct) => { - return queryReserve(ws, withdrawalGroupId, ct); - }); - logger.trace( - "returning early from withdrawal for long-polling in background", - ); - return { - type: TaskRunResultType.Longpoll, - }; - } - case WithdrawalGroupStatus.PendingWaitConfirmBank: { - const res = await processReserveBankStatus(ws, withdrawalGroupId); - switch (res.status) { - case BankStatusResultCode.Aborted: - case BankStatusResultCode.Done: - return TaskRunResult.finished(); - case BankStatusResultCode.Waiting: { - return TaskRunResult.pending(); - } - } - break; - } - case WithdrawalGroupStatus.Done: - case WithdrawalGroupStatus.FailedBankAborted: { - // FIXME - return TaskRunResult.pending(); - } - case WithdrawalGroupStatus.PendingAml: - // FIXME: Handle this case, withdrawal doesn't support AML yet. - return TaskRunResult.pending(); - case WithdrawalGroupStatus.PendingKyc: - return processWithdrawalGroupPendingKyc(ws, withdrawalGroup); - 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: - // 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 ws.exchangeOps.fetchFreshExchange(ws, exchangeBaseUrl); - - if (exchange.currency != instructedAmount.currency) { - // Specifiying 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 - .mktx((x) => [x.denominations]) - .runReadOnly(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, - }; - return ret; -} - -export interface GetWithdrawalDetailsForUriOpts { - restrictAge?: number; -} - -/** - * Get more information about a taler://withdraw URI. - * - * As side effects, the bank (via the bank integration API) is queried - * and the exchange suggested by the bank is permanently 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) { - // FIXME: right now the exchange gets permanently added, - // we might want to only temporarily add it. - try { - await ws.exchangeOps.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`, - ); - } - } - - // Extract information about possible exchanges for the withdrawal - // operation from the database. - - const exchanges: ExchangeListItem[] = []; - - await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.operationRetries, - ]) - .runReadOnly(async (tx) => { - const exchangeRecords = await tx.exchanges.iter().toArray(); - for (const r of exchangeRecords) { - const exchangeDetails = await ws.exchangeOps.getExchangeDetails( - tx, - r.baseUrl, - ); - const retryRecord = await tx.operationRetries.get( - TaskIdentifiers.forExchangeUpdate(r), - ); - if (exchangeDetails) { - exchanges.push( - makeExchangeListItem(r, exchangeDetails, retryRecord?.lastError), - ); - } - } - }); - - return { - amount: Amounts.stringify(info.amount), - defaultExchangeBaseUrl: info.suggestedExchange, - possibleExchanges: exchanges, - }; -} - -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: GetReadOnlyAccess<{ - withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; - exchanges: typeof WalletStoresV1.exchanges; - exchangeDetails: typeof WalletStoresV1.exchangeDetails; - }>, - withdrawalGroupId: string, -): Promise<string[]> { - const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId); - checkDbInvariant(!!withdrawalGroup); - const exchangeDetails = await getExchangeDetails( - 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 - .mktx((x) => [x.withdrawalGroups]) - .runReadOnly(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 - .mktx((x) => [x.withdrawalGroups]) - .runReadOnly(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(); - } - 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 - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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); -} - -interface BankStatusResult { - status: BankStatusResultCode; -} - -async function processReserveBankStatus( - ws: InternalWalletState, - withdrawalGroupId: string, -): Promise<BankStatusResult> { - 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 { - status: BankStatusResultCode.Done, - }; - } - - if ( - withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated - ) { - throw Error("wrong withdrawal record type"); - } - const bankInfo = withdrawalGroup.wgInfo.bankInfo; - if (!bankInfo) { - return { - status: BankStatusResultCode.Done, - }; - } - - 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 - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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 { - status: BankStatusResultCode.Aborted, - }; - } - - // 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 - .mktx((x) => [x.withdrawalGroups]) - .runReadWrite(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 (status.transfer_done) { - return { - status: BankStatusResultCode.Done, - }; - } else { - return { - status: BankStatusResultCode.Waiting, - }; - } -} - -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 - .mktx((x) => [x.withdrawalGroups]) - .runReadOnly(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, - }; - - const exchangeInfo = 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; -} - -export async function internalPerformCreateWithdrawalGroup( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; - reserves: typeof WalletStoresV1.reserves; - exchanges: typeof WalletStoresV1.exchanges; - }>, - prep: PrepareCreateWithdrawalGroupResult, -): Promise<PerformCreateWithdrawalGroupResult> { - const { withdrawalGroup } = prep; - if (!prep.creationInfo) { - return { withdrawalGroup, 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()); - exchange.entryStatus = ExchangeEntryDbRecordStatus.Used; - await tx.exchanges.put(exchange); - } - - const oldTxState = { - major: TransactionMajorState.None, - minor: undefined, - }; - const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup); - const transitionInfo = { - oldTxState, - newTxState, - }; - - return { withdrawalGroup, transitionInfo }; -} - -/** - * 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 - .mktx((x) => [ - x.withdrawalGroups, - x.reserves, - x.exchanges, - x.exchangeDetails, - ]) - .runReadWrite(async (tx) => { - return await internalPerformCreateWithdrawalGroup(ws, tx, prep); - }); - 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 - .mktx((x) => [x.withdrawalGroups]) - .runReadOnly(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 ws.exchangeOps.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 transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - - // 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.workAvailable.trigger(); - - 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 ws.exchangeOps.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 transactionId = constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId, - }); - - const exchangePaytoUris = await ws.db - .mktx((x) => [x.withdrawalGroups, x.exchanges, x.exchangeDetails]) - .runReadOnly(async (tx) => { - return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId); - }); - - ws.workAvailable.trigger(); - - return { - reservePub: withdrawalGroup.reservePub, - exchangePaytoUris: exchangePaytoUris, - withdrawalAccountsList: withdrawalAccountsList, - transactionId, - }; -} diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index 5d58f4c2f..090a11cf0 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -30,12 +30,21 @@ import { AbsoluteTime, AmountJson, Amounts, + AmountString, + assertUnreachable, + AsyncFlag, + checkDbInvariant, + CheckPaymentResponse, + CheckPayTemplateReponse, + CheckPayTemplateRequest, codecForAbortResponse, codecForMerchantContractTerms, - codecForMerchantOrderRefundPickupResponse, codecForMerchantOrderStatusPaid, codecForMerchantPayResponse, + codecForPostOrderResponse, codecForProposal, + codecForWalletRefundResponse, + codecForWalletTemplateDetails, CoinDepositPermission, CoinRefreshRequest, ConfirmPayResult, @@ -53,14 +62,17 @@ import { MerchantCoinRefundStatus, MerchantContractTerms, MerchantPayResponse, + MerchantUsingTemplateDetails, NotificationType, + parsePayTemplateUri, parsePayUri, parseTalerUri, - PayCoinSelection, PreparePayResult, PreparePayResultType, + PreparePayTemplateRequest, randomBytes, RefreshReason, + SelectedProspectiveCoin, SharePaymentResult, StartRefundQueryForUriResponse, stringifyPayUri, @@ -68,10 +80,13 @@ import { TalerError, TalerErrorCode, TalerErrorDetail, + TalerMerchantApi, + TalerMerchantInstanceHttpClient, TalerPreciseTimestamp, TalerProtocolViolationError, TalerUriAction, TransactionAction, + TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, @@ -87,45 +102,38 @@ import { readUnexpectedResponseDetails, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; -import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; +import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js"; +import { + constructTaskIdentifier, + PendingTaskType, + spendCoins, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + TransitionResultType, +} from "./common.js"; +import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; import { - BackupProviderStateTag, CoinRecord, + DbCoinSelection, DenominationRecord, PurchaseRecord, PurchaseStatus, - RefundReason, - WalletStoresV1, -} from "../db.js"; -import { - getCandidateWithdrawalDenomsTx, - PendingTaskType, RefundGroupRecord, RefundGroupStatus, RefundItemRecord, RefundItemStatus, + RefundReason, timestampPreciseToDb, timestampProtocolFromDb, timestampProtocolToDb, -} 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 { GetReadOnlyAccess } from "../util/query.js"; -import { - constructTaskIdentifier, - DbRetryInfo, - runLongpollAsync, - runTaskWithErrorReporting, - spendCoins, - TaskIdentifiers, - TaskRunResult, - TaskRunResultType, -} from "./common.js"; + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, + WalletStoresV1, +} from "./db.js"; +import { DbReadWriteTransaction, StoreNames } from "./query.js"; import { calculateRefreshOutput, createRefreshGroup, @@ -134,14 +142,323 @@ import { import { constructTransactionIdentifier, notifyTransition, - stopLongpolling, + parseTransactionIdentifier, } from "./transactions.js"; +import { + EXCHANGE_COINS_LOCK, + getDenomInfo, + WalletExecutionContext, +} from "./wallet.js"; /** * Logger. */ const logger = new Logger("pay-merchant.ts"); +export class PayMerchantTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + public proposalId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId, + }); + } + + /** + * Transition a payment transition. + */ + async transition( + f: (rec: PurchaseRecord) => Promise<TransitionResultType>, + ): Promise<void> { + return this.transitionExtra( + { + extraStores: [], + }, + f, + ); + } + + /** + * Transition a payment transition. + * Extra object stores may be accessed during the transition. + */ + async transitionExtra< + StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [], + >( + opts: { extraStores: StoreNameArray }, + f: ( + rec: PurchaseRecord, + tx: DbReadWriteTransaction< + typeof WalletStoresV1, + ["purchases", ...StoreNameArray] + >, + ) => Promise<TransitionResultType>, + ): Promise<void> { + const ws = this.wex; + const extraStores = opts.extraStores ?? []; + const transitionInfo = await ws.db.runReadWriteTx( + { storeNames: ["purchases", ...extraStores] }, + async (tx) => { + const purchaseRec = await tx.purchases.get(this.proposalId); + if (!purchaseRec) { + throw Error("purchase not found anymore"); + } + const oldTxState = computePayMerchantTransactionState(purchaseRec); + const res = await f(purchaseRec, tx); + switch (res) { + case TransitionResultType.Transition: { + await tx.purchases.put(purchaseRec); + const newTxState = computePayMerchantTransactionState(purchaseRec); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + notifyTransition(ws, this.transactionId, transitionInfo); + } + + async deleteTransaction(): Promise<void> { + const { wex: ws, proposalId } = this; + await ws.db.runReadWriteTx( + { storeNames: ["purchases", "tombstones"] }, + async (tx) => { + let found = false; + const purchase = await tx.purchases.get(proposalId); + if (purchase) { + found = true; + await tx.purchases.delete(proposalId); + } + if (found) { + await tx.tombstones.put({ + id: TombstoneTag.DeletePayment + ":" + proposalId, + }); + } + }, + ); + } + + async suspendTransaction(): Promise<void> { + const { wex, proposalId, transactionId } = this; + wex.taskScheduler.stopShepherdTask(this.taskId); + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + const oldTxState = computePayMerchantTransactionState(purchase); + let newStatus = transitionSuspend[purchase.purchaseStatus]; + if (!newStatus) { + return undefined; + } + await tx.purchases.put(purchase); + const newTxState = computePayMerchantTransactionState(purchase); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { wex, proposalId, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { + storeNames: [ + "purchases", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + "coins", + "operationRetries", + ], + }, + async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + const oldTxState = computePayMerchantTransactionState(purchase); + const oldStatus = purchase.purchaseStatus; + switch (oldStatus) { + case PurchaseStatus.Done: + return; + case PurchaseStatus.PendingPaying: + case PurchaseStatus.SuspendedPaying: { + purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; + if (purchase.payInfo && purchase.payInfo.payCoinSelection) { + const coinSel = purchase.payInfo.payCoinSelection; + const currency = Amounts.currencyOf( + purchase.payInfo.totalPayCost, + ); + const refreshCoins: CoinRefreshRequest[] = []; + for (let i = 0; i < coinSel.coinPubs.length; i++) { + refreshCoins.push({ + amount: coinSel.coinContributions[i], + coinPub: coinSel.coinPubs[i], + }); + } + await createRefreshGroup( + wex, + tx, + currency, + refreshCoins, + RefreshReason.AbortPay, + this.transactionId, + ); + } + break; + } + case PurchaseStatus.PendingQueryingAutoRefund: + case PurchaseStatus.SuspendedQueryingAutoRefund: + case PurchaseStatus.PendingAcceptRefund: + case PurchaseStatus.SuspendedPendingAcceptRefund: + case PurchaseStatus.PendingQueryingRefund: + case PurchaseStatus.SuspendedQueryingRefund: + if (!purchase.timestampFirstSuccessfulPay) { + throw Error("invalid state"); + } + purchase.purchaseStatus = PurchaseStatus.Done; + break; + case PurchaseStatus.DialogProposed: + purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused; + break; + default: + return; + } + await tx.purchases.put(purchase); + await tx.operationRetries.delete(this.taskId); + const newTxState = computePayMerchantTransactionState(purchase); + return { oldTxState, newTxState }; + }, + ); + wex.taskScheduler.stopShepherdTask(this.taskId); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(this.taskId); + } + + async resumeTransaction(): Promise<void> { + const { wex, proposalId, transactionId, taskId: retryTag } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + const oldTxState = computePayMerchantTransactionState(purchase); + let newStatus = transitionResume[purchase.purchaseStatus]; + if (!newStatus) { + return undefined; + } + await tx.purchases.put(purchase); + const newTxState = computePayMerchantTransactionState(purchase); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(this.taskId); + } + + async failTransaction(): Promise<void> { + const { wex, proposalId, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { + storeNames: [ + "purchases", + "refreshGroups", + "denominations", + "coinAvailability", + "coins", + "operationRetries", + ], + }, + async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + const oldTxState = computePayMerchantTransactionState(purchase); + let newState: PurchaseStatus | undefined = undefined; + switch (purchase.purchaseStatus) { + case PurchaseStatus.AbortingWithRefund: + newState = PurchaseStatus.FailedAbort; + break; + } + if (newState) { + purchase.purchaseStatus = newState; + await tx.purchases.put(purchase); + } + const newTxState = computePayMerchantTransactionState(purchase); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.stopShepherdTask(this.taskId); + } +} + +export class RefundTransactionContext implements TransactionContext { + public transactionId: TransactionIdStr; + public taskId: TaskIdStr | undefined = undefined; + constructor( + public wex: WalletExecutionContext, + public refundGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refund, + refundGroupId, + }); + } + + async deleteTransaction(): Promise<void> { + const { wex, refundGroupId, transactionId } = this; + await wex.db.runReadWriteTx( + { storeNames: ["refundGroups", "tombstones"] }, + async (tx) => { + const refundRecord = await tx.refundGroups.get(refundGroupId); + if (!refundRecord) { + return; + } + await tx.refundGroups.delete(refundGroupId); + await tx.tombstones.put({ id: transactionId }); + // FIXME: Also tombstone the refund items, so that they won't reappear. + }, + ); + } + + suspendTransaction(): Promise<void> { + throw new Error("Unsupported operation"); + } + + abortTransaction(): Promise<void> { + throw new Error("Unsupported operation"); + } + + resumeTransaction(): Promise<void> { + throw new Error("Unsupported operation"); + } + + failTransaction(): Promise<void> { + throw new Error("Unsupported operation"); + } +} + /** * Compute the total cost of a payment to the customer. * @@ -150,54 +467,42 @@ const logger = new Logger("pay-merchant.ts"); * of coins that are too small to spend. */ export async function getTotalPaymentCost( - ws: InternalWalletState, - pcs: PayCoinSelection, + wex: WalletExecutionContext, + currency: string, + pcs: SelectedProspectiveCoin[], ): Promise<AmountJson> { - const currency = Amounts.currencyOf(pcs.paymentAmount); - return ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { + return wex.db.runReadOnlyTx( + { storeNames: ["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"); - } + for (let i = 0; i < pcs.length; i++) { const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, + pcs[i].exchangeBaseUrl, + pcs[i].denomPubHash, ]); if (!denom) { throw Error( "can't calculate payment cost, denomination for coin not found", ); } - const allDenoms = await getCandidateWithdrawalDenomsTx( - ws, + const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; + const refreshCost = await getTotalRefreshCost( + wex, 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(Amounts.parseOrThrow(pcs[i].contribution)); costs.push(refreshCost); } - const zero = Amounts.zeroOfAmount(pcs.paymentAmount); + const zero = Amounts.zeroOfCurrency(currency); return Amounts.sum([zero, ...costs]).amount; - }); + }, + ); } async function failProposalPermanently( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, err: TalerErrorDetail, ): Promise<void> { @@ -205,9 +510,9 @@ async function failProposalPermanently( tag: TransactionType.Payment, proposalId, }); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; @@ -218,24 +523,15 @@ async function failProposalPermanently( 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({}), - }); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); } function getPayRequestTimeout(purchase: PurchaseRecord): Duration { return Duration.multiply( { d_ms: 15000 }, - 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5, + 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5, ); } @@ -243,11 +539,9 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration { * Return the proposal download data for a purchase, throw if not available. */ export async function expectProposalDownload( - ws: InternalWalletState, + wex: WalletExecutionContext, p: PurchaseRecord, - parentTx?: GetReadOnlyAccess<{ - contractTerms: typeof WalletStoresV1.contractTerms; - }>, + parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>, ): Promise<{ contractData: WalletContractData; contractTermsRaw: any; @@ -279,9 +573,10 @@ export async function expectProposalDownload( if (parentTx) { return getFromTransaction(parentTx); } - return await ws.db - .mktx((x) => [x.contractTerms]) - .runReadOnly(getFromTransaction); + return await wex.db.runReadOnlyTx( + { storeNames: ["contractTerms"] }, + getFromTransaction, + ); } export function extractContractData( @@ -290,12 +585,6 @@ export function extractContractData( 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, @@ -306,10 +595,8 @@ export function extractContractData( 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, @@ -325,27 +612,32 @@ export function extractContractData( } async function processDownloadProposal( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, ): Promise<TaskRunResult> { - const proposal = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return await tx.purchases.get(proposalId); - }); + }, + ); if (!proposal) { return TaskRunResult.finished(); } + const ctx = new PayMerchantTransactionContext(wex, proposalId); + if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) { + logger.error( + `unexpected state ${proposal.purchaseStatus}/${ + PurchaseStatus[proposal.purchaseStatus] + } for ${ctx.transactionId} in processDownloadProposal`, + ); return TaskRunResult.finished(); } - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); + const transactionId = ctx.transactionId; const orderClaimUrl = new URL( `orders/${proposal.orderId}/claim`, @@ -363,17 +655,10 @@ async function processDownloadProposal( requestBody.token = proposal.claimToken; } - const opId = TaskIdentifiers.forPay(proposal); - const retryRecord = await ws.db - .mktx((x) => [x.operationRetries]) - .runReadOnly(async (tx) => { - return tx.operationRetries.get(opId); - }); - - const httpResponse = await ws.http.fetch(orderClaimUrl, { + const httpResponse = await wex.http.fetch(orderClaimUrl, { method: "POST", body: requestBody, - timeout: getProposalRequestTimeout(retryRecord?.retryInfo), + cancellationToken: wex.cancellationToken, }); const r = await readSuccessResponseJsonOrErrorCode( httpResponse, @@ -418,7 +703,7 @@ async function processDownloadProposal( {}, "validation for well-formedness failed", ); - await failProposalPermanently(ws, proposalId, err); + await failProposalPermanently(wex, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, @@ -444,7 +729,7 @@ async function processDownloadProposal( {}, `schema validation failed: ${e}`, ); - await failProposalPermanently(ws, proposalId, err); + await failProposalPermanently(wex, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, @@ -452,7 +737,7 @@ async function processDownloadProposal( ); } - const sigValid = await ws.cryptoApi.isValidContractTermsSignature({ + const sigValid = await wex.cryptoApi.isValidContractTermsSignature({ contractTermsHash, merchantPub: parsedContractTerms.merchant_pub, sig: proposalResp.sig, @@ -467,7 +752,7 @@ async function processDownloadProposal( }, "merchant's signature on contract terms is invalid", ); - await failProposalPermanently(ws, proposalId, err); + await failProposalPermanently(wex, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, @@ -489,7 +774,7 @@ async function processDownloadProposal( }, "merchant base URL mismatch", ); - await failProposalPermanently(ws, proposalId, err); + await failProposalPermanently(wex, proposalId, err); throw makePendingOperationFailedError( err, TransactionType.Payment, @@ -505,9 +790,9 @@ async function processDownloadProposal( logger.trace(`extracted contract data: ${j2s(contractData)}`); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases, x.contractTerms]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases", "contractTerms"] }, + async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; @@ -537,7 +822,12 @@ async function processDownloadProposal( } // FIXME: Adjust this to account for refunds, don't count as repurchase // if original order is refunded. - if (otherPurchase && otherPurchase.refundAmountAwaiting === undefined) { + if ( + otherPurchase && + (otherPurchase.purchaseStatus == PurchaseStatus.Done || + otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying || + otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay) + ) { logger.warn("repurchase detected"); p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected; p.repurchaseProposalId = otherPurchase.proposalId; @@ -553,11 +843,12 @@ async function processDownloadProposal( oldTxState, newTxState, }; - }); + }, + ); - notifyTransition(ws, transactionId, transitionInfo); + notifyTransition(wex, transactionId, transitionInfo); - return TaskRunResult.finished(); + return TaskRunResult.progress(); } /** @@ -565,22 +856,23 @@ async function processDownloadProposal( * record for the provided arguments already exists, * return the old proposal ID. */ -async function createPurchase( - ws: InternalWalletState, +async function createOrReusePurchase( + wex: WalletExecutionContext, merchantBaseUrl: string, orderId: string, sessionId: string | undefined, claimToken: string | undefined, noncePriv: string | undefined, ): Promise<string> { - const oldProposals = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const oldProposals = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.getAll([ merchantBaseUrl, orderId, ]); - }); + }, + ); const oldProposal = oldProposals.find((p) => { return ( @@ -589,43 +881,51 @@ async function createPurchase( p.claimToken === claimToken ); }); - /* If we have already claimed this proposal with the same sessionId - * nonce and claim token, reuse it. */ + // 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 ) { - // FIXME: This lacks proper error handling - await processDownloadProposal(ws, oldProposal.proposalId); - + 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); + const download = await expectProposalDownload(wex, oldProposal); + const paid = await checkIfOrderIsAlreadyPaid( + wex, + download.contractData, + false, + ); + logger.info(`old proposal paid: ${paid}`); if (paid) { - //if this transaction was shared and the order is paid then it - //means that another wallet already paid the proposal - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + // if this transaction was shared and the order is paid then it + // means that another wallet already paid the proposal + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const p = await tx.purchases.get(oldProposal.proposalId); if (!p) { logger.warn("purchase does not exist anymore"); return; } const oldTxState = computePayMerchantTransactionState(p); - p.purchaseStatus = PurchaseStatus.FailedClaim; + p.purchaseStatus = PurchaseStatus.FailedPaidByOther; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; - }); + }, + ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: oldProposal.proposalId, }); - notifyTransition(ws, transactionId, transitionInfo); + notifyTransition(wex, transactionId, transitionInfo); } } return oldProposal.proposalId; @@ -637,10 +937,10 @@ async function createPurchase( shared = true; noncePair = { priv: noncePriv, - pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, + pub: (await wex.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, }; } else { - noncePair = await ws.cryptoApi.createEddsaKeypair({}); + noncePair = await wex.cryptoApi.createEddsaKeypair({}); } const { priv, pub } = noncePair; @@ -671,9 +971,9 @@ async function createPurchase( shared: shared, }; - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { await tx.purchases.put(proposalRecord); const oldTxState: TransactionState = { major: TransactionMajorState.None, @@ -683,20 +983,19 @@ async function createPurchase( oldTxState, newTxState, }; - }); + }, + ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); - notifyTransition(ws, transactionId, transitionInfo); - - await processDownloadProposal(ws, proposalId); + notifyTransition(wex, transactionId, transitionInfo); return proposalId; } async function storeFirstPaySuccess( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, sessionId: string | undefined, payResponse: MerchantPayResponse, @@ -706,9 +1005,9 @@ async function storeFirstPaySuccess( proposalId, }); const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases, x.contractTerms]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["contractTerms", "purchases"] }, + async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { @@ -756,12 +1055,13 @@ async function storeFirstPaySuccess( oldTxState, newTxState, }; - }); - notifyTransition(ws, transactionId, transitionInfo); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); } async function storePayReplaySuccess( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, sessionId: string | undefined, ): Promise<void> { @@ -769,9 +1069,9 @@ async function storePayReplaySuccess( tag: TransactionType.Payment, proposalId, }); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) { @@ -793,8 +1093,9 @@ async function storePayReplaySuccess( await tx.purchases.put(purchase); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); } /** @@ -806,17 +1107,18 @@ async function storePayReplaySuccess( * (3) re-do coin selection with the bad coin removed */ async function handleInsufficientFunds( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, err: TalerErrorDetail, ): Promise<void> { logger.trace("handling insufficient funds, trying to re-select coins"); - const proposal = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(proposalId); - }); + }, + ); if (!proposal) { return; } @@ -842,7 +1144,7 @@ async function handleInsufficientFunds( throw new TalerProtocolViolationError(); } - const { contractData } = await expectProposalDownload(ws, proposal); + const { contractData } = await expectProposalDownload(wex, proposal); const prevPayCoins: PreviousPayCoins = []; @@ -852,64 +1154,62 @@ async function handleInsufficientFunds( } const payCoinSelection = payInfo.payCoinSelection; + if (!payCoinSelection) { + return; + } - await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { + await wex.db.runReadOnlyTx( + { storeNames: ["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, + const res = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: 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; + switch (res.type) { + case "failure": + logger.trace("insufficient funds for coin re-selection"); + return; + case "prospective": + return; + case "success": + break; + default: + assertUnreachable(res); } logger.trace("re-selected coins"); - await ws.db - .mktx((x) => [ - x.purchases, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - ]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { + storeNames: [ + "purchases", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "refreshSessions", + ], + }, + async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; @@ -918,10 +1218,14 @@ async function handleInsufficientFunds( if (!payInfo) { return; } - payInfo.payCoinSelection = res.coinSel; + // Convert to DB format + payInfo.payCoinSelection = { + coinContributions: res.coinSel.coins.map((x) => x.contribution), + coinPubs: res.coinSel.coins.map((x) => x.coinPub), + }; payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); await tx.purchases.put(p); - await spendCoins(ws, tx, { + await spendCoins(wex, tx, { // allocationId: `txn:proposal:${p.proposalId}`, allocationId: constructTransactionIdentifier({ tag: TransactionType.Payment, @@ -933,9 +1237,10 @@ async function handleInsufficientFunds( ), refreshReason: RefreshReason.PayMerchant, }); - }); + }, + ); - ws.notify({ + wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, @@ -944,41 +1249,20 @@ async function handleInsufficientFunds( }); } -async function unblockBackup( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - await ws.db - .mktx((x) => [x.backupProviders]) - .runReadWrite(async (tx) => { - await tx.backupProviders.indexes.byPaymentProposalId - .iter(proposalId) - .forEachAsync(async (bp) => { - bp.state = { - tag: BackupProviderStateTag.Ready, - nextBackupTimestamp: timestampPreciseToDb( - TalerPreciseTimestamp.now(), - ), - }; - tx.backupProviders.put(bp); - }); - }); -} - -// FIXME: Should probably not be exported in its current state // FIXME: Should take a transaction ID instead of a proposal ID // FIXME: Does way more than checking the payment // FIXME: Should return immediately. -export async function checkPaymentByProposalId( - ws: InternalWalletState, +async function checkPaymentByProposalId( + wex: WalletExecutionContext, proposalId: string, sessionId?: string, ): Promise<PreparePayResult> { - let proposal = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + let proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(proposalId); - }); + }, + ); if (!proposal) { throw Error(`could not get proposal ${proposalId}`); } @@ -986,17 +1270,18 @@ export async function checkPaymentByProposalId( const existingProposalId = proposal.repurchaseProposalId; if (existingProposalId) { logger.trace("using existing purchase for same product"); - const oldProposal = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const oldProposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(existingProposalId); - }); + }, + ); if (oldProposal) { proposal = oldProposal; } } } - const d = await expectProposalDownload(ws, proposal); + const d = await expectProposalDownload(wex, proposal); const contractData = d.contractData; const merchantSig = d.contractData.merchantSig; if (!merchantSig) { @@ -1005,10 +1290,11 @@ export async function checkPaymentByProposalId( proposalId = proposal.proposalId; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); + const currency = Amounts.currencyOf(contractData.amount); + + const ctx = new PayMerchantTransactionContext(wex, proposalId); + + const transactionId = ctx.transactionId; const talerUri = stringifyTalerUri({ type: TalerUriAction.Pay, @@ -1019,47 +1305,63 @@ export async function checkPaymentByProposalId( }); // First check if we already paid for it. - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const purchase = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(proposalId); - }); + }, + ); if ( !purchase || purchase.purchaseStatus === PurchaseStatus.DialogProposed || purchase.purchaseStatus === PurchaseStatus.DialogShared ) { + const instructedAmount = Amounts.parseOrThrow(contractData.amount); // If not already paid, check if we could pay for it. - const res = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + const res = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + contractTermsAmount: instructedAmount, depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), prevPayCoins: [], requiredMinimumAge: contractData.minimumAge, - wireMethod: contractData.wireMethod, + restrictWireMethod: 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, - }; + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (res.type) { + case "failure": { + logger.info("not allowing payment, insufficient coins"); + logger.info( + `insufficient balance details: ${j2s( + res.insufficientBalanceDetails, + )}`, + ); + return { + status: PreparePayResultType.InsufficientBalance, + contractTerms: d.contractTermsRaw, + proposalId: proposal.proposalId, + transactionId, + amountRaw: Amounts.stringify(d.contractData.amount), + talerUri, + balanceDetails: res.insufficientBalanceDetails, + }; + } + case "prospective": + coins = res.result.prospectiveCoins; + break; + case "success": + coins = res.coinSel.coins; + break; + default: + assertUnreachable(res); } - const totalCost = await getTotalPaymentCost(ws, res.coinSel); + const totalCost = await getTotalPaymentCost(wex, currency, coins); logger.trace("costInfo", totalCost); logger.trace("coinsForPayment", res); @@ -1069,7 +1371,7 @@ export async function checkPaymentByProposalId( transactionId, proposalId: proposal.proposalId, amountEffective: Amounts.stringify(totalCost), - amountRaw: Amounts.stringify(res.coinSel.paymentAmount), + amountRaw: Amounts.stringify(instructedAmount), contractTermsHash: d.contractData.contractTermsHash, talerUri, }; @@ -1083,9 +1385,9 @@ export async function checkPaymentByProposalId( "automatically re-submitting payment with different session ID", ); logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { return; @@ -1096,15 +1398,16 @@ export async function checkPaymentByProposalId( await tx.purchases.put(p); const newTxState = computePayMerchantTransactionState(p); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - // FIXME: What about error handling?! This doesn't properly store errors in the DB. - const r = await processPurchasePay(ws, proposalId, { forceNow: true }); - if (r.type !== TaskRunResultType.Finished) { - // FIXME: This does not surface the original error - throw Error("submitting pay failed"); - } - const download = await expectProposalDownload(ws, purchase); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(ctx.taskId); + + // FIXME: Consider changing the API here so that we don't have to + // wait inline for the repurchase. + + await waitPaymentResult(wex, proposalId, sessionId); + const download = await expectProposalDownload(wex, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, @@ -1119,12 +1422,12 @@ export async function checkPaymentByProposalId( talerUri, }; } else if (!purchase.timestampFirstSuccessfulPay) { - const download = await expectProposalDownload(ws, purchase); + const download = await expectProposalDownload(wex, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, contractTermsHash: download.contractData.contractTermsHash, - paid: false, + paid: purchase.purchaseStatus === PurchaseStatus.FailedPaidByOther, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: purchase.payInfo ? Amounts.stringify(purchase.payInfo.totalPayCost) @@ -1138,7 +1441,7 @@ export async function checkPaymentByProposalId( purchase.purchaseStatus === PurchaseStatus.Done || purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund || purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund; - const download = await expectProposalDownload(ws, purchase); + const download = await expectProposalDownload(wex, purchase); return { status: PreparePayResultType.AlreadyConfirmed, contractTerms: download.contractTermsRaw, @@ -1157,20 +1460,21 @@ export async function checkPaymentByProposalId( } export async function getContractTermsDetails( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, ): Promise<WalletContractData> { - const proposal = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(proposalId); - }); + }, + ); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); } - const d = await expectProposalDownload(ws, proposal); + const d = await expectProposalDownload(wex, proposal); return d.contractData; } @@ -1182,7 +1486,7 @@ export async function getContractTermsDetails( * yet send to the merchant. */ export async function preparePayForUri( - ws: InternalWalletState, + wex: WalletExecutionContext, talerPayUri: string, ): Promise<PreparePayResult> { const uriResult = parsePayUri(talerPayUri); @@ -1197,8 +1501,8 @@ export async function preparePayForUri( ); } - const proposalId = await createPurchase( - ws, + const proposalId = await createOrReusePurchase( + wex, uriResult.merchantBaseUrl, uriResult.orderId, uriResult.sessionId, @@ -1206,7 +1510,187 @@ export async function preparePayForUri( uriResult.noncePriv, ); - return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId); + await waitProposalDownloaded(wex, proposalId); + + return checkPaymentByProposalId(wex, proposalId, uriResult.sessionId); +} + +/** + * Wait until a proposal is at least downloaded. + */ +async function waitProposalDownloaded( + wex: WalletExecutionContext, + proposalId: string, +): Promise<void> { + // FIXME: This doesn't support cancellation yet + const ctx = new PayMerchantTransactionContext(wex, proposalId); + + logger.info(`waiting for ${ctx.transactionId} to be downloaded`); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + // FIXME: We should use Symbol.dispose magic here for cleanup! + + const payNotifFlag = new AsyncFlag(); + // Raise exchangeNotifFlag whenever we get a notification + // about our exchange. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ) { + logger.info(`raising update notification: ${j2s(notif)}`); + payNotifFlag.raise(); + } + }); + + try { + await internalWaitProposalDownloaded(ctx, payNotifFlag); + logger.info(`done waiting for ${ctx.transactionId} to be downloaded`); + } finally { + cancelNotif(); + } +} + +async function internalWaitProposalDownloaded( + ctx: PayMerchantTransactionContext, + payNotifFlag: AsyncFlag, +): Promise<void> { + while (true) { + const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["purchases", "operationRetries"] }, + async (tx) => { + return { + purchase: await tx.purchases.get(ctx.proposalId), + retryInfo: await tx.operationRetries.get(ctx.taskId), + }; + }, + ); + if (!purchase) { + throw Error("purchase does not exist anymore"); + } + if (purchase.download) { + return; + } + if (retryInfo) { + if (retryInfo.lastError) { + throw TalerError.fromUncheckedDetail(retryInfo.lastError); + } else { + throw Error("transient error while waiting for proposal download"); + } + } + await payNotifFlag.wait(); + payNotifFlag.reset(); + } +} + +async function downloadTemplate( + wex: WalletExecutionContext, + merchantBaseUrl: string, + templateId: string, +): Promise<TalerMerchantApi.WalletTemplateDetails> { + const reqUrl = new URL(`templates/${templateId}`, merchantBaseUrl); + const httpReq = await wex.http.fetch(reqUrl.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + const resp = await readSuccessResponseJsonOrThrow( + httpReq, + codecForWalletTemplateDetails(), + ); + return resp; +} + +export async function checkPayForTemplate( + wex: WalletExecutionContext, + req: CheckPayTemplateRequest, +): Promise<CheckPayTemplateReponse> { + const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri); + if (!parsedUri) { + throw Error("invalid taler-template URI"); + } + const templateDetails = await downloadTemplate( + wex, + parsedUri.merchantBaseUrl, + parsedUri.templateId, + ); + + const merchantApi = new TalerMerchantInstanceHttpClient( + parsedUri.merchantBaseUrl, + wex.http, + ); + + const cfg = await merchantApi.getConfig(); + if (cfg.type === "fail") { + throw TalerError.fromUncheckedDetail(cfg.detail); + } + + return { + templateDetails, + supportedCurrencies: Object.keys(cfg.body.currencies), + }; +} + +export async function preparePayForTemplate( + wex: WalletExecutionContext, + req: PreparePayTemplateRequest, +): Promise<PreparePayResult> { + const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri); + if (!parsedUri) { + throw Error("invalid taler-template URI"); + } + logger.trace(`parsed URI: ${j2s(parsedUri)}`); + const templateDetails: MerchantUsingTemplateDetails = {}; + + const templateInfo = await downloadTemplate( + wex, + parsedUri.merchantBaseUrl, + parsedUri.templateId, + ); + + const templateParamsAmount = req.templateParams?.amount as + | AmountString + | undefined; + if (templateParamsAmount === null) { + const amountFromUri = templateInfo.editable_defaults?.amount; + if (amountFromUri != null) { + templateDetails.amount = amountFromUri as AmountString; + } + } else { + templateDetails.amount = templateParamsAmount; + } + + const templateParamsSummary = req.templateParams?.summary; + if (templateParamsSummary === null) { + const summaryFromUri = templateInfo.editable_defaults?.summary; + if (summaryFromUri != null) { + templateDetails.summary = summaryFromUri; + } + } else { + templateDetails.summary = templateParamsSummary; + } + + const reqUrl = new URL( + `templates/${parsedUri.templateId}`, + parsedUri.merchantBaseUrl, + ); + const httpReq = await wex.http.fetch(reqUrl.href, { + method: "POST", + body: templateDetails, + }); + const resp = await readSuccessResponseJsonOrThrow( + httpReq, + codecForPostOrderResponse(), + ); + + const payUri = stringifyPayUri({ + merchantBaseUrl: parsedUri.merchantBaseUrl, + orderId: resp.order_id, + sessionId: "", + claimToken: resp.token, + }); + + return await preparePayForUri(wex, payUri); } /** @@ -1215,8 +1699,8 @@ export async function preparePayForUri( * Accesses the database and the crypto worker. */ export async function generateDepositPermissions( - ws: InternalWalletState, - payCoinSel: PayCoinSelection, + wex: WalletExecutionContext, + payCoinSel: DbCoinSelection, contractData: WalletContractData, ): Promise<CoinDepositPermission[]> { const depositPermissions: CoinDepositPermission[] = []; @@ -1224,10 +1708,10 @@ export async function generateDepositPermissions( coin: CoinRecord; denom: DenominationRecord; }> = []; - await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { - for (let i = 0; i < payCoinSel.coinPubs.length; i++) { + await wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations"] }, + async (tx) => { + for (let i = 0; i < payCoinSel.coinContributions.length; i++) { const coin = await tx.coins.get(payCoinSel.coinPubs[i]); if (!coin) { throw Error("can't pay, allocated coin not found anymore"); @@ -1243,18 +1727,14 @@ export async function generateDepositPermissions( } coinWithDenom.push({ coin, denom }); } - }); + }, + ); - for (let i = 0; i < payCoinSel.coinPubs.length; i++) { + for (let i = 0; i < payCoinSel.coinContributions.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({ + const dp = await wex.cryptoApi.signDepositPermission({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, contractTermsHash: contractData.contractTermsHash, @@ -1276,71 +1756,107 @@ export async function generateDepositPermissions( return depositPermissions; } -/** - * Run the operation handler for a payment - * and return the result as a {@link ConfirmPayResult}. - */ -async function runPayForConfirmPay( - ws: InternalWalletState, - proposalId: string, +async function internalWaitPaymentResult( + ctx: PayMerchantTransactionContext, + purchaseNotifFlag: AsyncFlag, + waitSessionId?: string, ): Promise<ConfirmPayResult> { - logger.trace("processing proposal for confirmPay"); - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - const res = await runTaskWithErrorReporting(ws, taskId, async () => { - return await processPurchasePay(ws, proposalId, { forceNow: true }); - }); - logger.trace(`processPurchasePay response type ${res.type}`); - switch (res.type) { - case TaskRunResultType.Finished: { - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - if (!purchase) { - throw Error("purchase record not available anymore"); + while (true) { + const txRes = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["purchases", "operationRetries"] }, + async (tx) => { + const purchase = await tx.purchases.get(ctx.proposalId); + const retryRecord = await tx.operationRetries.get(ctx.taskId); + return { purchase, retryRecord }; + }, + ); + + if (!txRes.purchase) { + throw Error("purchase gone"); + } + + const purchase = txRes.purchase; + + logger.info( + `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`, + ); + + const d = await expectProposalDownload(ctx.wex, purchase); + + if (txRes.purchase.timestampFirstSuccessfulPay) { + if ( + waitSessionId == null || + txRes.purchase.lastSessionId === waitSessionId + ) { + return { + type: ConfirmPayResultType.Done, + contractTerms: d.contractTermsRaw, + transactionId: ctx.transactionId, + }; } - const d = await expectProposalDownload(ws, purchase); - return { - type: ConfirmPayResultType.Done, - contractTerms: d.contractTermsRaw, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }), - }; } - case TaskRunResultType.Error: { - // We hide transient errors from the caller. - const opRetry = await ws.db - .mktx((x) => [x.operationRetries]) - .runReadOnly(async (tx) => tx.operationRetries.get(taskId)); + + if (txRes.retryRecord) { return { type: ConfirmPayResultType.Pending, - lastError: opRetry?.lastError, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }), + lastError: txRes.retryRecord.lastError, + transactionId: ctx.transactionId, }; } - case TaskRunResultType.Pending: - logger.trace("reporting pending as confirmPay response"); + + if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) { return { - type: ConfirmPayResultType.Pending, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }), - lastError: undefined, + type: ConfirmPayResultType.Done, + contractTerms: d.contractTermsRaw, + transactionId: ctx.transactionId, }; - case TaskRunResultType.Longpoll: - throw Error("unexpected processPurchasePay result (longpoll)"); - default: - assertUnreachable(res); + } + + await purchaseNotifFlag.wait(); + purchaseNotifFlag.reset(); + } +} + +/** + * Wait until either: + * a) the payment succeeded (if provided under the {@param waitSessionId}), or + * b) the attempt to pay failed (merchant unavailable, etc.) + */ +async function waitPaymentResult( + wex: WalletExecutionContext, + proposalId: string, + waitSessionId?: string, +): Promise<ConfirmPayResult> { + // FIXME: We don't support cancelletion yet! + const ctx = new PayMerchantTransactionContext(wex, proposalId); + wex.taskScheduler.startShepherdTask(ctx.taskId); + + // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. + const purchaseNotifFlag = new AsyncFlag(); + // Raise purchaseNotifFlag whenever we get a notification + // about our purchase. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ) { + purchaseNotifFlag.raise(); + } + }); + + try { + logger.info(`waiting for first payment success on ${ctx.transactionId}`); + const res = await internalWaitPaymentResult( + ctx, + purchaseNotifFlag, + waitSessionId, + ); + logger.info( + `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`, + ); + return res; + } finally { + cancelNotif(); } } @@ -1348,37 +1864,38 @@ async function runPayForConfirmPay( * Confirm payment for a proposal previously claimed by the wallet. */ export async function confirmPay( - ws: InternalWalletState, - proposalId: string, + wex: WalletExecutionContext, + transactionId: string, sessionIdOverride?: string, forcedCoinSel?: ForcedCoinSel, ): Promise<ConfirmPayResult> { + const parsedTx = parseTransactionIdentifier(transactionId); + if (parsedTx?.tag !== TransactionType.Payment) { + throw Error("expected payment transaction ID"); + } + const proposalId = parsedTx.proposalId; logger.trace( `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, ); - const proposal = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const proposal = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(proposalId); - }); + }, + ); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); } - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - - const d = await expectProposalDownload(ws, proposal); + const d = await expectProposalDownload(wex, proposal); if (!d) { throw Error("proposal is in invalid state"); } - const existingPurchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const existingPurchase = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const purchase = await tx.purchases.get(proposalId); if ( purchase && @@ -1393,42 +1910,62 @@ export async function confirmPay( await tx.purchases.put(purchase); } return purchase; - }); + }, + ); if (existingPurchase && existingPurchase.payInfo) { logger.trace("confirmPay: submitting payment for existing purchase"); - return runPayForConfirmPay(ws, proposalId); + const ctx = new PayMerchantTransactionContext( + wex, + existingPurchase.proposalId, + ); + await wex.taskScheduler.resetTaskRetries(ctx.taskId); + return waitPaymentResult(wex, proposalId); } logger.trace("confirmPay: purchase record does not exist yet"); const contractData = d.contractData; - const selectCoinsResult = await selectPayCoinsNew(ws, { - auditors: [], - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, + const currency = Amounts.currencyOf(contractData.amount); + + const selectCoinsResult = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee), prevPayCoins: [], requiredMinimumAge: contractData.minimumAge, forcedSelection: forcedCoinSel, }); - logger.trace("coin selection result", selectCoinsResult); + let coins: SelectedProspectiveCoin[] | undefined = undefined; - 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"); + switch (selectCoinsResult.type) { + case "failure": { + // Should not happen, since checkPay should be called first + // FIXME: Actually, this should be handled gracefully, + // and the status should be stored in the DB. + logger.warn("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + case "prospective": { + coins = selectCoinsResult.result.prospectiveCoins; + break; + } + case "success": + coins = selectCoinsResult.coinSel.coins; + break; + default: + assertUnreachable(selectCoinsResult); } - const coinSelection = selectCoinsResult.coinSel; - const payCostInfo = await getTotalPaymentCost(ws, coinSelection); + logger.trace("coin selection result", selectCoinsResult); + + const payCostInfo = await getTotalPaymentCost(wex, currency, coins); let sessionId: string | undefined; if (sessionIdOverride) { @@ -1441,15 +1978,18 @@ export async function confirmPay( `recording payment on ${proposal.orderId} with session ID ${sessionId}`, ); - const transitionInfo = await ws.db - .mktx((x) => [ - x.purchases, - x.coins, - x.refreshGroups, - x.denominations, - x.coinAvailability, - ]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { + storeNames: [ + "purchases", + "coins", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + ], + }, + async (tx) => { const p = await tx.purchases.get(proposal.proposalId); if (!p) { return; @@ -1459,26 +1999,37 @@ export async function confirmPay( case PurchaseStatus.DialogShared: case PurchaseStatus.DialogProposed: p.payInfo = { - payCoinSelection: coinSelection, - payCoinSelectionUid: encodeCrock(getRandomBytes(16)), totalPayCost: Amounts.stringify(payCostInfo), }; + if (selectCoinsResult.type === "success") { + p.payInfo.payCoinSelection = { + coinContributions: selectCoinsResult.coinSel.coins.map( + (x) => x.contribution, + ), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + }; + p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16)); + } p.lastSessionId = sessionId; p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now()); p.purchaseStatus = PurchaseStatus.PendingPaying; await tx.purchases.put(p); - 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, - }); + if (p.payInfo.payCoinSelection) { + const sel = p.payInfo.payCoinSelection; + await spendCoins(wex, tx, { + //`txn:proposal:${p.proposalId}` + allocationId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: proposalId, + }), + coinPubs: sel.coinPubs, + contributions: sel.coinContributions.map((x) => + Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayMerchant, + }); + } + break; case PurchaseStatus.Done: case PurchaseStatus.PendingPaying: @@ -1487,26 +2038,34 @@ export async function confirmPay( } const newTxState = computePayMerchantTransactionState(p); return { oldTxState, newTxState }; - }); + }, + ); - notifyTransition(ws, transactionId, transitionInfo); - ws.notify({ + notifyTransition(wex, transactionId, transitionInfo); + wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, }); - return runPayForConfirmPay(ws, proposalId); + const ctx = new PayMerchantTransactionContext(wex, proposalId); + + // In case we're sharing the payment and we're long-polling + wex.taskScheduler.stopShepherdTask(ctx.taskId); + + // Wait until we have completed the first attempt to pay. + return waitPaymentResult(wex, proposalId); } export async function processPurchase( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, ): Promise<TaskRunResult> { - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const purchase = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(proposalId); - }); + }, + ); if (!purchase) { return { type: TaskRunResultType.Error, @@ -1522,26 +2081,27 @@ export async function processPurchase( switch (purchase.purchaseStatus) { case PurchaseStatus.PendingDownloadingProposal: - return processDownloadProposal(ws, proposalId); + return processDownloadProposal(wex, proposalId); case PurchaseStatus.PendingPaying: case PurchaseStatus.PendingPayingReplay: - return processPurchasePay(ws, proposalId); + return processPurchasePay(wex, proposalId); case PurchaseStatus.PendingQueryingRefund: - return processPurchaseQueryRefund(ws, purchase); + return processPurchaseQueryRefund(wex, purchase); case PurchaseStatus.PendingQueryingAutoRefund: - return processPurchaseAutoRefund(ws, purchase); + return processPurchaseAutoRefund(wex, purchase); case PurchaseStatus.AbortingWithRefund: - return processPurchaseAbortingRefund(ws, purchase); + return processPurchaseAbortingRefund(wex, purchase); case PurchaseStatus.PendingAcceptRefund: - return processPurchaseAcceptRefund(ws, purchase); + return processPurchaseAcceptRefund(wex, purchase); case PurchaseStatus.DialogShared: - return processPurchaseDialogShared(ws, purchase); + return processPurchaseDialogShared(wex, purchase); case PurchaseStatus.FailedClaim: case PurchaseStatus.Done: case PurchaseStatus.DoneRepurchaseDetected: case PurchaseStatus.DialogProposed: case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedIncompletePayment: + case PurchaseStatus.AbortedOrderDeleted: case PurchaseStatus.AbortedRefunded: case PurchaseStatus.SuspendedAbortingWithRefund: case PurchaseStatus.SuspendedDownloadingProposal: @@ -1551,23 +2111,23 @@ export async function processPurchase( case PurchaseStatus.SuspendedQueryingAutoRefund: case PurchaseStatus.SuspendedQueryingRefund: case PurchaseStatus.FailedAbort: + case PurchaseStatus.FailedPaidByOther: return TaskRunResult.finished(); default: assertUnreachable(purchase.purchaseStatus); - // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`); } } -export async function processPurchasePay( - ws: InternalWalletState, +async function processPurchasePay( + wex: WalletExecutionContext, proposalId: string, - options: unknown = {}, ): Promise<TaskRunResult> { - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const purchase = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(proposalId); - }); + }, + ); if (!purchase) { return { type: TaskRunResultType.Error, @@ -1589,38 +2149,45 @@ export async function processPurchasePay( } logger.trace(`processing purchase pay ${proposalId}`); + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const sessionId = purchase.lastSessionId; logger.trace(`paying with session ID ${sessionId}`); const payInfo = purchase.payInfo; checkDbInvariant(!!payInfo, "payInfo"); - const download = await expectProposalDownload(ws, purchase); + const download = await expectProposalDownload(wex, purchase); if (purchase.shared) { - const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + const paid = await checkIfOrderIsAlreadyPaid( + wex, + download.contractData, + false, + ); if (paid) { - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { logger.warn("purchase does not exist anymore"); return; } const oldTxState = computePayMerchantTransactionState(p); - p.purchaseStatus = PurchaseStatus.FailedClaim; + p.purchaseStatus = PurchaseStatus.FailedPaidByOther; const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; - }); + }, + ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); - notifyTransition(ws, transactionId, transitionInfo); + notifyTransition(wex, transactionId, transitionInfo); return { type: TaskRunResultType.Error, @@ -1632,6 +2199,110 @@ export async function processPurchasePay( } } + const contractData = download.contractData; + const currency = Amounts.currencyOf(download.contractData.amount); + + if (!payInfo.payCoinSelection) { + const selectCoinsResult = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + }); + switch (selectCoinsResult.type) { + case "failure": { + // Should not happen, since checkPay should be called first + // FIXME: Actually, this should be handled gracefully, + // and the status should be stored in the DB. + logger.warn("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + case "prospective": { + throw Error("insufficient balance (pending refresh)"); + } + case "success": + break; + default: + assertUnreachable(selectCoinsResult); + } + + logger.trace("coin selection result", selectCoinsResult); + + const payCostInfo = await getTotalPaymentCost( + wex, + currency, + selectCoinsResult.coinSel.coins, + ); + + const transitionDone = await wex.db.runReadWriteTx( + { + storeNames: [ + "purchases", + "coins", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + ], + }, + async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + return false; + } + if (p.payInfo?.payCoinSelection) { + return false; + } + switch (p.purchaseStatus) { + case PurchaseStatus.DialogShared: + case PurchaseStatus.DialogProposed: + p.payInfo = { + totalPayCost: Amounts.stringify(payCostInfo), + payCoinSelection: { + coinContributions: selectCoinsResult.coinSel.coins.map( + (x) => x.contribution, + ), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + }, + }; + p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16)); + p.purchaseStatus = PurchaseStatus.PendingPaying; + await tx.purchases.put(p); + + await spendCoins(wex, tx, { + //`txn:proposal:${p.proposalId}` + allocationId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: proposalId, + }), + coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub), + contributions: selectCoinsResult.coinSel.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayMerchant, + }); + return true; + case PurchaseStatus.Done: + case PurchaseStatus.PendingPaying: + default: + break; + } + return false; + }, + ); + + if (transitionDone) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } + } + if (!purchase.merchantPaySig) { const payUrl = new URL( `orders/${download.contractData.orderId}/pay`, @@ -1641,7 +2312,7 @@ export async function processPurchasePay( let depositPermissions: CoinDepositPermission[]; // FIXME: Cache! depositPermissions = await generateDepositPermissions( - ws, + wex, payInfo.payCoinSelection, download.contractData, ); @@ -1651,16 +2322,16 @@ export async function processPurchasePay( session_id: purchase.lastSessionId, }; - logger.trace( - "making pay request ... ", - JSON.stringify(reqBody, undefined, 2), - ); + if (logger.shouldLogTrace()) { + logger.trace(`making pay request ... ${j2s(reqBody)}`); + } - const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - ws.http.fetch(payUrl, { + const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => + wex.http.fetch(payUrl, { method: "POST", body: reqBody, timeout: getPayRequestTimeout(purchase), + cancellationToken: wex.cancellationToken, }), ); @@ -1686,20 +2357,24 @@ export async function processPurchasePay( 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) => { - console.log("handling insufficient funds failed"); - console.log(`${e.toString()}`); + // FIXME: Why? We're already in a (background) task! + handleInsufficientFunds(wex, proposalId, err).catch(async (e) => { + logger.error("handling insufficient funds failed"); + logger.error(`${e.toString()}`); }); // FIXME: Should we really consider this to be pending? - return TaskRunResult.pending(); + return TaskRunResult.backoff(); } } if (resp.status >= 400 && resp.status <= 499) { logger.trace("got generic 4xx from merchant"); const err = await readTalerErrorResponse(resp); + if (logger.shouldLogTrace()) { + logger.trace(`error body: ${j2s(err)}`); + } throwUnexpectedRequestError(resp, err); } @@ -1711,7 +2386,7 @@ export async function processPurchasePay( logger.trace("got success from pay URL", merchantResp); const merchantPub = download.contractData.merchantPub; - const { valid } = await ws.cryptoApi.isValidPaymentSignature({ + const { valid } = await wex.cryptoApi.isValidPaymentSignature({ contractHash: download.contractData.contractTermsHash, merchantPub, sig: merchantResp.sig, @@ -1723,8 +2398,7 @@ export async function processPurchasePay( throw Error("merchant payment signature invalid"); } - await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp); - await unblockBackup(ws, proposalId); + await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp); } else { const payAgainUrl = new URL( `orders/${download.contractData.orderId}/paid`, @@ -1736,8 +2410,12 @@ export async function processPurchasePay( session_id: sessionId ?? "", }; logger.trace(`/paid request body: ${j2s(reqBody)}`); - const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - ws.http.postJson(payAgainUrl, reqBody), + const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => + wex.http.fetch(payAgainUrl, { + method: "POST", + body: reqBody, + cancellationToken: wex.cancellationToken, + }), ); logger.trace(`/paid response status: ${resp.status}`); if ( @@ -1750,24 +2428,23 @@ export async function processPurchasePay( "/paid failed", ); } - await storePayReplaySuccess(ws, proposalId, sessionId); - await unblockBackup(ws, proposalId); + await storePayReplaySuccess(wex, proposalId, sessionId); } - return TaskRunResult.finished(); + return TaskRunResult.progress(); } export async function refuseProposal( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, ): Promise<void> { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId, }); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const proposal = await tx.purchases.get(proposalId); if (!proposal) { logger.trace(`proposal ${proposalId} not found, won't refuse proposal`); @@ -1784,118 +2461,10 @@ export async function refuseProposal( const newTxState = computePayMerchantTransactionState(proposal); await tx.purchases.put(proposal); return { oldTxState, newTxState }; - }); - - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function abortPayMerchant( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - const opId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - const transitionInfo = await ws.db - .mktx((x) => [ - x.purchases, - x.refreshGroups, - x.denominations, - x.coinAvailability, - x.coins, - x.operationRetries, - ]) - .runReadWrite(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; - } - if (oldStatus === PurchaseStatus.PendingPaying) { - purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; - } - await tx.purchases.put(purchase); - if (oldStatus === PurchaseStatus.PendingPaying) { - 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, - ); - } - } - await tx.operationRetries.delete(opId); - const newTxState = computePayMerchantTransactionState(purchase); - return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - ws.workAvailable.trigger(); -} + }, + ); -export async function failPaymentTransaction( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - const opId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - const transitionInfo = await ws.db - .mktx((x) => [ - x.purchases, - x.refreshGroups, - x.denominations, - x.coinAvailability, - x.coins, - x.operationRetries, - ]) - .runReadWrite(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.workAvailable.trigger(); + notifyTransition(wex, transactionId, transitionInfo); } const transitionSuspend: { @@ -1942,73 +2511,6 @@ const transitionResume: { }, }; -export async function suspendPayMerchant( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - const opId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - stopLongpolling(ws, opId); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(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); - ws.workAvailable.trigger(); -} - -export async function resumePayMerchant( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - const opId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - stopLongpolling(ws, opId); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(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 }; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); - ws.workAvailable.trigger(); -} - export function computePayMerchantTransactionState( purchaseRecord: PurchaseRecord, ): TransactionState { @@ -2102,6 +2604,7 @@ export function computePayMerchantTransactionState( major: TransactionMajorState.Failed, minor: TransactionMinorState.Refused, }; + case PurchaseStatus.AbortedOrderDeleted: case PurchaseStatus.AbortedRefunded: return { major: TransactionMajorState.Aborted, @@ -2129,6 +2632,13 @@ export function computePayMerchantTransactionState( major: TransactionMajorState.Failed, minor: TransactionMinorState.AbortingBank, }; + case PurchaseStatus.FailedPaidByOther: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.PaidByOther, + }; + default: + assertUnreachable(purchaseRecord.purchaseStatus); } } @@ -2182,7 +2692,7 @@ export function computePayMerchantTransactionActions( return []; // Final States case PurchaseStatus.AbortedProposalRefused: - return [TransactionAction.Delete]; + case PurchaseStatus.AbortedOrderDeleted: case PurchaseStatus.AbortedRefunded: return [TransactionAction.Delete]; case PurchaseStatus.Done: @@ -2195,17 +2705,21 @@ export function computePayMerchantTransactionActions( return [TransactionAction.Delete]; case PurchaseStatus.FailedAbort: return [TransactionAction.Delete]; + case PurchaseStatus.FailedPaidByOther: + return [TransactionAction.Delete]; + default: + assertUnreachable(purchaseRecord.purchaseStatus); } } export async function sharePayment( - ws: InternalWalletState, + wex: WalletExecutionContext, merchantBaseUrl: string, orderId: string, ): Promise<SharePaymentResult> { - const result = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const result = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const p = await tx.purchases.indexes.byUrlAndOrderId.get([ merchantBaseUrl, orderId, @@ -2218,25 +2732,42 @@ export async function sharePayment( p.purchaseStatus !== PurchaseStatus.DialogProposed && p.purchaseStatus !== PurchaseStatus.DialogShared ) { - //FIXME: purchase can be shared before being paid + // FIXME: purchase can be shared before being paid return undefined; } + const oldTxState = computePayMerchantTransactionState(p); if (p.purchaseStatus === PurchaseStatus.DialogProposed) { p.purchaseStatus = PurchaseStatus.DialogShared; p.shared = true; - tx.purchases.put(p); + await tx.purchases.put(p); } + const newTxState = computePayMerchantTransactionState(p); + return { + proposalId: p.proposalId, nonce: p.noncePriv, session: p.lastSessionId ?? p.downloadSessionId, token: p.claimToken, + transitionInfo: { + oldTxState, + newTxState, + }, }; - }); + }, + ); if (result === undefined) { throw Error("This purchase can't be shared"); } + + const ctx = new PayMerchantTransactionContext(wex, result.proposalId); + + notifyTransition(wex, ctx.transactionId, result.transitionInfo); + + // schedule a task to watch for the status + wex.taskScheduler.startShepherdTask(ctx.taskId); + const privatePayUri = stringifyPayUri({ merchantBaseUrl, orderId, @@ -2244,12 +2775,14 @@ export async function sharePayment( noncePriv: result.nonce, claimToken: result.token, }); + return { privatePayUri }; } async function checkIfOrderIsAlreadyPaid( - ws: InternalWalletState, + wex: WalletExecutionContext, contract: WalletContractData, + doLongPolling: boolean, ) { const requestUrl = new URL( `orders/${contract.orderId}`, @@ -2257,9 +2790,14 @@ async function checkIfOrderIsAlreadyPaid( ); requestUrl.searchParams.set("h_contract", contract.contractTermsHash); - requestUrl.searchParams.set("timeout_ms", "1000"); + if (doLongPolling) { + requestUrl.searchParams.set("timeout_ms", "30000"); + } + + const resp = await wex.http.fetch(requestUrl.href, { + cancellationToken: wex.cancellationToken, + }); - const resp = await ws.http.fetch(requestUrl.href); if ( resp.status === HttpStatusCode.Ok || resp.status === HttpStatusCode.Accepted || @@ -2269,185 +2807,178 @@ async function checkIfOrderIsAlreadyPaid( } else if (resp.status === HttpStatusCode.PaymentRequired) { return false; } - //forbidden, not found, not acceptable + // forbidden, not found, not acceptable throw Error(`this order cant be paid: ${resp.status}`); } async function processPurchaseDialogShared( - ws: InternalWalletState, + wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise<TaskRunResult> { const proposalId = purchase.proposalId; logger.trace(`processing dialog-shared for proposal ${proposalId}`); - - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - - // FIXME: Put this logic into runLongpollAsync? - if (ws.activeLongpoll[taskId]) { - return TaskRunResult.longpoll(); - } - const download = await expectProposalDownload(ws, purchase); + const download = await expectProposalDownload(wex, purchase); if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) { return TaskRunResult.finished(); } - runLongpollAsync(ws, taskId, async (ct) => { - const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); - if (paid) { - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(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 { - ready: true, - }; - } + const paid = await checkIfOrderIsAlreadyPaid( + wex, + download.contractData, + true, + ); + if (paid) { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.FailedPaidByOther; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); - return { - ready: false, - }; - }); + notifyTransition(wex, transactionId, transitionInfo); + } - return TaskRunResult.longpoll(); + return TaskRunResult.backoff(); } async function processPurchaseAutoRefund( - ws: InternalWalletState, + wex: WalletExecutionContext, 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, }); - // FIXME: Put this logic into runLongpollAsync? - if (ws.activeLongpoll[taskId]) { - return TaskRunResult.longpoll(); - } + const download = await expectProposalDownload(wex, purchase); - const download = await expectProposalDownload(ws, purchase); + const noAutoRefundOrExpired = + !purchase.autoRefundDeadline || + AbsoluteTime.isExpired( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(purchase.autoRefundDeadline), + ), + ); - runLongpollAsync(ws, taskId, async (ct) => { - if ( - !purchase.autoRefundDeadline || - AbsoluteTime.isExpired( - AbsoluteTime.fromProtocolTimestamp( - timestampProtocolFromDb(purchase.autoRefundDeadline), - ), - ) - ) { - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(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 { - ready: true, - }; - } + const totalKnownRefund = await wex.db.runReadOnlyTx( + { storeNames: ["refundGroups"] }, + async (tx) => { + const refunds = await tx.refundGroups.indexes.byProposalId.getAll( + purchase.proposalId, + ); + const am = Amounts.parseOrThrow(download.contractData.amount); + return refunds.reduce((prev, cur) => { + if ( + cur.status === RefundGroupStatus.Done || + cur.status === RefundGroupStatus.Pending + ) { + return Amounts.add(prev, cur.amountEffective).amount; + } + return prev; + }, Amounts.zeroOfAmount(am)); + }, + ); - const requestUrl = new URL( - `orders/${download.contractData.orderId}`, - download.contractData.merchantBaseUrl, - ); - requestUrl.searchParams.set( - "h_contract", - download.contractData.contractTermsHash, + const refundedIsLessThanPrice = + Amounts.cmp(download.contractData.amount, totalKnownRefund) === +1; + const nothingMoreToRefund = !refundedIsLessThanPrice; + + if (noAutoRefundOrExpired || nothingMoreToRefund) { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.Done; + p.refundAmountAwaiting = undefined; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.finished(); + } - requestUrl.searchParams.set("timeout_ms", "1000"); - requestUrl.searchParams.set("await_refund_obtained", "yes"); + 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); + requestUrl.searchParams.set("timeout_ms", "10000"); + requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund)); - // FIXME: Check other status codes! + const resp = await wex.http.fetch(requestUrl.href, { + cancellationToken: wex.cancellationToken, + }); - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), - ); + // FIXME: Check other status codes! - if (orderStatus.refund_pending) { - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(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 { - ready: true, - }; - } else { - return { - ready: false, - }; - } - }); + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); + + if (orderStatus.refund_pending) { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.progress(); + } - return TaskRunResult.longpoll(); + return TaskRunResult.longpollReturnedPending(); } async function processPurchaseAbortingRefund( - ws: InternalWalletState, + wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise<TaskRunResult> { const proposalId = purchase.proposalId; - const download = await expectProposalDownload(ws, purchase); + const download = await expectProposalDownload(wex, purchase); logger.trace(`processing aborting-refund for proposal ${proposalId}`); const requestUrl = new URL( @@ -2462,22 +2993,18 @@ async function processPurchaseAbortingRefund( throw Error("can't abort, no coins selected"); } - await ws.db - .mktx((x) => [x.coins]) - .runReadOnly(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, - }); - } - }); + await wex.db.runReadOnlyTx({ storeNames: ["coins"] }, async (tx) => { + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + const coin = await tx.coins.get(coinPub); + checkDbInvariant(!!coin, "expected coin to be present"); + abortingCoins.push({ + coin_pub: coinPub, + contribution: Amounts.stringify(payCoinSelection.coinContributions[i]), + exchange_url: coin.exchangeBaseUrl, + }); + } + }); const abortReq: AbortRequest = { h_contract: download.contractData.contractTermsHash, @@ -2486,9 +3013,31 @@ async function processPurchaseAbortingRefund( logger.trace(`making order abort request to ${requestUrl.href}`); - const request = await ws.http.postJson(requestUrl.href, abortReq); + const abortHttpResp = await wex.http.fetch(requestUrl.href, { + method: "POST", + body: abortReq, + cancellationToken: wex.cancellationToken, + }); + + if (abortHttpResp.status === HttpStatusCode.NotFound) { + const err = await readTalerErrorResponse(abortHttpResp); + if ( + err.code === + TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND + ) { + const ctx = new PayMerchantTransactionContext(wex, proposalId); + await ctx.transition(async (rec) => { + if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) { + rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; + return TransitionResultType.Transition; + } + return TransitionResultType.Stay; + }); + } + } + const abortResp = await readSuccessResponseJsonOrThrow( - request, + abortHttpResp, codecForAbortResponse(), ); @@ -2514,17 +3063,17 @@ async function processPurchaseAbortingRefund( ), }); } - return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund); + return await storeRefunds(wex, purchase, refunds, RefundReason.AbortRefund); } async function processPurchaseQueryRefund( - ws: InternalWalletState, + wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise<TaskRunResult> { const proposalId = purchase.proposalId; logger.trace(`processing query-refund for proposal ${proposalId}`); - const download = await expectProposalDownload(ws, purchase); + const download = await expectProposalDownload(wex, purchase); const requestUrl = new URL( `orders/${download.contractData.orderId}`, @@ -2535,7 +3084,9 @@ async function processPurchaseQueryRefund( download.contractData.contractTermsHash, ); - const resp = await ws.http.fetch(requestUrl.href); + const resp = await wex.http.fetch(requestUrl.href, { + cancellationToken: wex.cancellationToken, + }); const orderStatus = await readSuccessResponseJsonOrThrow( resp, codecForMerchantOrderStatusPaid(), @@ -2547,9 +3098,9 @@ async function processPurchaseQueryRefund( }); if (!orderStatus.refund_pending) { - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { logger.warn("purchase does not exist anymore"); @@ -2564,18 +3115,19 @@ async function processPurchaseQueryRefund( const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - return TaskRunResult.finished(); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.progress(); } else { const refundAwaiting = Amounts.sub( Amounts.parseOrThrow(orderStatus.refund_amount), Amounts.parseOrThrow(orderStatus.refund_taken), ).amount; - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const p = await tx.purchases.get(purchase.proposalId); if (!p) { logger.warn("purchase does not exist anymore"); @@ -2590,19 +3142,18 @@ async function processPurchaseQueryRefund( const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - return TaskRunResult.finished(); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.progress(); } } async function processPurchaseAcceptRefund( - ws: InternalWalletState, + wex: WalletExecutionContext, purchase: PurchaseRecord, ): Promise<TaskRunResult> { - const proposalId = purchase.proposalId; - - const download = await expectProposalDownload(ws, purchase); + const download = await expectProposalDownload(wex, purchase); const requestUrl = new URL( `orders/${download.contractData.orderId}/refund`, @@ -2611,16 +3162,20 @@ async function processPurchaseAcceptRefund( logger.trace(`making refund request to ${requestUrl.href}`); - const request = await ws.http.postJson(requestUrl.href, { - h_contract: download.contractData.contractTermsHash, + const request = await wex.http.fetch(requestUrl.href, { + method: "POST", + body: { + h_contract: download.contractData.contractTermsHash, + }, + cancellationToken: wex.cancellationToken, }); const refundResponse = await readSuccessResponseJsonOrThrow( request, - codecForMerchantOrderRefundPickupResponse(), + codecForWalletRefundResponse(), ); return await storeRefunds( - ws, + wex, purchase, refundResponse.refunds, RefundReason.AbortRefund, @@ -2628,7 +3183,7 @@ async function processPurchaseAcceptRefund( } export async function startRefundQueryForUri( - ws: InternalWalletState, + wex: WalletExecutionContext, talerUri: string, ): Promise<StartRefundQueryForUriResponse> { const parsedUri = parseTalerUri(talerUri); @@ -2638,14 +3193,15 @@ export async function startRefundQueryForUri( if (parsedUri.type !== TalerUriAction.Refund) { throw Error("expected taler://refund URI"); } - const purchaseRecord = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const purchaseRecord = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.get([ parsedUri.merchantBaseUrl, parsedUri.orderId, ]); - }); + }, + ); if (!purchaseRecord) { logger.error( `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`, @@ -2657,23 +3213,20 @@ export async function startRefundQueryForUri( tag: TransactionType.Payment, proposalId, }); - await startQueryRefund(ws, proposalId); + await startQueryRefund(wex, proposalId); return { transactionId, }; } export async function startQueryRefund( - ws: InternalWalletState, + wex: WalletExecutionContext, proposalId: string, ): Promise<void> { - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId, - }); - const transitionInfo = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["purchases"] }, + async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { logger.warn(`purchase ${proposalId} does not exist anymore`); @@ -2687,16 +3240,66 @@ export async function startQueryRefund( const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - ws.workAvailable.trigger(); + }, + ); + notifyTransition(wex, ctx.transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(ctx.taskId); +} + +async function computeRefreshRequest( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction<["coins", "denominations"]>, + items: RefundItemRecord[], +): Promise<CoinRefreshRequest[]> { + const refreshCoins: CoinRefreshRequest[] = []; + for (const item of items) { + const coin = await tx.coins.get(item.coinPub); + if (!coin) { + throw Error("coin not found"); + } + const denomInfo = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denomInfo) { + throw Error("denom not found"); + } + if (item.status === RefundItemStatus.Done) { + const refundedAmount = Amounts.sub( + item.refundAmount, + denomInfo.feeRefund, + ).amount; + refreshCoins.push({ + amount: Amounts.stringify(refundedAmount), + coinPub: item.coinPub, + }); + } + } + return refreshCoins; +} + +/** + * Compute the refund item status based on the merchant's response. + */ +function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus { + if (rf.type === "success") { + return RefundItemStatus.Done; + } else { + if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { + return RefundItemStatus.Pending; + } else { + return RefundItemStatus.Failed; + } + } } /** * Store refunds, possibly creating a new refund group. */ async function storeRefunds( - ws: InternalWalletState, + wex: WalletExecutionContext, purchase: PurchaseRecord, refunds: MerchantCoinRefundStatus[], reason: RefundReason, @@ -2711,62 +3314,25 @@ async function storeRefunds( const newRefundGroupId = encodeCrock(randomBytes(32)); const now = TalerPreciseTimestamp.now(); - const download = await expectProposalDownload(ws, purchase); + const download = await expectProposalDownload(wex, purchase); const currency = Amounts.currencyOf(download.contractData.amount); - const getItemStatus = (rf: MerchantCoinRefundStatus) => { - if (rf.type === "success") { - return RefundItemStatus.Done; - } else { - if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { - return RefundItemStatus.Pending; - } else { - return RefundItemStatus.Failed; - } - } - }; - - const result = await ws.db - .mktx((x) => [ - x.purchases, - x.refundGroups, - x.refundItems, - x.coins, - x.denominations, - x.coinAvailability, - x.refreshGroups, - ]) - .runReadWrite(async (tx) => { - const computeRefreshRequest = async (items: RefundItemRecord[]) => { - 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; - }; - + const result = await wex.db.runReadWriteTx( + { + storeNames: [ + "coins", + "denominations", + "purchases", + "refundItems", + "refundGroups", + "denominations", + "coins", + "coinAvailability", + "refreshGroups", + "refreshSessions", + ], + }, + async (tx) => { const myPurchase = await tx.purchases.get(purchase.proposalId); if (!myPurchase) { logger.warn("purchase group not found anymore"); @@ -2847,9 +3413,13 @@ async function storeRefunds( // we can compute the raw/effective amounts. if (newGroup) { const amountsRaw = newGroupRefunds.map((x) => x.refundAmount); - const refreshCoins = await computeRefreshRequest(newGroupRefunds); + const refreshCoins = await computeRefreshRequest( + wex, + tx, + newGroupRefunds, + ); const outInfo = await calculateRefreshOutput( - ws, + wex, tx, currency, refreshCoins, @@ -2867,52 +3437,75 @@ async function storeRefunds( myPurchase.proposalId, ); - logger.info( - `refund groups for proposal ${myPurchase.proposalId}: ${j2s( - refundGroups, - )}`, - ); - for (const refundGroup of refundGroups) { - if (refundGroup.status === RefundGroupStatus.Aborted) { - continue; - } - if (refundGroup.status === RefundGroupStatus.Done) { - continue; + 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( + 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++; + } } - logger.info(`refund items pending for refund group: ${numPending}`); if (numPending === 0) { - logger.info("refund group is done!"); // We're done for this refund group! - refundGroup.status = RefundGroupStatus.Done; + if (numFailed === 0) { + refundGroup.status = RefundGroupStatus.Done; + } else { + refundGroup.status = RefundGroupStatus.Failed; + } await tx.refundGroups.put(refundGroup); - const refreshCoins = await computeRefreshRequest(items); + const refreshCoins = await computeRefreshRequest(wex, tx, items); await createRefreshGroup( - ws, + wex, tx, Amounts.currencyOf(download.contractData.amount), refreshCoins, RefreshReason.Refund, + // Since refunds are really just pseudo-transactions, + // the originating transaction for the refresh is the payment transaction. + constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: myPurchase.proposalId, + }), ); } } const oldTxState = computePayMerchantTransactionState(myPurchase); + + const shouldCheckAutoRefund = + myPurchase.autoRefundDeadline && + !AbsoluteTime.isExpired( + AbsoluteTime.fromProtocolTimestamp( + timestampProtocolFromDb(myPurchase.autoRefundDeadline), + ), + ); + if (numPendingItemsTotal === 0) { if (isAborting) { myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; + } else if (shouldCheckAutoRefund) { + myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund; } else { myPurchase.purchaseStatus = PurchaseStatus.Done; } + myPurchase.refundAmountAwaiting = undefined; } await tx.purchases.put(myPurchase); const newTxState = computePayMerchantTransactionState(myPurchase); @@ -2924,19 +3517,20 @@ async function storeRefunds( newTxState, }, }; - }); + }, + ); if (!result) { return TaskRunResult.finished(); } - notifyTransition(ws, transactionId, result.transitionInfo); + notifyTransition(wex, transactionId, result.transitionInfo); if (result.numPendingItemsTotal > 0) { - return TaskRunResult.pending(); + return TaskRunResult.backoff(); + } else { + return TaskRunResult.progress(); } - - return TaskRunResult.finished(); } export function computeRefundTransactionState( @@ -2959,5 +3553,9 @@ export function computeRefundTransactionState( 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/pay-peer-common.ts index 1a5dc6e89..bfd39b657 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -22,49 +22,37 @@ import { AmountString, Amounts, Codec, - Logger, + SelectedProspectiveCoin, TalerProtocolTimestamp, buildCodecForObject, + checkDbInvariant, codecForAmountString, codecForTimestamp, codecOptional, } from "@gnu-taler/taler-util"; -import { SpendCoinDetails } from "../crypto/cryptoImplementation.js"; -import { - DenominationRecord, - 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 { SpendCoinDetails } from "./crypto/cryptoImplementation.js"; +import { DbPeerPushPaymentCoinSelection, ReserveRecord } from "./db.js"; import { getTotalRefreshCost } from "./refresh.js"; -import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; - -const logger = new Logger("operations/peer-to-peer.ts"); +import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; /** - * Get information about the coin selected for signatures - * - * @param ws - * @param csel - * @returns + * Get information about the coin selected for signatures. */ export async function queryCoinInfosForSelection( - ws: InternalWalletState, - csel: PeerPushPaymentCoinSelection, + wex: WalletExecutionContext, + csel: DbPeerPushPaymentCoinSelection, ): Promise<SpendCoinDetails[]> { let infos: SpendCoinDetails[] = []; - await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { + await wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations"] }, + async (tx) => { for (let i = 0; i < csel.coinPubs.length; i++) { const coin = await tx.coins.get(csel.coinPubs[i]); if (!coin) { throw Error("coin not found anymore"); } - const denom = await ws.getDenomInfo( - ws, + const denom = await getDenomInfo( + wex, tx, coin.exchangeBaseUrl, coin.denomPubHash, @@ -81,57 +69,48 @@ export async function queryCoinInfosForSelection( contribution: csel.contributions[i], }); } - }); + }, + ); return infos; } export async function getTotalPeerPaymentCost( - ws: InternalWalletState, - pcs: SelectedPeerCoin[], + wex: WalletExecutionContext, + pcs: SelectedProspectiveCoin[], ): Promise<AmountJson> { - const currency = Amounts.currencyOf(pcs[0].contribution); - return ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { + return wex.db.runReadOnlyTx( + { storeNames: ["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, + const denomInfo = await getDenomInfo( + wex, tx, - coin.exchangeBaseUrl, - coin.denomPubHash, + pcs[i].exchangeBaseUrl, + pcs[i].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, + const refreshCost = await getTotalRefreshCost( + wex, + tx, 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 { @@ -148,18 +127,18 @@ export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> => .build("ExchangePurseStatus"); export async function getMergeReserveInfo( - ws: InternalWalletState, + wex: WalletExecutionContext, req: { exchangeBaseUrl: string; }, ): Promise<ReserveRecord> { // We have to eagerly create the key pair outside of the transaction, // due to the async crypto API. - const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); + const newReservePair = await wex.cryptoApi.createEddsaKeypair({}); - const mergeReserveRecord: ReserveRecord = await ws.db - .mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups]) - .runReadWrite(async (tx) => { + const mergeReserveRecord: ReserveRecord = await wex.db.runReadWriteTx( + { storeNames: ["exchanges", "reserves"] }, + async (tx) => { const ex = await tx.exchanges.get(req.exchangeBaseUrl); checkDbInvariant(!!ex); if (ex.currentMergeReserveRowId != null) { @@ -177,7 +156,8 @@ export async function getMergeReserveInfo( 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/pay-peer-pull-credit.ts index 292116bd5..840c244d0 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -17,7 +17,6 @@ import { AbsoluteTime, Amounts, - CancellationToken, CheckPeerPullCreditRequest, CheckPeerPullCreditResponse, ContractTermsUtil, @@ -33,12 +32,15 @@ import { TalerProtocolTimestamp, TalerUriAction, TransactionAction, + TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, WalletAccountMergeFlags, WalletKycUuid, + assertUnreachable, + checkDbInvariant, codecForAny, codecForWalletKycUuid, encodeCrock, @@ -48,11 +50,16 @@ import { stringifyTalerUri, talerPaytoFromExchangeReserve, } from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { - readSuccessResponseJsonOrErrorCode, - readSuccessResponseJsonOrThrow, - throwUnexpectedRequestError, -} from "@gnu-taler/taler-util/http"; + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; import { KycPendingInfo, KycUserType, @@ -63,19 +70,8 @@ import { timestampOptionalPreciseFromDb, timestampPreciseFromDb, timestampPreciseToDb, - fetchFreshExchange, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { PendingTaskType } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { - LongpollResult, - TaskRunResult, - TaskRunResultType, - constructTaskIdentifier, - runLongpollAsync, -} from "./common.js"; +} from "./db.js"; +import { fetchFreshExchange } from "./exchanges.js"; import { codecForExchangePurseStatus, getMergeReserveInfo, @@ -83,29 +79,303 @@ import { import { constructTransactionIdentifier, notifyTransition, - stopLongpolling, } from "./transactions.js"; +import { WalletExecutionContext } from "./wallet.js"; import { getExchangeWithdrawalInfo, internalCreateWithdrawalGroup, + waitWithdrawalFinal, } from "./withdraw.js"; const logger = new Logger("pay-peer-pull-credit.ts"); +export class PeerPullCreditTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + public pursePub: string, + ) { + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPullCredit, + pursePub, + }); + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub, + }); + } + + async deleteTransaction(): Promise<void> { + const { wex: ws, pursePub } = this; + await ws.db.runReadWriteTx( + { storeNames: ["withdrawalGroups", "peerPullCredit", "tombstones"] }, + async (tx) => { + const pullIni = await tx.peerPullCredit.get(pursePub); + if (!pullIni) { + return; + } + if (pullIni.withdrawalGroupId) { + const withdrawalGroupId = pullIni.withdrawalGroupId; + const withdrawalGroupRecord = + await tx.withdrawalGroups.get(withdrawalGroupId); + if (withdrawalGroupRecord) { + await tx.withdrawalGroups.delete(withdrawalGroupId); + await tx.tombstones.put({ + id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId, + }); + } + } + await tx.peerPullCredit.delete(pursePub); + await tx.tombstones.put({ + id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub, + }); + }, + ); + + return; + } + + async suspendTransaction(): Promise<void> { + const { wex, pursePub, taskId: retryTag, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { + const pullCreditRec = await tx.peerPullCredit.get(pursePub); + if (!pullCreditRec) { + logger.warn(`peer pull credit ${pursePub} not found`); + return; + } + let newStatus: PeerPullPaymentCreditStatus | undefined = undefined; + switch (pullCreditRec.status) { + case PeerPullPaymentCreditStatus.PendingCreatePurse: + newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse; + break; + case PeerPullPaymentCreditStatus.PendingMergeKycRequired: + newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired; + break; + case PeerPullPaymentCreditStatus.PendingWithdrawing: + newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing; + break; + case PeerPullPaymentCreditStatus.PendingReady: + newStatus = PeerPullPaymentCreditStatus.SuspendedReady; + break; + case PeerPullPaymentCreditStatus.AbortingDeletePurse: + newStatus = + PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse; + break; + case PeerPullPaymentCreditStatus.Done: + case PeerPullPaymentCreditStatus.SuspendedCreatePurse: + case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: + case PeerPullPaymentCreditStatus.SuspendedReady: + case PeerPullPaymentCreditStatus.SuspendedWithdrawing: + case PeerPullPaymentCreditStatus.Aborted: + case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.Expired: + case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: + break; + default: + assertUnreachable(pullCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPullCreditTransactionState(pullCreditRec); + pullCreditRec.status = newStatus; + const newTxState = + computePeerPullCreditTransactionState(pullCreditRec); + await tx.peerPullCredit.put(pullCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + wex.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + } + + async failTransaction(): Promise<void> { + const { wex, pursePub, taskId: retryTag, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { + const pullCreditRec = await tx.peerPullCredit.get(pursePub); + if (!pullCreditRec) { + logger.warn(`peer pull credit ${pursePub} not found`); + return; + } + let newStatus: PeerPullPaymentCreditStatus | undefined = undefined; + switch (pullCreditRec.status) { + case PeerPullPaymentCreditStatus.PendingCreatePurse: + case PeerPullPaymentCreditStatus.PendingMergeKycRequired: + case PeerPullPaymentCreditStatus.PendingWithdrawing: + case PeerPullPaymentCreditStatus.PendingReady: + case PeerPullPaymentCreditStatus.Done: + case PeerPullPaymentCreditStatus.SuspendedCreatePurse: + case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: + case PeerPullPaymentCreditStatus.SuspendedReady: + case PeerPullPaymentCreditStatus.SuspendedWithdrawing: + case PeerPullPaymentCreditStatus.Aborted: + case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.Expired: + break; + case PeerPullPaymentCreditStatus.AbortingDeletePurse: + case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: + newStatus = PeerPullPaymentCreditStatus.Failed; + break; + default: + assertUnreachable(pullCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPullCreditTransactionState(pullCreditRec); + pullCreditRec.status = newStatus; + const newTxState = + computePeerPullCreditTransactionState(pullCreditRec); + await tx.peerPullCredit.put(pullCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.stopShepherdTask(retryTag); + } + + async resumeTransaction(): Promise<void> { + const { wex, pursePub, taskId: retryTag, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { + const pullCreditRec = await tx.peerPullCredit.get(pursePub); + if (!pullCreditRec) { + logger.warn(`peer pull credit ${pursePub} not found`); + return; + } + let newStatus: PeerPullPaymentCreditStatus | undefined = undefined; + switch (pullCreditRec.status) { + case PeerPullPaymentCreditStatus.PendingCreatePurse: + case PeerPullPaymentCreditStatus.PendingMergeKycRequired: + case PeerPullPaymentCreditStatus.PendingWithdrawing: + case PeerPullPaymentCreditStatus.PendingReady: + case PeerPullPaymentCreditStatus.AbortingDeletePurse: + case PeerPullPaymentCreditStatus.Done: + case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.Expired: + case PeerPullPaymentCreditStatus.Aborted: + break; + case PeerPullPaymentCreditStatus.SuspendedCreatePurse: + newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse; + break; + case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: + newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired; + break; + case PeerPullPaymentCreditStatus.SuspendedReady: + newStatus = PeerPullPaymentCreditStatus.PendingReady; + break; + case PeerPullPaymentCreditStatus.SuspendedWithdrawing: + newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing; + break; + case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: + newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse; + break; + default: + assertUnreachable(pullCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPullCreditTransactionState(pullCreditRec); + pullCreditRec.status = newStatus; + const newTxState = + computePeerPullCreditTransactionState(pullCreditRec); + await tx.peerPullCredit.put(pullCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + } + + async abortTransaction(): Promise<void> { + const { wex, pursePub, taskId: retryTag, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { + const pullCreditRec = await tx.peerPullCredit.get(pursePub); + if (!pullCreditRec) { + logger.warn(`peer pull credit ${pursePub} not found`); + return; + } + let newStatus: PeerPullPaymentCreditStatus | undefined = undefined; + switch (pullCreditRec.status) { + case PeerPullPaymentCreditStatus.PendingCreatePurse: + case PeerPullPaymentCreditStatus.PendingMergeKycRequired: + newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse; + break; + case PeerPullPaymentCreditStatus.PendingWithdrawing: + throw Error("can't abort anymore"); + case PeerPullPaymentCreditStatus.PendingReady: + newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse; + break; + case PeerPullPaymentCreditStatus.Done: + case PeerPullPaymentCreditStatus.SuspendedCreatePurse: + case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: + case PeerPullPaymentCreditStatus.SuspendedReady: + case PeerPullPaymentCreditStatus.SuspendedWithdrawing: + case PeerPullPaymentCreditStatus.Aborted: + case PeerPullPaymentCreditStatus.AbortingDeletePurse: + case PeerPullPaymentCreditStatus.Failed: + case PeerPullPaymentCreditStatus.Expired: + case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: + break; + default: + assertUnreachable(pullCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPullCreditTransactionState(pullCreditRec); + pullCreditRec.status = newStatus; + const newTxState = + computePeerPullCreditTransactionState(pullCreditRec); + await tx.peerPullCredit.put(pullCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + wex.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + } +} + async function queryPurseForPeerPullCredit( - ws: InternalWalletState, + wex: WalletExecutionContext, pullIni: PeerPullCreditRecord, - cancellationToken: CancellationToken, -): Promise<LongpollResult> { +): 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, { + const resp = await wex.http.fetch(purseDepositUrl.href, { timeout: { d_ms: 60000 }, - cancellationToken, + cancellationToken: wex.cancellationToken, }); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, @@ -117,9 +387,9 @@ async function queryPurseForPeerPullCredit( switch (resp.status) { case HttpStatusCode.Gone: { // Exchange says that purse doesn't exist anymore => expired! - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { const finPi = await tx.peerPullCredit.get(pullIni.pursePub); if (!finPi) { logger.warn("peerPullCredit not found anymore"); @@ -132,12 +402,14 @@ async function queryPurseForPeerPullCredit( await tx.peerPullCredit.put(finPi); const newTxState = computePeerPullCreditTransactionState(finPi); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - return { ready: true }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.backoff(); } case HttpStatusCode.NotFound: - return { ready: false }; + // FIXME: Maybe check error code? 404 could also mean something else. + return TaskRunResult.longpollReturnedPending(); } const result = await readSuccessResponseJsonOrThrow( @@ -151,20 +423,21 @@ async function queryPurseForPeerPullCredit( if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) { logger.info("purse not ready yet (no deposit)"); - return { ready: false }; + return TaskRunResult.backoff(); } - const reserve = await ws.db - .mktx((x) => [x.reserves]) - .runReadOnly(async (tx) => { + const reserve = await wex.db.runReadOnlyTx( + { storeNames: ["reserves"] }, + async (tx) => { return await tx.reserves.get(pullIni.mergeReserveRowId); - }); + }, + ); if (!reserve) { throw Error("reserve for peer pull credit not found in wallet DB"); } - await internalCreateWithdrawalGroup(ws, { + await internalCreateWithdrawalGroup(wex, { amount: Amounts.parseOrThrow(pullIni.amount), wgInfo: { withdrawalType: WithdrawalRecordType.PeerPullCredit, @@ -178,9 +451,9 @@ async function queryPurseForPeerPullCredit( pub: reserve.reservePub, }, }); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { const finPi = await tx.peerPullCredit.get(pullIni.pursePub); if (!finPi) { logger.warn("peerPullCredit not found anymore"); @@ -193,15 +466,14 @@ async function queryPurseForPeerPullCredit( await tx.peerPullCredit.put(finPi); const newTxState = computePeerPullCreditTransactionState(finPi); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - return { - ready: true, - }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.backoff(); } async function longpollKycStatus( - ws: InternalWalletState, + wex: WalletExecutionContext, pursePub: string, exchangeUrl: string, kycInfo: KycPendingInfo, @@ -211,65 +483,52 @@ async function longpollKycStatus( 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 wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, }); - - runLongpollAsync(ws, retryTag, async (ct) => { - const url = new URL( - `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, - exchangeUrl, + if ( + kycStatusRes.status === HttpStatusCode.Ok || + // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge + // remove after the exchange is fixed or clarified + kycStatusRes.status === HttpStatusCode.NoContent + ) { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { + const peerIni = await tx.peerPullCredit.get(pursePub); + if (!peerIni) { + return; + } + if ( + peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired + ) { + return; + } + const oldTxState = computePeerPullCreditTransactionState(peerIni); + peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse; + const newTxState = computePeerPullCreditTransactionState(peerIni); + await tx.peerPullCredit.put(peerIni); + return { oldTxState, newTxState }; + }, ); - url.searchParams.set("timeout_ms", "10000"); - logger.info(`kyc url ${url.href}`); - const kycStatusRes = await ws.http.fetch(url.href, { - method: "GET", - cancellationToken: ct, - }); - 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 - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(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); - return { ready: true }; - } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - // FIXME: Do we have to update the URL here? - return { ready: false }; - } else { - throw Error( - `unexpected response from kyc-check (${kycStatusRes.status})`, - ); - } - }); - return { - type: TaskRunResultType.Longpoll, - }; + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.progress(); + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + return TaskRunResult.longpollReturnedPending(); + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } } async function processPeerPullCreditAbortingDeletePurse( - ws: InternalWalletState, + wex: WalletExecutionContext, peerPullIni: PeerPullCreditRecord, ): Promise<TaskRunResult> { const { pursePub, pursePriv } = peerPullIni; @@ -278,27 +537,30 @@ async function processPeerPullCreditAbortingDeletePurse( pursePub, }); - const sigResp = await ws.cryptoApi.signDeletePurse({ + const sigResp = await wex.cryptoApi.signDeletePurse({ pursePriv, }); const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl); - const resp = await ws.http.fetch(purseUrl.href, { + const resp = await wex.http.fetch(purseUrl.href, { method: "DELETE", headers: { "taler-purse-signature": sigResp.sig, }, + cancellationToken: wex.cancellationToken, }); logger.info(`deleted purse with response status ${resp.status}`); - const transitionInfo = await ws.db - .mktx((x) => [ - x.peerPullCredit, - x.refreshGroups, - x.denominations, - x.coinAvailability, - x.coins, - ]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { + storeNames: [ + "peerPullCredit", + "refreshGroups", + "denominations", + "coinAvailability", + "coins", + ], + }, + async (tx) => { const ppiRec = await tx.peerPullCredit.get(pursePub); if (!ppiRec) { return undefined; @@ -314,28 +576,30 @@ async function processPeerPullCreditAbortingDeletePurse( oldTxState, newTxState, }; - }); - notifyTransition(ws, transactionId, transitionInfo); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); - return TaskRunResult.pending(); + return TaskRunResult.backoff(); } async function handlePeerPullCreditWithdrawing( - ws: InternalWalletState, + wex: WalletExecutionContext, pullIni: PeerPullCreditRecord, ): Promise<TaskRunResult> { if (!pullIni.withdrawalGroupId) { throw Error("invalid db state (withdrawing, but no withdrawal group ID"); } + await waitWithdrawalFinal(wex, pullIni.withdrawalGroupId); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPullCredit, pursePub: pullIni.pursePub, }); const wgId = pullIni.withdrawalGroupId; let finished: boolean = false; - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit, x.withdrawalGroups]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit", "withdrawalGroups"] }, + async (tx) => { const ppi = await tx.peerPullCredit.get(pullIni.pursePub); if (!ppi) { finished = true; @@ -364,37 +628,40 @@ async function handlePeerPullCreditWithdrawing( oldTxState, newTxState, }; - }); - notifyTransition(ws, transactionId, transitionInfo); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); if (finished) { return TaskRunResult.finished(); } else { // FIXME: Return indicator that we depend on the other operation! - return TaskRunResult.pending(); + return TaskRunResult.backoff(); } } async function handlePeerPullCreditCreatePurse( - ws: InternalWalletState, + wex: WalletExecutionContext, pullIni: PeerPullCreditRecord, ): Promise<TaskRunResult> { const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount)); const pursePub = pullIni.pursePub; - const mergeReserve = await ws.db - .mktx((x) => [x.reserves]) - .runReadOnly(async (tx) => { + const mergeReserve = await wex.db.runReadOnlyTx( + { storeNames: ["reserves"] }, + async (tx) => { return tx.reserves.get(pullIni.mergeReserveRowId); - }); + }, + ); if (!mergeReserve) { throw Error("merge reserve for peer pull payment not found in database"); } - const contractTermsRecord = await ws.db - .mktx((x) => [x.contractTerms]) - .runReadOnly(async (tx) => { + const contractTermsRecord = await wex.db.runReadOnlyTx( + { storeNames: ["contractTerms"] }, + async (tx) => { return tx.contractTerms.get(pullIni.contractTermsHash); - }); + }, + ); if (!contractTermsRecord) { throw Error("contract terms for peer pull payment not found in database"); @@ -407,7 +674,7 @@ async function handlePeerPullCreditCreatePurse( mergeReserve.reservePub, ); - const econtractResp = await ws.cryptoApi.encryptContractForDeposit({ + const econtractResp = await wex.cryptoApi.encryptContractForDeposit({ contractPriv: pullIni.contractPriv, contractPub: pullIni.contractPub, contractTerms: contractTermsRecord.contractTermsRaw, @@ -419,7 +686,7 @@ async function handlePeerPullCreditCreatePurse( const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp); const purseExpiration = contractTerms.purse_expiration; - const sigRes = await ws.cryptoApi.signReservePurseCreate({ + const sigRes = await wex.cryptoApi.signReservePurseCreate({ contractTermsHash: pullIni.contractTermsHash, flags: WalletAccountMergeFlags.CreateWithPurseFee, mergePriv: pullIni.mergePriv, @@ -455,16 +722,17 @@ async function handlePeerPullCreditCreatePurse( pullIni.exchangeBaseUrl, ); - const httpResp = await ws.http.fetch(reservePurseMergeUrl.href, { + const httpResp = await wex.http.fetch(reservePurseMergeUrl.href, { method: "POST", body: reservePurseReqBody, + cancellationToken: wex.cancellationToken, }); if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) { const respJson = await httpResp.json(); const kycPending = codecForWalletKycUuid().decode(respJson); logger.info(`kyc uuid response: ${j2s(kycPending)}`); - return processPeerPullCreditKycRequired(ws, pullIni, kycPending); + return processPeerPullCreditKycRequired(wex, pullIni, kycPending); } const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); @@ -476,9 +744,9 @@ async function handlePeerPullCreditCreatePurse( pursePub: pullIni.pursePub, }); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { const pi2 = await tx.peerPullCredit.get(pursePub); if (!pi2) { return; @@ -488,21 +756,22 @@ async function handlePeerPullCreditCreatePurse( await tx.peerPullCredit.put(pi2); const newTxState = computePeerPullCreditTransactionState(pi2); return { oldTxState, newTxState }; - }); - notifyTransition(ws, transactionId, transitionInfo); - - return TaskRunResult.finished(); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.backoff(); } export async function processPeerPullCredit( - ws: InternalWalletState, + wex: WalletExecutionContext, pursePub: string, ): Promise<TaskRunResult> { - const pullIni = await ws.db - .mktx((x) => [x.peerPullCredit]) - .runReadOnly(async (tx) => { + const pullIni = await wex.db.runReadOnlyTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { return tx.peerPullCredit.get(pursePub); - }); + }, + ); if (!pullIni) { throw Error("peer pull payment initiation not found in database"); } @@ -512,14 +781,6 @@ export async function processPeerPullCredit( pursePub, }); - // We're already running! - if (ws.activeLongpoll[retryTag]) { - logger.info("peer-pull-credit already in long-polling, returning!"); - return { - type: TaskRunResultType.Longpoll, - }; - } - logger.trace(`processing ${retryTag}, status=${pullIni.status}`); switch (pullIni.status) { @@ -527,21 +788,13 @@ export async function processPeerPullCredit( return TaskRunResult.finished(); } case PeerPullPaymentCreditStatus.PendingReady: - runLongpollAsync(ws, retryTag, async (cancellationToken) => - queryPurseForPeerPullCredit(ws, pullIni, cancellationToken), - ); - logger.trace( - "returning early from processPeerPullCredit for long-polling in background", - ); - return { - type: TaskRunResultType.Longpoll, - }; + return queryPurseForPeerPullCredit(wex, pullIni); case PeerPullPaymentCreditStatus.PendingMergeKycRequired: { if (!pullIni.kycInfo) { throw Error("invalid state, kycInfo required"); } return await longpollKycStatus( - ws, + wex, pursePub, pullIni.exchangeBaseUrl, pullIni.kycInfo, @@ -549,11 +802,11 @@ export async function processPeerPullCredit( ); } case PeerPullPaymentCreditStatus.PendingCreatePurse: - return handlePeerPullCreditCreatePurse(ws, pullIni); + return handlePeerPullCreditCreatePurse(wex, pullIni); case PeerPullPaymentCreditStatus.AbortingDeletePurse: - return await processPeerPullCreditAbortingDeletePurse(ws, pullIni); + return await processPeerPullCreditAbortingDeletePurse(wex, pullIni); case PeerPullPaymentCreditStatus.PendingWithdrawing: - return handlePeerPullCreditWithdrawing(ws, pullIni); + return handlePeerPullCreditWithdrawing(wex, pullIni); case PeerPullPaymentCreditStatus.Aborted: case PeerPullPaymentCreditStatus.Failed: case PeerPullPaymentCreditStatus.Expired: @@ -571,7 +824,7 @@ export async function processPeerPullCredit( } async function processPeerPullCreditKycRequired( - ws: InternalWalletState, + wex: WalletExecutionContext, peerIni: PeerPullCreditRecord, kycPending: WalletKycUuid, ): Promise<TaskRunResult> { @@ -588,8 +841,9 @@ async function processPeerPullCreditKycRequired( ); logger.info(`kyc url ${url.href}`); - const kycStatusRes = await ws.http.fetch(url.href, { + const kycStatusRes = await wex.http.fetch(url.href, { method: "GET", + cancellationToken: wex.cancellationToken, }); if ( @@ -599,13 +853,13 @@ async function processPeerPullCreditKycRequired( kycStatusRes.status === HttpStatusCode.NoContent ) { logger.warn("kyc requested, but already fulfilled"); - return TaskRunResult.finished(); + 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 - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(async (tx) => { + const { transitionInfo, result } = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit"] }, + async (tx) => { const peerInc = await tx.peerPullCredit.get(pursePub); if (!peerInc) { return { @@ -637,9 +891,10 @@ async function processPeerPullCreditKycRequired( transitionInfo: { oldTxState, newTxState }, result: res, }; - }); - notifyTransition(ws, transactionId, transitionInfo); - return TaskRunResult.pending(); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.backoff(); } else { throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); } @@ -649,7 +904,7 @@ async function processPeerPullCreditKycRequired( * Check fees and available exchanges for a peer push payment initiation. */ export async function checkPeerPullPaymentInitiation( - ws: InternalWalletState, + wex: WalletExecutionContext, req: CheckPeerPullCreditRequest, ): Promise<CheckPeerPullCreditResponse> { // FIXME: We don't support exchanges with purse fees yet. @@ -663,7 +918,7 @@ export async function checkPeerPullPaymentInitiation( if (req.exchangeBaseUrl) { exchangeUrl = req.exchangeBaseUrl; } else { - exchangeUrl = await getPreferredExchangeForCurrency(ws, currency); + exchangeUrl = await getPreferredExchangeForCurrency(wex, currency); } if (!exchangeUrl) { @@ -673,7 +928,7 @@ export async function checkPeerPullPaymentInitiation( logger.trace(`found ${exchangeUrl} as preferred exchange`); const wi = await getExchangeWithdrawalInfo( - ws, + wex, exchangeUrl, Amounts.parseOrThrow(req.amount), undefined, @@ -698,14 +953,14 @@ export async function checkPeerPullPaymentInitiation( * Find a preferred exchange based on when we withdrew last from this exchange. */ async function getPreferredExchangeForCurrency( - ws: InternalWalletState, + wex: WalletExecutionContext, currency: string, ): Promise<string | undefined> { // Find an exchange with the matching currency. // Prefer exchanges with the most recent withdrawal. - const url = await ws.db - .mktx((x) => [x.exchanges]) - .runReadOnly(async (tx) => { + const url = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges"] }, + async (tx) => { const exchanges = await tx.exchanges.iter().toArray(); let candidate = undefined; for (const e of exchanges) { @@ -740,7 +995,8 @@ async function getPreferredExchangeForCurrency( return candidate.baseUrl; } return undefined; - }); + }, + ); return url; } @@ -748,7 +1004,7 @@ async function getPreferredExchangeForCurrency( * Initiate a peer pull payment. */ export async function initiatePeerPullPayment( - ws: InternalWalletState, + wex: WalletExecutionContext, req: InitiatePeerPullCreditRequest, ): Promise<InitiatePeerPullCreditResponse> { const currency = Amounts.currencyOf(req.partialContractTerms.amount); @@ -756,7 +1012,7 @@ export async function initiatePeerPullPayment( if (req.exchangeBaseUrl) { maybeExchangeBaseUrl = req.exchangeBaseUrl; } else { - maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency); + maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(wex, currency); } if (!maybeExchangeBaseUrl) { @@ -765,20 +1021,20 @@ export async function initiatePeerPullPayment( const exchangeBaseUrl = maybeExchangeBaseUrl; - await fetchFreshExchange(ws, exchangeBaseUrl); + await fetchFreshExchange(wex, exchangeBaseUrl); - const mergeReserveInfo = await getMergeReserveInfo(ws, { + const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: exchangeBaseUrl, }); - const pursePair = await ws.cryptoApi.createEddsaKeypair({}); - const mergePair = await ws.cryptoApi.createEddsaKeypair({}); + const pursePair = await wex.cryptoApi.createEddsaKeypair({}); + const mergePair = await wex.cryptoApi.createEddsaKeypair({}); const contractTerms = req.partialContractTerms; const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); - const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({}); + const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({}); const withdrawalGroupId = encodeCrock(getRandomBytes(32)); @@ -788,7 +1044,7 @@ export async function initiatePeerPullPayment( const contractEncNonce = encodeCrock(getRandomBytes(24)); const wi = await getExchangeWithdrawalInfo( - ws, + wex, exchangeBaseUrl, Amounts.parseOrThrow(req.partialContractTerms.amount), undefined, @@ -796,9 +1052,9 @@ export async function initiatePeerPullPayment( const mergeTimestamp = TalerPreciseTimestamp.now(); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit, x.contractTerms]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullCredit", "contractTerms"] }, + async (tx) => { const ppi: PeerPullCreditRecord = { amount: req.partialContractTerms.amount, contractTermsHash: hContractTerms, @@ -826,285 +1082,30 @@ export async function initiatePeerPullPayment( h: hContractTerms, }); return { oldTxState, newTxState }; - }); + }, + ); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub: pursePair.pub, - }); + const ctx = new PeerPullCreditTransactionContext(wex, pursePair.pub); + + notifyTransition(wex, ctx.transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(ctx.taskId); // The pending-incoming balance has changed. - ws.notify({ + wex.ws.notify({ type: NotificationType.BalanceChange, - hintTransactionId: transactionId, + hintTransactionId: ctx.transactionId, }); - notifyTransition(ws, transactionId, transitionInfo); - - ws.workAvailable.trigger(); - return { talerUri: stringifyTalerUri({ type: TalerUriAction.PayPull, exchangeBaseUrl: exchangeBaseUrl, contractPriv: contractKeyPair.priv, }), - transactionId, + transactionId: ctx.transactionId, }; } -export async function suspendPeerPullCreditTransaction( - ws: InternalWalletState, - pursePub: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullCredit, - pursePub, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(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; - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function abortPeerPullCreditTransaction( - ws: InternalWalletState, - pursePub: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullCredit, - pursePub, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(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; - }); - notifyTransition(ws, transactionId, transitionInfo); -} - -export async function failPeerPullCreditTransaction( - ws: InternalWalletState, - pursePub: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullCredit, - pursePub, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(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); -} - -export async function resumePeerPullCreditTransaction( - ws: InternalWalletState, - pursePub: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPullCredit, - pursePub, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPullCredit, - pursePub, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPullCredit]) - .runReadWrite(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; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - export function computePeerPullCreditTransactionState( pullCreditRecord: PeerPullCreditRecord, ): TransactionState { diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts new file mode 100644 index 000000000..0355b58ad --- /dev/null +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -0,0 +1,1019 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * @fileoverview + * Implementation of the peer-pull-debit transaction, i.e. + * paying for an invoice the wallet received from another wallet. + */ + +/** + * Imports. + */ +import { + AcceptPeerPullPaymentResponse, + Amounts, + CoinRefreshRequest, + ConfirmPeerPullDebitRequest, + ContractTermsUtil, + ExchangePurseDeposits, + HttpStatusCode, + Logger, + NotificationType, + ObservabilityEventType, + PeerContractTerms, + PreparePeerPullDebitRequest, + PreparePeerPullDebitResponse, + RefreshReason, + SelectedProspectiveCoin, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolViolationError, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + assertUnreachable, + checkLogicInvariant, + codecForAny, + codecForExchangeGetContractResponse, + codecForPeerContractTerms, + decodeCrock, + eddsaGetPublic, + encodeCrock, + getRandomBytes, + j2s, + parsePayPullUri, +} from "@gnu-taler/taler-util"; +import { + HttpResponse, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, +} from "@gnu-taler/taler-util/http"; +import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js"; +import { + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TransactionContext, + TransitionResultType, + constructTaskIdentifier, + spendCoins, +} from "./common.js"; +import { + PeerPullDebitRecordStatus, + PeerPullPaymentIncomingRecord, + RefreshOperationStatus, + WalletStoresV1, + timestampPreciseToDb, +} from "./db.js"; +import { + codecForExchangePurseStatus, + getTotalPeerPaymentCost, + queryCoinInfosForSelection, +} from "./pay-peer-common.js"; +import { DbReadWriteTransaction, StoreNames } from "./query.js"; +import { createRefreshGroup } from "./refresh.js"; +import { + constructTransactionIdentifier, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; +import { WalletExecutionContext } from "./wallet.js"; + +const logger = new Logger("pay-peer-pull-debit.ts"); + +/** + * Common context for a peer-pull-debit transaction. + */ +export class PeerPullDebitTransactionContext implements TransactionContext { + wex: WalletExecutionContext; + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + peerPullDebitId: string; + + constructor(wex: WalletExecutionContext, peerPullDebitId: string) { + this.wex = wex; + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPullDebit, + peerPullDebitId, + }); + this.peerPullDebitId = peerPullDebitId; + } + + async deleteTransaction(): Promise<void> { + const transactionId = this.transactionId; + const ws = this.wex; + const peerPullDebitId = this.peerPullDebitId; + await ws.db.runReadWriteTx( + { storeNames: ["peerPullDebit", "tombstones"] }, + async (tx) => { + const debit = await tx.peerPullDebit.get(peerPullDebitId); + if (debit) { + await tx.peerPullDebit.delete(peerPullDebitId); + await tx.tombstones.put({ id: transactionId }); + } + }, + ); + } + + async suspendTransaction(): Promise<void> { + const taskId = this.taskId; + const transactionId = this.transactionId; + const wex = this.wex; + const peerPullDebitId = this.peerPullDebitId; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit"] }, + async (tx) => { + const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId); + if (!pullDebitRec) { + logger.warn(`peer pull debit ${peerPullDebitId} not found`); + return; + } + let newStatus: PeerPullDebitRecordStatus | undefined = undefined; + switch (pullDebitRec.status) { + case PeerPullDebitRecordStatus.DialogProposed: + break; + case PeerPullDebitRecordStatus.Done: + break; + case PeerPullDebitRecordStatus.PendingDeposit: + newStatus = PeerPullDebitRecordStatus.SuspendedDeposit; + break; + case PeerPullDebitRecordStatus.SuspendedDeposit: + break; + case PeerPullDebitRecordStatus.Aborted: + break; + case PeerPullDebitRecordStatus.AbortingRefresh: + newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh; + break; + case PeerPullDebitRecordStatus.Failed: + break; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + break; + default: + assertUnreachable(pullDebitRec.status); + } + if (newStatus != null) { + const oldTxState = computePeerPullDebitTransactionState(pullDebitRec); + pullDebitRec.status = newStatus; + const newTxState = computePeerPullDebitTransactionState(pullDebitRec); + await tx.peerPullDebit.put(pullDebitRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.stopShepherdTask(taskId); + } + + async resumeTransaction(): Promise<void> { + const ctx = this; + await ctx.transition(async (pi) => { + switch (pi.status) { + case PeerPullDebitRecordStatus.SuspendedDeposit: + pi.status = PeerPullDebitRecordStatus.PendingDeposit; + return TransitionResultType.Transition; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + return TransitionResultType.Transition; + case PeerPullDebitRecordStatus.Aborted: + case PeerPullDebitRecordStatus.AbortingRefresh: + case PeerPullDebitRecordStatus.Failed: + case PeerPullDebitRecordStatus.DialogProposed: + case PeerPullDebitRecordStatus.Done: + case PeerPullDebitRecordStatus.PendingDeposit: + return TransitionResultType.Stay; + } + }); + this.wex.taskScheduler.startShepherdTask(this.taskId); + } + + async failTransaction(): Promise<void> { + const ctx = this; + await ctx.transition(async (pi) => { + switch (pi.status) { + case PeerPullDebitRecordStatus.SuspendedDeposit: + case PeerPullDebitRecordStatus.PendingDeposit: + case PeerPullDebitRecordStatus.AbortingRefresh: + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + // FIXME: Should we also abort the corresponding refresh session?! + pi.status = PeerPullDebitRecordStatus.Failed; + return TransitionResultType.Transition; + default: + return TransitionResultType.Stay; + } + }); + this.wex.taskScheduler.stopShepherdTask(this.taskId); + } + + async abortTransaction(): Promise<void> { + const ctx = this; + await ctx.transitionExtra( + { + extraStores: [ + "coinAvailability", + "denominations", + "refreshGroups", + "refreshSessions", + "coins", + "coinAvailability", + ], + }, + async (pi, tx) => { + switch (pi.status) { + case PeerPullDebitRecordStatus.SuspendedDeposit: + case PeerPullDebitRecordStatus.PendingDeposit: + break; + default: + return TransitionResultType.Stay; + } + const currency = Amounts.currencyOf(pi.totalCostEstimated); + const coinPubs: CoinRefreshRequest[] = []; + + if (!pi.coinSel) { + throw Error("invalid db state"); + } + + for (let i = 0; i < pi.coinSel.coinPubs.length; i++) { + coinPubs.push({ + amount: pi.coinSel.contributions[i], + coinPub: pi.coinSel.coinPubs[i], + }); + } + + const refresh = await createRefreshGroup( + ctx.wex, + tx, + currency, + coinPubs, + RefreshReason.AbortPeerPullDebit, + this.transactionId, + ); + + pi.status = PeerPullDebitRecordStatus.AbortingRefresh; + pi.abortRefreshGroupId = refresh.refreshGroupId; + return TransitionResultType.Transition; + }, + ); + } + + async transition( + f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResultType>, + ): Promise<void> { + return this.transitionExtra( + { + extraStores: [], + }, + f, + ); + } + + async transitionExtra< + StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [], + >( + opts: { extraStores: StoreNameArray }, + f: ( + rec: PeerPullPaymentIncomingRecord, + tx: DbReadWriteTransaction< + typeof WalletStoresV1, + ["peerPullDebit", ...StoreNameArray] + >, + ) => Promise<TransitionResultType>, + ): Promise<void> { + const wex = this.wex; + const extraStores = opts.extraStores ?? []; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit", ...extraStores] }, + async (tx) => { + const pi = await tx.peerPullDebit.get(this.peerPullDebitId); + if (!pi) { + throw Error("peer pull payment not found anymore"); + } + const oldTxState = computePeerPullDebitTransactionState(pi); + const res = await f(pi, tx); + switch (res) { + case TransitionResultType.Transition: { + await tx.peerPullDebit.put(pi); + const newTxState = computePeerPullDebitTransactionState(pi); + return { + oldTxState, + newTxState, + }; + } + default: + return undefined; + } + }, + ); + wex.taskScheduler.stopShepherdTask(this.taskId); + notifyTransition(wex, this.transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(this.taskId); + } +} + +async function handlePurseCreationConflict( + ctx: PeerPullDebitTransactionContext, + peerPullInc: PeerPullPaymentIncomingRecord, + resp: HttpResponse, +): Promise<TaskRunResult> { + const ws = ctx.wex; + const errResp = await readTalerErrorResponse(resp); + if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { + await ctx.failTransaction(); + return TaskRunResult.finished(); + } + + // FIXME: Properly parse! + const brokenCoinPub = (errResp as any).coin_pub; + logger.trace(`excluded broken coin pub=${brokenCoinPub}`); + + if (!brokenCoinPub) { + // FIXME: Details! + throw new TalerProtocolViolationError(); + } + + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const sel = peerPullInc.coinSel; + if (!sel) { + throw Error("invalid state (coin selection expected)"); + } + + const repair: PreviousPayCoins = []; + + for (let i = 0; i < sel.coinPubs.length; i++) { + if (sel.coinPubs[i] != brokenCoinPub) { + repair.push({ + coinPub: sel.coinPubs[i], + contribution: Amounts.parseOrThrow(sel.contributions[i]), + }); + } + } + + const coinSelRes = await selectPeerCoins(ws, { + instructedAmount, + repair, + }); + + switch (coinSelRes.type) { + case "failure": + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + case "prospective": + throw Error( + "insufficient balance to re-select coins to repair double spending (blocked on refresh)", + ); + case "success": + break; + default: + assertUnreachable(coinSelRes); + } + + const totalAmount = await getTotalPeerPaymentCost( + ws, + coinSelRes.result.coins, + ); + + await ws.db.runReadWriteTx({ storeNames: ["peerPullDebit"] }, async (tx) => { + const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId); + if (!myPpi) { + return; + } + switch (myPpi.status) { + case PeerPullDebitRecordStatus.PendingDeposit: + case PeerPullDebitRecordStatus.SuspendedDeposit: { + const sel = coinSelRes.result; + myPpi.coinSel = { + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + break; + } + default: + return; + } + await tx.peerPullDebit.put(myPpi); + }); + return TaskRunResult.backoff(); +} + +async function processPeerPullDebitPendingDeposit( + wex: WalletExecutionContext, + peerPullInc: PeerPullPaymentIncomingRecord, +): Promise<TaskRunResult> { + const ctx = new PeerPullDebitTransactionContext( + wex, + peerPullInc.peerPullDebitId, + ); + + const pursePub = peerPullInc.pursePub; + + const coinSel = peerPullInc.coinSel; + + if (!coinSel) { + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + throw Error("insufficient balance (locked behind refresh)"); + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const peerPullDebitId = peerPullInc.peerPullDebitId; + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + + // FIXME: Missing notification here! + + const transitionDone = await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "coins", + "denominations", + "refreshGroups", + "refreshSessions", + "peerPullDebit", + "coinAvailability", + ], + }, + async (tx) => { + const pi = await tx.peerPullDebit.get(peerPullDebitId); + if (!pi) { + return false; + } + if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return false; + } + if (pi.coinSel) { + return false; + } + await spendCoins(wex, tx, { + // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); + pi.coinSel = { + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + await tx.peerPullDebit.put(pi); + return true; + }, + ); + if (transitionDone) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } + } + + const purseDepositUrl = new URL( + `purses/${pursePub}/deposit`, + peerPullInc.exchangeBaseUrl, + ); + + // FIXME: We could skip batches that we've already submitted. + + const coins = await queryCoinInfosForSelection(wex, coinSel); + + const maxBatchSize = 100; + + for (let i = 0; i < coins.length; i += maxBatchSize) { + const batchSize = Math.min(maxBatchSize, coins.length - i); + + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `Depositing batch at ${i}/${coins.length} of size ${batchSize}`, + }); + + const batchCoins = coins.slice(i, i + batchSize); + const depositSigsResp = await wex.cryptoApi.signPurseDeposits({ + exchangeBaseUrl: peerPullInc.exchangeBaseUrl, + pursePub: peerPullInc.pursePub, + coins: batchCoins, + }); + + const depositPayload: ExchangePurseDeposits = { + deposits: depositSigsResp.deposits, + }; + + if (logger.shouldLogTrace()) { + logger.trace(`purse deposit payload: ${j2s(depositPayload)}`); + } + + const httpResp = await wex.http.fetch(purseDepositUrl.href, { + method: "POST", + body: depositPayload, + cancellationToken: wex.cancellationToken, + }); + + switch (httpResp.status) { + case HttpStatusCode.Ok: { + const resp = await readSuccessResponseJsonOrThrow( + httpResp, + codecForAny(), + ); + logger.trace(`purse deposit response: ${j2s(resp)}`); + continue; + } + case HttpStatusCode.Gone: { + await ctx.abortTransaction(); + return TaskRunResult.backoff(); + } + case HttpStatusCode.Conflict: { + return handlePurseCreationConflict(ctx, peerPullInc, httpResp); + } + default: { + const errResp = await readTalerErrorResponse(httpResp); + return { + type: TaskRunResultType.Error, + errorDetail: errResp, + }; + } + } + } + + // All batches succeeded, we can transition! + + await ctx.transition(async (r) => { + if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) { + return TransitionResultType.Stay; + } + r.status = PeerPullDebitRecordStatus.Done; + return TransitionResultType.Transition; + }); + return TaskRunResult.finished(); +} + +async function processPeerPullDebitAbortingRefresh( + wex: WalletExecutionContext, + peerPullInc: PeerPullPaymentIncomingRecord, +): Promise<TaskRunResult> { + const peerPullDebitId = peerPullInc.peerPullDebitId; + const abortRefreshGroupId = peerPullInc.abortRefreshGroupId; + checkLogicInvariant(!!abortRefreshGroupId); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }); + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit", "refreshGroups"] }, + async (tx) => { + const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId); + let newOpState: PeerPullDebitRecordStatus | undefined; + if (!refreshGroup) { + // Maybe it got manually deleted? Means that we should + // just go into failed. + logger.warn("no aborting refresh group found for deposit group"); + newOpState = PeerPullDebitRecordStatus.Failed; + } else { + if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) { + newOpState = PeerPullDebitRecordStatus.Aborted; + } else if ( + refreshGroup.operationStatus === RefreshOperationStatus.Failed + ) { + newOpState = PeerPullDebitRecordStatus.Failed; + } + } + if (newOpState) { + const newDg = await tx.peerPullDebit.get(peerPullDebitId); + if (!newDg) { + return; + } + const oldTxState = computePeerPullDebitTransactionState(newDg); + newDg.status = newOpState; + const newTxState = computePeerPullDebitTransactionState(newDg); + await tx.peerPullDebit.put(newDg); + return { oldTxState, newTxState }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + // FIXME: Shouldn't this be finished in some cases?! + return TaskRunResult.backoff(); +} + +export async function processPeerPullDebit( + wex: WalletExecutionContext, + peerPullDebitId: string, +): Promise<TaskRunResult> { + const peerPullInc = await wex.db.runReadOnlyTx( + { storeNames: ["peerPullDebit"] }, + async (tx) => { + return tx.peerPullDebit.get(peerPullDebitId); + }, + ); + if (!peerPullInc) { + throw Error("peer pull debit not found"); + } + + switch (peerPullInc.status) { + case PeerPullDebitRecordStatus.PendingDeposit: + return await processPeerPullDebitPendingDeposit(wex, peerPullInc); + case PeerPullDebitRecordStatus.AbortingRefresh: + return await processPeerPullDebitAbortingRefresh(wex, peerPullInc); + } + return TaskRunResult.finished(); +} + +export async function confirmPeerPullDebit( + wex: WalletExecutionContext, + req: ConfirmPeerPullDebitRequest, +): Promise<AcceptPeerPullPaymentResponse> { + let peerPullDebitId: string; + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) { + throw Error("invalid peer-pull-debit transaction identifier"); + } + peerPullDebitId = parsedTx.peerPullDebitId; + + const peerPullInc = await wex.db.runReadOnlyTx( + { storeNames: ["peerPullDebit"] }, + async (tx) => { + return tx.peerPullDebit.get(peerPullDebitId); + }, + ); + + if (!peerPullInc) { + throw Error( + `can't accept unknown incoming p2p pull payment (${req.transactionId})`, + ); + } + + const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount); + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = coinSelRes.result.prospectiveCoins; + break; + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + + // FIXME: Missing notification here! + + await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "coins", + "denominations", + "refreshGroups", + "refreshSessions", + "peerPullDebit", + "coinAvailability", + ], + }, + async (tx) => { + const pi = await tx.peerPullDebit.get(peerPullDebitId); + if (!pi) { + throw Error(); + } + if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) { + return; + } + if (coinSelRes.type == "success") { + await spendCoins(wex, tx, { + // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`, + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId, + }), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); + pi.coinSel = { + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => x.contribution), + totalCost: Amounts.stringify(totalAmount), + }; + } + pi.status = PeerPullDebitRecordStatus.PendingDeposit; + await tx.peerPullDebit.put(pi); + }, + ); + + const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId); + + const transactionId = ctx.transactionId; + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + transactionId, + }; +} + +/** + * Look up information about an incoming peer pull payment. + * Store the results in the wallet DB. + */ +export async function preparePeerPullDebit( + wex: WalletExecutionContext, + req: PreparePeerPullDebitRequest, +): Promise<PreparePeerPullDebitResponse> { + const uri = parsePayPullUri(req.talerUri); + + if (!uri) { + throw Error("got invalid taler://pay-pull URI"); + } + + const existing = await wex.db.runReadOnlyTx( + { storeNames: ["peerPullDebit", "contractTerms"] }, + async (tx) => { + const peerPullDebitRecord = + await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([ + uri.exchangeBaseUrl, + uri.contractPriv, + ]); + if (!peerPullDebitRecord) { + return; + } + const contractTerms = await tx.contractTerms.get( + peerPullDebitRecord.contractTermsHash, + ); + if (!contractTerms) { + return; + } + return { peerPullDebitRecord, contractTerms }; + }, + ); + + if (existing) { + return { + amount: existing.peerPullDebitRecord.amount, + amountRaw: existing.peerPullDebitRecord.amount, + amountEffective: existing.peerPullDebitRecord.totalCostEstimated, + contractTerms: existing.contractTerms.contractTermsRaw, + peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId, + }), + }; + } + + const exchangeBaseUrl = uri.exchangeBaseUrl; + const contractPriv = uri.contractPriv; + const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); + + const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); + + const contractHttpResp = await wex.http.fetch(getContractUrl.href); + + const contractResp = await readSuccessResponseJsonOrThrow( + contractHttpResp, + codecForExchangeGetContractResponse(), + ); + + const pursePub = contractResp.purse_pub; + + const dec = await wex.cryptoApi.decryptContractForDeposit({ + ciphertext: contractResp.econtract, + contractPriv: contractPriv, + pursePub: pursePub, + }); + + const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl); + + const purseHttpResp = await wex.http.fetch(getPurseUrl.href); + + const purseStatus = await readSuccessResponseJsonOrThrow( + purseHttpResp, + codecForExchangePurseStatus(), + ); + + const peerPullDebitId = encodeCrock(getRandomBytes(32)); + + let contractTerms: PeerContractTerms; + + if (dec.contractTerms) { + contractTerms = codecForPeerContractTerms().decode(dec.contractTerms); + // FIXME: Check that the purseStatus balance matches contract terms amount + } else { + // FIXME: In this case, where do we get the purse expiration from?! + // https://bugs.gnunet.org/view.php?id=7706 + throw Error("pull payments without contract terms not supported yet"); + } + + const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms); + + // FIXME: Why don't we compute the totalCost here?! + + const instructedAmount = Amounts.parseOrThrow(contractTerms.amount); + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + if (logger.shouldLogTrace()) { + logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + } + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = coinSelRes.result.prospectiveCoins; + break; + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + + await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit", "contractTerms"] }, + async (tx) => { + await tx.contractTerms.put({ + h: contractTermsHash, + contractTermsRaw: contractTerms, + }), + await tx.peerPullDebit.add({ + peerPullDebitId, + contractPriv: contractPriv, + exchangeBaseUrl: exchangeBaseUrl, + pursePub: pursePub, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + contractTermsHash, + amount: contractTerms.amount, + status: PeerPullDebitRecordStatus.DialogProposed, + totalCostEstimated: Amounts.stringify(totalAmount), + }); + }, + ); + + return { + amount: contractTerms.amount, + amountEffective: Amounts.stringify(totalAmount), + amountRaw: contractTerms.amount, + contractTerms: contractTerms, + peerPullDebitId, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId: peerPullDebitId, + }), + }; +} + +export function computePeerPullDebitTransactionState( + pullDebitRecord: PeerPullPaymentIncomingRecord, +): TransactionState { + switch (pullDebitRecord.status) { + case PeerPullDebitRecordStatus.DialogProposed: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }; + case PeerPullDebitRecordStatus.PendingDeposit: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Deposit, + }; + case PeerPullDebitRecordStatus.Done: + return { + major: TransactionMajorState.Done, + }; + case PeerPullDebitRecordStatus.SuspendedDeposit: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.Deposit, + }; + case PeerPullDebitRecordStatus.Aborted: + return { + major: TransactionMajorState.Aborted, + }; + case PeerPullDebitRecordStatus.AbortingRefresh: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.Refresh, + }; + case PeerPullDebitRecordStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + return { + major: TransactionMajorState.SuspendedAborting, + minor: TransactionMinorState.Refresh, + }; + } +} + +export function computePeerPullDebitTransactionActions( + pullDebitRecord: PeerPullPaymentIncomingRecord, +): TransactionAction[] { + switch (pullDebitRecord.status) { + case PeerPullDebitRecordStatus.DialogProposed: + return []; + case PeerPullDebitRecordStatus.PendingDeposit: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPullDebitRecordStatus.Done: + return [TransactionAction.Delete]; + case PeerPullDebitRecordStatus.SuspendedDeposit: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPullDebitRecordStatus.Aborted: + return [TransactionAction.Delete]; + case PeerPullDebitRecordStatus.AbortingRefresh: + return [TransactionAction.Fail, TransactionAction.Suspend]; + case PeerPullDebitRecordStatus.Failed: + return [TransactionAction.Delete]; + case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: + return [TransactionAction.Resume, TransactionAction.Fail]; + } +} diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts index 78263c4c3..93f1a63a7 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -30,12 +30,15 @@ import { TalerPreciseTimestamp, TalerProtocolTimestamp, TransactionAction, + TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, WalletAccountMergeFlags, WalletKycUuid, + assertUnreachable, + checkDbInvariant, codecForAny, codecForExchangeGetContractResponse, codecForPeerContractTerms, @@ -51,24 +54,23 @@ import { } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { - InternalWalletState, + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { KycPendingInfo, KycUserType, - PeerPushPaymentIncomingRecord, PeerPushCreditStatus, - PendingTaskType, + PeerPushPaymentIncomingRecord, WithdrawalGroupStatus, WithdrawalRecordType, timestampPreciseToDb, -} from "../index.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { - TaskRunResult, - TaskRunResultType, - constructTaskIdentifier, - runLongpollAsync, -} from "./common.js"; +} from "./db.js"; import { fetchFreshExchange } from "./exchanges.js"; import { codecForExchangePurseStatus, @@ -79,18 +81,281 @@ import { constructTransactionIdentifier, notifyTransition, parseTransactionIdentifier, - stopLongpolling, } from "./transactions.js"; +import { WalletExecutionContext } from "./wallet.js"; import { + PerformCreateWithdrawalGroupResult, getExchangeWithdrawalInfo, internalPerformCreateWithdrawalGroup, internalPrepareCreateWithdrawalGroup, + waitWithdrawalFinal, } from "./withdraw.js"; const logger = new Logger("pay-peer-push-credit.ts"); +export class PeerPushCreditTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + public peerPushCreditId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPushCredit, + peerPushCreditId, + }); + } + + async deleteTransaction(): Promise<void> { + const { wex, peerPushCreditId } = this; + await wex.db.runReadWriteTx( + { storeNames: ["withdrawalGroups", "peerPushCredit", "tombstones"] }, + async (tx) => { + const pushInc = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushInc) { + return; + } + if (pushInc.withdrawalGroupId) { + const withdrawalGroupId = pushInc.withdrawalGroupId; + const withdrawalGroupRecord = + await tx.withdrawalGroups.get(withdrawalGroupId); + if (withdrawalGroupRecord) { + await tx.withdrawalGroups.delete(withdrawalGroupId); + await tx.tombstones.put({ + id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId, + }); + } + } + await tx.peerPushCredit.delete(peerPushCreditId); + await tx.tombstones.put({ + id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId, + }); + }, + ); + return; + } + + async suspendTransaction(): Promise<void> { + const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushCredit"] }, + async (tx) => { + const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushCreditRec) { + logger.warn(`peer push credit ${peerPushCreditId} not found`); + return; + } + let newStatus: PeerPushCreditStatus | undefined = undefined; + switch (pushCreditRec.status) { + case PeerPushCreditStatus.DialogProposed: + case PeerPushCreditStatus.Done: + case PeerPushCreditStatus.SuspendedMerge: + case PeerPushCreditStatus.SuspendedMergeKycRequired: + case PeerPushCreditStatus.SuspendedWithdrawing: + break; + case PeerPushCreditStatus.PendingMergeKycRequired: + newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired; + break; + case PeerPushCreditStatus.PendingMerge: + newStatus = PeerPushCreditStatus.SuspendedMerge; + break; + case PeerPushCreditStatus.PendingWithdrawing: + // FIXME: Suspend internal withdrawal transaction! + newStatus = PeerPushCreditStatus.SuspendedWithdrawing; + break; + case PeerPushCreditStatus.Aborted: + break; + case PeerPushCreditStatus.Failed: + break; + default: + assertUnreachable(pushCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + const newTxState = + computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.stopShepherdTask(retryTag); + } + + async abortTransaction(): Promise<void> { + const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushCredit"] }, + async (tx) => { + const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushCreditRec) { + logger.warn(`peer push credit ${peerPushCreditId} not found`); + return; + } + let newStatus: PeerPushCreditStatus | undefined = undefined; + switch (pushCreditRec.status) { + case PeerPushCreditStatus.DialogProposed: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.Done: + break; + case PeerPushCreditStatus.SuspendedMerge: + case PeerPushCreditStatus.SuspendedMergeKycRequired: + case PeerPushCreditStatus.SuspendedWithdrawing: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.PendingMergeKycRequired: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.PendingMerge: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.PendingWithdrawing: + newStatus = PeerPushCreditStatus.Aborted; + break; + case PeerPushCreditStatus.Aborted: + break; + case PeerPushCreditStatus.Failed: + break; + default: + assertUnreachable(pushCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + const newTxState = + computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + } + + async resumeTransaction(): Promise<void> { + const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushCredit"] }, + async (tx) => { + const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushCreditRec) { + logger.warn(`peer push credit ${peerPushCreditId} not found`); + return; + } + let newStatus: PeerPushCreditStatus | undefined = undefined; + switch (pushCreditRec.status) { + case PeerPushCreditStatus.DialogProposed: + case PeerPushCreditStatus.Done: + case PeerPushCreditStatus.PendingMergeKycRequired: + case PeerPushCreditStatus.PendingMerge: + case PeerPushCreditStatus.PendingWithdrawing: + case PeerPushCreditStatus.SuspendedMerge: + newStatus = PeerPushCreditStatus.PendingMerge; + break; + case PeerPushCreditStatus.SuspendedMergeKycRequired: + newStatus = PeerPushCreditStatus.PendingMergeKycRequired; + break; + case PeerPushCreditStatus.SuspendedWithdrawing: + // FIXME: resume underlying "internal-withdrawal" transaction. + newStatus = PeerPushCreditStatus.PendingWithdrawing; + break; + case PeerPushCreditStatus.Aborted: + break; + case PeerPushCreditStatus.Failed: + break; + default: + assertUnreachable(pushCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + const newTxState = + computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + } + + async failTransaction(): Promise<void> { + const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushCredit"] }, + async (tx) => { + const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId); + if (!pushCreditRec) { + logger.warn(`peer push credit ${peerPushCreditId} not found`); + return; + } + let newStatus: PeerPushCreditStatus | undefined = undefined; + switch (pushCreditRec.status) { + case PeerPushCreditStatus.Done: + case PeerPushCreditStatus.Aborted: + case PeerPushCreditStatus.Failed: + // Already in a final state. + return; + case PeerPushCreditStatus.DialogProposed: + case PeerPushCreditStatus.PendingMergeKycRequired: + case PeerPushCreditStatus.PendingMerge: + case PeerPushCreditStatus.PendingWithdrawing: + case PeerPushCreditStatus.SuspendedMerge: + case PeerPushCreditStatus.SuspendedMergeKycRequired: + case PeerPushCreditStatus.SuspendedWithdrawing: + newStatus = PeerPushCreditStatus.Failed; + break; + default: + assertUnreachable(pushCreditRec.status); + } + if (newStatus != null) { + const oldTxState = + computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + const newTxState = + computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + wex.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + } +} + export async function preparePeerPushCredit( - ws: InternalWalletState, + wex: WalletExecutionContext, req: PreparePeerPushCreditRequest, ): Promise<PreparePeerPushCreditResponse> { const uri = parsePayPushUri(req.talerUri); @@ -99,9 +364,9 @@ export async function preparePeerPushCredit( throw Error("got invalid taler://pay-push URI"); } - const existing = await ws.db - .mktx((x) => [x.contractTerms, x.peerPushCredit]) - .runReadOnly(async (tx) => { + const existing = await wex.db.runReadOnlyTx( + { storeNames: ["contractTerms", "peerPushCredit"] }, + async (tx) => { const existingPushInc = await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([ uri.exchangeBaseUrl, @@ -122,7 +387,8 @@ export async function preparePeerPushCredit( existingContractTermsRec.contractTermsRaw, ); return { existingPushInc, existingContractTerms }; - }); + }, + ); if (existing) { return { @@ -141,14 +407,14 @@ export async function preparePeerPushCredit( const exchangeBaseUrl = uri.exchangeBaseUrl; - await fetchFreshExchange(ws, exchangeBaseUrl); + await fetchFreshExchange(wex, exchangeBaseUrl); const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); - const contractHttpResp = await ws.http.fetch(getContractUrl.href); + const contractHttpResp = await wex.http.fetch(getContractUrl.href); const contractResp = await readSuccessResponseJsonOrThrow( contractHttpResp, @@ -157,7 +423,7 @@ export async function preparePeerPushCredit( const pursePub = contractResp.purse_pub; - const dec = await ws.cryptoApi.decryptContractForMerge({ + const dec = await wex.cryptoApi.decryptContractForMerge({ ciphertext: contractResp.econtract, contractPriv: contractPriv, pursePub: pursePub, @@ -165,7 +431,7 @@ export async function preparePeerPushCredit( const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); - const purseHttpResp = await ws.http.fetch(getPurseUrl.href); + const purseHttpResp = await wex.http.fetch(getPurseUrl.href); const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms); @@ -187,15 +453,15 @@ export async function preparePeerPushCredit( const withdrawalGroupId = encodeCrock(getRandomBytes(32)); const wi = await getExchangeWithdrawalInfo( - ws, + wex, exchangeBaseUrl, Amounts.parseOrThrow(purseStatus.balance), undefined, ); - const transitionInfo = await ws.db - .mktx((x) => [x.contractTerms, x.peerPushCredit]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["contractTerms", "peerPushCredit"] }, + async (tx) => { const rec: PeerPushPaymentIncomingRecord = { peerPushCreditId, contractPriv: contractPriv, @@ -225,16 +491,17 @@ export async function preparePeerPushCredit( }, newTxState, } satisfies TransitionInfo; - }); + }, + ); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushCreditId, }); - notifyTransition(ws, transactionId, transitionInfo); + notifyTransition(wex, transactionId, transitionInfo); - ws.notify({ + wex.ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, }); @@ -251,7 +518,7 @@ export async function preparePeerPushCredit( } async function longpollKycStatus( - ws: InternalWalletState, + wex: WalletExecutionContext, peerPushCreditId: string, exchangeUrl: string, kycInfo: KycPendingInfo, @@ -261,62 +528,51 @@ async function longpollKycStatus( tag: TransactionType.PeerPushCredit, peerPushCreditId, }); - const retryTag = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushCredit, - peerPushCreditId, + const url = new URL( + `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, + exchangeUrl, + ); + url.searchParams.set("timeout_ms", "30000"); + logger.info(`kyc url ${url.href}`); + const kycStatusRes = await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, }); - - runLongpollAsync(ws, retryTag, async (ct) => { - const url = new URL( - `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, - exchangeUrl, + if ( + kycStatusRes.status === HttpStatusCode.Ok || + //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge + // remove after the exchange is fixed or clarified + kycStatusRes.status === HttpStatusCode.NoContent + ) { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushCredit"] }, + async (tx) => { + const peerInc = await tx.peerPushCredit.get(peerPushCreditId); + if (!peerInc) { + return; + } + if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) { + return; + } + const oldTxState = computePeerPushCreditTransactionState(peerInc); + peerInc.status = PeerPushCreditStatus.PendingMerge; + const newTxState = computePeerPushCreditTransactionState(peerInc); + await tx.peerPushCredit.put(peerInc); + return { oldTxState, newTxState }; + }, ); - url.searchParams.set("timeout_ms", "10000"); - logger.info(`kyc url ${url.href}`); - const kycStatusRes = await ws.http.fetch(url.href, { - method: "GET", - cancellationToken: ct, - }); - 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 - .mktx((x) => [x.peerPushCredit]) - .runReadWrite(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); - return { ready: true }; - } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - // FIXME: Do we have to update the URL here? - return { ready: false }; - } else { - throw Error( - `unexpected response from kyc-check (${kycStatusRes.status})`, - ); - } - }); - return { - type: TaskRunResultType.Longpoll, - }; + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.progress(); + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + // FIXME: Do we have to update the URL here? + return TaskRunResult.longpollReturnedPending(); + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } } async function processPeerPushCreditKycRequired( - ws: InternalWalletState, + wex: WalletExecutionContext, peerInc: PeerPushPaymentIncomingRecord, kycPending: WalletKycUuid, ): Promise<TaskRunResult> { @@ -333,8 +589,9 @@ async function processPeerPushCreditKycRequired( ); logger.info(`kyc url ${url.href}`); - const kycStatusRes = await ws.http.fetch(url.href, { + const kycStatusRes = await wex.http.fetch(url.href, { method: "GET", + cancellationToken: wex.cancellationToken, }); if ( @@ -348,9 +605,9 @@ async function processPeerPushCreditKycRequired( } else if (kycStatusRes.status === HttpStatusCode.Accepted) { const kycStatus = await kycStatusRes.json(); logger.info(`kyc status: ${j2s(kycStatus)}`); - const { transitionInfo, result } = await ws.db - .mktx((x) => [x.peerPushCredit]) - .runReadWrite(async (tx) => { + const { transitionInfo, result } = await wex.db.runReadWriteTx( + { storeNames: ["peerPushCredit"] }, + async (tx) => { const peerInc = await tx.peerPushCredit.get(peerPushCreditId); if (!peerInc) { return { @@ -382,8 +639,9 @@ async function processPeerPushCreditKycRequired( transitionInfo: { oldTxState, newTxState }, result: res, }; - }); - notifyTransition(ws, transactionId, transitionInfo); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); return result; } else { throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); @@ -391,7 +649,7 @@ async function processPeerPushCreditKycRequired( } async function handlePendingMerge( - ws: InternalWalletState, + wex: WalletExecutionContext, peerInc: PeerPushPaymentIncomingRecord, contractTerms: PeerContractTerms, ): Promise<TaskRunResult> { @@ -403,7 +661,7 @@ async function handlePendingMerge( const amount = Amounts.parseOrThrow(contractTerms.amount); - const mergeReserveInfo = await getMergeReserveInfo(ws, { + const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: peerInc.exchangeBaseUrl, }); @@ -414,7 +672,7 @@ async function handlePendingMerge( mergeReserveInfo.reservePub, ); - const sigRes = await ws.cryptoApi.signPurseMerge({ + const sigRes = await wex.cryptoApi.signPurseMerge({ contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms), flags: WalletAccountMergeFlags.MergeFullyPaidPurse, mergePriv: peerInc.mergePriv, @@ -439,7 +697,7 @@ async function handlePendingMerge( reserve_sig: sigRes.accountSig, }; - const mergeHttpResp = await ws.http.fetch(mergePurseUrl.href, { + const mergeHttpResp = await wex.http.fetch(mergePurseUrl.href, { method: "POST", body: mergeReq, }); @@ -448,7 +706,7 @@ async function handlePendingMerge( const respJson = await mergeHttpResp.json(); const kycPending = codecForWalletKycUuid().decode(respJson); logger.info(`kyc uuid response: ${j2s(kycPending)}`); - return processPeerPushCreditKycRequired(ws, peerInc, kycPending); + return processPeerPushCreditKycRequired(wex, peerInc, kycPending); } logger.trace(`merge request: ${j2s(mergeReq)}`); @@ -458,7 +716,7 @@ async function handlePendingMerge( ); logger.trace(`merge response: ${j2s(res)}`); - const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(ws, { + const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(wex, { amount, wgInfo: { withdrawalType: WithdrawalRecordType.PeerPushCredit, @@ -472,33 +730,36 @@ async function handlePendingMerge( }, }); - const txRes = await ws.db - .mktx((x) => [ - x.contractTerms, - x.peerPushCredit, - x.withdrawalGroups, - x.reserves, - x.exchanges, - x.exchangeDetails, - ]) - .runReadWrite(async (tx) => { + const txRes = await wex.db.runReadWriteTx( + { + storeNames: [ + "contractTerms", + "peerPushCredit", + "withdrawalGroups", + "reserves", + "exchanges", + "exchangeDetails", + ], + }, + async (tx) => { const peerInc = await tx.peerPushCredit.get(peerPushCreditId); if (!peerInc) { return undefined; } - let withdrawalTransition: TransitionInfo | undefined; const oldTxState = computePeerPushCreditTransactionState(peerInc); + let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined = + undefined; switch (peerInc.status) { case PeerPushCreditStatus.PendingMerge: case PeerPushCreditStatus.PendingMergeKycRequired: { peerInc.status = PeerPushCreditStatus.PendingWithdrawing; - const wgRes = await internalPerformCreateWithdrawalGroup( - ws, + wgCreateRes = await internalPerformCreateWithdrawalGroup( + wex, tx, withdrawalGroupPrep, ); - withdrawalTransition = wgRes.transitionInfo; - peerInc.withdrawalGroupId = wgRes.withdrawalGroup.withdrawalGroupId; + peerInc.withdrawalGroupId = + wgCreateRes.withdrawalGroup.withdrawalGroupId; break; } } @@ -506,35 +767,41 @@ async function handlePendingMerge( const newTxState = computePeerPushCreditTransactionState(peerInc); return { peerPushCreditTransition: { oldTxState, newTxState }, - withdrawalTransition, + wgCreateRes, }; - }); + }, + ); + // Transaction was committed, now we can emit notifications. + if (txRes?.wgCreateRes?.exchangeNotif) { + wex.ws.notify(txRes.wgCreateRes.exchangeNotif); + } notifyTransition( - ws, + wex, withdrawalGroupPrep.transactionId, - txRes?.withdrawalTransition, + txRes?.wgCreateRes?.transitionInfo, ); - notifyTransition(ws, transactionId, txRes?.peerPushCreditTransition); + notifyTransition(wex, transactionId, txRes?.peerPushCreditTransition); - return TaskRunResult.finished(); + return TaskRunResult.backoff(); } async function handlePendingWithdrawing( - ws: InternalWalletState, + wex: WalletExecutionContext, peerInc: PeerPushPaymentIncomingRecord, ): Promise<TaskRunResult> { if (!peerInc.withdrawalGroupId) { throw Error("invalid db state (withdrawing, but no withdrawal group ID"); } + await waitWithdrawalFinal(wex, peerInc.withdrawalGroupId); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushCreditId: peerInc.peerPushCreditId, }); const wgId = peerInc.withdrawalGroupId; let finished: boolean = false; - const transitionInfo = await ws.db - .mktx((x) => [x.peerPushCredit, x.withdrawalGroups]) - .runReadWrite(async (tx) => { + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushCredit", "withdrawalGroups"] }, + async (tx) => { const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId); if (!ppi) { finished = true; @@ -563,25 +830,26 @@ async function handlePendingWithdrawing( oldTxState, newTxState, }; - }); - notifyTransition(ws, transactionId, transitionInfo); + }, + ); + notifyTransition(wex, transactionId, transitionInfo); if (finished) { return TaskRunResult.finished(); } else { // FIXME: Return indicator that we depend on the other operation! - return TaskRunResult.pending(); + return TaskRunResult.backoff(); } } export async function processPeerPushCredit( - ws: InternalWalletState, + wex: WalletExecutionContext, peerPushCreditId: string, ): Promise<TaskRunResult> { let peerInc: PeerPushPaymentIncomingRecord | undefined; let contractTerms: PeerContractTerms | undefined; - await ws.db - .mktx((x) => [x.contractTerms, x.peerPushCredit]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["contractTerms", "peerPushCredit"] }, + async (tx) => { peerInc = await tx.peerPushCredit.get(peerPushCreditId); if (!peerInc) { return; @@ -591,9 +859,8 @@ export async function processPeerPushCredit( contractTerms = ctRec.contractTermsRaw; } await tx.peerPushCredit.put(peerInc); - }); - - checkDbInvariant(!!contractTerms); + }, + ); if (!peerInc) { throw Error( @@ -601,13 +868,19 @@ export async function processPeerPushCredit( ); } + 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, + wex, peerPushCreditId, peerInc.exchangeBaseUrl, peerInc.kycInfo, @@ -616,10 +889,10 @@ export async function processPeerPushCredit( } case PeerPushCreditStatus.PendingMerge: - return handlePendingMerge(ws, peerInc, contractTerms); + return handlePendingMerge(wex, peerInc, contractTerms); case PeerPushCreditStatus.PendingWithdrawing: - return handlePendingWithdrawing(ws, peerInc); + return handlePendingWithdrawing(wex, peerInc); default: return TaskRunResult.finished(); @@ -627,29 +900,25 @@ export async function processPeerPushCredit( } export async function confirmPeerPushCredit( - ws: InternalWalletState, + wex: WalletExecutionContext, 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"); + 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; - await ws.db - .mktx((x) => [x.contractTerms, x.peerPushCredit]) - .runReadWrite(async (tx) => { + logger.trace(`confirming peer-push-credit ${peerPushCreditId}`); + + await wex.db.runReadWriteTx( + { storeNames: ["contractTerms", "peerPushCredit"] }, + async (tx) => { peerInc = await tx.peerPushCredit.get(peerPushCreditId); if (!peerInc) { return; @@ -658,15 +927,18 @@ export async function confirmPeerPushCredit( peerInc.status = PeerPushCreditStatus.PendingMerge; } await tx.peerPushCredit.put(peerInc); - }); + }, + ); if (!peerInc) { throw Error( - `can't accept unknown incoming p2p push payment (${req.peerPushCreditId})`, + `can't accept unknown incoming p2p push payment (${req.transactionId})`, ); } - ws.workAvailable.trigger(); + const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId); + + wex.taskScheduler.startShepherdTask(ctx.taskId); const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, @@ -678,200 +950,6 @@ export async function confirmPeerPushCredit( }; } -export async function suspendPeerPushCreditTransaction( - ws: InternalWalletState, - peerPushCreditId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushCredit, - peerPushCreditId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushCredit, - peerPushCreditId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPushCredit]) - .runReadWrite(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); -} - -export async function abortPeerPushCreditTransaction( - ws: InternalWalletState, - peerPushCreditId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushCredit, - peerPushCreditId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushCredit, - peerPushCreditId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPushCredit]) - .runReadWrite(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); -} - -export async function failPeerPushCreditTransaction( - ws: InternalWalletState, - peerPushCreditId: string, -) { - // We don't have any "aborting" states! - throw Error("can't run cancel-aborting on peer-push-credit transaction"); -} - -export async function resumePeerPushCreditTransaction( - ws: InternalWalletState, - peerPushCreditId: string, -) { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.PeerPushCredit, - peerPushCreditId, - }); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushCredit, - peerPushCreditId, - }); - stopLongpolling(ws, taskId); - const transitionInfo = await ws.db - .mktx((x) => [x.peerPushCredit]) - .runReadWrite(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; - }); - ws.workAvailable.trigger(); - notifyTransition(ws, transactionId, transitionInfo); -} - export function computePeerPushCreditTransactionState( pushCreditRecord: PeerPushPaymentIncomingRecord, ): TransactionState { diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts new file mode 100644 index 000000000..6452407ff --- /dev/null +++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -0,0 +1,1322 @@ +/* + This file is part of GNU Taler + (C) 2022-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Amounts, + CheckPeerPushDebitRequest, + CheckPeerPushDebitResponse, + CoinRefreshRequest, + ContractTermsUtil, + ExchangePurseDeposits, + HttpStatusCode, + InitiatePeerPushDebitRequest, + InitiatePeerPushDebitResponse, + Logger, + NotificationType, + RefreshReason, + SelectedProspectiveCoin, + TalerError, + TalerErrorCode, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TalerProtocolViolationError, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + assertUnreachable, + checkDbInvariant, + checkLogicInvariant, + encodeCrock, + getRandomBytes, + j2s, +} from "@gnu-taler/taler-util"; +import { + HttpResponse, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, +} from "@gnu-taler/taler-util/http"; +import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js"; +import { + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TransactionContext, + constructTaskIdentifier, + spendCoins, +} from "./common.js"; +import { EncryptContractRequest } from "./crypto/cryptoTypes.js"; +import { + PeerPushDebitRecord, + PeerPushDebitStatus, + RefreshOperationStatus, + timestampPreciseToDb, + timestampProtocolFromDb, + timestampProtocolToDb, +} from "./db.js"; +import { + codecForExchangePurseStatus, + getTotalPeerPaymentCost, + queryCoinInfosForSelection, +} from "./pay-peer-common.js"; +import { createRefreshGroup, waitRefreshFinal } from "./refresh.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; +import { WalletExecutionContext } from "./wallet.js"; + +const logger = new Logger("pay-peer-push-debit.ts"); + +export class PeerPushDebitTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + public pursePub: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPushDebit, + pursePub, + }); + } + + async deleteTransaction(): Promise<void> { + const { wex, pursePub, transactionId } = this; + await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit", "tombstones"] }, + async (tx) => { + const debit = await tx.peerPushDebit.get(pursePub); + if (debit) { + await tx.peerPushDebit.delete(pursePub); + await tx.tombstones.put({ id: transactionId }); + } + }, + ); + } + + async suspendTransaction(): Promise<void> { + const { wex, pursePub, transactionId, taskId: retryTag } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit"] }, + async (tx) => { + const pushDebitRec = await tx.peerPushDebit.get(pursePub); + if (!pushDebitRec) { + logger.warn(`peer push debit ${pursePub} not found`); + return; + } + let newStatus: PeerPushDebitStatus | undefined = undefined; + switch (pushDebitRec.status) { + case PeerPushDebitStatus.PendingCreatePurse: + newStatus = PeerPushDebitStatus.SuspendedCreatePurse; + break; + case PeerPushDebitStatus.AbortingRefreshDeleted: + newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshDeleted; + break; + case PeerPushDebitStatus.AbortingRefreshExpired: + newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshExpired; + break; + case PeerPushDebitStatus.AbortingDeletePurse: + newStatus = PeerPushDebitStatus.SuspendedAbortingDeletePurse; + break; + case PeerPushDebitStatus.PendingReady: + newStatus = PeerPushDebitStatus.SuspendedReady; + break; + case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted: + case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: + case PeerPushDebitStatus.SuspendedReady: + case PeerPushDebitStatus.SuspendedCreatePurse: + case PeerPushDebitStatus.Done: + case PeerPushDebitStatus.Aborted: + case PeerPushDebitStatus.Failed: + case PeerPushDebitStatus.Expired: + // Do nothing + break; + default: + assertUnreachable(pushDebitRec.status); + } + if (newStatus != null) { + const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); + pushDebitRec.status = newStatus; + const newTxState = computePeerPushDebitTransactionState(pushDebitRec); + await tx.peerPushDebit.put(pushDebitRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + wex.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + } + + async abortTransaction(): Promise<void> { + const { wex, pursePub, transactionId, taskId: retryTag } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit"] }, + async (tx) => { + const pushDebitRec = await tx.peerPushDebit.get(pursePub); + if (!pushDebitRec) { + logger.warn(`peer push debit ${pursePub} not found`); + return; + } + let newStatus: PeerPushDebitStatus | undefined = undefined; + switch (pushDebitRec.status) { + case PeerPushDebitStatus.PendingReady: + case PeerPushDebitStatus.SuspendedReady: + newStatus = PeerPushDebitStatus.AbortingDeletePurse; + break; + case PeerPushDebitStatus.SuspendedCreatePurse: + case PeerPushDebitStatus.PendingCreatePurse: + // Network request might already be in-flight! + newStatus = PeerPushDebitStatus.AbortingDeletePurse; + break; + case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted: + case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: + case PeerPushDebitStatus.AbortingRefreshDeleted: + case PeerPushDebitStatus.AbortingRefreshExpired: + case PeerPushDebitStatus.Done: + case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.Aborted: + case PeerPushDebitStatus.Expired: + case PeerPushDebitStatus.Failed: + // Do nothing + break; + default: + assertUnreachable(pushDebitRec.status); + } + if (newStatus != null) { + const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); + pushDebitRec.status = newStatus; + const newTxState = computePeerPushDebitTransactionState(pushDebitRec); + await tx.peerPushDebit.put(pushDebitRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + wex.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + } + + async resumeTransaction(): Promise<void> { + const { wex, pursePub, transactionId, taskId: retryTag } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit"] }, + async (tx) => { + const pushDebitRec = await tx.peerPushDebit.get(pursePub); + if (!pushDebitRec) { + logger.warn(`peer push debit ${pursePub} not found`); + return; + } + let newStatus: PeerPushDebitStatus | undefined = undefined; + switch (pushDebitRec.status) { + case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + newStatus = PeerPushDebitStatus.AbortingDeletePurse; + break; + case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted: + newStatus = PeerPushDebitStatus.AbortingRefreshDeleted; + break; + case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: + newStatus = PeerPushDebitStatus.AbortingRefreshExpired; + break; + case PeerPushDebitStatus.SuspendedReady: + newStatus = PeerPushDebitStatus.PendingReady; + break; + case PeerPushDebitStatus.SuspendedCreatePurse: + newStatus = PeerPushDebitStatus.PendingCreatePurse; + break; + case PeerPushDebitStatus.PendingCreatePurse: + case PeerPushDebitStatus.AbortingRefreshDeleted: + case PeerPushDebitStatus.AbortingRefreshExpired: + case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.PendingReady: + case PeerPushDebitStatus.Done: + case PeerPushDebitStatus.Aborted: + case PeerPushDebitStatus.Failed: + case PeerPushDebitStatus.Expired: + // Do nothing + break; + default: + assertUnreachable(pushDebitRec.status); + } + if (newStatus != null) { + const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); + pushDebitRec.status = newStatus; + const newTxState = computePeerPushDebitTransactionState(pushDebitRec); + await tx.peerPushDebit.put(pushDebitRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + wex.taskScheduler.startShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + } + + async failTransaction(): Promise<void> { + const { wex, pursePub, transactionId, taskId: retryTag } = this; + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit"] }, + async (tx) => { + const pushDebitRec = await tx.peerPushDebit.get(pursePub); + if (!pushDebitRec) { + logger.warn(`peer push debit ${pursePub} not found`); + return; + } + let newStatus: PeerPushDebitStatus | undefined = undefined; + switch (pushDebitRec.status) { + case PeerPushDebitStatus.AbortingRefreshDeleted: + case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted: + // FIXME: What to do about the refresh group? + newStatus = PeerPushDebitStatus.Failed; + break; + case PeerPushDebitStatus.AbortingDeletePurse: + case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + case PeerPushDebitStatus.AbortingRefreshExpired: + case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: + case PeerPushDebitStatus.PendingReady: + case PeerPushDebitStatus.SuspendedReady: + case PeerPushDebitStatus.SuspendedCreatePurse: + case PeerPushDebitStatus.PendingCreatePurse: + newStatus = PeerPushDebitStatus.Failed; + break; + case PeerPushDebitStatus.Done: + case PeerPushDebitStatus.Aborted: + case PeerPushDebitStatus.Failed: + case PeerPushDebitStatus.Expired: + // Do nothing + break; + default: + assertUnreachable(pushDebitRec.status); + } + if (newStatus != null) { + const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); + pushDebitRec.status = newStatus; + const newTxState = computePeerPushDebitTransactionState(pushDebitRec); + await tx.peerPushDebit.put(pushDebitRec); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + wex.taskScheduler.stopShepherdTask(retryTag); + notifyTransition(wex, transactionId, transitionInfo); + wex.taskScheduler.startShepherdTask(retryTag); + } +} + +export async function checkPeerPushDebit( + wex: WalletExecutionContext, + req: CheckPeerPushDebitRequest, +): Promise<CheckPeerPushDebitResponse> { + const instructedAmount = Amounts.parseOrThrow(req.amount); + logger.trace( + `checking peer push debit for ${Amounts.stringify(instructedAmount)}`, + ); + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + let coins: SelectedProspectiveCoin[] | undefined = undefined; + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = coinSelRes.result.prospectiveCoins; + break; + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + logger.trace(`selected peer coins (len=${coins.length})`); + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + logger.trace("computed total peer payment cost"); + return { + exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, + amountEffective: Amounts.stringify(totalAmount), + amountRaw: req.amount, + maxExpirationDate: coinSelRes.result.maxExpirationDate, + }; +} + +async function handlePurseCreationConflict( + wex: WalletExecutionContext, + peerPushInitiation: PeerPushDebitRecord, + resp: HttpResponse, +): Promise<TaskRunResult> { + const pursePub = peerPushInitiation.pursePub; + const errResp = await readTalerErrorResponse(resp); + const ctx = new PeerPushDebitTransactionContext(wex, pursePub); + if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) { + await ctx.failTransaction(); + return TaskRunResult.finished(); + } + + // FIXME: Properly parse! + const brokenCoinPub = (errResp as any).coin_pub; + logger.trace(`excluded broken coin pub=${brokenCoinPub}`); + + if (!brokenCoinPub) { + // FIXME: Details! + throw new TalerProtocolViolationError(); + } + + const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount); + const sel = peerPushInitiation.coinSel; + + checkDbInvariant(!!sel); + + const repair: PreviousPayCoins = []; + + for (let i = 0; i < sel.coinPubs.length; i++) { + if (sel.coinPubs[i] != brokenCoinPub) { + repair.push({ + coinPub: sel.coinPubs[i], + contribution: Amounts.parseOrThrow(sel.contributions[i]), + }); + } + } + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + repair, + }); + + switch (coinSelRes.type) { + case "failure": + case "prospective": + // FIXME: Details! + throw Error( + "insufficient balance to re-select coins to repair double spending", + ); + case "success": + break; + default: + assertUnreachable(coinSelRes); + } + + await wex.db.runReadWriteTx({ storeNames: ["peerPushDebit"] }, async (tx) => { + const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub); + if (!myPpi) { + return; + } + switch (myPpi.status) { + case PeerPushDebitStatus.PendingCreatePurse: + case PeerPushDebitStatus.SuspendedCreatePurse: { + const sel = coinSelRes.result; + myPpi.coinSel = { + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => x.contribution), + }; + break; + } + default: + return; + } + await tx.peerPushDebit.put(myPpi); + }); + return TaskRunResult.progress(); +} + +async function processPeerPushDebitCreateReserve( + wex: WalletExecutionContext, + peerPushInitiation: PeerPushDebitRecord, +): Promise<TaskRunResult> { + const pursePub = peerPushInitiation.pursePub; + const purseExpiration = peerPushInitiation.purseExpiration; + const hContractTerms = peerPushInitiation.contractTermsHash; + const ctx = new PeerPushDebitTransactionContext(wex, pursePub); + const transactionId = ctx.transactionId; + + logger.trace(`processing ${transactionId} pending(create-reserve)`); + + const contractTermsRecord = await wex.db.runReadOnlyTx( + { storeNames: ["contractTerms"] }, + async (tx) => { + return tx.contractTerms.get(hContractTerms); + }, + ); + + if (!contractTermsRecord) { + throw Error( + `db invariant failed, contract terms for ${transactionId} missing`, + ); + } + + if (!peerPushInitiation.coinSel) { + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount), + }); + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + throw Error("insufficient funds (blocked on refresh)"); + case "success": + break; + default: + assertUnreachable(coinSelRes); + } + const transitionDone = await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "contractTerms", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "refreshSessions", + "peerPushDebit", + ], + }, + async (tx) => { + const ppi = await tx.peerPushDebit.get(pursePub); + if (!ppi) { + return false; + } + if (ppi.coinSel) { + return false; + } + + ppi.coinSel = { + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => x.contribution), + }; + // FIXME: Instead of directly doing a spendCoin here, + // we might want to mark the coins as used and spend them + // after we've been able to create the purse. + await spendCoins(wex, tx, { + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub, + }), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPush, + }); + + await tx.peerPushDebit.put(ppi); + return true; + }, + ); + if (transitionDone) { + return TaskRunResult.progress(); + } + return TaskRunResult.backoff(); + } + + const purseSigResp = await wex.cryptoApi.signPurseCreation({ + hContractTerms, + mergePub: peerPushInitiation.mergePub, + minAge: 0, + purseAmount: peerPushInitiation.amount, + purseExpiration: timestampProtocolFromDb(purseExpiration), + pursePriv: peerPushInitiation.pursePriv, + }); + + const coins = await queryCoinInfosForSelection( + wex, + peerPushInitiation.coinSel, + ); + + const encryptContractRequest: EncryptContractRequest = { + contractTerms: contractTermsRecord.contractTermsRaw, + mergePriv: peerPushInitiation.mergePriv, + pursePriv: peerPushInitiation.pursePriv, + pursePub: peerPushInitiation.pursePub, + contractPriv: peerPushInitiation.contractPriv, + contractPub: peerPushInitiation.contractPub, + nonce: peerPushInitiation.contractEncNonce, + }; + + const econtractResp = await wex.cryptoApi.encryptContractForMerge( + encryptContractRequest, + ); + + const maxBatchSize = 100; + + for (let i = 0; i < coins.length; i += maxBatchSize) { + const batchSize = Math.min(maxBatchSize, coins.length - i); + const batchCoins = coins.slice(i, i + batchSize); + + const depositSigsResp = await wex.cryptoApi.signPurseDeposits({ + exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl, + pursePub: peerPushInitiation.pursePub, + coins: batchCoins, + }); + + if (i == 0) { + // First batch creates the purse! + + logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`); + + const createPurseUrl = new URL( + `purses/${peerPushInitiation.pursePub}/create`, + peerPushInitiation.exchangeBaseUrl, + ); + + const reqBody = { + amount: peerPushInitiation.amount, + merge_pub: peerPushInitiation.mergePub, + purse_sig: purseSigResp.sig, + h_contract_terms: hContractTerms, + purse_expiration: timestampProtocolFromDb(purseExpiration), + deposits: depositSigsResp.deposits, + min_age: 0, + econtract: econtractResp.econtract, + }; + + if (logger.shouldLogTrace()) { + logger.trace(`request body: ${j2s(reqBody)}`); + } + + const httpResp = await wex.http.fetch(createPurseUrl.href, { + method: "POST", + body: reqBody, + cancellationToken: wex.cancellationToken, + }); + + switch (httpResp.status) { + case HttpStatusCode.Ok: + // Possibly on to the next batch. + continue; + case HttpStatusCode.Forbidden: { + // FIXME: Store this error! + await ctx.failTransaction(); + return TaskRunResult.finished(); + } + case HttpStatusCode.Conflict: { + // Handle double-spending + return handlePurseCreationConflict(wex, peerPushInitiation, httpResp); + } + default: { + const errResp = await readTalerErrorResponse(httpResp); + return { + type: TaskRunResultType.Error, + errorDetail: errResp, + }; + } + } + } else { + const purseDepositUrl = new URL( + `purses/${pursePub}/deposit`, + peerPushInitiation.exchangeBaseUrl, + ); + + const depositPayload: ExchangePurseDeposits = { + deposits: depositSigsResp.deposits, + }; + + const httpResp = await wex.http.fetch(purseDepositUrl.href, { + method: "POST", + body: depositPayload, + cancellationToken: wex.cancellationToken, + }); + + switch (httpResp.status) { + case HttpStatusCode.Ok: + // Possibly on to the next batch. + continue; + case HttpStatusCode.Forbidden: { + // FIXME: Store this error! + await ctx.failTransaction(); + return TaskRunResult.finished(); + } + case HttpStatusCode.Conflict: { + // Handle double-spending + return handlePurseCreationConflict(wex, peerPushInitiation, httpResp); + } + default: { + const errResp = await readTalerErrorResponse(httpResp); + return { + type: TaskRunResultType.Error, + errorDetail: errResp, + }; + } + } + } + } + + // All batches done! + + await transitionPeerPushDebitTransaction(wex, pursePub, { + stFrom: PeerPushDebitStatus.PendingCreatePurse, + stTo: PeerPushDebitStatus.PendingReady, + }); + + return TaskRunResult.backoff(); +} + +async function processPeerPushDebitAbortingDeletePurse( + wex: WalletExecutionContext, + peerPushInitiation: PeerPushDebitRecord, +): Promise<TaskRunResult> { + const { pursePub, pursePriv } = peerPushInitiation; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub, + }); + + const sigResp = await wex.cryptoApi.signDeletePurse({ + pursePriv, + }); + const purseUrl = new URL( + `purses/${pursePub}`, + peerPushInitiation.exchangeBaseUrl, + ); + const resp = await wex.http.fetch(purseUrl.href, { + method: "DELETE", + headers: { + "taler-purse-signature": sigResp.sig, + }, + cancellationToken: wex.cancellationToken, + }); + logger.info(`deleted purse with response status ${resp.status}`); + + const transitionInfo = await wex.db.runReadWriteTx( + { + storeNames: [ + "peerPushDebit", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + "coins", + ], + }, + async (tx) => { + const ppiRec = await tx.peerPushDebit.get(pursePub); + if (!ppiRec) { + return undefined; + } + if (ppiRec.status !== PeerPushDebitStatus.AbortingDeletePurse) { + return undefined; + } + const currency = Amounts.currencyOf(ppiRec.amount); + const oldTxState = computePeerPushDebitTransactionState(ppiRec); + const coinPubs: CoinRefreshRequest[] = []; + + if (!ppiRec.coinSel) { + return undefined; + } + + for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) { + coinPubs.push({ + amount: ppiRec.coinSel.contributions[i], + coinPub: ppiRec.coinSel.coinPubs[i], + }); + } + + const refresh = await createRefreshGroup( + wex, + tx, + currency, + coinPubs, + RefreshReason.AbortPeerPushDebit, + transactionId, + ); + ppiRec.status = PeerPushDebitStatus.AbortingRefreshDeleted; + ppiRec.abortRefreshGroupId = refresh.refreshGroupId; + await tx.peerPushDebit.put(ppiRec); + const newTxState = computePeerPushDebitTransactionState(ppiRec); + return { + oldTxState, + newTxState, + }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + + return TaskRunResult.backoff(); +} + +interface SimpleTransition { + stFrom: PeerPushDebitStatus; + stTo: PeerPushDebitStatus; +} + +// FIXME: This should be a transition on the peer push debit transaction context! +async function transitionPeerPushDebitTransaction( + wex: WalletExecutionContext, + pursePub: string, + transitionSpec: SimpleTransition, +): Promise<void> { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub, + }); + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit"] }, + async (tx) => { + const ppiRec = await tx.peerPushDebit.get(pursePub); + if (!ppiRec) { + return undefined; + } + if (ppiRec.status !== transitionSpec.stFrom) { + return undefined; + } + const oldTxState = computePeerPushDebitTransactionState(ppiRec); + ppiRec.status = transitionSpec.stTo; + await tx.peerPushDebit.put(ppiRec); + const newTxState = computePeerPushDebitTransactionState(ppiRec); + return { + oldTxState, + newTxState, + }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); +} + +async function processPeerPushDebitAbortingRefreshDeleted( + wex: WalletExecutionContext, + peerPushInitiation: PeerPushDebitRecord, +): Promise<TaskRunResult> { + const pursePub = peerPushInitiation.pursePub; + const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId; + checkLogicInvariant(!!abortRefreshGroupId); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub: peerPushInitiation.pursePub, + }); + if (peerPushInitiation.abortRefreshGroupId) { + await waitRefreshFinal(wex, peerPushInitiation.abortRefreshGroupId); + } + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["refreshGroups", "peerPushDebit"] }, + async (tx) => { + const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId); + let newOpState: PeerPushDebitStatus | undefined; + if (!refreshGroup) { + // Maybe it got manually deleted? Means that we should + // just go into failed. + logger.warn("no aborting refresh group found for deposit group"); + newOpState = PeerPushDebitStatus.Failed; + } else { + if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) { + newOpState = PeerPushDebitStatus.Aborted; + } else if ( + refreshGroup.operationStatus === RefreshOperationStatus.Failed + ) { + newOpState = PeerPushDebitStatus.Failed; + } + } + if (newOpState) { + const newDg = await tx.peerPushDebit.get(pursePub); + if (!newDg) { + return; + } + const oldTxState = computePeerPushDebitTransactionState(newDg); + newDg.status = newOpState; + const newTxState = computePeerPushDebitTransactionState(newDg); + await tx.peerPushDebit.put(newDg); + return { oldTxState, newTxState }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + // FIXME: Shouldn't this be finished in some cases?! + return TaskRunResult.backoff(); +} + +async function processPeerPushDebitAbortingRefreshExpired( + wex: WalletExecutionContext, + peerPushInitiation: PeerPushDebitRecord, +): Promise<TaskRunResult> { + const pursePub = peerPushInitiation.pursePub; + const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId; + checkLogicInvariant(!!abortRefreshGroupId); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub: peerPushInitiation.pursePub, + }); + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit", "refreshGroups"] }, + async (tx) => { + const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId); + let newOpState: PeerPushDebitStatus | undefined; + if (!refreshGroup) { + // Maybe it got manually deleted? Means that we should + // just go into failed. + logger.warn("no aborting refresh group found for deposit group"); + newOpState = PeerPushDebitStatus.Failed; + } else { + if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) { + newOpState = PeerPushDebitStatus.Expired; + } else if ( + refreshGroup.operationStatus === RefreshOperationStatus.Failed + ) { + newOpState = PeerPushDebitStatus.Failed; + } + } + if (newOpState) { + const newDg = await tx.peerPushDebit.get(pursePub); + if (!newDg) { + return; + } + const oldTxState = computePeerPushDebitTransactionState(newDg); + newDg.status = newOpState; + const newTxState = computePeerPushDebitTransactionState(newDg); + await tx.peerPushDebit.put(newDg); + return { oldTxState, newTxState }; + } + return undefined; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + // FIXME: Shouldn't this be finished in some cases?! + return TaskRunResult.backoff(); +} + +/** + * Process the "pending(ready)" state of a peer-push-debit transaction. + */ +async function processPeerPushDebitReady( + wex: WalletExecutionContext, + peerPushInitiation: PeerPushDebitRecord, +): Promise<TaskRunResult> { + logger.trace("processing peer-push-debit pending(ready)"); + const pursePub = peerPushInitiation.pursePub; + const transactionId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPushDebit, + pursePub, + }); + const mergeUrl = new URL( + `purses/${pursePub}/merge`, + peerPushInitiation.exchangeBaseUrl, + ); + mergeUrl.searchParams.set("timeout_ms", "30000"); + logger.info(`long-polling on purse status at ${mergeUrl.href}`); + const resp = await wex.http.fetch(mergeUrl.href, { + // timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + if (resp.status === HttpStatusCode.Ok) { + const purseStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangePurseStatus(), + ); + const mergeTimestamp = purseStatus.merge_timestamp; + logger.info(`got purse status ${j2s(purseStatus)}`); + if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) { + return TaskRunResult.backoff(); + } else { + await transitionPeerPushDebitTransaction( + wex, + peerPushInitiation.pursePub, + { + stFrom: PeerPushDebitStatus.PendingReady, + stTo: PeerPushDebitStatus.Done, + }, + ); + return TaskRunResult.progress(); + } + } else if (resp.status === HttpStatusCode.Gone) { + logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`); + const transitionInfo = await wex.db.runReadWriteTx( + { + storeNames: [ + "peerPushDebit", + "refreshGroups", + "refreshSessions", + "denominations", + "coinAvailability", + "coins", + ], + }, + async (tx) => { + const ppiRec = await tx.peerPushDebit.get(pursePub); + if (!ppiRec) { + return undefined; + } + if (ppiRec.status !== PeerPushDebitStatus.PendingReady) { + return undefined; + } + const currency = Amounts.currencyOf(ppiRec.amount); + const oldTxState = computePeerPushDebitTransactionState(ppiRec); + const coinPubs: CoinRefreshRequest[] = []; + + if (ppiRec.coinSel) { + for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) { + coinPubs.push({ + amount: ppiRec.coinSel.contributions[i], + coinPub: ppiRec.coinSel.coinPubs[i], + }); + } + + const refresh = await createRefreshGroup( + wex, + tx, + currency, + coinPubs, + RefreshReason.AbortPeerPushDebit, + transactionId, + ); + + ppiRec.abortRefreshGroupId = refresh.refreshGroupId; + } + ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired; + await tx.peerPushDebit.put(ppiRec); + const newTxState = computePeerPushDebitTransactionState(ppiRec); + return { + oldTxState, + newTxState, + }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + return TaskRunResult.backoff(); + } else { + logger.warn(`unexpected HTTP status for purse: ${resp.status}`); + return TaskRunResult.longpollReturnedPending(); + } +} + +export async function processPeerPushDebit( + wex: WalletExecutionContext, + pursePub: string, +): Promise<TaskRunResult> { + const peerPushInitiation = await wex.db.runReadOnlyTx( + { storeNames: ["peerPushDebit"] }, + async (tx) => { + return tx.peerPushDebit.get(pursePub); + }, + ); + if (!peerPushInitiation) { + throw Error("peer push payment not found"); + } + + switch (peerPushInitiation.status) { + case PeerPushDebitStatus.PendingCreatePurse: + return processPeerPushDebitCreateReserve(wex, peerPushInitiation); + case PeerPushDebitStatus.PendingReady: + return processPeerPushDebitReady(wex, peerPushInitiation); + case PeerPushDebitStatus.AbortingDeletePurse: + return processPeerPushDebitAbortingDeletePurse(wex, peerPushInitiation); + case PeerPushDebitStatus.AbortingRefreshDeleted: + return processPeerPushDebitAbortingRefreshDeleted( + wex, + peerPushInitiation, + ); + case PeerPushDebitStatus.AbortingRefreshExpired: + return processPeerPushDebitAbortingRefreshExpired( + wex, + peerPushInitiation, + ); + default: { + const txState = computePeerPushDebitTransactionState(peerPushInitiation); + logger.warn( + `not processing peer-push-debit transaction in state ${j2s(txState)}`, + ); + } + } + + return TaskRunResult.finished(); +} + +/** + * Initiate sending a peer-to-peer push payment. + */ +export async function initiatePeerPushDebit( + wex: WalletExecutionContext, + req: InitiatePeerPushDebitRequest, +): Promise<InitiatePeerPushDebitResponse> { + const instructedAmount = Amounts.parseOrThrow( + req.partialContractTerms.amount, + ); + const purseExpiration = req.partialContractTerms.purse_expiration; + const contractTerms = req.partialContractTerms; + + const pursePair = await wex.cryptoApi.createEddsaKeypair({}); + const mergePair = await wex.cryptoApi.createEddsaKeypair({}); + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + + const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({}); + + const coinSelRes = await selectPeerCoins(wex, { + instructedAmount, + }); + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (coinSelRes.type) { + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = coinSelRes.result.prospectiveCoins; + break; + case "success": + coins = coinSelRes.result.coins; + break; + default: + assertUnreachable(coinSelRes); + } + + const sel = coinSelRes.result; + + logger.info(`selected p2p coins (push):`); + logger.trace(`${j2s(coinSelRes)}`); + + const totalAmount = await getTotalPeerPaymentCost(wex, coins); + + logger.info(`computed total peer payment cost`); + + const pursePub = pursePair.pub; + + const ctx = new PeerPushDebitTransactionContext(wex, pursePub); + + const transactionId = ctx.transactionId; + + const contractEncNonce = encodeCrock(getRandomBytes(24)); + + const transitionInfo = await wex.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "contractTerms", + "coins", + "coinAvailability", + "denominations", + "refreshGroups", + "refreshSessions", + "peerPushDebit", + ], + }, + async (tx) => { + const ppi: PeerPushDebitRecord = { + amount: Amounts.stringify(instructedAmount), + contractPriv: contractKeyPair.priv, + contractPub: contractKeyPair.pub, + contractTermsHash: hContractTerms, + exchangeBaseUrl: sel.exchangeBaseUrl, + mergePriv: mergePair.priv, + mergePub: mergePair.pub, + purseExpiration: timestampProtocolToDb(purseExpiration), + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + status: PeerPushDebitStatus.PendingCreatePurse, + contractEncNonce, + totalCost: Amounts.stringify(totalAmount), + }; + + if (coinSelRes.type === "success") { + ppi.coinSel = { + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => x.contribution), + }; + // FIXME: Instead of directly doing a spendCoin here, + // we might want to mark the coins as used and spend them + // after we've been able to create the purse. + await spendCoins(wex, tx, { + allocationId: constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub: pursePair.pub, + }), + coinPubs: coinSelRes.result.coins.map((x) => x.coinPub), + contributions: coinSelRes.result.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPush, + }); + } + + await tx.peerPushDebit.add(ppi); + + await tx.contractTerms.put({ + h: hContractTerms, + contractTermsRaw: contractTerms, + }); + + const newTxState = computePeerPushDebitTransactionState(ppi); + return { + oldTxState: { major: TransactionMajorState.None }, + newTxState, + }; + }, + ); + notifyTransition(wex, transactionId, transitionInfo); + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + contractPriv: contractKeyPair.priv, + mergePriv: mergePair.priv, + pursePub: pursePair.pub, + exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub: pursePair.pub, + }), + }; +} + +export function computePeerPushDebitTransactionActions( + ppiRecord: PeerPushDebitRecord, +): TransactionAction[] { + switch (ppiRecord.status) { + case PeerPushDebitStatus.PendingCreatePurse: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPushDebitStatus.PendingReady: + return [TransactionAction.Abort, TransactionAction.Suspend]; + case PeerPushDebitStatus.Aborted: + return [TransactionAction.Delete]; + case PeerPushDebitStatus.AbortingDeletePurse: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case PeerPushDebitStatus.AbortingRefreshDeleted: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case PeerPushDebitStatus.AbortingRefreshExpired: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted: + return [TransactionAction.Resume, TransactionAction.Fail]; + case PeerPushDebitStatus.SuspendedCreatePurse: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPushDebitStatus.SuspendedReady: + return [TransactionAction.Resume, TransactionAction.Abort]; + case PeerPushDebitStatus.Done: + return [TransactionAction.Delete]; + case PeerPushDebitStatus.Expired: + return [TransactionAction.Delete]; + case PeerPushDebitStatus.Failed: + return [TransactionAction.Delete]; + } +} + +export function computePeerPushDebitTransactionState( + ppiRecord: PeerPushDebitRecord, +): TransactionState { + switch (ppiRecord.status) { + case PeerPushDebitStatus.PendingCreatePurse: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.CreatePurse, + }; + case PeerPushDebitStatus.PendingReady: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Ready, + }; + case PeerPushDebitStatus.Aborted: + return { + major: TransactionMajorState.Aborted, + }; + case PeerPushDebitStatus.AbortingDeletePurse: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.DeletePurse, + }; + case PeerPushDebitStatus.AbortingRefreshDeleted: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.Refresh, + }; + case PeerPushDebitStatus.AbortingRefreshExpired: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.RefreshExpired, + }; + case PeerPushDebitStatus.SuspendedAbortingDeletePurse: + return { + major: TransactionMajorState.SuspendedAborting, + minor: TransactionMinorState.DeletePurse, + }; + case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: + return { + major: TransactionMajorState.SuspendedAborting, + minor: TransactionMinorState.RefreshExpired, + }; + case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted: + return { + major: TransactionMajorState.SuspendedAborting, + minor: TransactionMinorState.Refresh, + }; + case PeerPushDebitStatus.SuspendedCreatePurse: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.CreatePurse, + }; + case PeerPushDebitStatus.SuspendedReady: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.Ready, + }; + case PeerPushDebitStatus.Done: + return { + major: TransactionMajorState.Done, + }; + case PeerPushDebitStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + case PeerPushDebitStatus.Expired: + return { + major: TransactionMajorState.Expired, + }; + } +} diff --git a/packages/taler-wallet-core/src/pending-types.ts b/packages/taler-wallet-core/src/pending-types.ts deleted file mode 100644 index f8406033a..000000000 --- a/packages/taler-wallet-core/src/pending-types.ts +++ /dev/null @@ -1,252 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Type and schema definitions for pending tasks in the wallet. - * - * These are only used internally, and are not part of the stable public - * interface to the wallet. - */ - -/** - * Imports. - */ -import { TalerErrorDetail, AbsoluteTime } from "@gnu-taler/taler-util"; -import { DbRetryInfo } from "./operations/common.js"; - -export enum PendingTaskType { - ExchangeUpdate = "exchange-update", - ExchangeCheckRefresh = "exchange-check-refresh", - Purchase = "purchase", - Refresh = "refresh", - Recoup = "recoup", - RewardPickup = "reward-pickup", - Withdraw = "withdraw", - Deposit = "deposit", - Backup = "backup", - PeerPushDebit = "peer-push-debit", - PeerPullCredit = "peer-pull-credit", - PeerPushCredit = "peer-push-credit", - PeerPullDebit = "peer-pull-debit", -} - -/** - * Information about a pending operation. - */ -export type PendingTaskInfo = PendingTaskInfoCommon & - ( - | PendingExchangeUpdateTask - | PendingExchangeCheckRefreshTask - | PendingPurchaseTask - | PendingRefreshTask - | PendingTipPickupTask - | PendingWithdrawTask - | PendingRecoupTask - | PendingDepositTask - | PendingBackupTask - | PendingPeerPushInitiationTask - | PendingPeerPullInitiationTask - | PendingPeerPullDebitTask - | PendingPeerPushCreditTask - ); - -export interface PendingBackupTask { - type: PendingTaskType.Backup; - backupProviderBaseUrl: string; - lastError: TalerErrorDetail | undefined; -} - -/** - * The wallet is currently updating information about an exchange. - */ -export interface PendingExchangeUpdateTask { - type: PendingTaskType.ExchangeUpdate; - exchangeBaseUrl: string; - lastError: TalerErrorDetail | undefined; -} - -/** - * The wallet wants to send a peer push payment. - */ -export interface PendingPeerPushInitiationTask { - type: PendingTaskType.PeerPushDebit; - pursePub: string; -} - -/** - * The wallet wants to send a peer pull payment. - */ -export interface PendingPeerPullInitiationTask { - type: PendingTaskType.PeerPullCredit; - pursePub: string; -} - -/** - * The wallet wants to send a peer pull payment. - */ -export interface PendingPeerPullDebitTask { - type: PendingTaskType.PeerPullDebit; - peerPullDebitId: string; -} - -/** - */ -export interface PendingPeerPushCreditTask { - type: PendingTaskType.PeerPushCredit; - peerPushCreditId: string; -} - -/** - * The wallet should check whether coins from this exchange - * need to be auto-refreshed. - */ -export interface PendingExchangeCheckRefreshTask { - type: PendingTaskType.ExchangeCheckRefresh; - exchangeBaseUrl: string; -} - -export enum ReserveType { - /** - * Manually created. - */ - Manual = "manual", - /** - * Withdrawn from a bank that has "tight" Taler integration - */ - TalerBankWithdraw = "taler-bank-withdraw", -} - -/** - * Status of an ongoing withdrawal operation. - */ -export interface PendingRefreshTask { - type: PendingTaskType.Refresh; - lastError?: TalerErrorDetail; - refreshGroupId: string; - finishedPerCoin: boolean[]; - retryInfo?: DbRetryInfo; -} - -/** - * The wallet is picking up a tip that the user has accepted. - */ -export interface PendingTipPickupTask { - type: PendingTaskType.RewardPickup; - tipId: string; - merchantBaseUrl: string; - merchantTipId: string; -} - -/** - * A purchase needs to be processed (i.e. for download / payment / refund). - */ -export interface PendingPurchaseTask { - type: PendingTaskType.Purchase; - proposalId: string; - retryInfo?: DbRetryInfo; - /** - * Status of the payment as string, used only for debugging. - */ - statusStr: string; - lastError: TalerErrorDetail | undefined; -} - -export interface PendingRecoupTask { - type: PendingTaskType.Recoup; - recoupGroupId: string; - retryInfo?: DbRetryInfo; - lastError: TalerErrorDetail | undefined; -} - -/** - * Status of an ongoing withdrawal operation. - */ -export interface PendingWithdrawTask { - type: PendingTaskType.Withdraw; - lastError: TalerErrorDetail | undefined; - retryInfo?: DbRetryInfo; - withdrawalGroupId: string; -} - -/** - * Status of an ongoing deposit operation. - */ -export interface PendingDepositTask { - type: PendingTaskType.Deposit; - lastError: TalerErrorDetail | undefined; - retryInfo: DbRetryInfo | undefined; - depositGroupId: string; -} - -declare const __taskId: unique symbol; -export type TaskId = string & { [__taskId]: true }; - -/** - * Fields that are present in every pending operation. - */ -export interface PendingTaskInfoCommon { - /** - * Type of the pending operation. - */ - type: PendingTaskType; - - /** - * Unique identifier for the pending task. - */ - id: TaskId; - - /** - * Set to true if the operation indicates that something is really in progress, - * as opposed to some regular scheduled operation that can be tried later. - */ - givesLifeness: boolean; - - /** - * Operation is active and waiting for a longpoll result. - */ - isLongpolling: boolean; - - /** - * Operation is waiting to be executed. - */ - isDue: boolean; - - /** - * Timestamp when the pending operation should be executed next. - */ - timestampDue: AbsoluteTime; - - /** - * Retry info. Currently used to stop the wallet after any operation - * exceeds a number of retries. - */ - retryInfo?: DbRetryInfo; - - /** - * Internal operation status for debugging. - */ - internalOperationStatus?: string; -} - -/** - * Response returned from the pending operations API. - */ -export interface PendingOperationsResponse { - /** - * List of pending operations. - */ - pendingOperations: PendingTaskInfo[]; -} diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/query.ts index 309c17a43..dc15bbdd1 100644 --- a/packages/taler-wallet-core/src/util/query.ts +++ b/packages/taler-wallet-core/src/query.ts @@ -15,27 +15,33 @@ */ /** - * Database query abstractions. - * @module Query + * @fileoverview + * Query helpers for IndexedDB databases. + * * @author Florian Dold */ /** * Imports. */ -import { openPromise } from "./promiseUtils.js"; import { - IDBRequest, - IDBTransaction, - IDBValidKey, + IDBCursor, IDBDatabase, IDBFactory, - IDBVersionChangeEvent, - IDBCursor, IDBKeyPath, IDBKeyRange, + IDBRequest, + IDBTransaction, + IDBTransactionMode, + IDBValidKey, + IDBVersionChangeEvent, } from "@gnu-taler/idb-bridge"; -import { Codec, Logger, j2s } from "@gnu-taler/taler-util"; +import { + CancellationToken, + Codec, + Logger, + openPromise, +} from "@gnu-taler/taler-util"; const logger = new Logger("query.ts"); @@ -250,9 +256,9 @@ export function openDatabase( ): Promise<IDBDatabase> { return new Promise<IDBDatabase>((resolve, reject) => { const req = idbFactory.open(databaseName, databaseVersion); - req.onerror = (e) => { - logger.error("database error", e); - reject(new Error("database error")); + req.onerror = (event) => { + // @ts-expect-error + reject(new Error(`database opening error`, { cause: req.error })); }; req.onsuccess = (e) => { req.result.onversionchange = (evt: IDBVersionChangeEvent) => { @@ -268,11 +274,17 @@ export function openDatabase( const db = req.result; const newVersion = e.newVersion; if (!newVersion) { - throw Error("upgrade needed, but new version unknown"); + // @ts-expect-error + throw Error("upgrade needed, but new version unknown", { + cause: req.error, + }); } const transaction = req.transaction; if (!transaction) { - throw Error("no transaction handle available in upgrade handler"); + // @ts-expect-error + throw Error("no transaction handle available in upgrade handler", { + cause: req.error, + }); } logger.info( `handling upgradeneeded event on ${databaseName} from ${e.oldVersion} to ${e.newVersion}`, @@ -344,6 +356,11 @@ interface IndexReadOnlyAccessor<RecordType> { query?: IDBKeyRange | IDBValidKey, count?: number, ): Promise<RecordType[]>; + getAllKeys( + query?: IDBKeyRange | IDBValidKey, + count?: number, + ): Promise<IDBValidKey[]>; + count(query?: IDBValidKey): Promise<number>; } type GetIndexReadOnlyAccess<RecordType, IndexMap> = { @@ -357,6 +374,11 @@ interface IndexReadWriteAccessor<RecordType> { query?: IDBKeyRange | IDBValidKey, count?: number, ): Promise<RecordType[]>; + getAllKeys( + query?: IDBKeyRange | IDBValidKey, + count?: number, + ): Promise<IDBValidKey[]>; + count(query?: IDBValidKey): Promise<number>; } type GetIndexReadWriteAccess<RecordType, IndexMap> = { @@ -365,6 +387,10 @@ type GetIndexReadWriteAccess<RecordType, IndexMap> = { export interface StoreReadOnlyAccessor<RecordType, IndexMap> { get(key: IDBValidKey): Promise<RecordType | undefined>; + getAll( + query?: IDBKeyRange | IDBValidKey, + count?: number, + ): Promise<RecordType[]>; iter(query?: IDBValidKey): ResultStream<RecordType>; indexes: GetIndexReadOnlyAccess<RecordType, IndexMap>; } @@ -378,6 +404,10 @@ export interface InsertResponse { export interface StoreReadWriteAccessor<RecordType, IndexMap> { get(key: IDBValidKey): Promise<RecordType | undefined>; + getAll( + query?: IDBKeyRange | IDBValidKey, + count?: number, + ): Promise<RecordType[]>; iter(query?: IDBValidKey): ResultStream<RecordType>; put(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>; add(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>; @@ -454,8 +484,8 @@ type DerefKeyPath<T, P> = P extends `${infer PX extends keyof T & KeyPathComponents}` ? T[PX] : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}` - ? DerefKeyPath<T[P0], Rest> - : unknown; + ? DerefKeyPath<T[P0], Rest> + : unknown; /** * Return a path if it is a valid dot-separate path to an object. @@ -465,8 +495,8 @@ type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T & KeyPathComponents}` ? PX : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}` - ? `${P0}.${ValidateKeyPath<T[P0], Rest>}` - : never; + ? `${P0}.${ValidateKeyPath<T[P0], Rest>}` + : never; // function foo<T, P>( // x: T, @@ -475,85 +505,66 @@ type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T & // foo({x: [0,1,2]}, "x.0"); -export type GetReadOnlyAccess<BoundStores> = { - [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes< - infer StoreName, - infer RecordType, - infer IndexMap - > - ? StoreReadOnlyAccessor<RecordType, IndexMap> - : unknown; -}; - export type StoreNames<StoreMap> = StoreMap extends { [P in keyof StoreMap]: StoreWithIndexes<infer SN1, infer SD1, infer IM1>; } ? keyof StoreMap : unknown; -export type DbReadOnlyTransaction< +export type DbReadWriteTransaction< StoreMap, - Stores extends StoreNames<StoreMap> & string, + StoresArr extends Array<StoreNames<StoreMap>>, > = StoreMap extends { - [P in Stores]: StoreWithIndexes<infer SN1, infer SD1, infer IM1>; + [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>; } ? { - [P in Stores]: StoreMap[P] extends StoreWithIndexes< - infer StoreName, + [X in StoresArr[number] & + keyof StoreMap]: StoreMap[X] extends StoreWithIndexes< + infer _StoreName, infer RecordType, infer IndexMap > - ? StoreReadOnlyAccessor<RecordType, IndexMap> + ? StoreReadWriteAccessor<RecordType, IndexMap> : unknown; } - : unknown; + : never; -export type DbReadWriteTransaction< +export type DbReadOnlyTransaction< StoreMap, - Stores extends StoreNames<StoreMap> & string, + StoresArr extends Array<StoreNames<StoreMap>>, > = StoreMap extends { - [P in Stores]: StoreWithIndexes<infer SN1, infer SD1, infer IM1>; + [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>; } ? { - [P in Stores]: StoreMap[P] extends StoreWithIndexes< - infer StoreName, + [X in StoresArr[number] & + keyof StoreMap]: StoreMap[X] extends StoreWithIndexes< + infer _StoreName, infer RecordType, infer IndexMap > - ? StoreReadWriteAccessor<RecordType, IndexMap> + ? StoreReadOnlyAccessor<RecordType, IndexMap> : unknown; } - : unknown; - -export type GetReadWriteAccess<BoundStores> = { - [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes< - infer StoreName, - infer RecordType, - infer IndexMap - > - ? StoreReadWriteAccessor<RecordType, IndexMap> - : unknown; -}; - -type ReadOnlyTransactionFunction<BoundStores, T> = ( - t: GetReadOnlyAccess<BoundStores>, - rawTx: IDBTransaction, -) => Promise<T>; - -type ReadWriteTransactionFunction<BoundStores, T> = ( - t: GetReadWriteAccess<BoundStores>, - rawTx: IDBTransaction, -) => Promise<T>; + : never; -export interface TransactionContext<BoundStores> { - runReadWrite<T>(f: ReadWriteTransactionFunction<BoundStores, T>): Promise<T>; - runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>; +/** + * Convert the type of an array to a union of the contents. + * + * Example: + * Input ["foo", "bar"] + * Output "foo" | "bar" + */ +export type UnionFromArray<Arr> = Arr extends { + [X in keyof Arr]: Arr[X] & string; } + ? Arr[keyof Arr & number] + : unknown; function runTx<Arg, Res>( tx: IDBTransaction, arg: Arg, f: (t: Arg, t2: IDBTransaction) => Promise<Res>, + triggerContext: InternalTriggerContext, ): Promise<Res> { const stack = Error("Failed transaction was started here."); return new Promise((resolve, reject) => { @@ -574,6 +585,7 @@ function runTx<Arg, Res>( logger.error(`${stack.stack}`); reject(Error(msg)); } + triggerContext.handleAfterCommit(); resolve(funResult); }; tx.onerror = () => { @@ -618,6 +630,7 @@ function runTx<Arg, Res>( function makeReadContext( tx: IDBTransaction, storePick: { [n: string]: StoreWithIndexes<any, any, any> }, + triggerContext: InternalTriggerContext, ): any { const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {}; for (const storeAlias in storePick) { @@ -630,10 +643,12 @@ function makeReadContext( const indexName = indexDescriptor.name; indexes[indexAlias] = { get(key) { + triggerContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).index(indexName).get(key); return requestToPromise(req); }, iter(query) { + triggerContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) @@ -641,21 +656,42 @@ function makeReadContext( return new ResultStream<any>(req); }, getAll(query, count) { + triggerContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) .getAll(query, count); return requestToPromise(req); }, + getAllKeys(query, count) { + triggerContext.storesAccessed.add(storeName); + const req = tx + .objectStore(storeName) + .index(indexName) + .getAllKeys(query, count); + return requestToPromise(req); + }, + count(query) { + triggerContext.storesAccessed.add(storeName); + const req = tx.objectStore(storeName).index(indexName).count(query); + return requestToPromise(req); + }, }; } ctx[storeAlias] = { indexes, get(key) { + triggerContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).get(key); return requestToPromise(req); }, + getAll(query, count) { + triggerContext.storesAccessed.add(storeName); + const req = tx.objectStore(storeName).getAll(query, count); + return requestToPromise(req); + }, iter(query) { + triggerContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).openCursor(query); return new ResultStream<any>(req); }, @@ -667,6 +703,7 @@ function makeReadContext( function makeWriteContext( tx: IDBTransaction, storePick: { [n: string]: StoreWithIndexes<any, any, any> }, + triggerContext: InternalTriggerContext, ): any { const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {}; for (const storeAlias in storePick) { @@ -679,10 +716,12 @@ function makeWriteContext( const indexName = indexDescriptor.name; indexes[indexAlias] = { get(key) { + triggerContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).index(indexName).get(key); return requestToPromise(req); }, iter(query) { + triggerContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) @@ -690,25 +729,48 @@ function makeWriteContext( return new ResultStream<any>(req); }, getAll(query, count) { + triggerContext.storesAccessed.add(storeName); const req = tx .objectStore(storeName) .index(indexName) .getAll(query, count); return requestToPromise(req); }, + getAllKeys(query, count) { + triggerContext.storesAccessed.add(storeName); + const req = tx + .objectStore(storeName) + .index(indexName) + .getAllKeys(query, count); + return requestToPromise(req); + }, + count(query) { + triggerContext.storesAccessed.add(storeName); + const req = tx.objectStore(storeName).index(indexName).count(query); + return requestToPromise(req); + }, }; } ctx[storeAlias] = { indexes, get(key) { + triggerContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).get(key); return requestToPromise(req); }, + getAll(query, count) { + triggerContext.storesAccessed.add(storeName); + const req = tx.objectStore(storeName).getAll(query, count); + return requestToPromise(req); + }, iter(query) { + triggerContext.storesAccessed.add(storeName); const req = tx.objectStore(storeName).openCursor(query); return new ResultStream<any>(req); }, async add(r, k) { + triggerContext.storesAccessed.add(storeName); + triggerContext.storesModified.add(storeName); const req = tx.objectStore(storeName).add(r, k); const key = await requestToPromise(req); return { @@ -716,6 +778,8 @@ function makeWriteContext( }; }, async put(r, k) { + triggerContext.storesAccessed.add(storeName); + triggerContext.storesModified.add(storeName); const req = tx.objectStore(storeName).put(r, k); const key = await requestToPromise(req); return { @@ -723,6 +787,8 @@ function makeWriteContext( }; }, delete(k) { + triggerContext.storesAccessed.add(storeName); + triggerContext.storesModified.add(storeName); const req = tx.objectStore(storeName).delete(k); return requestToPromise(req); }, @@ -731,128 +797,208 @@ function makeWriteContext( return ctx; } -type StoreNamesOf<X> = X extends { [x: number]: infer F } - ? F extends { storeName: infer I } - ? I - : never - : never; +export interface DbAccess<StoreMap> { + idbHandle(): IDBDatabase; + + runAllStoresReadWriteTx<T>( + options: { + label?: string; + }, + txf: ( + tx: DbReadWriteTransaction<StoreMap, Array<StoreNames<StoreMap>>>, + ) => Promise<T>, + ): Promise<T>; + + runAllStoresReadOnlyTx<T>( + options: { + label?: string; + }, + txf: ( + tx: DbReadOnlyTransaction<StoreMap, Array<StoreNames<StoreMap>>>, + ) => Promise<T>, + ): Promise<T>; + + runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>( + opts: { + storeNames: StoreNameArray; + label?: string; + }, + txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>, + ): Promise<T>; + + runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>( + opts: { + storeNames: StoreNameArray; + label?: string; + }, + txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>, + ): Promise<T>; +} + +export interface AfterCommitInfo { + mode: IDBTransactionMode; + scope: Set<string>; + accessedStores: Set<string>; + modifiedStores: Set<string>; +} + +export interface TriggerSpec { + /** + * Trigger run after every successful commit, run outside of the transaction. + */ + afterCommit?: (info: AfterCommitInfo) => void; + + // onRead(store, value) + // initState<State> () => State + // beforeCommit<State>? (tx: Transaction, s: State | undefined) => Promise<void>; +} + +class InternalTriggerContext { + storesScope: Set<string>; + storesAccessed: Set<string> = new Set(); + storesModified: Set<string> = new Set(); + + constructor( + private triggerSpec: TriggerSpec, + private mode: IDBTransactionMode, + scope: string[], + ) { + this.storesScope = new Set(scope); + } + + handleAfterCommit() { + if (this.triggerSpec.afterCommit) { + this.triggerSpec.afterCommit({ + mode: this.mode, + accessedStores: this.storesAccessed, + modifiedStores: this.storesModified, + scope: this.storesScope, + }); + } + } +} /** * Type-safe access to a database with a particular store map. * * A store map is the metadata that describes the store. */ -export class DbAccess<StoreMap> { - constructor(private db: IDBDatabase, private stores: StoreMap) {} +export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> { + constructor( + private db: IDBDatabase, + private stores: StoreMap, + private triggers: TriggerSpec = {}, + private cancellationToken: CancellationToken, + ) {} idbHandle(): IDBDatabase { return this.db; } - /** - * Run a transaction with all object stores. - */ - mktxAll(): TransactionContext<StoreMap> { - const storeNames: string[] = []; + runAllStoresReadWriteTx<T>( + options: { + label?: string; + }, + txf: ( + tx: DbReadWriteTransaction<StoreMap, Array<StoreNames<StoreMap>>>, + ) => Promise<T>, + ): Promise<T> { const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } = {}; - for (let i = 0; i < this.db.objectStoreNames.length; i++) { - const sn = this.db.objectStoreNames[i]; + const strStoreNames: string[] = []; + for (const sn of Object.keys(this.stores as any)) { const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>; - if (!swi) { - throw Error(`store metadata not available (${sn})`); - } - storeNames.push(sn); - accessibleStores[sn] = swi; + strStoreNames.push(swi.storeName); + accessibleStores[swi.storeName] = swi; } + const mode = "readwrite"; + const triggerContext = new InternalTriggerContext( + this.triggers, + mode, + strStoreNames, + ); + const tx = this.db.transaction(strStoreNames, mode); + const writeContext = makeWriteContext(tx, accessibleStores, triggerContext); + return runTx(tx, writeContext, txf, triggerContext); + } - const storeMapKeys = Object.keys(this.stores as any); - for (const storeMapKey of storeMapKeys) { - const swi = (this.stores as any)[storeMapKey] as StoreWithIndexes< - any, - any, - any - >; - if (!accessibleStores[swi.storeName]) { - const version = this.db.version; - throw Error( - `store '${swi.storeName}' required by schema but not in database (minver=${version})`, - ); - } + async runAllStoresReadOnlyTx<T>( + options: { + label?: string; + }, + txf: ( + tx: DbReadOnlyTransaction<StoreMap, Array<StoreNames<StoreMap>>>, + ) => Promise<T>, + ): Promise<T> { + const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } = + {}; + const strStoreNames: string[] = []; + for (const sn of Object.keys(this.stores as any)) { + const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>; + strStoreNames.push(swi.storeName); + accessibleStores[swi.storeName] = swi; } - - const runReadOnly = <T>( - txf: ReadOnlyTransactionFunction<StoreMap, T>, - ): Promise<T> => { - const tx = this.db.transaction(storeNames, "readonly"); - const readContext = makeReadContext(tx, accessibleStores); - return runTx(tx, readContext, txf); - }; - - const runReadWrite = <T>( - txf: ReadWriteTransactionFunction<StoreMap, T>, - ): Promise<T> => { - const tx = this.db.transaction(storeNames, "readwrite"); - const writeContext = makeWriteContext(tx, accessibleStores); - return runTx(tx, writeContext, txf); - }; - - return { - runReadOnly, - runReadWrite, - }; + const mode = "readonly"; + const triggerContext = new InternalTriggerContext( + this.triggers, + mode, + strStoreNames, + ); + const tx = this.db.transaction(strStoreNames, mode); + const writeContext = makeReadContext(tx, accessibleStores, triggerContext); + const res = await runTx(tx, writeContext, txf, triggerContext); + return res; } - /** - * Run a transaction with selected object stores. - * - * The {@link namePicker} must be a function that selects a list of object - * stores from all available object stores. - */ - mktx< - StoreNames extends keyof StoreMap, - Stores extends StoreMap[StoreNames], - StoreList extends Stores[], - BoundStores extends { - [X in StoreNamesOf<StoreList>]: StoreList[number] & { storeName: X }; + async runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>( + opts: { + storeNames: StoreNameArray; }, - >(namePicker: (x: StoreMap) => StoreList): TransactionContext<BoundStores> { - const storeNames: string[] = []; + txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>, + ): Promise<T> { const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } = {}; - - const storePick = namePicker(this.stores) as any; - if (typeof storePick !== "object" || storePick === null) { - throw Error(); - } - for (const swiPicked of storePick) { - const swi = swiPicked as StoreWithIndexes<any, any, any>; - if (swi.mark !== storeWithIndexesSymbol) { - throw Error("invalid store descriptor returned from selector function"); - } - storeNames.push(swi.storeName); + const strStoreNames: string[] = []; + for (const sn of opts.storeNames) { + const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>; + strStoreNames.push(swi.storeName); accessibleStores[swi.storeName] = swi; } + const mode = "readwrite"; + const triggerContext = new InternalTriggerContext( + this.triggers, + mode, + strStoreNames, + ); + const tx = this.db.transaction(strStoreNames, mode); + const writeContext = makeWriteContext(tx, accessibleStores, triggerContext); + const res = await runTx(tx, writeContext, txf, triggerContext); + return res; + } - const runReadOnly = <T>( - txf: ReadOnlyTransactionFunction<BoundStores, T>, - ): Promise<T> => { - const tx = this.db.transaction(storeNames, "readonly"); - const readContext = makeReadContext(tx, accessibleStores); - return runTx(tx, readContext, txf); - }; - - const runReadWrite = <T>( - txf: ReadWriteTransactionFunction<BoundStores, T>, - ): Promise<T> => { - const tx = this.db.transaction(storeNames, "readwrite"); - const writeContext = makeWriteContext(tx, accessibleStores); - return runTx(tx, writeContext, txf); - }; - - return { - runReadOnly, - runReadWrite, - }; + runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>( + opts: { + storeNames: StoreNameArray; + }, + txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>, + ): Promise<T> { + const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } = + {}; + const strStoreNames: string[] = []; + for (const sn of opts.storeNames) { + const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>; + strStoreNames.push(swi.storeName); + accessibleStores[swi.storeName] = swi; + } + const mode = "readonly"; + const triggerContext = new InternalTriggerContext( + this.triggers, + mode, + strStoreNames, + ); + const tx = this.db.transaction(strStoreNames, mode); + const readContext = makeReadContext(tx, accessibleStores, triggerContext); + const res = runTx(tx, readContext, txf, triggerContext); + return res; } } diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/recoup.ts index 782e98d1c..6a09f9a0e 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/recoup.ts @@ -30,7 +30,10 @@ import { Logger, RefreshReason, TalerPreciseTimestamp, + TransactionIdStr, + TransactionType, URL, + checkDbInvariant, codecForRecoupConfirmation, codecForReserveStatus, encodeCrock, @@ -39,37 +42,40 @@ import { } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { + PendingTaskType, + TaskIdStr, + TaskRunResult, + TransactionContext, + constructTaskIdentifier, +} from "./common.js"; +import { CoinRecord, CoinSourceType, RecoupGroupRecord, + RecoupOperationStatus, RefreshCoinSource, - WalletStoresV1, + WalletDbReadWriteTransaction, WithdrawCoinSource, WithdrawalGroupStatus, WithdrawalRecordType, timestampPreciseToDb, -} from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadWriteAccess } from "../util/query.js"; -import { TaskRunResult } from "./common.js"; +} from "./db.js"; import { createRefreshGroup } from "./refresh.js"; +import { constructTransactionIdentifier } from "./transactions.js"; +import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; -const logger = new Logger("operations/recoup.ts"); +export const logger = new Logger("operations/recoup.ts"); /** * Store a recoup group record in the database after marking * a coin in the group as finished. */ -async function putGroupAsFinished( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - recoupGroups: typeof WalletStoresV1.recoupGroups; - denominations: typeof WalletStoresV1.denominations; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coins: typeof WalletStoresV1.coins; - }>, +export async function putGroupAsFinished( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["recoupGroups", "denominations", "refreshGroups", "coins"] + >, recoupGroup: RecoupGroupRecord, coinIdx: number, ): Promise<void> { @@ -84,7 +90,7 @@ async function putGroupAsFinished( } async function recoupRewardCoin( - ws: InternalWalletState, + wex: WalletExecutionContext, recoupGroupId: string, coinIdx: number, coin: CoinRecord, @@ -92,14 +98,9 @@ async function recoupRewardCoin( // We can't really recoup a coin we got via tipping. // Thus we just put the coin to sleep. // FIXME: somehow report this to the user - await ws.db - .mktx((stores) => [ - stores.recoupGroups, - stores.denominations, - stores.refreshGroups, - stores.coins, - ]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["recoupGroups", "denominations", "refreshGroups", "coins"] }, + async (tx) => { const recoupGroup = await tx.recoupGroups.get(recoupGroupId); if (!recoupGroup) { return; @@ -107,90 +108,23 @@ async function recoupRewardCoin( 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 - .mktx((x) => [x.denominations]) - .runReadOnly(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.postJson(reqUrl.href, recoupRequest); - const recoupConfirmation = await readSuccessResponseJsonOrThrow( - resp, - codecForRecoupConfirmation(), + await putGroupAsFinished(wex, tx, recoupGroup, coinIdx); + }, ); - - 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 - .mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups]) - .runReadWrite(async (tx) => { - const recoupGroup = await tx.recoupGroups.get(recoupGroupId); - if (!recoupGroup) { - return; - } - if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { - return; - } - const updatedCoin = await tx.coins.get(coin.coinPub); - if (!updatedCoin) { - return; - } - updatedCoin.status = CoinStatus.Dormant; - await tx.coins.put(updatedCoin); - await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); - }); } async function recoupRefreshCoin( - ws: InternalWalletState, + wex: WalletExecutionContext, recoupGroupId: string, coinIdx: number, coin: CoinRecord, cs: RefreshCoinSource, ): Promise<void> { - const d = await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { - const denomInfo = await ws.getDenomInfo( - ws, + const d = await wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations"] }, + async (tx) => { + const denomInfo = await getDenomInfo( + wex, tx, coin.exchangeBaseUrl, coin.denomPubHash, @@ -199,13 +133,14 @@ async function recoupRefreshCoin( return; } return { denomInfo }; - }); + }, + ); if (!d) { // FIXME: We should at least emit some pending operation / warning for this? return; } - const recoupRequest = await ws.cryptoApi.createRecoupRefreshRequest({ + const recoupRequest = await wex.cryptoApi.createRecoupRefreshRequest({ blindingKey: coin.blindingKey, coinPriv: coin.coinPriv, coinPub: coin.coinPub, @@ -219,7 +154,10 @@ async function recoupRefreshCoin( ); logger.trace(`making recoup request for ${coin.coinPub}`); - const resp = await ws.http.postJson(reqUrl.href, recoupRequest); + const resp = await wex.http.fetch(reqUrl.href, { + method: "POST", + body: recoupRequest, + }); const recoupConfirmation = await readSuccessResponseJsonOrThrow( resp, codecForRecoupConfirmation(), @@ -229,9 +167,9 @@ async function recoupRefreshCoin( throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`); } - await ws.db - .mktx((x) => [x.coins, x.denominations, x.recoupGroups, x.refreshGroups]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["coins", "denominations", "recoupGroups", "refreshGroups"] }, + async (tx) => { const recoupGroup = await tx.recoupGroups.get(recoupGroupId); if (!recoupGroup) { return; @@ -249,14 +187,14 @@ async function recoupRefreshCoin( logger.warn("refresh old coin for recoup not found"); return; } - const oldCoinDenom = await ws.getDenomInfo( - ws, + const oldCoinDenom = await getDenomInfo( + wex, tx, oldCoin.exchangeBaseUrl, oldCoin.denomPubHash, ); - const revokedCoinDenom = await ws.getDenomInfo( - ws, + const revokedCoinDenom = await getDenomInfo( + wex, tx, revokedCoin.exchangeBaseUrl, revokedCoin.denomPubHash, @@ -281,19 +219,93 @@ async function recoupRefreshCoin( } await tx.coins.put(revokedCoin); await tx.coins.put(oldCoin); - await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); - }); + await putGroupAsFinished(wex, tx, recoupGroup, coinIdx); + }, + ); +} + +export async function recoupWithdrawCoin( + wex: WalletExecutionContext, + recoupGroupId: string, + coinIdx: number, + coin: CoinRecord, + cs: WithdrawCoinSource, +): Promise<void> { + const reservePub = cs.reservePub; + const denomInfo = await wex.db.runReadOnlyTx( + { storeNames: ["denominations"] }, + async (tx) => { + const denomInfo = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + return denomInfo; + }, + ); + if (!denomInfo) { + // FIXME: We should at least emit some pending operation / warning for this? + return; + } + + const recoupRequest = await wex.cryptoApi.createRecoupRequest({ + blindingKey: coin.blindingKey, + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + denomPub: denomInfo.denomPub, + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + }); + const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); + logger.trace(`requesting recoup via ${reqUrl.href}`); + const resp = await wex.http.fetch(reqUrl.href, { + method: "POST", + body: recoupRequest, + }); + const recoupConfirmation = await readSuccessResponseJsonOrThrow( + resp, + codecForRecoupConfirmation(), + ); + + logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`); + + if (recoupConfirmation.reserve_pub !== reservePub) { + throw Error(`Coin's reserve doesn't match reserve on recoup`); + } + + // FIXME: verify that our expectations about the amount match + await wex.db.runReadWriteTx( + { storeNames: ["coins", "denominations", "recoupGroups", "refreshGroups"] }, + async (tx) => { + const recoupGroup = await tx.recoupGroups.get(recoupGroupId); + if (!recoupGroup) { + return; + } + if (recoupGroup.recoupFinishedPerCoin[coinIdx]) { + return; + } + const updatedCoin = await tx.coins.get(coin.coinPub); + if (!updatedCoin) { + return; + } + updatedCoin.status = CoinStatus.Dormant; + await tx.coins.put(updatedCoin); + await putGroupAsFinished(wex, tx, recoupGroup, coinIdx); + }, + ); } export async function processRecoupGroup( - ws: InternalWalletState, + wex: WalletExecutionContext, recoupGroupId: string, ): Promise<TaskRunResult> { - let recoupGroup = await ws.db - .mktx((x) => [x.recoupGroups]) - .runReadOnly(async (tx) => { + let recoupGroup = await wex.db.runReadOnlyTx( + { storeNames: ["recoupGroups"] }, + async (tx) => { return tx.recoupGroups.get(recoupGroupId); - }); + }, + ); if (!recoupGroup) { return TaskRunResult.finished(); } @@ -303,7 +315,7 @@ export async function processRecoupGroup( } const ps = recoupGroup.coinPubs.map(async (x, i) => { try { - await processRecoup(ws, recoupGroupId, i); + await processRecoupForCoin(wex, recoupGroupId, i); } catch (e) { logger.warn(`processRecoup failed: ${e}`); throw e; @@ -311,11 +323,12 @@ export async function processRecoupGroup( }); await Promise.all(ps); - recoupGroup = await ws.db - .mktx((x) => [x.recoupGroups]) - .runReadOnly(async (tx) => { + recoupGroup = await wex.db.runReadOnlyTx( + { storeNames: ["recoupGroups"] }, + async (tx) => { return tx.recoupGroups.get(recoupGroupId); - }); + }, + ); if (!recoupGroup) { return TaskRunResult.finished(); } @@ -332,9 +345,9 @@ export async function processRecoupGroup( const reservePrivMap: Record<string, string> = {}; for (let i = 0; i < recoupGroup.coinPubs.length; i++) { const coinPub = recoupGroup.coinPubs[i]; - await ws.db - .mktx((x) => [x.coins, x.reserves]) - .runReadOnly(async (tx) => { + await wex.db.runReadOnlyTx( + { storeNames: ["coins", "reserves"] }, + async (tx) => { const coin = await tx.coins.get(coinPub); if (!coin) { throw Error(`Coin ${coinPub} not found, can't request recoup`); @@ -349,7 +362,8 @@ export async function processRecoupGroup( reserveSet.add(coin.coinSource.reservePub); reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv; } - }); + }, + ); } for (const reservePub of reserveSet) { @@ -359,13 +373,13 @@ export async function processRecoupGroup( ); logger.info(`querying reserve status for recoup via ${reserveUrl}`); - const resp = await ws.http.fetch(reserveUrl.href); + const resp = await wex.http.fetch(reserveUrl.href); const result = await readSuccessResponseJsonOrThrow( resp, codecForReserveStatus(), ); - await internalCreateWithdrawalGroup(ws, { + await internalCreateWithdrawalGroup(wex, { amount: Amounts.parseOrThrow(result.balance), exchangeBaseUrl: recoupGroup.exchangeBaseUrl, reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, @@ -379,42 +393,82 @@ export async function processRecoupGroup( }); } - await ws.db - .mktx((x) => [ - x.recoupGroups, - x.coinAvailability, - x.denominations, - x.refreshGroups, - x.coins, - ]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { + storeNames: [ + "recoupGroups", + "coinAvailability", + "denominations", + "refreshGroups", + "refreshSessions", + "coins", + ], + }, + async (tx) => { const rg2 = await tx.recoupGroups.get(recoupGroupId); if (!rg2) { return; } rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now()); + rg2.operationStatus = RecoupOperationStatus.Finished; if (rg2.scheduleRefreshCoins.length > 0) { - const refreshGroupId = await createRefreshGroup( - ws, + await createRefreshGroup( + wex, tx, Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount), rg2.scheduleRefreshCoins, RefreshReason.Recoup, + constructTransactionIdentifier({ + tag: TransactionType.Recoup, + recoupGroupId: rg2.recoupGroupId, + }), ); } await tx.recoupGroups.put(rg2); - }); + }, + ); return TaskRunResult.finished(); } +export class RecoupTransactionContext implements TransactionContext { + abortTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + suspendTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + resumeTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + failTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + deleteTransaction(): Promise<void> { + throw new Error("Method not implemented."); + } + public transactionId: TransactionIdStr; + public taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + private recoupGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Recoup, + recoupGroupId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId, + }); + } +} + export async function createRecoupGroup( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - recoupGroups: typeof WalletStoresV1.recoupGroups; - denominations: typeof WalletStoresV1.denominations; - refreshGroups: typeof WalletStoresV1.refreshGroups; - coins: typeof WalletStoresV1.coins; - }>, + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["recoupGroups", "denominations", "refreshGroups", "coins"] + >, exchangeBaseUrl: string, coinPubs: string[], ): Promise<string> { @@ -428,13 +482,14 @@ export async function createRecoupGroup( 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); + await putGroupAsFinished(wex, tx, recoupGroup, coinIdx); continue; } await tx.coins.put(coin); @@ -442,20 +497,24 @@ export async function createRecoupGroup( await tx.recoupGroups.put(recoupGroup); + const ctx = new RecoupTransactionContext(wex, recoupGroupId); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + return recoupGroupId; } /** * Run the recoup protocol for a single coin in a recoup group. */ -async function processRecoup( - ws: InternalWalletState, +async function processRecoupForCoin( + wex: WalletExecutionContext, recoupGroupId: string, coinIdx: number, ): Promise<void> { - const coin = await ws.db - .mktx((x) => [x.recoupGroups, x.coins]) - .runReadOnly(async (tx) => { + const coin = await wex.db.runReadOnlyTx( + { storeNames: ["coins", "recoupGroups"] }, + async (tx) => { const recoupGroup = await tx.recoupGroups.get(recoupGroupId); if (!recoupGroup) { return; @@ -474,7 +533,8 @@ async function processRecoup( throw Error(`Coin ${coinPub} not found, can't request recoup`); } return coin; - }); + }, + ); if (!coin) { return; @@ -484,11 +544,11 @@ async function processRecoup( switch (cs.type) { case CoinSourceType.Reward: - return recoupRewardCoin(ws, recoupGroupId, coinIdx, coin); + return recoupRewardCoin(wex, recoupGroupId, coinIdx, coin); case CoinSourceType.Refresh: - return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs); + return recoupRefreshCoin(wex, recoupGroupId, coinIdx, coin, cs); case CoinSourceType.Withdraw: - return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs); + return recoupWithdrawCoin(wex, recoupGroupId, coinIdx, coin, cs); default: throw Error("unknown coin source type"); } diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts new file mode 100644 index 000000000..7800967e6 --- /dev/null +++ b/packages/taler-wallet-core/src/refresh.ts @@ -0,0 +1,1883 @@ +/* + This file is part of GNU Taler + (C) 2019-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * @fileoverview + * Implementation of the refresh transaction. + */ + +/** + * Imports. + */ +import { + AgeCommitment, + AgeRestriction, + AmountJson, + Amounts, + amountToPretty, + assertUnreachable, + AsyncFlag, + checkDbInvariant, + codecForCoinHistoryResponse, + codecForExchangeMeltResponse, + codecForExchangeRevealResponse, + CoinPublicKeyString, + CoinRefreshRequest, + CoinStatus, + DenominationInfo, + DenomKeyType, + Duration, + encodeCrock, + ExchangeMeltRequest, + ExchangeProtocolVersion, + ExchangeRefreshRevealRequest, + fnutil, + ForceRefreshRequest, + getErrorDetailFromException, + getRandomBytes, + HashCodeString, + HttpStatusCode, + j2s, + Logger, + makeErrorDetail, + NotificationType, + RefreshReason, + TalerError, + TalerErrorCode, + TalerErrorDetail, + TalerPreciseTimestamp, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionState, + TransactionType, + URL, + WalletNotification, +} from "@gnu-taler/taler-util"; +import { + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, + throwUnexpectedRequestError, +} from "@gnu-taler/taler-util/http"; +import { + constructTaskIdentifier, + makeCoinsVisible, + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + TransitionResult, + TransitionResultType, +} from "./common.js"; +import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; +import { + DerivedRefreshSession, + RefreshNewDenomInfo, +} from "./crypto/cryptoTypes.js"; +import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js"; +import { + CoinAvailabilityRecord, + CoinRecord, + CoinSourceType, + DenominationRecord, + RefreshCoinStatus, + RefreshGroupPerExchangeInfo, + RefreshGroupRecord, + RefreshOperationStatus, + RefreshSessionRecord, + timestampPreciseToDb, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, + WalletDbStoresArr, +} from "./db.js"; +import { selectWithdrawalDenominations } from "./denomSelection.js"; +import { + constructTransactionIdentifier, + notifyTransition, + TransitionInfo, +} from "./transactions.js"; +import { + EXCHANGE_COINS_LOCK, + getDenomInfo, + WalletExecutionContext, +} from "./wallet.js"; +import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; + +const logger = new Logger("refresh.ts"); + +/** + * Update the materialized refresh transaction based + * on the refresh group record. + */ +async function updateRefreshTransaction( + ctx: RefreshTransactionContext, + tx: WalletDbReadWriteTransaction< + [ + "refreshGroups", + "transactions", + "operationRetries", + "exchanges", + "exchangeDetails", + ] + >, +): Promise<void> {} + +export class RefreshTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + public refreshGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.Refresh, + refreshGroupId, + }); + } + + /** + * Transition a withdrawal transaction. + * Extra object stores may be accessed during the transition. + */ + async transition<StoreNameArray extends WalletDbStoresArr = []>( + opts: { extraStores?: StoreNameArray; transactionLabel?: string }, + f: ( + rec: RefreshGroupRecord | undefined, + tx: WalletDbReadWriteTransaction< + [ + "refreshGroups", + "transactions", + "operationRetries", + "exchanges", + "exchangeDetails", + ...StoreNameArray, + ] + >, + ) => Promise<TransitionResult<RefreshGroupRecord>>, + ): Promise<TransitionInfo | undefined> { + const baseStores = [ + "refreshGroups" as const, + "transactions" as const, + "operationRetries" as const, + "exchanges" as const, + "exchangeDetails" as const, + ]; + let stores = opts.extraStores + ? [...baseStores, ...opts.extraStores] + : baseStores; + const transitionInfo = await this.wex.db.runReadWriteTx( + { storeNames: stores }, + async (tx) => { + const wgRec = await tx.refreshGroups.get(this.refreshGroupId); + let oldTxState: TransactionState; + if (wgRec) { + oldTxState = computeRefreshTransactionState(wgRec); + } else { + oldTxState = { + major: TransactionMajorState.None, + }; + } + const res = await f(wgRec, tx); + switch (res.type) { + case TransitionResultType.Transition: { + await tx.refreshGroups.put(res.rec); + await updateRefreshTransaction(this, tx); + const newTxState = computeRefreshTransactionState(res.rec); + return { + oldTxState, + newTxState, + }; + } + case TransitionResultType.Delete: + await tx.refreshGroups.delete(this.refreshGroupId); + await updateRefreshTransaction(this, tx); + return { + oldTxState, + newTxState: { + major: TransactionMajorState.None, + }, + }; + default: + return undefined; + } + }, + ); + notifyTransition(this.wex, this.transactionId, transitionInfo); + return transitionInfo; + } + + async deleteTransaction(): Promise<void> { + await this.transition( + { + extraStores: ["tombstones"], + }, + async (rec, tx) => { + if (!rec) { + return TransitionResult.stay(); + } + await tx.tombstones.put({ + id: TombstoneTag.DeleteRefreshGroup + ":" + this.refreshGroupId, + }); + return TransitionResult.delete(); + }, + ); + } + + async suspendTransaction(): Promise<void> { + await this.transition({}, async (rec, tx) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.operationStatus) { + case RefreshOperationStatus.Finished: + case RefreshOperationStatus.Suspended: + case RefreshOperationStatus.Failed: + return TransitionResult.stay(); + case RefreshOperationStatus.Pending: { + rec.operationStatus = RefreshOperationStatus.Suspended; + return TransitionResult.transition(rec); + } + default: + assertUnreachable(rec.operationStatus); + } + }); + } + + async abortTransaction(): Promise<void> { + // Refresh transactions only support fail, not abort. + throw new Error("refresh transactions cannot be aborted"); + } + + async resumeTransaction(): Promise<void> { + await this.transition({}, async (rec, tx) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.operationStatus) { + case RefreshOperationStatus.Finished: + case RefreshOperationStatus.Failed: + case RefreshOperationStatus.Pending: + return TransitionResult.stay(); + case RefreshOperationStatus.Suspended: { + rec.operationStatus = RefreshOperationStatus.Pending; + return TransitionResult.transition(rec); + } + default: + assertUnreachable(rec.operationStatus); + } + }); + } + + async failTransaction(): Promise<void> { + await this.transition({}, async (rec, tx) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.operationStatus) { + case RefreshOperationStatus.Finished: + case RefreshOperationStatus.Failed: + return TransitionResult.stay(); + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: { + rec.operationStatus = RefreshOperationStatus.Failed; + return TransitionResult.transition(rec); + } + default: + assertUnreachable(rec.operationStatus); + } + }); + } +} + +export async function getTotalRefreshCost( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["denominations"]>, + refreshedDenom: DenominationInfo, + amountLeft: AmountJson, +): Promise<AmountJson> { + const cacheKey = `denom=${refreshedDenom.exchangeBaseUrl}/${ + refreshedDenom.denomPubHash + };left=${Amounts.stringify(amountLeft)}`; + const cacheRes = wex.ws.refreshCostCache.get(cacheKey); + if (cacheRes) { + return cacheRes; + } + const allDenoms = await getCandidateWithdrawalDenomsTx( + wex, + tx, + refreshedDenom.exchangeBaseUrl, + Amounts.currencyOf(amountLeft), + ); + const res = getTotalRefreshCostInternal( + allDenoms, + refreshedDenom, + amountLeft, + ); + wex.ws.refreshCostCache.put(cacheKey, res); + return res; +} + +/** + * Get the amount that we lose when refreshing a coin of the given denomination + * with a certain amount left. + * + * If the amount left is zero, then the refresh cost + * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of + * the right denominations), then the cost is the full amount left. + * + * Considers refresh fees, withdrawal fees after refresh and amounts too small + * to refresh. + */ +export function getTotalRefreshCostInternal( + denoms: DenominationRecord[], + refreshedDenom: DenominationInfo, + amountLeft: AmountJson, +): AmountJson { + const withdrawAmount = Amounts.sub( + amountLeft, + refreshedDenom.feeRefresh, + ).amount; + const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x])); + const withdrawDenoms = selectWithdrawalDenominations( + withdrawAmount, + denoms, + false, + ); + const resultingAmount = Amounts.add( + Amounts.zeroOfCurrency(withdrawAmount.currency), + ...withdrawDenoms.selectedDenoms.map( + (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount, + ), + ).amount; + const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; + logger.trace( + `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty( + totalCost, + )}`, + ); + return totalCost; +} + +async function getCoinAvailabilityForDenom( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["coins", "coinAvailability", "denominations"] + >, + denom: DenominationInfo, + ageRestriction: number, +): Promise<CoinAvailabilityRecord> { + checkDbInvariant(!!denom); + let car = await tx.coinAvailability.get([ + denom.exchangeBaseUrl, + denom.denomPubHash, + ageRestriction, + ]); + if (!car) { + car = { + maxAge: ageRestriction, + value: denom.value, + currency: Amounts.currencyOf(denom.value), + denomPubHash: denom.denomPubHash, + exchangeBaseUrl: denom.exchangeBaseUrl, + freshCoinCount: 0, + visibleCoinCount: 0, + }; + } + return car; +} + +/** + * Create a refresh session for one particular coin inside a refresh group. + */ +async function initRefreshSession( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["refreshSessions", "coinAvailability", "coins", "denominations"] + >, + refreshGroup: RefreshGroupRecord, + coinIndex: number, +): Promise<void> { + const refreshGroupId = refreshGroup.refreshGroupId; + logger.trace( + `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`, + ); + const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex]; + const oldCoin = await tx.coins.get(oldCoinPub); + if (!oldCoin) { + throw Error("Can't refresh, coin not found"); + } + + const exchangeBaseUrl = oldCoin.exchangeBaseUrl; + + const sessionSecretSeed = encodeCrock(getRandomBytes(64)); + + const oldDenom = await getDenomInfo( + wex, + tx, + exchangeBaseUrl, + oldCoin.denomPubHash, + ); + + if (!oldDenom) { + throw Error("db inconsistent: denomination for coin not found"); + } + + const currency = refreshGroup.currency; + + const availableDenoms = await getCandidateWithdrawalDenomsTx( + wex, + tx, + exchangeBaseUrl, + currency, + ); + + const availableAmount = Amounts.sub( + refreshGroup.inputPerCoin[coinIndex], + oldDenom.feeRefresh, + ).amount; + + const newCoinDenoms = selectWithdrawalDenominations( + availableAmount, + availableDenoms, + wex.ws.config.testing.denomselAllowLate, + ); + + if (newCoinDenoms.selectedDenoms.length === 0) { + logger.trace( + `not refreshing, available amount ${amountToPretty( + availableAmount, + )} too small`, + ); + refreshGroup.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; + return; + } + + for (let i = 0; i < newCoinDenoms.selectedDenoms.length; i++) { + const dph = newCoinDenoms.selectedDenoms[i].denomPubHash; + const denom = await getDenomInfo(wex, tx, oldDenom.exchangeBaseUrl, dph); + if (!denom) { + logger.error(`denom ${dph} not in DB`); + continue; + } + const car = await getCoinAvailabilityForDenom( + wex, + tx, + denom, + oldCoin.maxAge, + ); + car.pendingRefreshOutputCount = + (car.pendingRefreshOutputCount ?? 0) + + newCoinDenoms.selectedDenoms[i].count; + await tx.coinAvailability.put(car); + } + + const newSession: RefreshSessionRecord = { + coinIndex, + refreshGroupId, + norevealIndex: undefined, + sessionSecretSeed: sessionSecretSeed, + newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({ + count: x.count, + denomPubHash: x.denomPubHash, + })), + amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue), + }; + await tx.refreshSessions.put(newSession); +} + +/** + * Uninitialize a refresh session. + * + * Adjust the coin availability of involved coins. + */ +async function destroyRefreshSession( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["denominations", "coinAvailability", "coins"] + >, + refreshGroup: RefreshGroupRecord, + refreshSession: RefreshSessionRecord, +): Promise<void> { + for (let i = 0; i < refreshSession.newDenoms.length; i++) { + const oldCoin = await tx.coins.get( + refreshGroup.oldCoinPubs[refreshSession.coinIndex], + ); + if (!oldCoin) { + continue; + } + const dph = refreshSession.newDenoms[i].denomPubHash; + const denom = await getDenomInfo(wex, tx, oldCoin.exchangeBaseUrl, dph); + if (!denom) { + logger.error(`denom ${dph} not in DB`); + continue; + } + const car = await getCoinAvailabilityForDenom( + wex, + tx, + denom, + oldCoin.maxAge, + ); + checkDbInvariant(car.pendingRefreshOutputCount != null); + car.pendingRefreshOutputCount = + car.pendingRefreshOutputCount - refreshSession.newDenoms[i].count; + await tx.coinAvailability.put(car); + } +} + +function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration { + return Duration.fromSpec({ + seconds: 5, + }); +} + +/** + * Run the melt step of a refresh session. + * + * If the melt step succeeds or fails permanently, + * the status in the refresh group is updated. + * + * When a transient error occurs, an exception is thrown. + */ +async function refreshMelt( + wex: WalletExecutionContext, + refreshGroupId: string, + coinIndex: number, +): Promise<void> { + const ctx = new RefreshTransactionContext(wex, refreshGroupId); + const d = await wex.db.runReadWriteTx( + { + storeNames: [ + "refreshGroups", + "refreshSessions", + "coins", + "denominations", + ], + }, + async (tx) => { + const refreshGroup = await tx.refreshGroups.get(refreshGroupId); + if (!refreshGroup) { + return; + } + const refreshSession = await tx.refreshSessions.get([ + refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + return; + } + if (refreshSession.norevealIndex !== undefined) { + return; + } + + const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]); + checkDbInvariant(!!oldCoin, "melt coin doesn't exist"); + const oldDenom = await getDenomInfo( + wex, + tx, + oldCoin.exchangeBaseUrl, + oldCoin.denomPubHash, + ); + checkDbInvariant( + !!oldDenom, + "denomination for melted coin doesn't exist", + ); + + const newCoinDenoms: RefreshNewDenomInfo[] = []; + + for (const dh of refreshSession.newDenoms) { + const newDenom = await getDenomInfo( + wex, + tx, + oldCoin.exchangeBaseUrl, + dh.denomPubHash, + ); + checkDbInvariant( + !!newDenom, + "new denomination for refresh not in database", + ); + newCoinDenoms.push({ + count: dh.count, + denomPub: newDenom.denomPub, + denomPubHash: newDenom.denomPubHash, + feeWithdraw: newDenom.feeWithdraw, + value: Amounts.stringify(newDenom.value), + }); + } + return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession }; + }, + ); + + if (!d) { + return; + } + + const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d; + + let exchangeProtocolVersion: ExchangeProtocolVersion; + switch (d.oldDenom.denomPub.cipher) { + case DenomKeyType.Rsa: { + exchangeProtocolVersion = ExchangeProtocolVersion.V12; + break; + } + default: + throw Error("unsupported key type"); + } + + const derived = await wex.cryptoApi.deriveRefreshSession({ + exchangeProtocolVersion, + kappa: 3, + meltCoinDenomPubHash: oldCoin.denomPubHash, + meltCoinPriv: oldCoin.coinPriv, + meltCoinPub: oldCoin.coinPub, + feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh), + meltCoinMaxAge: oldCoin.maxAge, + meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, + newCoinDenoms, + sessionSecretSeed: refreshSession.sessionSecretSeed, + }); + + const reqUrl = new URL( + `coins/${oldCoin.coinPub}/melt`, + oldCoin.exchangeBaseUrl, + ); + + let maybeAch: HashCodeString | undefined; + if (oldCoin.ageCommitmentProof) { + maybeAch = AgeRestriction.hashCommitment( + oldCoin.ageCommitmentProof.commitment, + ); + } + + const meltReqBody: ExchangeMeltRequest = { + coin_pub: oldCoin.coinPub, + confirm_sig: derived.confirmSig, + denom_pub_hash: oldCoin.denomPubHash, + denom_sig: oldCoin.denomSig, + rc: derived.hash, + value_with_fee: Amounts.stringify(derived.meltValueWithFee), + age_commitment_hash: maybeAch, + }; + + const resp = await wex.ws.runSequentialized( + [EXCHANGE_COINS_LOCK], + async () => { + return await wex.http.fetch(reqUrl.href, { + method: "POST", + body: meltReqBody, + timeout: getRefreshRequestTimeout(refreshGroup), + cancellationToken: wex.cancellationToken, + }); + }, + ); + + switch (resp.status) { + case HttpStatusCode.NotFound: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltNotFound(ctx, coinIndex, errDetail); + return; + } + case HttpStatusCode.Gone: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltGone(ctx, coinIndex, errDetail); + return; + } + case HttpStatusCode.Conflict: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshMeltConflict( + ctx, + coinIndex, + errDetail, + derived, + oldCoin, + ); + return; + } + case HttpStatusCode.Ok: + break; + default: { + const errDetail = await readTalerErrorResponse(resp); + throwUnexpectedRequestError(resp, errDetail); + } + } + + const meltResponse = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeMeltResponse(), + ); + + const norevealIndex = meltResponse.noreveal_index; + + refreshSession.norevealIndex = norevealIndex; + + await wex.db.runReadWriteTx( + { storeNames: ["refreshGroups", "refreshSessions"] }, + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); + if (!rs) { + return; + } + if (rs.norevealIndex !== undefined) { + return; + } + rs.norevealIndex = norevealIndex; + await tx.refreshSessions.put(rs); + }, + ); +} + +async function handleRefreshMeltGone( + ctx: RefreshTransactionContext, + coinIndex: number, + errDetails: TalerErrorDetail, +): Promise<void> { + // const expiredMsg = codecForDenominationExpiredMessage().decode(errDetails); + + // FIXME: Validate signature. + + await ctx.wex.db.runReadWriteTx( + { + storeNames: [ + "refreshGroups", + "refreshSessions", + "coins", + "denominations", + "coinAvailability", + ], + }, + async (tx) => { + const rg = await tx.refreshGroups.get(ctx.refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { + return; + } + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + const refreshSession = await tx.refreshSessions.get([ + ctx.refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + throw Error("db invariant failed: missing refresh session in database"); + } + refreshSession.lastError = errDetails; + await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); + await tx.refreshGroups.put(rg); + await tx.refreshSessions.put(refreshSession); + }, + ); +} + +async function handleRefreshMeltConflict( + ctx: RefreshTransactionContext, + coinIndex: number, + errDetails: TalerErrorDetail, + derived: DerivedRefreshSession, + oldCoin: CoinRecord, +): Promise<void> { + // Just log for better diagnostics here, error status + // will be handled later. + logger.error( + `melt request for ${Amounts.stringify( + derived.meltValueWithFee, + )} failed in refresh group ${ctx.refreshGroupId} due to conflict`, + ); + + const historySig = await ctx.wex.cryptoApi.signCoinHistoryRequest({ + coinPriv: oldCoin.coinPriv, + coinPub: oldCoin.coinPub, + startOffset: 0, + }); + + const historyUrl = new URL( + `coins/${oldCoin.coinPub}/history`, + oldCoin.exchangeBaseUrl, + ); + + const historyResp = await ctx.wex.http.fetch(historyUrl.href, { + method: "GET", + headers: { + "Taler-Coin-History-Signature": historySig.sig, + }, + cancellationToken: ctx.wex.cancellationToken, + }); + + const historyJson = await readSuccessResponseJsonOrThrow( + historyResp, + codecForCoinHistoryResponse(), + ); + logger.info(`coin history: ${j2s(historyJson)}`); + + // FIXME: If response seems wrong, report to auditor (in the future!); + + await ctx.wex.db.runReadWriteTx( + { + storeNames: [ + "refreshGroups", + "refreshSessions", + "denominations", + "coins", + "coinAvailability", + ], + }, + async (tx) => { + const rg = await tx.refreshGroups.get(ctx.refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { + return; + } + if (Amounts.isZero(historyJson.balance)) { + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + const refreshSession = await tx.refreshSessions.get([ + ctx.refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + throw Error( + "db invariant failed: missing refresh session in database", + ); + } + refreshSession.lastError = errDetails; + await tx.refreshGroups.put(rg); + await tx.refreshSessions.put(refreshSession); + } else { + // Try again with new denoms! + rg.inputPerCoin[coinIndex] = historyJson.balance; + const refreshSession = await tx.refreshSessions.get([ + ctx.refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + throw Error( + "db invariant failed: missing refresh session in database", + ); + } + await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); + await tx.refreshSessions.delete([ctx.refreshGroupId, coinIndex]); + await initRefreshSession(ctx.wex, tx, rg, coinIndex); + } + }, + ); +} + +async function handleRefreshMeltNotFound( + ctx: RefreshTransactionContext, + coinIndex: number, + errDetails: TalerErrorDetail, +): Promise<void> { + // FIXME: Validate the exchange's error response + await ctx.wex.db.runReadWriteTx( + { + storeNames: [ + "refreshGroups", + "refreshSessions", + "coins", + "denominations", + "coinAvailability", + ], + }, + async (tx) => { + const rg = await tx.refreshGroups.get(ctx.refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { + return; + } + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + const refreshSession = await tx.refreshSessions.get([ + ctx.refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + throw Error("db invariant failed: missing refresh session in database"); + } + await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); + refreshSession.lastError = errDetails; + await tx.refreshGroups.put(rg); + await tx.refreshSessions.put(refreshSession); + }, + ); +} + +export async function assembleRefreshRevealRequest(args: { + cryptoApi: TalerCryptoInterface; + derived: DerivedRefreshSession; + norevealIndex: number; + oldCoinPub: CoinPublicKeyString; + oldCoinPriv: string; + newDenoms: { + denomPubHash: string; + count: number; + }[]; + oldAgeCommitment?: AgeCommitment; +}): Promise<ExchangeRefreshRevealRequest> { + const { + derived, + norevealIndex, + cryptoApi, + oldCoinPriv, + oldCoinPub, + newDenoms, + } = args; + const privs = Array.from(derived.transferPrivs); + privs.splice(norevealIndex, 1); + + const planchets = derived.planchetsForGammas[norevealIndex]; + if (!planchets) { + throw Error("refresh index error"); + } + + const newDenomsFlat: string[] = []; + const linkSigs: string[] = []; + + for (let i = 0; i < newDenoms.length; i++) { + const dsel = newDenoms[i]; + for (let j = 0; j < dsel.count; j++) { + const newCoinIndex = linkSigs.length; + const linkSig = await cryptoApi.signCoinLink({ + coinEv: planchets[newCoinIndex].coinEv, + newDenomHash: dsel.denomPubHash, + oldCoinPriv: oldCoinPriv, + oldCoinPub: oldCoinPub, + transferPub: derived.transferPubs[norevealIndex], + }); + linkSigs.push(linkSig.sig); + newDenomsFlat.push(dsel.denomPubHash); + } + } + + const req: ExchangeRefreshRevealRequest = { + coin_evs: planchets.map((x) => x.coinEv), + new_denoms_h: newDenomsFlat, + transfer_privs: privs, + transfer_pub: derived.transferPubs[norevealIndex], + link_sigs: linkSigs, + old_age_commitment: args.oldAgeCommitment?.publicKeys, + }; + return req; +} + +async function refreshReveal( + wex: WalletExecutionContext, + refreshGroupId: string, + coinIndex: number, +): Promise<void> { + logger.trace( + `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`, + ); + const ctx = new RefreshTransactionContext(wex, refreshGroupId); + const d = await wex.db.runReadOnlyTx( + { + storeNames: [ + "refreshGroups", + "refreshSessions", + "coins", + "denominations", + ], + }, + async (tx) => { + const refreshGroup = await tx.refreshGroups.get(refreshGroupId); + if (!refreshGroup) { + return; + } + const refreshSession = await tx.refreshSessions.get([ + refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + return; + } + const norevealIndex = refreshSession.norevealIndex; + if (norevealIndex === undefined) { + throw Error("can't reveal without melting first"); + } + + const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]); + checkDbInvariant(!!oldCoin, "melt coin doesn't exist"); + const oldDenom = await getDenomInfo( + wex, + tx, + oldCoin.exchangeBaseUrl, + oldCoin.denomPubHash, + ); + checkDbInvariant( + !!oldDenom, + "denomination for melted coin doesn't exist", + ); + + const newCoinDenoms: RefreshNewDenomInfo[] = []; + + for (const dh of refreshSession.newDenoms) { + const newDenom = await getDenomInfo( + wex, + tx, + oldCoin.exchangeBaseUrl, + dh.denomPubHash, + ); + checkDbInvariant( + !!newDenom, + "new denomination for refresh not in database", + ); + newCoinDenoms.push({ + count: dh.count, + denomPub: newDenom.denomPub, + denomPubHash: newDenom.denomPubHash, + feeWithdraw: newDenom.feeWithdraw, + value: Amounts.stringify(newDenom.value), + }); + } + return { + oldCoin, + oldDenom, + newCoinDenoms, + refreshSession, + refreshGroup, + norevealIndex, + }; + }, + ); + + if (!d) { + return; + } + + const { + oldCoin, + oldDenom, + newCoinDenoms, + refreshSession, + refreshGroup, + norevealIndex, + } = d; + + let exchangeProtocolVersion: ExchangeProtocolVersion; + switch (d.oldDenom.denomPub.cipher) { + case DenomKeyType.Rsa: { + exchangeProtocolVersion = ExchangeProtocolVersion.V12; + break; + } + default: + throw Error("unsupported key type"); + } + + const derived = await wex.cryptoApi.deriveRefreshSession({ + exchangeProtocolVersion, + kappa: 3, + meltCoinDenomPubHash: oldCoin.denomPubHash, + meltCoinPriv: oldCoin.coinPriv, + meltCoinPub: oldCoin.coinPub, + feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh), + newCoinDenoms, + meltCoinMaxAge: oldCoin.maxAge, + meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof, + sessionSecretSeed: refreshSession.sessionSecretSeed, + }); + + const reqUrl = new URL( + `refreshes/${derived.hash}/reveal`, + oldCoin.exchangeBaseUrl, + ); + + const req = await assembleRefreshRevealRequest({ + cryptoApi: wex.cryptoApi, + derived, + newDenoms: newCoinDenoms, + norevealIndex: norevealIndex, + oldCoinPriv: oldCoin.coinPriv, + oldCoinPub: oldCoin.coinPub, + oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment, + }); + + const resp = await wex.ws.runSequentialized( + [EXCHANGE_COINS_LOCK], + async () => { + return await wex.http.fetch(reqUrl.href, { + body: req, + method: "POST", + timeout: getRefreshRequestTimeout(refreshGroup), + cancellationToken: wex.cancellationToken, + }); + }, + ); + + switch (resp.status) { + case HttpStatusCode.Ok: + break; + case HttpStatusCode.Conflict: + case HttpStatusCode.Gone: { + const errDetail = await readTalerErrorResponse(resp); + await handleRefreshRevealError(ctx, coinIndex, errDetail); + return; + } + default: { + const errDetail = await readTalerErrorResponse(resp); + throwUnexpectedRequestError(resp, errDetail); + } + } + + const reveal = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeRevealResponse(), + ); + + const coins: CoinRecord[] = []; + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }); + + for (let i = 0; i < refreshSession.newDenoms.length; i++) { + const ncd = newCoinDenoms[i]; + for (let j = 0; j < refreshSession.newDenoms[i].count; j++) { + const newCoinIndex = coins.length; + const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex]; + if (ncd.denomPub.cipher !== DenomKeyType.Rsa) { + throw Error("cipher unsupported"); + } + const evSig = reveal.ev_sigs[newCoinIndex].ev_sig; + const denomSig = await wex.cryptoApi.unblindDenominationSignature({ + planchet: { + blindingKey: pc.blindingKey, + denomPub: ncd.denomPub, + }, + evSig, + }); + const coin: CoinRecord = { + blindingKey: pc.blindingKey, + coinPriv: pc.coinPriv, + coinPub: pc.coinPub, + denomPubHash: ncd.denomPubHash, + denomSig, + exchangeBaseUrl: oldCoin.exchangeBaseUrl, + status: CoinStatus.Fresh, + coinSource: { + type: CoinSourceType.Refresh, + refreshGroupId, + oldCoinPub: refreshGroup.oldCoinPubs[coinIndex], + }, + sourceTransactionId: transactionId, + coinEvHash: pc.coinEvHash, + maxAge: pc.maxAge, + ageCommitmentProof: pc.ageCommitmentProof, + spendAllocation: undefined, + }; + + coins.push(coin); + } + } + + await wex.db.runReadWriteTx( + { + storeNames: [ + "coins", + "denominations", + "coinAvailability", + "refreshGroups", + "refreshSessions", + ], + }, + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + logger.warn("no refresh session found"); + return; + } + if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { + return; + } + const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); + if (!rs) { + return; + } + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; + for (const coin of coins) { + const existingCoin = await tx.coins.get(coin.coinPub); + if (existingCoin) { + continue; + } + await tx.coins.add(coin); + const denomInfo = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant(!!denomInfo); + const car = await getCoinAvailabilityForDenom( + wex, + tx, + denomInfo, + coin.maxAge, + ); + checkDbInvariant( + car.pendingRefreshOutputCount != null && + car.pendingRefreshOutputCount > 0, + ); + car.pendingRefreshOutputCount--; + car.freshCoinCount++; + await tx.coinAvailability.put(car); + } + await tx.refreshGroups.put(rg); + }, + ); + logger.trace("refresh finished (end of reveal)"); +} + +async function handleRefreshRevealError( + ctx: RefreshTransactionContext, + coinIndex: number, + errDetails: TalerErrorDetail, +): Promise<void> { + await ctx.wex.db.runReadWriteTx( + { + storeNames: [ + "refreshGroups", + "refreshSessions", + "coins", + "denominations", + "coinAvailability", + ], + }, + async (tx) => { + const rg = await tx.refreshGroups.get(ctx.refreshGroupId); + if (!rg) { + return; + } + if (rg.timestampFinished) { + return; + } + if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) { + return; + } + rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + const refreshSession = await tx.refreshSessions.get([ + ctx.refreshGroupId, + coinIndex, + ]); + if (!refreshSession) { + throw Error("db invariant failed: missing refresh session in database"); + } + refreshSession.lastError = errDetails; + await destroyRefreshSession(ctx.wex, tx, rg, refreshSession); + await tx.refreshGroups.put(rg); + await tx.refreshSessions.put(refreshSession); + }, + ); +} + +export async function processRefreshGroup( + wex: WalletExecutionContext, + refreshGroupId: string, +): Promise<TaskRunResult> { + logger.trace(`processing refresh group ${refreshGroupId}`); + + const refreshGroup = await wex.db.runReadOnlyTx( + { storeNames: ["refreshGroups"] }, + async (tx) => tx.refreshGroups.get(refreshGroupId), + ); + if (!refreshGroup) { + return TaskRunResult.finished(); + } + if (refreshGroup.timestampFinished) { + return TaskRunResult.finished(); + } + + if ( + wex.ws.config.testing.devModeActive && + wex.ws.devExperimentState.blockRefreshes + ) { + throw Error("refresh blocked"); + } + + // Process refresh sessions of the group in parallel. + logger.trace( + `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`, + ); + let errors: TalerErrorDetail[] = []; + let inShutdown = false; + const ps = refreshGroup.oldCoinPubs.map((x, i) => + processRefreshSession(wex, refreshGroupId, i).catch((x) => { + if (x instanceof CryptoApiStoppedError) { + inShutdown = true; + logger.info( + "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.", + ); + return; + } + if (x instanceof TalerError) { + logger.warn("process refresh session got exception (TalerError)"); + logger.warn(`exc ${x}`); + logger.warn(`exc stack ${x.stack}`); + logger.warn(`error detail: ${j2s(x.errorDetail)}`); + } else { + logger.warn("process refresh session got exception"); + logger.warn(`exc ${x}`); + logger.warn(`exc stack ${x.stack}`); + } + errors.push(getErrorDetailFromException(x)); + }), + ); + await Promise.all(ps); + if (inShutdown) { + return TaskRunResult.finished(); + } + + const ctx = new RefreshTransactionContext(wex, refreshGroupId); + + // We've processed all refresh session and can now update the + // status of the whole refresh group. + + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["coins", "coinAvailability", "refreshGroups"] }, + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + if (!rg) { + return; + } + switch (rg.operationStatus) { + case RefreshOperationStatus.Pending: + break; + default: + return undefined; + } + const oldTxState = computeRefreshTransactionState(rg); + const allFinal = fnutil.all( + rg.statusPerCoin, + (x) => + x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed, + ); + const anyFailed = fnutil.any( + rg.statusPerCoin, + (x) => x === RefreshCoinStatus.Failed, + ); + if (allFinal) { + if (anyFailed) { + rg.timestampFinished = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + rg.operationStatus = RefreshOperationStatus.Failed; + } else { + rg.timestampFinished = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + rg.operationStatus = RefreshOperationStatus.Finished; + } + await makeCoinsVisible(wex, tx, ctx.transactionId); + await tx.refreshGroups.put(rg); + const newTxState = computeRefreshTransactionState(rg); + return { + oldTxState, + newTxState, + }; + } + return undefined; + }, + ); + + if (transitionInfo) { + notifyTransition(wex, ctx.transactionId, transitionInfo); + return TaskRunResult.progress(); + } + + if (errors.length > 0) { + return { + type: TaskRunResultType.Error, + errorDetail: makeErrorDetail( + TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE, + { + numErrors: errors.length, + errors: errors.slice(0, 5), + }, + ), + }; + } + + return TaskRunResult.backoff(); +} + +async function processRefreshSession( + wex: WalletExecutionContext, + refreshGroupId: string, + coinIndex: number, +): Promise<void> { + logger.trace( + `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`, + ); + let { refreshGroup, refreshSession } = await wex.db.runReadOnlyTx( + { storeNames: ["refreshGroups", "refreshSessions"] }, + async (tx) => { + const rg = await tx.refreshGroups.get(refreshGroupId); + const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]); + return { + refreshGroup: rg, + refreshSession: rs, + }; + }, + ); + if (!refreshGroup) { + return; + } + if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) { + return; + } + if (!refreshSession) { + // No refresh session for that coin. + return; + } + if (refreshSession.norevealIndex === undefined) { + await refreshMelt(wex, refreshGroupId, coinIndex); + } + await refreshReveal(wex, refreshGroupId, coinIndex); +} + +export interface RefreshOutputInfo { + outputPerCoin: AmountJson[]; + perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>; +} + +export async function calculateRefreshOutput( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + ["denominations", "coins", "refreshGroups", "coinAvailability"] + >, + currency: string, + oldCoinPubs: CoinRefreshRequest[], +): Promise<RefreshOutputInfo> { + const estimatedOutputPerCoin: AmountJson[] = []; + + const denomsPerExchange: Record<string, DenominationRecord[]> = {}; + + const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {}; + + for (const ocp of oldCoinPubs) { + const coin = await tx.coins.get(ocp.coinPub); + checkDbInvariant(!!coin, "coin must be in database"); + const denom = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant( + !!denom, + "denomination for existing coin must be in database", + ); + const refreshAmount = ocp.amount; + const cost = await getTotalRefreshCost( + wex, + tx, + denom, + Amounts.parseOrThrow(refreshAmount), + ); + const output = Amounts.sub(refreshAmount, cost).amount; + let exchInfo = infoPerExchange[coin.exchangeBaseUrl]; + if (!exchInfo) { + infoPerExchange[coin.exchangeBaseUrl] = exchInfo = { + outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)), + }; + } + exchInfo.outputEffective = Amounts.stringify( + Amounts.add(exchInfo.outputEffective, output).amount, + ); + estimatedOutputPerCoin.push(output); + } + + return { + outputPerCoin: estimatedOutputPerCoin, + perExchangeInfo: infoPerExchange, + }; +} + +async function applyRefreshToOldCoins( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["denominations", "coins", "refreshGroups", "coinAvailability"] + >, + oldCoinPubs: CoinRefreshRequest[], + refreshGroupId: string, +): Promise<void> { + for (const ocp of oldCoinPubs) { + const coin = await tx.coins.get(ocp.coinPub); + checkDbInvariant(!!coin, "coin must be in database"); + const denom = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant( + !!denom, + "denomination for existing coin must be in database", + ); + switch (coin.status) { + case CoinStatus.Dormant: + break; + case CoinStatus.Fresh: { + coin.status = CoinStatus.Dormant; + const coinAv = await tx.coinAvailability.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + coin.maxAge, + ]); + checkDbInvariant(!!coinAv); + checkDbInvariant(coinAv.freshCoinCount > 0); + coinAv.freshCoinCount--; + await tx.coinAvailability.put(coinAv); + break; + } + case CoinStatus.FreshSuspended: { + // For suspended coins, we don't have to adjust coin + // availability, as they are not counted as available. + coin.status = CoinStatus.Dormant; + break; + } + case CoinStatus.DenomLoss: + break; + default: + assertUnreachable(coin.status); + } + if (!coin.spendAllocation) { + coin.spendAllocation = { + amount: Amounts.stringify(ocp.amount), + // id: `txn:refresh:${refreshGroupId}`, + id: constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId, + }), + }; + } + await tx.coins.put(coin); + } +} + +export interface CreateRefreshGroupResult { + refreshGroupId: string; + notifications: WalletNotification[]; +} + +/** + * Create a refresh group for a list of coins. + * + * Refreshes the remaining amount on the coin, effectively capturing the remaining + * value in the refresh group. + * + * The caller must also ensure that the coins that should be refreshed exist + * in the current database transaction. + */ +export async function createRefreshGroup( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + [ + "denominations", + "coins", + "refreshGroups", + "refreshSessions", + "coinAvailability", + ] + >, + currency: string, + oldCoinPubs: CoinRefreshRequest[], + refreshReason: RefreshReason, + originatingTransactionId: string | undefined, +): Promise<CreateRefreshGroupResult> { + // FIXME: Check that involved exchanges are reasonably up-to-date. + // Otherwise, error out. + + const refreshGroupId = encodeCrock(getRandomBytes(32)); + + const outInfo = await calculateRefreshOutput(wex, tx, currency, oldCoinPubs); + + const estimatedOutputPerCoin = outInfo.outputPerCoin; + + await applyRefreshToOldCoins(wex, tx, oldCoinPubs, refreshGroupId); + + const refreshGroup: RefreshGroupRecord = { + operationStatus: RefreshOperationStatus.Pending, + currency, + timestampFinished: undefined, + statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending), + oldCoinPubs: oldCoinPubs.map((x) => x.coinPub), + originatingTransactionId, + reason: refreshReason, + refreshGroupId, + inputPerCoin: oldCoinPubs.map((x) => x.amount), + expectedOutputPerCoin: estimatedOutputPerCoin.map((x) => + Amounts.stringify(x), + ), + infoPerExchange: outInfo.perExchangeInfo, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + }; + + if (oldCoinPubs.length == 0) { + logger.warn("created refresh group with zero coins"); + refreshGroup.timestampFinished = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + refreshGroup.operationStatus = RefreshOperationStatus.Finished; + } + + for (let i = 0; i < oldCoinPubs.length; i++) { + await initRefreshSession(wex, tx, refreshGroup, i); + } + + await tx.refreshGroups.put(refreshGroup); + + const newTxState = computeRefreshTransactionState(refreshGroup); + + logger.trace(`created refresh group ${refreshGroupId}`); + + const ctx = new RefreshTransactionContext(wex, refreshGroupId); + + // Shepherd the task. + // If the current transaction fails to commit the refresh + // group to the DB, the shepherd will give up. + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + refreshGroupId, + notifications: [ + { + type: NotificationType.TransactionStateTransition, + transactionId: ctx.transactionId, + oldTxState: { + major: TransactionMajorState.None, + }, + newTxState, + }, + ], + }; +} + +export function computeRefreshTransactionState( + rg: RefreshGroupRecord, +): TransactionState { + switch (rg.operationStatus) { + case RefreshOperationStatus.Finished: + return { + major: TransactionMajorState.Done, + }; + case RefreshOperationStatus.Failed: + return { + major: TransactionMajorState.Failed, + }; + case RefreshOperationStatus.Pending: + return { + major: TransactionMajorState.Pending, + }; + case RefreshOperationStatus.Suspended: + return { + major: TransactionMajorState.Suspended, + }; + } +} + +export function computeRefreshTransactionActions( + rg: RefreshGroupRecord, +): TransactionAction[] { + switch (rg.operationStatus) { + case RefreshOperationStatus.Finished: + return [TransactionAction.Delete]; + case RefreshOperationStatus.Failed: + return [TransactionAction.Delete]; + case RefreshOperationStatus.Pending: + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; + case RefreshOperationStatus.Suspended: + return [TransactionAction.Resume, TransactionAction.Fail]; + } +} + +export function getRefreshesForTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<string[]> { + return wex.db.runReadOnlyTx({ storeNames: ["refreshGroups"] }, async (tx) => { + const groups = + await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll( + transactionId, + ); + return groups.map((x) => + constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId: x.refreshGroupId, + }), + ); + }); +} + +export interface ForceRefreshResult { + refreshGroupId: string; +} + +export async function forceRefresh( + wex: WalletExecutionContext, + req: ForceRefreshRequest, +): Promise<ForceRefreshResult> { + if (req.refreshCoinSpecs.length == 0) { + throw Error("refusing to create empty refresh group"); + } + const res = await wex.db.runReadWriteTx( + { + storeNames: [ + "refreshGroups", + "coinAvailability", + "refreshSessions", + "denominations", + "coins", + ], + }, + async (tx) => { + let coinPubs: CoinRefreshRequest[] = []; + for (const c of req.refreshCoinSpecs) { + const coin = await tx.coins.get(c.coinPub); + if (!coin) { + throw Error(`coin (pubkey ${c}) not found`); + } + const denom = await getDenomInfo( + wex, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant(!!denom); + coinPubs.push({ + coinPub: c.coinPub, + amount: c.amount ?? denom.value, + }); + } + return await createRefreshGroup( + wex, + tx, + Amounts.currencyOf(coinPubs[0].amount), + coinPubs, + RefreshReason.Manual, + undefined, + ); + }, + ); + + for (const notif of res.notifications) { + wex.ws.notify(notif); + } + + return { + refreshGroupId: res.refreshGroupId, + }; +} + +/** + * Wait until a refresh operation is final. + */ +export async function waitRefreshFinal( + wex: WalletExecutionContext, + refreshGroupId: string, +): Promise<void> { + const ctx = new RefreshTransactionContext(wex, refreshGroupId); + wex.taskScheduler.startShepherdTask(ctx.taskId); + + // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. + const refreshNotifFlag = new AsyncFlag(); + // Raise purchaseNotifFlag whenever we get a notification + // about our refresh. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ) { + refreshNotifFlag.raise(); + } + }); + const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { + cancelNotif(); + refreshNotifFlag.raise(); + }); + + try { + await internalWaitRefreshFinal(ctx, refreshNotifFlag); + } catch (e) { + unregisterOnCancelled(); + cancelNotif(); + } +} + +async function internalWaitRefreshFinal( + ctx: RefreshTransactionContext, + flag: AsyncFlag, +): Promise<void> { + while (true) { + if (ctx.wex.cancellationToken.isCancelled) { + throw Error("cancelled"); + } + + // Check if refresh is final + const res = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["refreshGroups", "operationRetries"] }, + async (tx) => { + return { + rg: await tx.refreshGroups.get(ctx.refreshGroupId), + }; + }, + ); + const { rg } = res; + if (!rg) { + // Must've been deleted, we consider that final. + return; + } + switch (rg.operationStatus) { + case RefreshOperationStatus.Failed: + case RefreshOperationStatus.Finished: + // Transaction is final + return; + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: + break; + } + + // Wait for the next transition + await flag.wait(); + flag.reset(); + } +} diff --git a/packages/taler-wallet-core/src/remote.ts b/packages/taler-wallet-core/src/remote.ts index 164f7cfe9..d7623baab 100644 --- a/packages/taler-wallet-core/src/remote.ts +++ b/packages/taler-wallet-core/src/remote.ts @@ -17,13 +17,13 @@ import { CoreApiRequestEnvelope, CoreApiResponse, - j2s, Logger, + OpenedPromise, + openPromise, TalerError, WalletNotification, } from "@gnu-taler/taler-util"; import { connectRpc, JsonMessage } from "@gnu-taler/taler-util/twrpc"; -import { OpenedPromise, openPromise } from "./index.js"; import { WalletCoreApiClient } from "./wallet-api-types.js"; const logger = new Logger("remote.ts"); @@ -44,6 +44,7 @@ export interface RemoteWallet { } export interface RemoteWalletConnectArgs { + name?: string; socketFilename: string; notificationHandler?: (n: WalletNotification) => void; } @@ -85,14 +86,13 @@ export async function createRemoteWallet( return { result: ctx, onDisconnect() { - logger.info("remote wallet disconnected"); + logger.info(`${args.name}: remote wallet disconnected`); }, onMessage(m) { // FIXME: use a codec for parsing the response envelope! - logger.info(`got message from remote wallet: ${j2s(m)}`); if (typeof m !== "object" || m == null) { - logger.warn("message from wallet not understood (wrong type)"); + logger.warn(`${args.name}: message not understood (wrong type)`); return; } const type = (m as any).type; @@ -100,13 +100,15 @@ export async function createRemoteWallet( const id = (m as any).id; if (typeof id !== "string") { logger.warn( - "message from wallet not understood (no id in response)", + `${args.name}: message not understood (no id in response)`, ); return; } const h = requestMap.get(id); if (!h) { - logger.warn(`no handler registered for response id ${id}`); + logger.warn( + `${args.name}: no handler registered for response id ${id}`, + ); return; } h.promiseCapability.resolve(m as any); @@ -115,7 +117,7 @@ export async function createRemoteWallet( args.notificationHandler((m as any).payload); } } else { - logger.warn("message from wallet not understood"); + logger.warn(`${args.name}: message not understood`); } }, }; diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts new file mode 100644 index 000000000..3b160d97f --- /dev/null +++ b/packages/taler-wallet-core/src/shepherd.ts @@ -0,0 +1,1128 @@ +/* + This file is part of GNU Taler + (C) 2024 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { GlobalIDB } from "@gnu-taler/idb-bridge"; +import { + AbsoluteTime, + AsyncCondition, + CancellationToken, + Duration, + Logger, + NotificationType, + ObservabilityContext, + ObservabilityEventType, + TalerErrorDetail, + TaskThrottler, + TransactionIdStr, + TransactionState, + TransactionType, + WalletNotification, + assertUnreachable, + getErrorDetailFromException, + j2s, + safeStringifyException, +} from "@gnu-taler/taler-util"; +import { processBackupForProvider } from "./backup/index.js"; +import { + DbRetryInfo, + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + constructTaskIdentifier, + getExchangeState, + parseTaskIdentifier, +} from "./common.js"; +import { + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + OperationRetryRecord, + WalletDbAllStoresReadOnlyTransaction, + WalletDbReadOnlyTransaction, + timestampAbsoluteFromDb, +} from "./db.js"; +import { + computeDepositTransactionStatus, + processDepositGroup, +} from "./deposits.js"; +import { + computeDenomLossTransactionStatus, + updateExchangeFromUrlHandler, +} from "./exchanges.js"; +import { + computePayMerchantTransactionState, + computeRefundTransactionState, + processPurchase, +} from "./pay-merchant.js"; +import { + computePeerPullCreditTransactionState, + processPeerPullCredit, +} from "./pay-peer-pull-credit.js"; +import { + computePeerPullDebitTransactionState, + processPeerPullDebit, +} from "./pay-peer-pull-debit.js"; +import { + computePeerPushCreditTransactionState, + processPeerPushCredit, +} from "./pay-peer-push-credit.js"; +import { + computePeerPushDebitTransactionState, + processPeerPushDebit, +} from "./pay-peer-push-debit.js"; +import { processRecoupGroup } from "./recoup.js"; +import { + computeRefreshTransactionState, + processRefreshGroup, +} from "./refresh.js"; +import { + constructTransactionIdentifier, + parseTransactionIdentifier, +} from "./transactions.js"; +import { + InternalWalletState, + WalletExecutionContext, + getNormalWalletExecutionContext, + getObservedWalletExecutionContext, +} from "./wallet.js"; +import { + computeWithdrawalTransactionStatus, + processWithdrawalGroup, +} from "./withdraw.js"; + +const logger = new Logger("shepherd.ts"); + +/** + * Info about one task being shepherded. + */ +interface ShepherdInfo { + cts: CancellationToken.Source; +} + +/** + * Check if a task is alive, i.e. whether it prevents + * the main task loop from exiting. + */ +function taskGivesLiveness(taskId: string): boolean { + const parsedTaskId = parseTaskIdentifier(taskId); + switch (parsedTaskId.tag) { + case PendingTaskType.Backup: + case PendingTaskType.ExchangeUpdate: + return false; + case PendingTaskType.Deposit: + case PendingTaskType.PeerPullCredit: + case PendingTaskType.PeerPullDebit: + case PendingTaskType.PeerPushCredit: + case PendingTaskType.Refresh: + case PendingTaskType.Recoup: + case PendingTaskType.RewardPickup: + case PendingTaskType.Withdraw: + case PendingTaskType.PeerPushDebit: + case PendingTaskType.Purchase: + return true; + default: + assertUnreachable(parsedTaskId); + } +} + +export interface TaskScheduler { + ensureRunning(): Promise<void>; + startShepherdTask(taskId: TaskIdStr): void; + stopShepherdTask(taskId: TaskIdStr): void; + resetTaskRetries(taskId: TaskIdStr): Promise<void>; + reload(): Promise<void>; + getActiveTasks(): TaskIdStr[]; + isIdle(): boolean; + shutdown(): Promise<void>; +} + +export class TaskSchedulerImpl implements TaskScheduler { + private sheps: Map<TaskIdStr, ShepherdInfo> = new Map(); + + private iterCond = new AsyncCondition(); + + private throttler = new TaskThrottler(); + + isRunning: boolean = false; + + constructor(private ws: InternalWalletState) {} + + private async loadTasksFromDb(): Promise<void> { + const activeTasks = await getActiveTaskIds(this.ws); + + logger.info(`active tasks from DB: ${j2s(activeTasks)}`); + + for (const tid of activeTasks.taskIds) { + this.startShepherdTask(tid); + } + } + + getActiveTasks(): TaskIdStr[] { + return [...this.sheps.keys()]; + } + + async shutdown(): Promise<void> { + const tasksIds = [...this.sheps.keys()]; + logger.info(`Stopping task shepherd.`); + for (const taskId of tasksIds) { + this.stopShepherdTask(taskId); + } + } + + async ensureRunning(): Promise<void> { + if (this.isRunning) { + return; + } + this.isRunning = true; + try { + await this.loadTasksFromDb(); + } catch (e) { + this.isRunning = false; + throw e; + } + this.run() + .catch((e) => { + logger.error("error running task loop"); + logger.error(`err: ${e}`); + }) + .then(() => { + logger.trace("done running task loop"); + this.isRunning = false; + }); + } + + isIdle(): boolean { + let alive = false; + const taskIds = [...this.sheps.keys()]; + for (const taskId of taskIds) { + if (taskGivesLiveness(taskId)) { + alive = true; + break; + } + } + // We're idle if no task is alive anymore. + return !alive; + } + + private async run(): Promise<void> { + logger.trace("Running task loop."); + logger.trace(`sheps: ${this.sheps.size}`); + while (true) { + if (this.ws.stopped) { + logger.trace("Breaking out of task loop (wallet stopped)."); + break; + } + + if (this.isIdle()) { + this.ws.notify({ + type: NotificationType.Idle, + }); + } + + await this.iterCond.wait(); + } + logger.trace("Done with task loop."); + } + + startShepherdTask(taskId: TaskIdStr): void { + this.ensureRunning().catch((e) => { + logger.error(`error running scheduler: ${safeStringifyException(e)}`); + }); + // Run in the background, no await! + this.internalStartShepherdTask(taskId); + } + + /** + * Stop and re-load all existing tasks. + * + * Mostly useful to interrupt all waits when time-travelling. + */ + async reload(): Promise<void> { + await this.ensureRunning(); + const tasksIds = [...this.sheps.keys()]; + logger.info(`reloading sheperd with ${tasksIds.length} tasks`); + for (const taskId of tasksIds) { + this.stopShepherdTask(taskId); + } + for (const taskId of tasksIds) { + this.startShepherdTask(taskId); + } + } + + private async internalStartShepherdTask(taskId: TaskIdStr): Promise<void> { + logger.trace(`Starting to shepherd task ${taskId}`); + const oldShep = this.sheps.get(taskId); + if (oldShep) { + logger.trace(`Already have a shepherd for ${taskId}`); + return; + } + logger.trace(`Creating new shepherd for ${taskId}`); + const newShep: ShepherdInfo = { + cts: CancellationToken.create(), + }; + this.sheps.set(taskId, newShep); + try { + await this.internalShepherdTask(taskId, newShep); + } finally { + logger.trace(`Done shepherding ${taskId}`); + this.sheps.delete(taskId); + this.iterCond.trigger(); + } + } + + stopShepherdTask(taskId: TaskIdStr): void { + logger.trace(`Stopping shepherding of ${taskId}`); + const oldShep = this.sheps.get(taskId); + if (oldShep) { + logger.trace(`Cancelling old shepherd for ${taskId}`); + oldShep.cts.cancel(); + this.sheps.delete(taskId); + this.iterCond.trigger(); + } + } + + restartShepherdTask(taskId: TaskIdStr): void { + this.stopShepherdTask(taskId); + this.startShepherdTask(taskId); + } + + async resetTaskRetries(taskId: TaskIdStr): Promise<void> { + const maybeNotification = await this.ws.db.runAllStoresReadWriteTx( + {}, + async (tx) => { + await tx.operationRetries.delete(taskId); + return taskToRetryNotification(this.ws, tx, taskId, undefined); + }, + ); + this.stopShepherdTask(taskId); + if (maybeNotification) { + this.ws.notify(maybeNotification); + } + this.startShepherdTask(taskId); + } + + private async wait( + taskId: TaskIdStr, + info: ShepherdInfo, + delay: Duration, + ): Promise<void> { + try { + await info.cts.token.racePromise(this.ws.timerGroup.resolveAfter(delay)); + } catch (e) { + logger.info(`waiting for ${taskId} interrupted`); + } + } + + private async internalShepherdTask( + taskId: TaskIdStr, + info: ShepherdInfo, + ): Promise<void> { + while (true) { + if (this.ws.stopped) { + logger.trace(`Shepherd for ${taskId} stopping as wallet is stopped`); + return; + } + if (info.cts.token.isCancelled) { + logger.trace(`Shepherd for ${taskId} got cancelled`); + return; + } + const isThrottled = this.throttler.applyThrottle(taskId); + if (isThrottled) { + logger.warn( + `task ${taskId} throttled, this is very likely a bug in wallet-core, please report`, + ); + logger.warn("waiting for 60 seconds"); + await this.ws.timerGroup.resolveAfter( + Duration.fromSpec({ seconds: 60 }), + ); + } + const wex = getWalletExecutionContextForTask( + this.ws, + taskId, + info.cts.token, + ); + const startTime = AbsoluteTime.now(); + logger.trace(`Shepherd for ${taskId} will call handler`); + let res: TaskRunResult; + try { + res = await callOperationHandlerForTaskId(wex, taskId); + } catch (e) { + res = { + type: TaskRunResultType.Error, + errorDetail: getErrorDetailFromException(e), + }; + } + if (info.cts.token.isCancelled) { + logger.trace("task cancelled, not processing result"); + return; + } + if (this.ws.stopped) { + logger.trace("wallet stopped, not processing result"); + return; + } + wex.oc.observe({ + type: ObservabilityEventType.ShepherdTaskResult, + resultType: res.type, + }); + switch (res.type) { + case TaskRunResultType.Error: { + logger.trace(`Shepherd for ${taskId} got error result.`); + const retryRecord = await storePendingTaskError( + this.ws, + taskId, + res.errorDetail, + ); + const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry); + const delay = AbsoluteTime.remaining(t); + logger.trace(`Waiting for ${delay.d_ms} ms`); + await this.wait(taskId, info, delay); + break; + } + case TaskRunResultType.Backoff: { + logger.trace(`Shepherd for ${taskId} got backoff result.`); + const retryRecord = await storePendingTaskPending(this.ws, taskId); + const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry); + const delay = AbsoluteTime.remaining(t); + logger.trace(`Waiting for ${delay.d_ms} ms`); + await this.wait(taskId, info, delay); + break; + } + case TaskRunResultType.Progress: { + logger.trace( + `Shepherd for ${taskId} got progress result, re-running immediately.`, + ); + await storeTaskProgress(this.ws, taskId); + break; + } + case TaskRunResultType.ScheduleLater: { + logger.trace(`Shepherd for ${taskId} got schedule-later result.`); + await storeTaskProgress(this.ws, taskId); + const delay = AbsoluteTime.remaining(res.runAt); + logger.trace(`Waiting for ${delay.d_ms} ms`); + await this.wait(taskId, info, delay); + break; + } + case TaskRunResultType.Finished: + logger.trace(`Shepherd for ${taskId} got finished result.`); + await storePendingTaskFinished(this.ws, taskId); + return; + case TaskRunResultType.LongpollReturnedPending: { + await storeTaskProgress(this.ws, taskId); + // Make sure that we are waiting a bit if long-polling returned too early. + const endTime = AbsoluteTime.now(); + const taskDuration = AbsoluteTime.difference(endTime, startTime); + if ( + Duration.cmp(taskDuration, Duration.fromSpec({ seconds: 20 })) < 0 + ) { + logger.info( + `long-poller for ${taskId} returned unexpectedly early (${taskDuration.d_ms} ms), waiting 10 seconds`, + ); + await this.wait(taskId, info, Duration.fromSpec({ seconds: 10 })); + } else { + logger.info(`task ${taskId} will long-poll again`); + } + break; + } + default: + assertUnreachable(res); + } + } + } +} + +async function storePendingTaskError( + ws: InternalWalletState, + pendingTaskId: string, + e: TalerErrorDetail, +): Promise<OperationRetryRecord> { + logger.info(`storing pending task error for ${pendingTaskId}`); + const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => { + let retryRecord = await tx.operationRetries.get(pendingTaskId); + if (!retryRecord) { + retryRecord = { + id: pendingTaskId, + lastError: e, + retryInfo: DbRetryInfo.reset(), + }; + } else { + retryRecord.lastError = e; + retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); + } + await tx.operationRetries.put(retryRecord); + return { + notification: await taskToRetryNotification(ws, tx, pendingTaskId, e), + retryRecord, + }; + }); + if (res?.notification) { + ws.notify(res.notification); + } + return res.retryRecord; +} + +/** + * Task made progress, clear error. + */ +async function storeTaskProgress( + ws: InternalWalletState, + pendingTaskId: string, +): Promise<void> { + await ws.db.runReadWriteTx( + { storeNames: ["operationRetries"] }, + async (tx) => { + await tx.operationRetries.delete(pendingTaskId); + }, + ); +} + +async function storePendingTaskPending( + ws: InternalWalletState, + pendingTaskId: string, +): Promise<OperationRetryRecord> { + const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => { + let retryRecord = await tx.operationRetries.get(pendingTaskId); + let hadError = false; + if (!retryRecord) { + retryRecord = { + id: pendingTaskId, + retryInfo: DbRetryInfo.reset(), + }; + } else { + if (retryRecord.lastError) { + hadError = true; + } + delete retryRecord.lastError; + retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); + } + await tx.operationRetries.put(retryRecord); + let notification: WalletNotification | undefined = undefined; + if (hadError) { + notification = await taskToRetryNotification( + ws, + tx, + pendingTaskId, + undefined, + ); + } + return { + notification, + retryRecord, + }; + }); + if (res.notification) { + ws.notify(res.notification); + } + return res.retryRecord; +} + +async function storePendingTaskFinished( + ws: InternalWalletState, + pendingTaskId: string, +): Promise<void> { + await ws.db.runReadWriteTx( + { storeNames: ["operationRetries"] }, + async (tx) => { + await tx.operationRetries.delete(pendingTaskId); + }, + ); +} + +function getWalletExecutionContextForTask( + ws: InternalWalletState, + taskId: TaskIdStr, + cancellationToken: CancellationToken, +): WalletExecutionContext { + let oc: ObservabilityContext; + let wex: WalletExecutionContext; + + if (ws.config.testing.emitObservabilityEvents) { + oc = { + observe(evt) { + if (ws.config.testing.emitObservabilityEvents) { + ws.notify({ + type: NotificationType.TaskObservabilityEvent, + taskId, + event: evt, + }); + } + }, + }; + + wex = getObservedWalletExecutionContext(ws, cancellationToken, oc); + } else { + oc = { + observe(evt) {}, + }; + wex = getNormalWalletExecutionContext(ws, cancellationToken, oc); + } + return wex; +} + +async function callOperationHandlerForTaskId( + wex: WalletExecutionContext, + taskId: TaskIdStr, +): Promise<TaskRunResult> { + const pending = parseTaskIdentifier(taskId); + switch (pending.tag) { + case PendingTaskType.ExchangeUpdate: + return await updateExchangeFromUrlHandler(wex, pending.exchangeBaseUrl); + case PendingTaskType.Refresh: + return await processRefreshGroup(wex, pending.refreshGroupId); + case PendingTaskType.Withdraw: + return await processWithdrawalGroup(wex, pending.withdrawalGroupId); + case PendingTaskType.Purchase: + return await processPurchase(wex, pending.proposalId); + case PendingTaskType.Recoup: + return await processRecoupGroup(wex, pending.recoupGroupId); + case PendingTaskType.Deposit: + return await processDepositGroup(wex, pending.depositGroupId); + case PendingTaskType.Backup: + return await processBackupForProvider(wex, pending.backupProviderBaseUrl); + case PendingTaskType.PeerPushDebit: + return await processPeerPushDebit(wex, pending.pursePub); + case PendingTaskType.PeerPullCredit: + return await processPeerPullCredit(wex, pending.pursePub); + case PendingTaskType.PeerPullDebit: + return await processPeerPullDebit(wex, pending.peerPullDebitId); + case PendingTaskType.PeerPushCredit: + return await processPeerPushCredit(wex, pending.peerPushCreditId); + case PendingTaskType.RewardPickup: + throw Error("not supported anymore"); + default: + return assertUnreachable(pending); + } + throw Error(`not reached ${pending.tag}`); +} + +/** + * Generate an appropriate error transition notification + * for applicable tasks. + * + * Namely, transition notifications are generated for: + * - exchange update errors + * - transactions + */ +async function taskToRetryNotification( + ws: InternalWalletState, + tx: WalletDbAllStoresReadOnlyTransaction, + pendingTaskId: string, + e: TalerErrorDetail | undefined, +): Promise<WalletNotification | undefined> { + const parsedTaskId = parseTaskIdentifier(pendingTaskId); + + switch (parsedTaskId.tag) { + case PendingTaskType.ExchangeUpdate: + return makeExchangeRetryNotification(ws, tx, pendingTaskId, e); + case PendingTaskType.PeerPullCredit: + case PendingTaskType.PeerPullDebit: + case PendingTaskType.Withdraw: + case PendingTaskType.PeerPushCredit: + case PendingTaskType.Deposit: + case PendingTaskType.Refresh: + case PendingTaskType.RewardPickup: + case PendingTaskType.PeerPushDebit: + case PendingTaskType.Purchase: + return makeTransactionRetryNotification(ws, tx, pendingTaskId, e); + case PendingTaskType.Backup: + case PendingTaskType.Recoup: + return undefined; + } +} + +async function getTransactionState( + ws: InternalWalletState, + tx: WalletDbReadOnlyTransaction< + [ + "depositGroups", + "withdrawalGroups", + "purchases", + "refundGroups", + "peerPullCredit", + "peerPullDebit", + "peerPushDebit", + "peerPushCredit", + "rewards", + "refreshGroups", + "denomLossEvents", + ] + >, + transactionId: string, +): Promise<TransactionState | undefined> { + const parsedTxId = parseTransactionIdentifier(transactionId); + if (!parsedTxId) { + throw Error("invalid tx identifier"); + } + switch (parsedTxId.tag) { + case TransactionType.Deposit: { + const rec = await tx.depositGroups.get(parsedTxId.depositGroupId); + if (!rec) { + return undefined; + } + return computeDepositTransactionStatus(rec); + } + case TransactionType.InternalWithdrawal: + case TransactionType.Withdrawal: { + const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId); + if (!rec) { + return undefined; + } + return computeWithdrawalTransactionStatus(rec); + } + case TransactionType.Payment: { + const rec = await tx.purchases.get(parsedTxId.proposalId); + if (!rec) { + return; + } + return computePayMerchantTransactionState(rec); + } + case TransactionType.Refund: { + const rec = await tx.refundGroups.get(parsedTxId.refundGroupId); + if (!rec) { + return undefined; + } + return computeRefundTransactionState(rec); + } + case TransactionType.PeerPullCredit: { + const rec = await tx.peerPullCredit.get(parsedTxId.pursePub); + if (!rec) { + return undefined; + } + return computePeerPullCreditTransactionState(rec); + } + case TransactionType.PeerPullDebit: { + const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId); + if (!rec) { + return undefined; + } + return computePeerPullDebitTransactionState(rec); + } + case TransactionType.PeerPushCredit: { + const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId); + if (!rec) { + return undefined; + } + return computePeerPushCreditTransactionState(rec); + } + case TransactionType.PeerPushDebit: { + const rec = await tx.peerPushDebit.get(parsedTxId.pursePub); + if (!rec) { + return undefined; + } + return computePeerPushDebitTransactionState(rec); + } + case TransactionType.Refresh: { + const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId); + if (!rec) { + return undefined; + } + return computeRefreshTransactionState(rec); + } + case TransactionType.Recoup: + throw Error("not yet supported"); + case TransactionType.DenomLoss: { + const rec = await tx.denomLossEvents.get(parsedTxId.denomLossEventId); + if (!rec) { + return undefined; + } + return computeDenomLossTransactionStatus(rec); + } + default: + assertUnreachable(parsedTxId); + } +} + +async function makeTransactionRetryNotification( + ws: InternalWalletState, + tx: WalletDbAllStoresReadOnlyTransaction, + pendingTaskId: string, + e: TalerErrorDetail | undefined, +): Promise<WalletNotification | undefined> { + const txId = convertTaskToTransactionId(pendingTaskId); + if (!txId) { + return undefined; + } + const txState = await getTransactionState(ws, tx, txId); + if (!txState) { + return undefined; + } + const notif: WalletNotification = { + type: NotificationType.TransactionStateTransition, + transactionId: txId, + oldTxState: txState, + newTxState: txState, + }; + if (e) { + notif.errorInfo = { + code: e.code as number, + hint: e.hint, + }; + } + return notif; +} + +async function makeExchangeRetryNotification( + ws: InternalWalletState, + tx: WalletDbAllStoresReadOnlyTransaction, + pendingTaskId: string, + e: TalerErrorDetail | undefined, +): Promise<WalletNotification | undefined> { + logger.info("making exchange retry notification"); + const parsedTaskId = parseTaskIdentifier(pendingTaskId); + if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) { + throw Error("invalid task identifier"); + } + const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl); + + if (!rec) { + logger.info(`exchange ${parsedTaskId.exchangeBaseUrl} not found`); + return undefined; + } + + const notif: WalletNotification = { + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: parsedTaskId.exchangeBaseUrl, + oldExchangeState: getExchangeState(rec), + newExchangeState: getExchangeState(rec), + }; + if (e) { + notif.errorInfo = { + code: e.code as number, + hint: e.hint, + }; + } + return notif; +} + +export function listTaskForTransactionId(transactionId: string): TaskIdStr[] { + const tid = parseTransactionIdentifier(transactionId); + if (!tid) { + throw Error("invalid task ID"); + } + switch (tid.tag) { + case TransactionType.Deposit: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.Deposit, + depositGroupId: tid.depositGroupId, + }), + ]; + case TransactionType.InternalWithdrawal: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId: tid.withdrawalGroupId, + }), + ]; + case TransactionType.Payment: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId: tid.proposalId, + }), + ]; + case TransactionType.PeerPullCredit: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.PeerPullCredit, + pursePub: tid.pursePub, + }), + ]; + case TransactionType.PeerPullDebit: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.PeerPullDebit, + peerPullDebitId: tid.peerPullDebitId, + }), + ]; + case TransactionType.PeerPushCredit: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.PeerPullCredit, + pursePub: tid.peerPushCreditId, + }), + ]; + case TransactionType.PeerPushDebit: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.PeerPullCredit, + pursePub: tid.pursePub, + }), + ]; + case TransactionType.Recoup: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId: tid.recoupGroupId, + }), + ]; + case TransactionType.Refresh: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.Refresh, + refreshGroupId: tid.refreshGroupId, + }), + ]; + case TransactionType.Refund: + return []; + case TransactionType.Withdrawal: + return [ + constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId: tid.withdrawalGroupId, + }), + ]; + case TransactionType.DenomLoss: + return []; + default: + assertUnreachable(tid); + } +} + +/** + * Convert the task ID for a task that processes a transaction int + * the ID for the transaction. + */ +export function convertTaskToTransactionId( + taskId: string, +): TransactionIdStr | undefined { + const parsedTaskId = parseTaskIdentifier(taskId); + switch (parsedTaskId.tag) { + case PendingTaskType.PeerPullCredit: + return constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub: parsedTaskId.pursePub, + }); + case PendingTaskType.PeerPullDebit: + return constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId: parsedTaskId.peerPullDebitId, + }); + // FIXME: This doesn't distinguish internal-withdrawal. + // Maybe we should have a different task type for that as well? + // Or maybe transaction IDs should be valid task identifiers? + case PendingTaskType.Withdraw: + return constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: parsedTaskId.withdrawalGroupId, + }); + case PendingTaskType.PeerPushCredit: + return constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId: parsedTaskId.peerPushCreditId, + }); + case PendingTaskType.Deposit: + return constructTransactionIdentifier({ + tag: TransactionType.Deposit, + depositGroupId: parsedTaskId.depositGroupId, + }); + case PendingTaskType.Refresh: + return constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId: parsedTaskId.refreshGroupId, + }); + case PendingTaskType.PeerPushDebit: + return constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub: parsedTaskId.pursePub, + }); + case PendingTaskType.Purchase: + return constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: parsedTaskId.proposalId, + }); + default: + return undefined; + } +} + +export interface ActiveTaskIdsResult { + taskIds: TaskIdStr[]; +} + +export async function getActiveTaskIds( + ws: InternalWalletState, +): Promise<ActiveTaskIdsResult> { + const res: ActiveTaskIdsResult = { + taskIds: [], + }; + await ws.db.runReadWriteTx( + { + storeNames: [ + "exchanges", + "refreshGroups", + "withdrawalGroups", + "purchases", + "depositGroups", + "recoupGroups", + "peerPullCredit", + "peerPushDebit", + "peerPullDebit", + "peerPushCredit", + ], + }, + async (tx) => { + const active = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + + // Withdrawals + + { + const activeRecs = + await tx.withdrawalGroups.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId: rec.withdrawalGroupId, + }); + res.taskIds.push(taskId); + } + } + + // Deposits + + { + const activeRecs = + await tx.depositGroups.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Deposit, + depositGroupId: rec.depositGroupId, + }); + res.taskIds.push(taskId); + } + } + + // Refreshes + + { + const activeRecs = + await tx.refreshGroups.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Refresh, + refreshGroupId: rec.refreshGroupId, + }); + res.taskIds.push(taskId); + } + } + + // Purchases + + { + const activeRecs = await tx.purchases.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId: rec.proposalId, + }); + res.taskIds.push(taskId); + } + } + + // peer-push-debit + + { + const activeRecs = + await tx.peerPushDebit.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPushDebit, + pursePub: rec.pursePub, + }); + res.taskIds.push(taskId); + } + } + + // peer-push-credit + + { + const activeRecs = + await tx.peerPushCredit.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPushCredit, + peerPushCreditId: rec.peerPushCreditId, + }); + res.taskIds.push(taskId); + } + } + + // peer-pull-debit + + { + const activeRecs = + await tx.peerPullDebit.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPullDebit, + peerPullDebitId: rec.peerPullDebitId, + }); + res.taskIds.push(taskId); + } + } + + // peer-pull-credit + + { + const activeRecs = + await tx.peerPullCredit.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.PeerPullCredit, + pursePub: rec.pursePub, + }); + res.taskIds.push(taskId); + } + } + + // recoup + + { + const activeRecs = + await tx.recoupGroups.indexes.byStatus.getAll(active); + for (const rec of activeRecs) { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId: rec.recoupGroupId, + }); + res.taskIds.push(taskId); + } + } + + // exchange update + + { + const exchanges = await tx.exchanges.getAll(); + for (const rec of exchanges) { + const taskIdUpdate = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: rec.baseUrl, + }); + res.taskIds.push(taskIdUpdate); + } + } + + // FIXME: Recoup! + }, + ); + + return res; +} diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/testing.ts index d75fb54a7..899c4a8b2 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/testing.ts @@ -25,8 +25,10 @@ */ import { AbsoluteTime, + addPaytoQueryParams, Amounts, AmountString, + checkLogicInvariant, CheckPaymentResponse, codecForAny, codecForCheckPaymentResponse, @@ -37,10 +39,9 @@ import { j2s, Logger, NotificationType, + parsePaytoUri, PreparePayResultType, - stringifyTalerUri, TalerCorebankApiClient, - TalerUriAction, TestPayArgs, TestPayResult, TransactionMajorState, @@ -54,10 +55,9 @@ import { HttpRequestLibrary, readSuccessResponseJsonOrThrow, } from "@gnu-taler/taler-util/http"; -import { OpenedPromise, openPromise } from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { checkLogicInvariant } from "../util/invariants.js"; import { getBalances } from "./balance.js"; +import { genericWaitForState } from "./common.js"; +import { createDepositGroup } from "./deposits.js"; import { fetchFreshExchange } from "./exchanges.js"; import { confirmPay, @@ -74,8 +74,9 @@ import { preparePeerPushCredit, } from "./pay-peer-push-credit.js"; import { initiatePeerPushDebit } from "./pay-peer-push-debit.js"; -import { getPendingOperations } from "./pending.js"; +import { getRefreshesForTransaction } from "./refresh.js"; import { getTransactionById, getTransactions } from "./transactions.js"; +import type { WalletExecutionContext } from "./wallet.js"; import { acceptWithdrawalFromUri } from "./withdraw.js"; const logger = new Logger("operations/testing.ts"); @@ -85,10 +86,22 @@ interface MerchantBackendInfo { 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, + wex: WalletExecutionContext, req: WithdrawTestBalanceRequest, -): Promise<void> { +): Promise<WithdrawTestBalanceResult> { const amount = req.amount; const exchangeBaseUrl = req.exchangeBaseUrl; const corebankApiBaseUrl = req.corebankApiBaseUrl; @@ -109,7 +122,7 @@ export async function withdrawTestBalance( amount, ); - await acceptWithdrawalFromUri(ws, { + const acceptResp = await acceptWithdrawalFromUri(wex, { talerWithdrawUri: wresp.taler_withdraw_uri, selectedExchange: exchangeBaseUrl, forcedDenomSel: req.forcedDenomSel, @@ -118,6 +131,11 @@ export async function withdrawTestBalance( await corebankClient.confirmWithdrawalOperation(bankUser.username, { withdrawalOperationId: wresp.withdrawal_id, }); + + return { + transactionId: acceptResp.transactionId, + accountPaytoUri: bankUser.accountPaytoUri, + }; } /** @@ -151,7 +169,9 @@ async function refund( reason, refund: refundAmount, }; - const resp = await http.postJson(reqUrl.href, refundReq, { + const resp = await http.fetch(reqUrl.href, { + method: "POST", + body: refundReq, headers: getMerchantAuthHeader(merchantBackend), }); const r = await readSuccessResponseJsonOrThrow(resp, codecForAny()); @@ -183,7 +203,9 @@ async function createOrder( wire_transfer_deadline: { t_s: t }, }, }; - const resp = await http.postJson(reqUrl, orderReq, { + const resp = await http.fetch(reqUrl, { + method: "POST", + body: orderReq, headers: getMerchantAuthHeader(merchantBackend), }); const r = await readSuccessResponseJsonOrThrow(resp, codecForAny()); @@ -210,14 +232,19 @@ async function checkPayment( return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse()); } +interface MakePaymentResult { + orderId: string; + paymentTransactionId: string; +} + async function makePayment( - ws: InternalWalletState, + wex: WalletExecutionContext, merchant: MerchantBackendInfo, amount: string, summary: string, -): Promise<{ orderId: string }> { +): Promise<MakePaymentResult> { const orderResp = await createOrder( - ws.http, + wex.http, merchant, amount, summary, @@ -226,7 +253,7 @@ async function makePayment( logger.trace("created order with orderId", orderResp.orderId); - let paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId); + let paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId); logger.trace("payment status", paymentStatus); @@ -235,7 +262,7 @@ async function makePayment( throw Error("no taler://pay/ URI in payment response"); } - const preparePayResult = await preparePayForUri(ws, talerPayUri); + const preparePayResult = await preparePayForUri(wex, talerPayUri); logger.trace("prepare pay result", preparePayResult); @@ -244,14 +271,14 @@ async function makePayment( } const confirmPayResult = await confirmPay( - ws, - preparePayResult.proposalId, + wex, + preparePayResult.transactionId, undefined, ); logger.trace("confirmPayResult", confirmPayResult); - paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId); + paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId); logger.trace("payment status after wallet payment:", paymentStatus); @@ -261,11 +288,12 @@ async function makePayment( return { orderId: orderResp.orderId, + paymentTransactionId: preparePayResult.transactionId, }; } export async function runIntegrationTest( - ws: InternalWalletState, + wex: WalletExecutionContext, args: IntegrationTestArgs, ): Promise<void> { logger.info("running test with arguments", args); @@ -274,15 +302,15 @@ export async function runIntegrationTest( const currency = parsedSpendAmount.currency; logger.info("withdrawing test balance"); - await withdrawTestBalance(ws, { + const withdrawRes1 = await withdrawTestBalance(wex, { amount: args.amountToWithdraw, corebankApiBaseUrl: args.corebankApiBaseUrl, exchangeBaseUrl: args.exchangeBaseUrl, }); - await waitUntilTransactionsFinal(ws); + await waitUntilGivenTransactionsFinal(wex, [withdrawRes1.transactionId]); logger.info("done withdrawing test balance"); - const balance = await getBalances(ws); + const balance = await getBalances(wex); logger.trace(JSON.stringify(balance, null, 2)); @@ -291,10 +319,17 @@ export async function runIntegrationTest( authToken: args.merchantAuthToken, }; - await makePayment(ws, myMerchant, args.amountToSpend, "hello world"); + const makePaymentRes = await makePayment( + wex, + myMerchant, + args.amountToSpend, + "hello world", + ); - // Wait until the refresh is done - await waitUntilTransactionsFinal(ws); + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + makePaymentRes.paymentTransactionId, + ); logger.trace("withdrawing test balance for refund"); const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`); @@ -302,24 +337,23 @@ export async function runIntegrationTest( const refundAmount = Amounts.parseOrThrow(`${currency}:6`); const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`); - await withdrawTestBalance(ws, { + const withdrawRes2 = await withdrawTestBalance(wex, { amount: Amounts.stringify(withdrawAmountTwo), corebankApiBaseUrl: args.corebankApiBaseUrl, exchangeBaseUrl: args.exchangeBaseUrl, }); - // Wait until the withdraw is done - await waitUntilTransactionsFinal(ws); + await waitUntilGivenTransactionsFinal(wex, [withdrawRes2.transactionId]); const { orderId: refundOrderId } = await makePayment( - ws, + wex, myMerchant, Amounts.stringify(spendAmountTwo), "order that will be refunded", ); const refundUri = await refund( - ws.http, + wex.http, myMerchant, refundOrderId, "test refund", @@ -328,17 +362,20 @@ export async function runIntegrationTest( logger.trace("refund URI", refundUri); - await startRefundQueryForUri(ws, refundUri); + const refundResp = await startRefundQueryForUri(wex, refundUri); logger.trace("integration test: applied refund"); // Wait until the refund is done - await waitUntilTransactionsFinal(ws); + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + refundResp.transactionId, + ); logger.trace("integration test: making payment after refund"); - await makePayment( - ws, + const paymentResp2 = await makePayment( + wex, myMerchant, Amounts.stringify(spendAmountThree), "payment after refund", @@ -346,7 +383,13 @@ export async function runIntegrationTest( logger.trace("integration test: make payment done"); - await waitUntilTransactionsFinal(ws); + await waitUntilGivenTransactionsFinal(wex, [ + paymentResp2.paymentTransactionId, + ]); + await waitUntilGivenTransactionsFinal( + wex, + await getRefreshesForTransaction(wex, paymentResp2.paymentTransactionId), + ); logger.trace("integration test: all done!"); } @@ -354,185 +397,181 @@ export async function runIntegrationTest( /** * Wait until all transactions are in a final state. */ -export async function waitUntilTransactionsFinal( - ws: InternalWalletState, +export async function waitUntilAllTransactionsFinal( + wex: WalletExecutionContext, ): 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) { + await wex.taskScheduler.ensureRunning(); + await genericWaitForState(wex, { + filterNotification(notif) { + if (notif.type !== NotificationType.TransactionStateTransition) { + return false; + } switch (notif.newTxState.major) { case TransactionMajorState.Pending: case TransactionMajorState.Aborting: - break; + return false; default: - p.resolve(); + return true; } - } - }); - 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; + }, + async checkState() { + const txs = await getTransactions(wex, { + includeRefreshes: true, + filterByState: "nonfinal", + }); + for (const tx of txs.transactions) { + switch (tx.txState.major) { + case TransactionMajorState.Pending: + case TransactionMajorState.Aborting: + case TransactionMajorState.Suspended: + case TransactionMajorState.SuspendedAborting: + logger.info( + `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, + ); + return false; + } } - } - if (finished) { - break; - } - // Wait until transaction state changed - await p.promise; - } - cancelNotifs(); + return true; + }, + }); logger.info("done waiting until all transactions are in a final state"); } +export async function waitTasksDone( + wex: WalletExecutionContext, +): Promise<void> { + await genericWaitForState(wex, { + async checkState() { + return wex.taskScheduler.isIdle(); + }, + filterNotification(notif) { + return notif.type === NotificationType.Idle; + }, + }); +} + /** - * Wait until pending work is processed. + * Wait until all chosen transactions are in a final state. */ -export async function waitUntilTasksProcessed( - ws: InternalWalletState, +export async function waitUntilGivenTransactionsFinal( + wex: WalletExecutionContext, + transactionIds: string[], ): Promise<void> { - logger.info("waiting until pending work is processed"); - ws.ensureTaskLoopRunning(); - let p: OpenedPromise<void> | undefined = undefined; - const cancelNotifs = ws.addNotificationListener((notif) => { - if (!p) { - return; - } - if (notif.type === NotificationType.PendingOperationProcessed) { - p.resolve(); - } - }); - while (1) { - p = openPromise(); - const pendingTasksResp = await getPendingOperations(ws); - logger.info(`waiting on pending ops: ${j2s(pendingTasksResp)}`); - let finished = true; - for (const task of pendingTasksResp.pendingOperations) { - if (task.isDue) { - finished = false; - } - logger.info(`continuing waiting for task ${task.id}`); - } - if (finished) { - break; - } - // Wait until task is done - await p.promise; + 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; } - logger.info("done waiting until pending work is processed"); - cancelNotifs(); + + const txIdSet = new Set(transactionIds); + + await genericWaitForState(wex, { + filterNotification(notif) { + if (notif.type !== NotificationType.TransactionStateTransition) { + return false; + } + if (!txIdSet.has(notif.transactionId)) { + return false; + } + switch (notif.newTxState.major) { + case TransactionMajorState.Pending: + case TransactionMajorState.Aborting: + case TransactionMajorState.Suspended: + case TransactionMajorState.SuspendedAborting: + return false; + } + return true; + }, + async checkState() { + const txs = await getTransactions(wex, { + includeRefreshes: true, + filterByState: "nonfinal", + }); + for (const tx of txs.transactions) { + if (!txIdSet.has(tx.transactionId)) { + // Don't look at this transaction, we're not interested in it. + continue; + } + switch (tx.txState.major) { + case TransactionMajorState.Pending: + case TransactionMajorState.Aborting: + case TransactionMajorState.Suspended: + case TransactionMajorState.SuspendedAborting: + logger.info( + `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, + ); + return false; + } + } + // No transaction is pending, we're done waiting! + return true; + }, + }); + logger.info("done waiting until given transactions are in a final state"); } export async function waitUntilRefreshesDone( - ws: InternalWalletState, + wex: WalletExecutionContext, ): 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) { + + await genericWaitForState(wex, { + filterNotification(notif) { + if (notif.type !== NotificationType.TransactionStateTransition) { + return false; + } switch (notif.newTxState.major) { case TransactionMajorState.Pending: case TransactionMajorState.Aborting: - break; + return false; 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; + return true; } - 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; + }, + async checkState() { + const txs = await getTransactions(wex, { + includeRefreshes: true, + filterByState: "nonfinal", + }); + for (const tx of txs.transactions) { + if (tx.type !== TransactionType.Refresh) { + continue; + } + switch (tx.txState.major) { + case TransactionMajorState.Pending: + case TransactionMajorState.Aborting: + case TransactionMajorState.Suspended: + case TransactionMajorState.SuspendedAborting: + logger.info( + `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`, + ); + return false; + } } - } - if (finished) { - break; - } - // Wait until transaction state changed - await p.promise; - } - cancelNotifs(); + return true; + }, + }); logger.info("done waiting until all refreshes are in a final state"); } async function waitUntilTransactionPendingReady( - ws: InternalWalletState, + wex: WalletExecutionContext, 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(); - } + return await waitTransactionState(wex, transactionId, { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Ready, }); - 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, + wex: WalletExecutionContext, transactionId: string, txState: TransactionState, ): Promise<void> { @@ -541,45 +580,50 @@ export async function waitTransactionState( txState, )})`, ); - ws.ensureTaskLoopRunning(); - let p: OpenedPromise<void> | undefined = undefined; - const cancelNotifs = ws.addNotificationListener((notif) => { - if (!p) { - return; - } - if (notif.type === NotificationType.TransactionStateTransition) { - p.resolve(); - } + await genericWaitForState(wex, { + async checkState() { + const tx = await getTransactionById(wex, { + transactionId, + }); + return ( + tx.txState.major === txState.major && tx.txState.minor === txState.minor + ); + }, + filterNotification(notif) { + return notif.type === NotificationType.TransactionStateTransition; + }, }); - 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( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + await waitUntilGivenTransactionsFinal(wex, [transactionId]); + await waitUntilGivenTransactionsFinal( + wex, + await getRefreshesForTransaction(wex, transactionId), + ); +} + +export async function waitUntilTransactionFinal( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + await waitUntilGivenTransactionsFinal(wex, [transactionId]); } export async function runIntegrationTest2( - ws: InternalWalletState, + wex: WalletExecutionContext, args: IntegrationTestV2Args, ): Promise<void> { - // FIXME: Make sure that a task look is running, since we're - // waiting for notifications. + await wex.taskScheduler.ensureRunning(); logger.info("running test with arguments", args); - const exchangeInfo = await fetchFreshExchange(ws, args.exchangeBaseUrl); + const exchangeInfo = await fetchFreshExchange(wex, args.exchangeBaseUrl); const currency = exchangeInfo.currency; @@ -587,15 +631,15 @@ export async function runIntegrationTest2( const amountToSpend = Amounts.parseOrThrow(`${currency}:2`); logger.info("withdrawing test balance"); - await withdrawTestBalance(ws, { + const withdrawalRes = await withdrawTestBalance(wex, { amount: Amounts.stringify(amountToWithdraw), corebankApiBaseUrl: args.corebankApiBaseUrl, exchangeBaseUrl: args.exchangeBaseUrl, }); - await waitUntilTransactionsFinal(ws); + await waitUntilTransactionFinal(wex, withdrawalRes.transactionId); logger.info("done withdrawing test balance"); - const balance = await getBalances(ws); + const balance = await getBalances(wex); logger.trace(JSON.stringify(balance, null, 2)); @@ -604,15 +648,17 @@ export async function runIntegrationTest2( authToken: args.merchantAuthToken, }; - await makePayment( - ws, + const makePaymentRes = await makePayment( + wex, myMerchant, Amounts.stringify(amountToSpend), "hello world", ); - // Wait until the refresh is done - await waitUntilTransactionsFinal(ws); + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + makePaymentRes.paymentTransactionId, + ); logger.trace("withdrawing test balance for refund"); const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`); @@ -620,24 +666,24 @@ export async function runIntegrationTest2( const refundAmount = Amounts.parseOrThrow(`${currency}:6`); const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`); - await withdrawTestBalance(ws, { + const withdrawalRes2 = await withdrawTestBalance(wex, { amount: Amounts.stringify(withdrawAmountTwo), corebankApiBaseUrl: args.corebankApiBaseUrl, exchangeBaseUrl: args.exchangeBaseUrl, }); // Wait until the withdraw is done - await waitUntilTransactionsFinal(ws); + await waitUntilTransactionFinal(wex, withdrawalRes2.transactionId); const { orderId: refundOrderId } = await makePayment( - ws, + wex, myMerchant, Amounts.stringify(spendAmountTwo), "order that will be refunded", ); const refundUri = await refund( - ws.http, + wex.http, myMerchant, refundOrderId, "test refund", @@ -646,27 +692,33 @@ export async function runIntegrationTest2( logger.trace("refund URI", refundUri); - await startRefundQueryForUri(ws, refundUri); + const refundResp = await startRefundQueryForUri(wex, refundUri); logger.trace("integration test: applied refund"); // Wait until the refund is done - await waitUntilTransactionsFinal(ws); + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + refundResp.transactionId, + ); logger.trace("integration test: making payment after refund"); - await makePayment( - ws, + const makePaymentRes2 = await makePayment( + wex, myMerchant, Amounts.stringify(spendAmountThree), "payment after refund", ); - logger.trace("integration test: make payment done"); + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + makePaymentRes2.paymentTransactionId, + ); - await waitUntilTransactionsFinal(ws); + logger.trace("integration test: make payment done"); - const peerPushInit = await initiatePeerPushDebit(ws, { + const peerPushInit = await initiatePeerPushDebit(wex, { partialContractTerms: { amount: `${currency}:1` as AmountString, summary: "Payment Peer Push Test", @@ -679,14 +731,8 @@ export async function runIntegrationTest2( }, }); - await waitUntilTransactionPendingReady(ws, peerPushInit.transactionId); - const talerUri = stringifyTalerUri({ - type: TalerUriAction.PayPush, - exchangeBaseUrl: peerPushInit.exchangeBaseUrl, - contractPriv: peerPushInit.contractPriv, - }); - - const txDetails = await getTransactionById(ws, { + await waitUntilTransactionPendingReady(wex, peerPushInit.transactionId); + const txDetails = await getTransactionById(wex, { transactionId: peerPushInit.transactionId, }); @@ -698,15 +744,15 @@ export async function runIntegrationTest2( throw Error("internal invariant failed"); } - const peerPushCredit = await preparePeerPushCredit(ws, { + const peerPushCredit = await preparePeerPushCredit(wex, { talerUri: txDetails.talerUri, }); - await confirmPeerPushCredit(ws, { + await confirmPeerPushCredit(wex, { transactionId: peerPushCredit.transactionId, }); - const peerPullInit = await initiatePeerPullPayment(ws, { + const peerPullInit = await initiatePeerPullPayment(wex, { partialContractTerms: { amount: `${currency}:1` as AmountString, summary: "Payment Peer Pull Test", @@ -719,23 +765,60 @@ export async function runIntegrationTest2( }, }); - await waitUntilTransactionPendingReady(ws, peerPullInit.transactionId); + await waitUntilTransactionPendingReady(wex, peerPullInit.transactionId); - const peerPullInc = await preparePeerPullDebit(ws, { + const peerPullInc = await preparePeerPullDebit(wex, { talerUri: peerPullInit.talerUri, }); - await confirmPeerPullDebit(ws, { - peerPullDebitId: peerPullInc.peerPullDebitId, + await confirmPeerPullDebit(wex, { + transactionId: peerPullInc.transactionId, }); - await waitUntilTransactionsFinal(ws); + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + peerPullInc.transactionId, + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + peerPullInit.transactionId, + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + peerPushCredit.transactionId, + ); + + await waitUntilTransactionWithAssociatedRefreshesFinal( + wex, + peerPushInit.transactionId, + ); + + let depositPayto = withdrawalRes.accountPaytoUri; + + const parsedPayto = parsePaytoUri(depositPayto); + if (!parsedPayto) { + throw Error("invalid payto"); + } + + // Work around libeufin-bank bug where receiver-name is missing + if (!parsedPayto.params["receiver-name"]) { + depositPayto = addPaytoQueryParams(depositPayto, { + "receiver-name": "Test", + }); + } + + await createDepositGroup(wex, { + amount: `${currency}:5` as AmountString, + depositPaytoUri: depositPayto, + }); logger.trace("integration test: all done!"); } export async function testPay( - ws: InternalWalletState, + wex: WalletExecutionContext, args: TestPayArgs, ): Promise<TestPayResult> { logger.trace("creating order"); @@ -744,40 +827,45 @@ export async function testPay( baseUrl: args.merchantBaseUrl, }; const orderResp = await createOrder( - ws.http, + wex.http, merchant, args.amount, args.summary, "taler://fulfillment-success/thank+you", ); logger.trace("created new order with order ID", orderResp.orderId); - const checkPayResp = await checkPayment(ws.http, merchant, orderResp.orderId); + const checkPayResp = await checkPayment( + wex.http, + merchant, + orderResp.orderId, + ); const talerPayUri = checkPayResp.taler_pay_uri; if (!talerPayUri) { console.error("fatal: no taler pay URI received from backend"); process.exit(1); } logger.trace("taler pay URI:", talerPayUri); - const result = await preparePayForUri(ws, talerPayUri); + const result = await preparePayForUri(wex, talerPayUri); if (result.status !== PreparePayResultType.PaymentPossible) { throw Error(`unexpected prepare pay status: ${result.status}`); } const r = await confirmPay( - ws, - result.proposalId, + wex, + result.transactionId, undefined, args.forcedCoinSel, ); if (r.type != ConfirmPayResultType.Done) { throw Error("payment not done"); } - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { + const purchase = await wex.db.runReadOnlyTx( + { storeNames: ["purchases"] }, + async (tx) => { return tx.purchases.get(result.proposalId); - }); + }, + ); checkLogicInvariant(!!purchase); return { - payCoinSelection: purchase.payInfo?.payCoinSelection!, + numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0, }; } diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index 9deb050d8..9a9fb524f 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -17,9 +17,12 @@ /** * Imports. */ +import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, Amounts, + assertUnreachable, + checkDbInvariant, DepositTransactionTrackingState, j2s, Logger, @@ -28,11 +31,13 @@ import { PeerContractTerms, RefundInfoShort, RefundPaymentInfo, + ScopeType, stringifyPayPullUri, stringifyPayPushUri, TalerErrorCode, TalerPreciseTimestamp, Transaction, + TransactionAction, TransactionByIdRequest, TransactionIdStr, TransactionMajorState, @@ -41,136 +46,96 @@ import { TransactionsResponse, TransactionState, TransactionType, + TransactionWithdrawal, WalletContractData, + WithdrawalTransactionByURIRequest, WithdrawalType, } from "@gnu-taler/taler-util"; import { + constructTaskIdentifier, + PendingTaskType, + TaskIdentifiers, + TaskIdStr, + TransactionContext, +} from "./common.js"; +import { + DenomLossEventRecord, DepositElementStatus, DepositGroupRecord, - ExchangeDetailsRecord, + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, OperationRetryRecord, PeerPullCreditRecord, PeerPullDebitRecordStatus, PeerPullPaymentIncomingRecord, PeerPushCreditStatus, PeerPushDebitRecord, + PeerPushDebitStatus, PeerPushPaymentIncomingRecord, PurchaseRecord, PurchaseStatus, RefreshGroupRecord, RefreshOperationStatus, RefundGroupRecord, - RewardRecord, + timestampPreciseFromDb, + timestampProtocolFromDb, + WalletDbReadOnlyTransaction, WithdrawalGroupRecord, WithdrawalGroupStatus, WithdrawalRecordType, -} from "../db.js"; -import { - GetReadOnlyAccess, - PeerPushDebitStatus, - timestampPreciseFromDb, - timestampProtocolFromDb, - WalletStoresV1, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { PendingTaskType } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { - constructTaskIdentifier, - resetPendingTaskTimeout, - TaskIdentifiers, - TombstoneTag, -} from "./common.js"; +} from "./db.js"; import { - abortDepositGroup, computeDepositTransactionActions, computeDepositTransactionStatus, - deleteDepositGroup, - failDepositTransaction, - resumeDepositGroup, - suspendDepositGroup, + DepositTransactionContext, } from "./deposits.js"; -import { getExchangeDetails } from "./exchanges.js"; import { - abortPayMerchant, + computeDenomLossTransactionStatus, + DenomLossTransactionContext, + ExchangeWireDetails, + fetchFreshExchange, + getExchangeWireDetailsInTx, +} from "./exchanges.js"; +import { computePayMerchantTransactionActions, computePayMerchantTransactionState, computeRefundTransactionState, expectProposalDownload, extractContractData, - failPaymentTransaction, - resumePayMerchant, - suspendPayMerchant, + PayMerchantTransactionContext, + RefundTransactionContext, } from "./pay-merchant.js"; import { - abortPeerPullCreditTransaction, computePeerPullCreditTransactionActions, computePeerPullCreditTransactionState, - failPeerPullCreditTransaction, - resumePeerPullCreditTransaction, - suspendPeerPullCreditTransaction, + PeerPullCreditTransactionContext, } from "./pay-peer-pull-credit.js"; import { - abortPeerPullDebitTransaction, computePeerPullDebitTransactionActions, computePeerPullDebitTransactionState, - failPeerPullDebitTransaction, - resumePeerPullDebitTransaction, - suspendPeerPullDebitTransaction, + PeerPullDebitTransactionContext, } from "./pay-peer-pull-debit.js"; import { - abortPeerPushCreditTransaction, computePeerPushCreditTransactionActions, computePeerPushCreditTransactionState, - failPeerPushCreditTransaction, - resumePeerPushCreditTransaction, - suspendPeerPushCreditTransaction, + PeerPushCreditTransactionContext, } from "./pay-peer-push-credit.js"; import { - abortPeerPushDebitTransaction, computePeerPushDebitTransactionActions, computePeerPushDebitTransactionState, - failPeerPushDebitTransaction, - resumePeerPushDebitTransaction, - suspendPeerPushDebitTransaction, + PeerPushDebitTransactionContext, } from "./pay-peer-push-debit.js"; import { - iterRecordsForDeposit, - iterRecordsForPeerPullDebit, - iterRecordsForPeerPullInitiation as iterRecordsForPeerPullCredit, - iterRecordsForPeerPushCredit, - iterRecordsForPeerPushInitiation as iterRecordsForPeerPushDebit, - iterRecordsForPurchase, - iterRecordsForRefresh, - iterRecordsForRefund, - iterRecordsForReward, - iterRecordsForWithdrawal, -} from "./pending.js"; -import { - abortRefreshGroup, computeRefreshTransactionActions, computeRefreshTransactionState, - failRefreshGroup, - resumeRefreshGroup, - suspendRefreshGroup, + RefreshTransactionContext, } from "./refresh.js"; +import type { WalletExecutionContext } from "./wallet.js"; import { - abortTipTransaction, - computeRewardTransactionStatus, - computeTipTransactionActions, - failTipTransaction, - resumeTipTransaction, - suspendRewardTransaction, -} from "./reward.js"; -import { - abortWithdrawalTransaction, augmentPaytoUrisForWithdrawal, computeWithdrawalTransactionActions, computeWithdrawalTransactionStatus, - failWithdrawalTransaction, - resumeWithdrawalTransaction, - suspendWithdrawalTransaction, + WithdrawTransactionContext, } from "./withdraw.js"; const logger = new Logger("taler-wallet-core:transactions.ts"); @@ -178,11 +143,39 @@ const logger = new Logger("taler-wallet-core:transactions.ts"); function shouldSkipCurrency( transactionsRequest: TransactionsRequest | undefined, currency: string, + exchangesInTransaction: string[], ): boolean { - if (!transactionsRequest?.currency) { - return false; + 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); + } } - return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase(); + // FIXME: remove next release + if (transactionsRequest?.currency) { + return ( + transactionsRequest.currency.toLowerCase() !== currency.toLowerCase() + ); + } + return false; } function shouldSkipSearch( @@ -206,7 +199,6 @@ function shouldSkipSearch( */ const txOrder: { [t in TransactionType]: number } = { [TransactionType.Withdrawal]: 1, - [TransactionType.Reward]: 2, [TransactionType.Payment]: 3, [TransactionType.PeerPullCredit]: 4, [TransactionType.PeerPullDebit]: 5, @@ -215,11 +207,13 @@ const txOrder: { [t in TransactionType]: number } = { [TransactionType.Refund]: 8, [TransactionType.Deposit]: 9, [TransactionType.Refresh]: 10, + [TransactionType.Recoup]: 11, [TransactionType.InternalWithdrawal]: 12, + [TransactionType.DenomLoss]: 13, }; export async function getTransactionById( - ws: InternalWalletState, + wex: WalletExecutionContext, req: TransactionByIdRequest, ): Promise<Transaction> { const parsedTx = parseTransactionIdentifier(req.transactionId); @@ -232,65 +226,89 @@ export async function getTransactionById( case TransactionType.InternalWithdrawal: case TransactionType.Withdrawal: { const withdrawalGroupId = parsedTx.withdrawalGroupId; - return await ws.db - .mktx((x) => [ - x.withdrawalGroups, - x.exchangeDetails, - x.exchanges, - x.operationRetries, - ]) - .runReadWrite(async (tx) => { - const withdrawalGroupRecord = await tx.withdrawalGroups.get( - withdrawalGroupId, - ); + return await wex.db.runReadWriteTx( + { + storeNames: [ + "withdrawalGroups", + "exchangeDetails", + "exchanges", + "operationRetries", + ], + }, + async (tx) => { + const withdrawalGroupRecord = + await tx.withdrawalGroups.get(withdrawalGroupId); if (!withdrawalGroupRecord) throw Error("not found"); const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); const ort = await tx.operationRetries.get(opId); + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + withdrawalGroupRecord.exchangeBaseUrl, + ); + if (!exchangeDetails) throw Error("not exchange details"); + if ( withdrawalGroupRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated ) { return buildTransactionForBankIntegratedWithdraw( withdrawalGroupRecord, + exchangeDetails, ort, ); } - const exchangeDetails = await getExchangeDetails( - tx, - withdrawalGroupRecord.exchangeBaseUrl, - ); - if (!exchangeDetails) throw Error("not exchange details"); return buildTransactionForManualWithdraw( withdrawalGroupRecord, exchangeDetails, ort, ); - }); + }, + ); + } + + case TransactionType.DenomLoss: { + const rec = await wex.db.runReadOnlyTx( + { storeNames: ["denomLossEvents"] }, + async (tx) => { + return tx.denomLossEvents.get(parsedTx.denomLossEventId); + }, + ); + if (!rec) { + throw Error("denom loss record not found"); + } + return buildTransactionForDenomLoss(rec); } + case TransactionType.Recoup: + throw new Error("not yet supported"); + case TransactionType.Payment: { const proposalId = parsedTx.proposalId; - return await ws.db - .mktx((x) => [ - x.purchases, - x.tombstones, - x.operationRetries, - x.refundGroups, - x.contractTerms, - ]) - .runReadWrite(async (tx) => { + return await wex.db.runReadWriteTx( + { + storeNames: [ + "purchases", + "tombstones", + "operationRetries", + "contractTerms", + "refundGroups", + ], + }, + async (tx) => { const purchase = await tx.purchases.get(proposalId); if (!purchase) throw Error("not found"); - const download = await expectProposalDownload(ws, purchase, tx); + const download = await expectProposalDownload(wex, purchase, tx); const contractData = download.contractData; const payOpId = TaskIdentifiers.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); - const refunds = await tx.refundGroups.indexes.byProposalId.getAll(purchase.proposalId) + const refunds = await tx.refundGroups.indexes.byProposalId.getAll( + purchase.proposalId, + ); return buildTransactionForPurchase( purchase, @@ -298,34 +316,33 @@ export async function getTransactionById( refunds, payRetryRecord, ); - }); + }, + ); } case TransactionType.Refresh: { - // FIXME: We should return info about the refresh here! - throw Error(`no tx for refresh`); - } - - case TransactionType.Reward: { - const tipId = parsedTx.walletRewardId; - return await ws.db - .mktx((x) => [x.rewards, x.operationRetries]) - .runReadWrite(async (tx) => { - const tipRecord = await tx.rewards.get(tipId); - if (!tipRecord) throw Error("not found"); - + // FIXME: We should return info about the refresh here!; + const refreshGroupId = parsedTx.refreshGroupId; + return await wex.db.runReadOnlyTx( + { storeNames: ["refreshGroups", "operationRetries"] }, + async (tx) => { + const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId); + if (!refreshGroupRec) { + throw Error("not found"); + } const retries = await tx.operationRetries.get( - TaskIdentifiers.forTipPickup(tipRecord), + TaskIdentifiers.forRefresh(refreshGroupRec), ); - return buildTransactionForTip(tipRecord, retries); - }); + return buildTransactionForRefresh(refreshGroupRec, retries); + }, + ); } case TransactionType.Deposit: { const depositGroupId = parsedTx.depositGroupId; - return await ws.db - .mktx((x) => [x.depositGroups, x.operationRetries]) - .runReadWrite(async (tx) => { + return await wex.db.runReadWriteTx( + { storeNames: ["depositGroups", "operationRetries"] }, + async (tx) => { const depositRecord = await tx.depositGroups.get(depositGroupId); if (!depositRecord) throw Error("not found"); @@ -333,13 +350,21 @@ export async function getTransactionById( TaskIdentifiers.forDeposit(depositRecord), ); return buildTransactionForDeposit(depositRecord, retries); - }); + }, + ); } case TransactionType.Refund: { - return await ws.db - .mktx((x) => [x.refundGroups, x.contractTerms, x.purchases]) - .runReadOnly(async (tx) => { + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "refundGroups", + "purchases", + "operationRetries", + "contractTerms", + ], + }, + async (tx) => { const refundRecord = await tx.refundGroups.get( parsedTx.refundGroupId, ); @@ -351,12 +376,13 @@ export async function getTransactionById( refundRecord?.proposalId, ); return buildTransactionForRefund(refundRecord, contractData); - }); + }, + ); } case TransactionType.PeerPullDebit: { - return await ws.db - .mktx((x) => [x.peerPullDebit, x.contractTerms]) - .runReadWrite(async (tx) => { + return await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit", "contractTerms"] }, + async (tx) => { const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId); if (!debit) throw Error("not found"); const contractTermsRec = await tx.contractTerms.get( @@ -368,13 +394,14 @@ export async function getTransactionById( debit, contractTermsRec.contractTermsRaw, ); - }); + }, + ); } case TransactionType.PeerPushDebit: { - return await ws.db - .mktx((x) => [x.peerPushDebit, x.contractTerms]) - .runReadWrite(async (tx) => { + return await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit", "contractTerms"] }, + async (tx) => { const debit = await tx.peerPushDebit.get(parsedTx.pursePub); if (!debit) throw Error("not found"); const ct = await tx.contractTerms.get(debit.contractTermsHash); @@ -383,19 +410,22 @@ export async function getTransactionById( debit, ct.contractTermsRaw, ); - }); + }, + ); } case TransactionType.PeerPushCredit: { const peerPushCreditId = parsedTx.peerPushCreditId; - return await ws.db - .mktx((x) => [ - x.peerPushCredit, - x.contractTerms, - x.withdrawalGroups, - x.operationRetries, - ]) - .runReadWrite(async (tx) => { + return await wex.db.runReadWriteTx( + { + storeNames: [ + "peerPushCredit", + "contractTerms", + "withdrawalGroups", + "operationRetries", + ], + }, + async (tx) => { const pushInc = await tx.peerPushCredit.get(peerPushCreditId); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); @@ -420,19 +450,22 @@ export async function getTransactionById( wg, wgOrt, ); - }); + }, + ); } case TransactionType.PeerPullCredit: { const pursePub = parsedTx.pursePub; - return await ws.db - .mktx((x) => [ - x.peerPullCredit, - x.contractTerms, - x.withdrawalGroups, - x.operationRetries, - ]) - .runReadWrite(async (tx) => { + return await wex.db.runReadWriteTx( + { + storeNames: [ + "peerPullCredit", + "contractTerms", + "withdrawalGroups", + "operationRetries", + ], + }, + async (tx) => { const pushInc = await tx.peerPullCredit.get(pursePub); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); @@ -458,7 +491,8 @@ export async function getTransactionById( wg, wgOrt, ); - }); + }, + ); } } } @@ -477,11 +511,14 @@ function buildTransactionForPushPaymentDebit( contractPriv: pi.contractPriv, }); } + const txState = computePeerPushDebitTransactionState(pi); return { type: TransactionType.PeerPushDebit, - txState: computePeerPushDebitTransactionState(pi), + txState, txActions: computePeerPushDebitTransactionActions(pi), - amountEffective: pi.totalCost, + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost)) + : pi.totalCost, amountRaw: pi.amount, exchangeBaseUrl: pi.exchangeBaseUrl, info: { @@ -503,13 +540,16 @@ function buildTransactionForPullPaymentDebit( contractTerms: PeerContractTerms, ort?: OperationRetryRecord, ): Transaction { + const txState = computePeerPullDebitTransactionState(pi); return { type: TransactionType.PeerPullDebit, - txState: computePeerPullDebitTransactionState(pi), + txState, txActions: computePeerPullDebitTransactionActions(pi), - amountEffective: pi.coinSel?.totalCost - ? pi.coinSel?.totalCost - : Amounts.stringify(pi.amount), + 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: { @@ -544,18 +584,23 @@ function buildTransactionForPeerPullCredit( const silentWithdrawalErrorForInvoice = wsrOrt?.lastError && wsrOrt.lastError.code === - TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE && + 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); + checkDbInvariant(wsr.instructedAmount !== undefined, "wg uninitialized"); + checkDbInvariant(wsr.denomsSel !== undefined, "wg uninitialized"); return { type: TransactionType.PeerPullCredit, - txState: computePeerPullCreditTransactionState(pullCredit), + txState, txActions: computePeerPullCreditTransactionActions(pullCredit), - amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue), + 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), @@ -574,19 +619,22 @@ function buildTransactionForPeerPullCredit( kycUrl: pullCredit.kycUrl, ...(wsrOrt?.lastError ? { - error: silentWithdrawalErrorForInvoice - ? undefined - : wsrOrt.lastError, - } + error: silentWithdrawalErrorForInvoice + ? undefined + : wsrOrt.lastError, + } : {}), }; } + const txState = computePeerPullCreditTransactionState(pullCredit); return { type: TransactionType.PeerPullCredit, - txState: computePeerPullCreditTransactionState(pullCredit), + txState, txActions: computePeerPullCreditTransactionActions(pullCredit), - amountEffective: Amounts.stringify(pullCredit.estimatedAmountEffective), + 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), @@ -611,26 +659,31 @@ function buildTransactionForPeerPushCredit( pushInc: PeerPushPaymentIncomingRecord, pushOrt: OperationRetryRecord | undefined, peerContractTerms: PeerContractTerms, - wsr: WithdrawalGroupRecord | undefined, + wg: WithdrawalGroupRecord | undefined, wsrOrt: OperationRetryRecord | undefined, ): Transaction { - if (wsr) { - if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) { + if (wg) { + if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) { throw Error("invalid withdrawal group type for push payment credit"); } + checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized"); + checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized"); + const txState = computePeerPushCreditTransactionState(pushInc); return { type: TransactionType.PeerPushCredit, - txState: computePeerPushCreditTransactionState(pushInc), + txState, txActions: computePeerPushCreditTransactionActions(pushInc), - amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue), - amountRaw: Amounts.stringify(wsr.instructedAmount), - exchangeBaseUrl: wsr.exchangeBaseUrl, + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wg.instructedAmount)) + : Amounts.stringify(wg.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wg.instructedAmount), + exchangeBaseUrl: wg.exchangeBaseUrl, info: { expiration: peerContractTerms.purse_expiration, summary: peerContractTerms.summary, }, - timestamp: timestampPreciseFromDb(wsr.timestampStart), + timestamp: timestampPreciseFromDb(wg.timestampStart), transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushCreditId: pushInc.peerPushCreditId, @@ -640,12 +693,15 @@ function buildTransactionForPeerPushCredit( }; } + const txState = computePeerPushCreditTransactionState(pushInc); return { type: TransactionType.PeerPushCredit, - txState: computePeerPushCreditTransactionState(pushInc), + txState, txActions: computePeerPushCreditTransactionActions(pushInc), - // FIXME: This is wrong, needs to consider fees! - amountEffective: Amounts.stringify(peerContractTerms.amount), + 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: { @@ -663,79 +719,103 @@ function buildTransactionForPeerPushCredit( } function buildTransactionForBankIntegratedWithdraw( - wgRecord: WithdrawalGroupRecord, + wg: WithdrawalGroupRecord, + exchangeDetails: ExchangeWireDetails, ort?: OperationRetryRecord, -): Transaction { - if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) +): TransactionWithdrawal { + if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error(""); - + } + const txState = computeWithdrawalTransactionStatus(wg); + const zero = Amounts.stringify( + Amounts.zeroOfCurrency(exchangeDetails.currency), + ); return { type: TransactionType.Withdrawal, - txState: computeWithdrawalTransactionStatus(wgRecord), - txActions: computeWithdrawalTransactionActions(wgRecord), - amountEffective: Amounts.stringify(wgRecord.denomsSel.totalCoinValue), - amountRaw: Amounts.stringify(wgRecord.instructedAmount), + txState, + txActions: computeWithdrawalTransactionActions(wg), + exchangeBaseUrl: wg.exchangeBaseUrl, + amountEffective: + isUnsuccessfulTransaction(txState) || !wg.denomsSel + ? zero + : Amounts.stringify(wg.denomsSel.totalCoinValue), + amountRaw: !wg.instructedAmount + ? zero + : Amounts.stringify(wg.instructedAmount), withdrawalDetails: { type: WithdrawalType.TalerBankIntegrationApi, - confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false, - exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts, - reservePub: wgRecord.reservePub, - bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl, + confirmed: wg.wgInfo.bankInfo.timestampBankConfirmed ? true : false, + exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts, + reservePub: wg.reservePub, + bankConfirmationUrl: wg.wgInfo.bankInfo.confirmUrl, reserveIsReady: - wgRecord.status === WithdrawalGroupStatus.Done || - wgRecord.status === WithdrawalGroupStatus.PendingReady, + wg.status === WithdrawalGroupStatus.Done || + wg.status === WithdrawalGroupStatus.PendingReady, }, - kycUrl: wgRecord.kycUrl, - exchangeBaseUrl: wgRecord.exchangeBaseUrl, - timestamp: timestampPreciseFromDb(wgRecord.timestampStart), + kycUrl: wg.kycUrl, + timestamp: timestampPreciseFromDb(wg.timestampStart), transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, - withdrawalGroupId: wgRecord.withdrawalGroupId, + withdrawalGroupId: wg.withdrawalGroupId, }), ...(ort?.lastError ? { error: ort.lastError } : {}), }; } +export function isUnsuccessfulTransaction(state: TransactionState): boolean { + return ( + state.major === TransactionMajorState.Aborted || + state.major === TransactionMajorState.Expired || + state.major === TransactionMajorState.Aborting || + state.major === TransactionMajorState.Deleted || + state.major === TransactionMajorState.Failed + ); +} + function buildTransactionForManualWithdraw( - withdrawalGroup: WithdrawalGroupRecord, - exchangeDetails: ExchangeDetailsRecord, + wg: WithdrawalGroupRecord, + exchangeDetails: ExchangeWireDetails, ort?: OperationRetryRecord, -): Transaction { - if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual) +): TransactionWithdrawal { + if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual) throw Error(""); const plainPaytoUris = exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; + checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized"); + checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized"); const exchangePaytoUris = augmentPaytoUrisForWithdrawal( plainPaytoUris, - withdrawalGroup.reservePub, - withdrawalGroup.instructedAmount, + wg.reservePub, + wg.instructedAmount, ); + const txState = computeWithdrawalTransactionStatus(wg); + return { type: TransactionType.Withdrawal, - txState: computeWithdrawalTransactionStatus(withdrawalGroup), - txActions: computeWithdrawalTransactionActions(withdrawalGroup), - amountEffective: Amounts.stringify( - withdrawalGroup.denomsSel.totalCoinValue, - ), - amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount), + txState, + txActions: computeWithdrawalTransactionActions(wg), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wg.instructedAmount)) + : Amounts.stringify(wg.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wg.instructedAmount), withdrawalDetails: { type: WithdrawalType.ManualTransfer, - reservePub: withdrawalGroup.reservePub, + reservePub: wg.reservePub, exchangePaytoUris, - exchangeCreditAccountDetails: withdrawalGroup.wgInfo.exchangeCreditAccounts, + exchangeCreditAccountDetails: wg.wgInfo.exchangeCreditAccounts, reserveIsReady: - withdrawalGroup.status === WithdrawalGroupStatus.Done || - withdrawalGroup.status === WithdrawalGroupStatus.PendingReady, + wg.status === WithdrawalGroupStatus.Done || + wg.status === WithdrawalGroupStatus.PendingReady, }, - kycUrl: withdrawalGroup.kycUrl, - exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl, - timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart), + kycUrl: wg.kycUrl, + exchangeBaseUrl: wg.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(wg.timestampStart), transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, - withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + withdrawalGroupId: wg.withdrawalGroupId, }), ...(ort?.lastError ? { error: ort.lastError } : {}), }; @@ -755,9 +835,12 @@ function buildTransactionForRefund( }; } + const txState = computeRefundTransactionState(refundRecord); return { type: TransactionType.Refund, - amountEffective: refundRecord.amountEffective, + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective)) + : refundRecord.amountEffective, amountRaw: refundRecord.amountRaw, refundedTransactionId: constructTransactionIdentifier({ tag: TransactionType.Payment, @@ -768,7 +851,7 @@ function buildTransactionForRefund( tag: TransactionType.Refund, refundGroupId: refundRecord.refundGroupId, }), - txState: computeRefundTransactionState(refundRecord), + txState, txActions: [], paymentInfo, }; @@ -786,21 +869,21 @@ function buildTransactionForRefresh( refreshGroupRecord.currency, refreshGroupRecord.expectedOutputPerCoin, ).amount; + const txState = computeRefreshTransactionState(refreshGroupRecord); return { type: TransactionType.Refresh, - txState: computeRefreshTransactionState(refreshGroupRecord), + txState, txActions: computeRefreshTransactionActions(refreshGroupRecord), refreshReason: refreshGroupRecord.reason, - amountEffective: Amounts.stringify( - Amounts.zeroOfCurrency(refreshGroupRecord.currency), - ), + 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.reasonDetails?.originatingTransactionId, + originatingTransactionId: refreshGroupRecord.originatingTransactionId, timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated), transactionId: constructTransactionIdentifier({ tag: TransactionType.Refresh, @@ -810,15 +893,37 @@ function buildTransactionForRefresh( }; } +function buildTransactionForDenomLoss(rec: DenomLossEventRecord): Transaction { + const txState = computeDenomLossTransactionStatus(rec); + return { + type: TransactionType.DenomLoss, + txState, + txActions: [TransactionAction.Delete], + amountRaw: Amounts.stringify(rec.amount), + amountEffective: Amounts.stringify(rec.amount), + timestamp: timestampPreciseFromDb(rec.timestampCreated), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.DenomLoss, + denomLossEventId: rec.denomLossEventId, + }), + lossEventType: rec.eventType, + exchangeBaseUrl: rec.exchangeBaseUrl, + }; +} + function buildTransactionForDeposit( dg: DepositGroupRecord, ort?: OperationRetryRecord, ): Transaction { let deposited = true; - for (const d of dg.statusPerCoin) { - if (d == DepositElementStatus.DepositPending) { - deposited = false; + if (dg.statusPerCoin) { + for (const d of dg.statusPerCoin) { + if (d == DepositElementStatus.DepositPending) { + deposited = false; + } } + } else { + deposited = false; } const trackingState: DepositTransactionTrackingState[] = []; @@ -832,12 +937,26 @@ function buildTransactionForDeposit( }); } + let wireTransferProgress = 0; + if (dg.statusPerCoin) { + wireTransferProgress = + (100 * + dg.statusPerCoin.reduce( + (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0), + 0, + )) / + dg.statusPerCoin.length; + } + + const txState = computeDepositTransactionStatus(dg); return { type: TransactionType.Deposit, - txState: computeDepositTransactionStatus(dg), + txState, txActions: computeDepositTransactionActions(dg), amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount), - amountEffective: Amounts.stringify(dg.totalPayCost), + 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), @@ -845,13 +964,7 @@ function buildTransactionForDeposit( tag: TransactionType.Deposit, depositGroupId: dg.depositGroupId, }), - wireTransferProgress: - (100 * - dg.statusPerCoin.reduce( - (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0), - 0, - )) / - dg.statusPerCoin.length, + wireTransferProgress, depositGroupId: dg.depositGroupId, trackingState, deposited, @@ -859,33 +972,8 @@ function buildTransactionForDeposit( }; } -function buildTransactionForTip( - tipRecord: RewardRecord, - ort?: OperationRetryRecord, -): Transaction { - checkLogicInvariant(!!tipRecord.acceptedTimestamp); - - return { - type: TransactionType.Reward, - txState: computeRewardTransactionStatus(tipRecord), - txActions: computeTipTransactionActions(tipRecord), - amountEffective: 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: GetReadOnlyAccess<{ - purchases: typeof WalletStoresV1.purchases; - contractTerms: typeof WalletStoresV1.contractTerms; - }>, + tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>, proposalId: string, ): Promise<WalletContractData | undefined> { let contractData: WalletContractData | undefined = undefined; @@ -908,16 +996,22 @@ async function lookupMaybeContractData( return contractData; } -async function buildTransactionForPurchase( +function buildTransactionForPurchase( purchaseRecord: PurchaseRecord, contractData: WalletContractData, refundsInfo: RefundGroupRecord[], ort?: OperationRetryRecord, -): Promise<Transaction> { +): Transaction { const zero = Amounts.zeroOfAmount(contractData.amount); const info: OrderShortInfo = { - merchant: contractData.merchant, + merchant: { + name: contractData.merchant.name, + address: contractData.merchant.address, + email: contractData.merchant.email, + jurisdiction: contractData.merchant.jurisdiction, + website: contractData.merchant.website, + }, orderId: contractData.orderId, summary: contractData.summary, summary_i18n: contractData.summaryI18n, @@ -928,26 +1022,31 @@ async function buildTransactionForPurchase( info.fulfillmentUrl = contractData.fulfillmentUrl; } - const refunds: RefundInfoShort[] = refundsInfo.map(r => ({ + const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({ amountEffective: r.amountEffective, amountRaw: r.amountRaw, - timestamp: TalerPreciseTimestamp.round(timestampPreciseFromDb(r.timestampCreated)), + 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: computePayMerchantTransactionState(purchaseRecord), + txState, txActions: computePayMerchantTransactionActions(purchaseRecord), amountRaw: Amounts.stringify(contractData.amount), - amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(zero) + : Amounts.stringify(purchaseRecord.payInfo.totalPayCost), totalRefundRaw: Amounts.stringify(zero), // FIXME! totalRefundEffective: Amounts.stringify(zero), // FIXME! refundPending: @@ -969,11 +1068,63 @@ async function buildTransactionForPurchase( }; } +export async function getWithdrawalTransactionByUri( + wex: WalletExecutionContext, + request: WithdrawalTransactionByURIRequest, +): Promise<TransactionWithdrawal | undefined> { + return await wex.db.runReadWriteTx( + { + storeNames: [ + "withdrawalGroups", + "exchangeDetails", + "exchanges", + "operationRetries", + ], + }, + async (tx) => { + const withdrawalGroupRecord = + await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + request.talerWithdrawUri, + ); + + if (!withdrawalGroupRecord) { + return undefined; + } + + const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); + const ort = await tx.operationRetries.get(opId); + + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + withdrawalGroupRecord.exchangeBaseUrl, + ); + if (!exchangeDetails) throw Error("not exchange details"); + + if ( + withdrawalGroupRecord.wgInfo.withdrawalType === + WithdrawalRecordType.BankIntegrated + ) { + return buildTransactionForBankIntegratedWithdraw( + withdrawalGroupRecord, + exchangeDetails, + ort, + ); + } + + return buildTransactionForManualWithdraw( + withdrawalGroupRecord, + exchangeDetails, + ort, + ); + }, + ); +} + /** * Retrieve the full event history for this wallet. */ export async function getTransactions( - ws: InternalWalletState, + wex: WalletExecutionContext, transactionsRequest?: TransactionsRequest, ): Promise<TransactionsResponse> { const transactions: Transaction[] = []; @@ -983,33 +1134,42 @@ export async function getTransactions( filter.onlyState = transactionsRequest.filterByState; } - await ws.db - .mktx((x) => [ - x.coins, - x.denominations, - x.depositGroups, - x.exchangeDetails, - x.exchanges, - x.operationRetries, - x.peerPullDebit, - x.peerPushDebit, - x.peerPushCredit, - x.peerPullCredit, - x.planchets, - x.purchases, - x.contractTerms, - x.recoupGroups, - x.rewards, - x.tombstones, - x.withdrawalGroups, - x.refreshGroups, - x.refundGroups, - ]) - .runReadOnly(async (tx) => { + await wex.db.runReadOnlyTx( + { + storeNames: [ + "coins", + "denominations", + "depositGroups", + "exchangeDetails", + "exchanges", + "operationRetries", + "peerPullDebit", + "peerPushDebit", + "peerPushCredit", + "peerPullCredit", + "planchets", + "purchases", + "contractTerms", + "recoupGroups", + "rewards", + "tombstones", + "withdrawalGroups", + "refreshGroups", + "refundGroups", + "denomLossEvents", + ], + }, + async (tx) => { await iterRecordsForPeerPushDebit(tx, filter, async (pi) => { const amount = Amounts.parseOrThrow(pi.amount); - - if (shouldSkipCurrency(transactionsRequest, amount.currency)) { + const exchangesInTx = [pi.exchangeBaseUrl]; + if ( + shouldSkipCurrency( + transactionsRequest, + amount.currency, + exchangesInTx, + ) + ) { return; } if (shouldSkipSearch(transactionsRequest, [])) { @@ -1024,7 +1184,14 @@ export async function getTransactions( await iterRecordsForPeerPullDebit(tx, filter, async (pi) => { const amount = Amounts.parseOrThrow(pi.amount); - if (shouldSkipCurrency(transactionsRequest, amount.currency)) { + const exchangesInTx = [pi.exchangeBaseUrl]; + if ( + shouldSkipCurrency( + transactionsRequest, + amount.currency, + exchangesInTx, + ) + ) { return; } if (shouldSkipSearch(transactionsRequest, [])) { @@ -1058,7 +1225,10 @@ export async function getTransactions( // Legacy transaction return; } - if (shouldSkipCurrency(transactionsRequest, pi.currency)) { + const exchangesInTx = [pi.exchangeBaseUrl]; + if ( + shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx) + ) { return; } if (shouldSkipSearch(transactionsRequest, [])) { @@ -1096,7 +1266,8 @@ export async function getTransactions( await iterRecordsForPeerPullCredit(tx, filter, async (pi) => { const currency = Amounts.currencyOf(pi.amount); - if (shouldSkipCurrency(transactionsRequest, currency)) { + const exchangesInTx = [pi.exchangeBaseUrl]; + if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) { return; } if (shouldSkipSearch(transactionsRequest, [])) { @@ -1129,7 +1300,23 @@ export async function getTransactions( await iterRecordsForRefund(tx, filter, async (refundGroup) => { const currency = Amounts.currencyOf(refundGroup.amountRaw); - if (shouldSkipCurrency(transactionsRequest, currency)) { + + const exchangesInTx: string[] = []; + const p = await tx.purchases.get(refundGroup.proposalId); + if (!p || !p.payInfo || !p.payInfo.payCoinSelection) { + //refund with no payment + return; + } + + // FIXME: This is very slow, should become obsolete with materialized transactions. + for (const cp of p.payInfo.payCoinSelection.coinPubs) { + const c = await tx.coins.get(cp); + if (c?.exchangeBaseUrl) { + exchangesInTx.push(c.exchangeBaseUrl); + } + } + + if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) { return; } const contractData = await lookupMaybeContractData( @@ -1140,7 +1327,12 @@ export async function getTransactions( }); await iterRecordsForRefresh(tx, filter, async (rg) => { - if (shouldSkipCurrency(transactionsRequest, rg.currency)) { + const exchangesInTx = rg.infoPerExchange + ? Object.keys(rg.infoPerExchange) + : []; + if ( + shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx) + ) { return; } let required = false; @@ -1161,9 +1353,18 @@ export async function getTransactions( await iterRecordsForWithdrawal(tx, filter, async (wsr) => { if ( + wsr.rawWithdrawalAmount === undefined || + wsr.exchangeBaseUrl == undefined + ) { + // skip prepared withdrawals which has not been confirmed + return; + } + const exchangesInTx = [wsr.exchangeBaseUrl]; + if ( shouldSkipCurrency( transactionsRequest, Amounts.currencyOf(wsr.rawWithdrawalAmount), + exchangesInTx, ) ) { return; @@ -1187,13 +1388,28 @@ export async function getTransactions( // 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: + case WithdrawalRecordType.BankIntegrated: { + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + wsr.exchangeBaseUrl, + ); + if (!exchangeDetails) { + // FIXME: report somehow + return; + } + transactions.push( - buildTransactionForBankIntegratedWithdraw(wsr, ort), + buildTransactionForBankIntegratedWithdraw( + wsr, + exchangeDetails, + ort, + ), ); return; + } + case WithdrawalRecordType.BankManual: { - const exchangeDetails = await getExchangeDetails( + const exchangeDetails = await getExchangeWireDetailsInTx( tx, wsr.exchangeBaseUrl, ); @@ -1201,7 +1417,6 @@ export async function getTransactions( // FIXME: report somehow return; } - transactions.push( buildTransactionForManualWithdraw(wsr, exchangeDetails, ort), ); @@ -1213,9 +1428,33 @@ export async function getTransactions( } }); + await iterRecordsForDenomLoss(tx, filter, async (rec) => { + const amount = Amounts.parseOrThrow(rec.amount); + const exchangesInTx = [rec.exchangeBaseUrl]; + if ( + shouldSkipCurrency( + transactionsRequest, + amount.currency, + exchangesInTx, + ) + ) { + return; + } + transactions.push(buildTransactionForDenomLoss(rec)); + }); + await iterRecordsForDeposit(tx, filter, async (dg) => { const amount = Amounts.parseOrThrow(dg.amount); - if (shouldSkipCurrency(transactionsRequest, amount.currency)) { + const exchangesInTx = dg.infoPerExchange + ? Object.keys(dg.infoPerExchange) + : []; + if ( + shouldSkipCurrency( + transactionsRequest, + amount.currency, + exchangesInTx, + ) + ) { return; } const opId = TaskIdentifiers.forDeposit(dg); @@ -1232,7 +1471,22 @@ export async function getTransactions( if (!purchase.payInfo) { return; } - if (shouldSkipCurrency(transactionsRequest, download.currency)) { + + 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( @@ -1258,10 +1512,12 @@ export async function getTransactions( const payOpId = TaskIdentifiers.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); - const refunds = await tx.refundGroups.indexes.byProposalId.getAll(purchase.proposalId) + const refunds = await tx.refundGroups.indexes.byProposalId.getAll( + purchase.proposalId, + ); transactions.push( - await buildTransactionForPurchase( + buildTransactionForPurchase( purchase, contractData, refunds, @@ -1269,24 +1525,8 @@ export async function getTransactions( ), ); }); - - await iterRecordsForReward(tx, filter, async (tipRecord) => { - if ( - shouldSkipCurrency( - transactionsRequest, - Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency, - ) - ) { - return; - } - if (!tipRecord.acceptedTimestamp) { - return; - } - const opId = TaskIdentifiers.forTipPickup(tipRecord); - const retryRecord = await tx.operationRetries.get(opId); - transactions.push(buildTransactionForTip(tipRecord, retryRecord)); - }); - }); + }, + ); // One-off checks, because of a bug where the wallet previously // did not migrate the DB correctly and caused these amounts @@ -1308,9 +1548,6 @@ export async function getTransactions( 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; @@ -1331,10 +1568,18 @@ export async function getTransactions( return sortSign * tsCmp; }; + if (transactionsRequest?.sort === "stable-ascending") { + transactions.sort(txCmp); + return { transactions }; + } + + const txPending = transactions.filter((x) => isPending(x)); + const txNotPending = transactions.filter((x) => !isPending(x)); + txPending.sort(txCmp); txNotPending.sort(txCmp); - return { transactions: [...txNotPending, ...txPending] }; + return { transactions: [...txPending, ...txNotPending] }; } export type ParsedTransactionIdentifier = @@ -1346,9 +1591,10 @@ export type ParsedTransactionIdentifier = | { 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.InternalWithdrawal; withdrawalGroupId: string } + | { tag: TransactionType.Recoup; recoupGroupId: string } + | { tag: TransactionType.DenomLoss; denomLossEventId: string }; export function constructTransactionIdentifier( pTxId: ParsedTransactionIdentifier, @@ -1370,12 +1616,14 @@ export function constructTransactionIdentifier( 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; + case TransactionType.DenomLoss: + return `txn:${pTxId.tag}:${pTxId.denomLossEventId}` as TransactionIdStr; default: assertUnreachable(pTxId); } @@ -1425,40 +1673,24 @@ export function parseTransactionIdentifier( 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], }; + case TransactionType.DenomLoss: + return { + tag: TransactionType.DenomLoss, + denomLossEventId: rest[0], + }; default: return undefined; } } -export function stopLongpolling(ws: InternalWalletState, taskId: string) { - const longpoll = ws.activeLongpoll[taskId]; - if (longpoll) { - logger.info(`cancelling long-polling for ${taskId}`); - longpoll.cancel(); - delete ws.activeLongpoll[taskId]; - } -} - -/** - * Immediately retry the underlying operation - * of a transaction. - */ -export async function retryTransaction( - ws: InternalWalletState, +function maybeTaskFromTransaction( transactionId: string, -): Promise<void> { - logger.info(`resetting retry timeout for ${transactionId}`); - +): TaskIdStr | undefined { const parsedTx = parseTransactionIdentifier(transactionId); if (!parsedTx) { @@ -1468,482 +1700,176 @@ export async function retryTransaction( // FIXME: We currently don't cancel active long-polling tasks here. switch (parsedTx.tag) { - case TransactionType.PeerPullCredit: { - const taskId = constructTaskIdentifier({ + case TransactionType.PeerPullCredit: + return constructTaskIdentifier({ tag: PendingTaskType.PeerPullCredit, pursePub: parsedTx.pursePub, }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } - case TransactionType.Deposit: { - const taskId = constructTaskIdentifier({ + case TransactionType.Deposit: + return constructTaskIdentifier({ tag: PendingTaskType.Deposit, depositGroupId: parsedTx.depositGroupId, }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } case TransactionType.InternalWithdrawal: - case TransactionType.Withdrawal: { - // FIXME: Abort current long-poller! - const taskId = constructTaskIdentifier({ + case TransactionType.Withdrawal: + return constructTaskIdentifier({ tag: PendingTaskType.Withdraw, withdrawalGroupId: parsedTx.withdrawalGroupId, }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } - case TransactionType.Payment: { - const taskId = constructTaskIdentifier({ + case TransactionType.Payment: + return constructTaskIdentifier({ tag: PendingTaskType.Purchase, proposalId: parsedTx.proposalId, }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } - case TransactionType.Reward: { - const taskId = constructTaskIdentifier({ - tag: PendingTaskType.RewardPickup, - walletRewardId: parsedTx.walletRewardId, - }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } - case TransactionType.Refresh: { - const taskId = constructTaskIdentifier({ + case TransactionType.Refresh: + return constructTaskIdentifier({ tag: PendingTaskType.Refresh, refreshGroupId: parsedTx.refreshGroupId, }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } - case TransactionType.PeerPullDebit: { - const taskId = constructTaskIdentifier({ + case TransactionType.PeerPullDebit: + return constructTaskIdentifier({ tag: PendingTaskType.PeerPullDebit, peerPullDebitId: parsedTx.peerPullDebitId, }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } - case TransactionType.PeerPushCredit: { - const taskId = constructTaskIdentifier({ + case TransactionType.PeerPushCredit: + return constructTaskIdentifier({ tag: PendingTaskType.PeerPushCredit, peerPushCreditId: parsedTx.peerPushCreditId, }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } - case TransactionType.PeerPushDebit: { - const taskId = constructTaskIdentifier({ + case TransactionType.PeerPushDebit: + return constructTaskIdentifier({ tag: PendingTaskType.PeerPushDebit, pursePub: parsedTx.pursePub, }); - await resetPendingTaskTimeout(ws, taskId); - stopLongpolling(ws, taskId); - break; - } case TransactionType.Refund: // Nothing to do for a refund transaction. - break; + return undefined; + case TransactionType.Recoup: + return constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId: parsedTx.recoupGroupId, + }); + case TransactionType.DenomLoss: + // Nothing to do for denom loss + return undefined; default: assertUnreachable(parsedTx); } } /** - * 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). + * Immediately retry the underlying operation + * of a transaction. */ -export async function suspendTransaction( - ws: InternalWalletState, +export async function retryTransaction( + wex: WalletExecutionContext, transactionId: string, ): Promise<void> { + logger.info(`resetting retry timeout for ${transactionId}`); + const taskId = maybeTaskFromTransaction(transactionId); + if (taskId) { + await wex.taskScheduler.resetTaskRetries(taskId); + } +} + +export async function retryAll(wex: WalletExecutionContext): Promise<void> { + const tasks = wex.taskScheduler.getActiveTasks(); + for (const task of tasks) { + await wex.taskScheduler.resetTaskRetries(task); + } +} + +async function getContextForTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<TransactionContext> { const tx = parseTransactionIdentifier(transactionId); if (!tx) { throw Error("invalid transaction ID"); } switch (tx.tag) { case TransactionType.Deposit: - await suspendDepositGroup(ws, tx.depositGroupId); - return; + return new DepositTransactionContext(wex, tx.depositGroupId); case TransactionType.Refresh: - await suspendRefreshGroup(ws, tx.refreshGroupId); - return; + return new RefreshTransactionContext(wex, tx.refreshGroupId); case TransactionType.InternalWithdrawal: case TransactionType.Withdrawal: - await suspendWithdrawalTransaction(ws, tx.withdrawalGroupId); - return; + return new WithdrawTransactionContext(wex, tx.withdrawalGroupId); case TransactionType.Payment: - await suspendPayMerchant(ws, tx.proposalId); - return; + return new PayMerchantTransactionContext(wex, tx.proposalId); case TransactionType.PeerPullCredit: - await suspendPeerPullCreditTransaction(ws, tx.pursePub); - break; + return new PeerPullCreditTransactionContext(wex, tx.pursePub); case TransactionType.PeerPushDebit: - await suspendPeerPushDebitTransaction(ws, tx.pursePub); - break; + return new PeerPushDebitTransactionContext(wex, tx.pursePub); case TransactionType.PeerPullDebit: - await suspendPeerPullDebitTransaction(ws, tx.peerPullDebitId); - break; + return new PeerPullDebitTransactionContext(wex, tx.peerPullDebitId); case TransactionType.PeerPushCredit: - await suspendPeerPushCreditTransaction(ws, tx.peerPushCreditId); - break; + return new PeerPushCreditTransactionContext(wex, tx.peerPushCreditId); case TransactionType.Refund: - throw Error("refund transactions can't be suspended or resumed"); - case TransactionType.Reward: - await suspendRewardTransaction(ws, tx.walletRewardId); - break; + return new RefundTransactionContext(wex, tx.refundGroupId); + case TransactionType.Recoup: + //return new RecoupTransactionContext(ws, tx.recoupGroupId); + throw new Error("not yet supported"); + case TransactionType.DenomLoss: + return new DenomLossTransactionContext(wex, tx.denomLossEventId); default: assertUnreachable(tx); } } +/** + * Suspends a pending transaction, stopping any associated network activities, + * but with a chance of trying again at a later time. This could be useful if + * a user needs to save battery power or bandwidth and an operation is expected + * to take longer (such as a backup, recovery or very large withdrawal operation). + */ +export async function suspendTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.suspendTransaction(); +} + export async function failTransaction( - ws: InternalWalletState, + wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const tx = parseTransactionIdentifier(transactionId); - if (!tx) { - throw Error("invalid transaction ID"); - } - switch (tx.tag) { - case TransactionType.Deposit: - await failDepositTransaction(ws, tx.depositGroupId); - return; - case TransactionType.InternalWithdrawal: - case TransactionType.Withdrawal: - await failWithdrawalTransaction(ws, tx.withdrawalGroupId); - return; - case TransactionType.Payment: - await failPaymentTransaction(ws, tx.proposalId); - return; - case TransactionType.Refund: - throw Error("can't do cancel-aborting on refund transaction"); - case TransactionType.Reward: - await failTipTransaction(ws, tx.walletRewardId); - return; - case TransactionType.Refresh: - await failRefreshGroup(ws, tx.refreshGroupId); - return; - case TransactionType.PeerPullCredit: - await failPeerPullCreditTransaction(ws, tx.pursePub); - return; - case TransactionType.PeerPullDebit: - await failPeerPullDebitTransaction(ws, tx.peerPullDebitId); - return; - case TransactionType.PeerPushCredit: - await failPeerPushCreditTransaction(ws, tx.peerPushCreditId); - return; - case TransactionType.PeerPushDebit: - await failPeerPushDebitTransaction(ws, tx.pursePub); - return; - default: - assertUnreachable(tx); - } + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.failTransaction(); } /** * Resume a suspended transaction. */ export async function resumeTransaction( - ws: InternalWalletState, + wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const tx = parseTransactionIdentifier(transactionId); - if (!tx) { - throw Error("invalid transaction ID"); - } - switch (tx.tag) { - case TransactionType.Deposit: - await resumeDepositGroup(ws, tx.depositGroupId); - return; - case TransactionType.Refresh: - await resumeRefreshGroup(ws, tx.refreshGroupId); - return; - case TransactionType.InternalWithdrawal: - case TransactionType.Withdrawal: - await resumeWithdrawalTransaction(ws, tx.withdrawalGroupId); - return; - case TransactionType.Payment: - await resumePayMerchant(ws, tx.proposalId); - return; - case TransactionType.PeerPullCredit: - await resumePeerPullCreditTransaction(ws, tx.pursePub); - break; - case TransactionType.PeerPushDebit: - await resumePeerPushDebitTransaction(ws, tx.pursePub); - break; - case TransactionType.PeerPullDebit: - await resumePeerPullDebitTransaction(ws, tx.peerPullDebitId); - break; - case TransactionType.PeerPushCredit: - await resumePeerPushCreditTransaction(ws, tx.peerPushCreditId); - break; - case TransactionType.Refund: - throw Error("refund transactions can't be suspended or resumed"); - case TransactionType.Reward: - await resumeTipTransaction(ws, tx.walletRewardId); - break; - } + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.resumeTransaction(); } /** * Permanently delete a transaction based on the transaction ID. */ export async function deleteTransaction( - ws: InternalWalletState, + wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const parsedTx = parseTransactionIdentifier(transactionId); - - if (!parsedTx) { - throw Error("invalid transaction ID"); - } - - switch (parsedTx.tag) { - case TransactionType.PeerPushCredit: { - const peerPushCreditId = parsedTx.peerPushCreditId; - await ws.db - .mktx((x) => [x.withdrawalGroups, x.peerPushCredit, x.tombstones]) - .runReadWrite(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; - } - - case TransactionType.PeerPullCredit: { - const pursePub = parsedTx.pursePub; - await ws.db - .mktx((x) => [x.withdrawalGroups, x.peerPullCredit, x.tombstones]) - .runReadWrite(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; - } - - case TransactionType.Withdrawal: { - const withdrawalGroupId = parsedTx.withdrawalGroupId; - await ws.db - .mktx((x) => [x.withdrawalGroups, x.tombstones]) - .runReadWrite(async (tx) => { - const withdrawalGroupRecord = await tx.withdrawalGroups.get( - withdrawalGroupId, - ); - if (withdrawalGroupRecord) { - await tx.withdrawalGroups.delete(withdrawalGroupId); - await tx.tombstones.put({ - id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId, - }); - return; - } - }); - return; - } - - case TransactionType.Payment: { - const proposalId = parsedTx.proposalId; - await ws.db - .mktx((x) => [x.purchases, x.tombstones]) - .runReadWrite(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, - }); - } - }); - return; - } - - case TransactionType.Refresh: { - const refreshGroupId = parsedTx.refreshGroupId; - await ws.db - .mktx((x) => [x.refreshGroups, x.tombstones]) - .runReadWrite(async (tx) => { - const rg = await tx.refreshGroups.get(refreshGroupId); - if (rg) { - await tx.refreshGroups.delete(refreshGroupId); - await tx.tombstones.put({ - id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId, - }); - } - }); - - return; - } - - case TransactionType.Reward: { - const tipId = parsedTx.walletRewardId; - await ws.db - .mktx((x) => [x.rewards, x.tombstones]) - .runReadWrite(async (tx) => { - const tipRecord = await tx.rewards.get(tipId); - if (tipRecord) { - await tx.rewards.delete(tipId); - await tx.tombstones.put({ - id: TombstoneTag.DeleteReward + ":" + tipId, - }); - } - }); - return; - } - - case TransactionType.Deposit: { - const depositGroupId = parsedTx.depositGroupId; - await deleteDepositGroup(ws, depositGroupId); - return; - } - - case TransactionType.Refund: { - const refundGroupId = parsedTx.refundGroupId; - await ws.db - .mktx((x) => [x.refundGroups, x.tombstones]) - .runReadWrite(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. - }); - return; - } - - case TransactionType.PeerPullDebit: { - const peerPullDebitId = parsedTx.peerPullDebitId; - await ws.db - .mktx((x) => [x.peerPullDebit, x.tombstones]) - .runReadWrite(async (tx) => { - const debit = await tx.peerPullDebit.get(peerPullDebitId); - if (debit) { - await tx.peerPullDebit.delete(peerPullDebitId); - await tx.tombstones.put({ id: transactionId }); - } - }); - - return; - } - - case TransactionType.PeerPushDebit: { - const pursePub = parsedTx.pursePub; - await ws.db - .mktx((x) => [x.peerPushDebit, x.tombstones]) - .runReadWrite(async (tx) => { - const debit = await tx.peerPushDebit.get(pursePub); - if (debit) { - await tx.peerPushDebit.delete(pursePub); - await tx.tombstones.put({ id: transactionId }); - } - }); - return; - } + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.deleteTransaction(); + if (ctx.taskId) { + wex.taskScheduler.stopShepherdTask(ctx.taskId); } } export async function abortTransaction( - ws: InternalWalletState, + wex: WalletExecutionContext, transactionId: string, ): Promise<void> { - const txId = parseTransactionIdentifier(transactionId); - if (!txId) { - throw Error("invalid transaction identifier"); - } - - switch (txId.tag) { - case TransactionType.Payment: { - await abortPayMerchant(ws, txId.proposalId); - break; - } - case TransactionType.Withdrawal: - case TransactionType.InternalWithdrawal: { - await abortWithdrawalTransaction(ws, txId.withdrawalGroupId); - break; - } - case TransactionType.Deposit: - await abortDepositGroup(ws, txId.depositGroupId); - break; - case TransactionType.Reward: - await abortTipTransaction(ws, txId.walletRewardId); - break; - case TransactionType.Refund: - throw Error("can't abort refund transactions"); - case TransactionType.Refresh: - await abortRefreshGroup(ws, txId.refreshGroupId); - break; - case TransactionType.PeerPullCredit: - await abortPeerPullCreditTransaction(ws, txId.pursePub); - break; - case TransactionType.PeerPullDebit: - await abortPeerPullDebitTransaction(ws, txId.peerPullDebitId); - break; - case TransactionType.PeerPushCredit: - await abortPeerPushCreditTransaction(ws, txId.peerPushCreditId); - break; - case TransactionType.PeerPushDebit: - await abortPeerPushDebitTransaction(ws, txId.pursePub); - break; - default: { - assertUnreachable(txId); - } - } + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.abortTransaction(); } export interface TransitionInfo { @@ -1955,7 +1881,7 @@ export interface TransitionInfo { * Notify of a state transition if necessary. */ export function notifyTransition( - ws: InternalWalletState, + wex: WalletExecutionContext, transactionId: string, transitionInfo: TransitionInfo | undefined, experimentalUserData: any = undefined, @@ -1967,7 +1893,7 @@ export function notifyTransition( transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor ) ) { - ws.notify({ + wex.ws.notify({ type: NotificationType.TransactionStateTransition, oldTxState: transitionInfo.oldTxState, newTxState: transitionInfo.newTxState, @@ -1975,5 +1901,188 @@ export function notifyTransition( experimentalUserData, }); } - ws.workAvailable.trigger(); +} + +/** + * Iterate refresh records based on a filter. + */ +async function iterRecordsForRefresh( + tx: WalletDbReadOnlyTransaction<["refreshGroups"]>, + filter: TransactionRecordFilter, + f: (r: RefreshGroupRecord) => Promise<void>, +): Promise<void> { + let refreshGroups: RefreshGroupRecord[]; + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + RefreshOperationStatus.Pending, + RefreshOperationStatus.Suspended, + ); + refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange); + } else { + refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(); + } + + for (const r of refreshGroups) { + await f(r); + } +} + +async function iterRecordsForWithdrawal( + tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>, + filter: TransactionRecordFilter, + f: (r: WithdrawalGroupRecord) => Promise<void>, +): Promise<void> { + let withdrawalGroupRecords: WithdrawalGroupRecord[]; + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + withdrawalGroupRecords = + await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange); + } else { + withdrawalGroupRecords = + await tx.withdrawalGroups.indexes.byStatus.getAll(); + } + for (const wgr of withdrawalGroupRecords) { + await f(wgr); + } +} + +async function iterRecordsForDeposit( + tx: WalletDbReadOnlyTransaction<["depositGroups"]>, + filter: TransactionRecordFilter, + f: (r: DepositGroupRecord) => Promise<void>, +): Promise<void> { + let dgs: DepositGroupRecord[]; + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange); + } else { + dgs = await tx.depositGroups.indexes.byStatus.getAll(); + } + + for (const dg of dgs) { + await f(dg); + } +} + +async function iterRecordsForDenomLoss( + tx: WalletDbReadOnlyTransaction<["denomLossEvents"]>, + filter: TransactionRecordFilter, + f: (r: DenomLossEventRecord) => Promise<void>, +): Promise<void> { + let dgs: DenomLossEventRecord[]; + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange); + } else { + dgs = await tx.denomLossEvents.indexes.byStatus.getAll(); + } + + for (const dg of dgs) { + await f(dg); + } +} + +async function iterRecordsForRefund( + tx: WalletDbReadOnlyTransaction<["refundGroups"]>, + filter: TransactionRecordFilter, + f: (r: RefundGroupRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.refundGroups.iter().forEachAsync(f); + } +} + +async function iterRecordsForPurchase( + tx: WalletDbReadOnlyTransaction<["purchases"]>, + filter: TransactionRecordFilter, + f: (r: PurchaseRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.purchases.indexes.byStatus.iter().forEachAsync(f); + } +} + +async function iterRecordsForPeerPullCredit( + tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>, + filter: TransactionRecordFilter, + f: (r: PeerPullCreditRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f); + } +} + +async function iterRecordsForPeerPullDebit( + tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>, + filter: TransactionRecordFilter, + f: (r: PeerPullPaymentIncomingRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f); + } +} + +async function iterRecordsForPeerPushDebit( + tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>, + filter: TransactionRecordFilter, + f: (r: PeerPushDebitRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f); + } +} + +async function iterRecordsForPeerPushCredit( + tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>, + filter: TransactionRecordFilter, + f: (r: PeerPushPaymentIncomingRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f); + } } diff --git a/packages/taler-wallet-core/src/util/assertUnreachable.ts b/packages/taler-wallet-core/src/util/assertUnreachable.ts deleted file mode 100644 index 1819fd09e..000000000 --- a/packages/taler-wallet-core/src/util/assertUnreachable.ts +++ /dev/null @@ -1,19 +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/> - */ - -export function assertUnreachable(x: never): never { - throw new Error(`Didn't expect to get here ${x}`); -} diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts deleted file mode 100644 index e3fbffe98..000000000 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ /dev/null @@ -1,1236 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Selection of coins for payments. - * - * @author Florian Dold - */ - -/** - * Imports. - */ -import { GlobalIDB } from "@gnu-taler/idb-bridge"; -import { - AbsoluteTime, - AccountRestriction, - AgeCommitmentProof, - AgeRestriction, - AllowedAuditorInfo, - AllowedExchangeInfo, - AmountJson, - AmountLike, - Amounts, - AmountString, - CoinPublicKeyString, - CoinStatus, - DenominationInfo, - DenominationPubKey, - DenomSelectionState, - Duration, - ForcedCoinSel, - ForcedDenomSel, - InternationalizedString, - j2s, - Logger, - parsePaytoUri, - PayCoinSelection, - PayMerchantInsufficientBalanceDetails, - PayPeerInsufficientBalanceDetails, - strcmp, - TalerProtocolTimestamp, - UnblindedSignature, -} from "@gnu-taler/taler-util"; -import { DenominationRecord } from "../db.js"; -import { - getAutoRefreshExecuteThreshold, - getExchangeDetails, - isWithdrawableDenom, - WalletDbReadOnlyTransaction, -} from "../index.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { - getMerchantPaymentBalanceDetails, - getPeerPaymentBalanceDetailsInTx, -} from "../operations/balance.js"; -import { checkDbInvariant, checkLogicInvariant } from "./invariants.js"; - -const logger = new Logger("coinSelection.ts"); - -/** - * Structure to describe a coin that is available to be - * used in a payment. - */ -export interface AvailableCoinInfo { - /** - * Public key of the coin. - */ - coinPub: string; - - /** - * Coin's denomination public key. - * - * FIXME: We should only need the denomPubHash here, if at all. - */ - denomPub: DenominationPubKey; - - /** - * Full value of the coin. - */ - value: AmountJson; - - /** - * Amount still remaining (typically the full amount, - * as coins are always refreshed after use.) - */ - availableAmount: AmountJson; - - /** - * Deposit fee for the coin. - */ - feeDeposit: AmountJson; - - exchangeBaseUrl: string; - - maxAge: number; - ageCommitmentProof?: AgeCommitmentProof; -} - -export type PreviousPayCoins = { - coinPub: string; - contribution: AmountJson; - feeDeposit: AmountJson; - exchangeBaseUrl: string; -}[]; - -export interface CoinCandidateSelection { - candidateCoins: AvailableCoinInfo[]; - wireFeesPerExchange: Record<string, AmountJson>; -} - -export interface SelectPayCoinRequest { - candidates: CoinCandidateSelection; - contractTermsAmount: AmountJson; - depositFeeLimit: AmountJson; - wireFeeLimit: AmountJson; - wireFeeAmortization: number; - prevPayCoins?: PreviousPayCoins; - requiredMinimumAge?: number; -} - -export interface CoinSelectionTally { - /** - * Amount that still needs to be paid. - * May increase during the computation when fees need to be covered. - */ - amountPayRemaining: AmountJson; - - /** - * Allowance given by the merchant towards wire fees - */ - amountWireFeeLimitRemaining: AmountJson; - - /** - * Allowance given by the merchant towards deposit fees - * (and wire fees after wire fee limit is exhausted) - */ - amountDepositFeeLimitRemaining: AmountJson; - - customerDepositFees: AmountJson; - - customerWireFees: AmountJson; - - wireFeeCoveredForExchange: Set<string>; - - lastDepositFee: AmountJson; -} - -/** - * Account for the fees of spending a coin. - */ -function tallyFees( - tally: Readonly<CoinSelectionTally>, - wireFeesPerExchange: Record<string, AmountJson>, - wireFeeAmortization: number, - exchangeBaseUrl: string, - feeDeposit: AmountJson, -): CoinSelectionTally { - const currency = tally.amountPayRemaining.currency; - let amountWireFeeLimitRemaining = tally.amountWireFeeLimitRemaining; - let amountDepositFeeLimitRemaining = tally.amountDepositFeeLimitRemaining; - let customerDepositFees = tally.customerDepositFees; - let customerWireFees = tally.customerWireFees; - let amountPayRemaining = tally.amountPayRemaining; - const wireFeeCoveredForExchange = new Set(tally.wireFeeCoveredForExchange); - - if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) { - const wf = - wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency); - const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf); - amountWireFeeLimitRemaining = Amounts.sub( - amountWireFeeLimitRemaining, - wfForgiven, - ).amount; - // The remaining, amortized amount needs to be paid by the - // wallet or covered by the deposit fee allowance. - let wfRemaining = Amounts.divide( - Amounts.sub(wf, wfForgiven).amount, - wireFeeAmortization, - ); - - // This is the amount forgiven via the deposit fee allowance. - const wfDepositForgiven = Amounts.min( - amountDepositFeeLimitRemaining, - wfRemaining, - ); - amountDepositFeeLimitRemaining = Amounts.sub( - amountDepositFeeLimitRemaining, - wfDepositForgiven, - ).amount; - - wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount; - customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount; - amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount; - - wireFeeCoveredForExchange.add(exchangeBaseUrl); - } - - const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining); - - amountDepositFeeLimitRemaining = Amounts.sub( - amountDepositFeeLimitRemaining, - dfForgiven, - ).amount; - - // How much does the user spend on deposit fees for this coin? - const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount; - customerDepositFees = Amounts.add(customerDepositFees, dfRemaining).amount; - amountPayRemaining = Amounts.add(amountPayRemaining, dfRemaining).amount; - - return { - amountDepositFeeLimitRemaining, - amountPayRemaining, - amountWireFeeLimitRemaining, - customerDepositFees, - customerWireFees, - wireFeeCoveredForExchange, - lastDepositFee: feeDeposit, - }; -} - -export type SelectPayCoinsResult = - | { - type: "failure"; - insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails; - } - | { type: "success"; coinSel: PayCoinSelection }; - -/** - * Given a list of candidate coins, select coins to spend under the merchant's - * constraints. - * - * The prevPayCoins can be specified to "repair" a coin selection - * by adding additional coins, after a broken (e.g. double-spent) coin - * has been removed from the selection. - * - * This function is only exported for the sake of unit tests. - */ -export async function selectPayCoinsNew( - ws: InternalWalletState, - req: SelectPayCoinRequestNg, -): Promise<SelectPayCoinsResult> { - const { - contractTermsAmount, - depositFeeLimit, - wireFeeLimit, - wireFeeAmortization, - } = req; - - // FIXME: Why don't we do this in a transaction? - const [candidateDenoms, wireFeesPerExchange] = - await selectPayMerchantCandidates(ws, req); - - const coinPubs: string[] = []; - const coinContributions: AmountJson[] = []; - const currency = contractTermsAmount.currency; - - let tally: CoinSelectionTally = { - amountPayRemaining: contractTermsAmount, - amountWireFeeLimitRemaining: wireFeeLimit, - amountDepositFeeLimitRemaining: depositFeeLimit, - customerDepositFees: Amounts.zeroOfCurrency(currency), - customerWireFees: Amounts.zeroOfCurrency(currency), - wireFeeCoveredForExchange: new Set(), - lastDepositFee: Amounts.zeroOfCurrency(currency), - }; - - const prevPayCoins = req.prevPayCoins ?? []; - - // Look at existing pay coin selection and tally up - for (const prev of prevPayCoins) { - tally = tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - prev.exchangeBaseUrl, - prev.feeDeposit, - ); - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - prev.contribution, - ).amount; - - coinPubs.push(prev.coinPub); - coinContributions.push(prev.contribution); - } - - let selectedDenom: SelResult | undefined; - if (req.forcedSelection) { - selectedDenom = selectForced(req, candidateDenoms); - } else { - // FIXME: Here, we should select coins in a smarter way. - // Instead of always spending the next-largest coin, - // we should try to find the smallest coin that covers the - // amount. - selectedDenom = selectGreedy( - req, - candidateDenoms, - wireFeesPerExchange, - tally, - ); - } - - if (!selectedDenom) { - const details = await getMerchantPaymentBalanceDetails(ws, { - acceptedAuditors: req.auditors, - acceptedExchanges: req.exchanges, - acceptedWireMethods: [req.wireMethod], - currency: Amounts.currencyOf(req.contractTermsAmount), - minAge: req.requiredMinimumAge ?? 0, - }); - let feeGapEstimate: AmountJson; - if ( - Amounts.cmp( - details.balanceMerchantDepositable, - req.contractTermsAmount, - ) >= 0 - ) { - // FIXME: We can probably give a better estimate. - feeGapEstimate = Amounts.add( - tally.amountPayRemaining, - tally.lastDepositFee, - ).amount; - } else { - feeGapEstimate = Amounts.zeroOfAmount(req.contractTermsAmount); - } - return { - type: "failure", - insufficientBalanceDetails: { - amountRequested: Amounts.stringify(req.contractTermsAmount), - balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable), - balanceAvailable: Amounts.stringify(details.balanceAvailable), - balanceMaterial: Amounts.stringify(details.balanceMaterial), - balanceMerchantAcceptable: Amounts.stringify( - details.balanceMerchantAcceptable, - ), - balanceMerchantDepositable: Amounts.stringify( - details.balanceMerchantDepositable, - ), - feeGapEstimate: Amounts.stringify(feeGapEstimate), - }, - }; - } - - const finalSel = selectedDenom; - - logger.trace(`coin selection request ${j2s(req)}`); - logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`); - - await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { - for (const dph of Object.keys(finalSel)) { - const selInfo = finalSel[dph]; - const numRequested = selInfo.contributions.length; - const query = [ - selInfo.exchangeBaseUrl, - selInfo.denomPubHash, - selInfo.maxAge, - CoinStatus.Fresh, - ]; - logger.trace(`query: ${j2s(query)}`); - const coins = - await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( - query, - numRequested, - ); - if (coins.length != numRequested) { - throw Error( - `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, - ); - } - coinPubs.push(...coins.map((x) => x.coinPub)); - coinContributions.push(...selInfo.contributions); - } - }); - - return { - type: "success", - coinSel: { - paymentAmount: Amounts.stringify(contractTermsAmount), - coinContributions: coinContributions.map((x) => Amounts.stringify(x)), - coinPubs, - customerDepositFees: Amounts.stringify(tally.customerDepositFees), - customerWireFees: Amounts.stringify(tally.customerWireFees), - }, - }; -} - -function makeAvailabilityKey( - exchangeBaseUrl: string, - denomPubHash: string, - maxAge: number, -): string { - return `${denomPubHash};${maxAge};${exchangeBaseUrl}`; -} - -/** - * Selection result. - */ -interface SelResult { - /** - * Map from an availability key - * to an array of contributions. - */ - [avKey: string]: { - exchangeBaseUrl: string; - denomPubHash: string; - expireWithdraw: TalerProtocolTimestamp; - expireDeposit: TalerProtocolTimestamp; - maxAge: number; - contributions: AmountJson[]; - }; -} - -export function testing_selectGreedy( - ...args: Parameters<typeof selectGreedy> -): ReturnType<typeof selectGreedy> { - return selectGreedy(...args); -} - -function selectGreedy( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], - wireFeesPerExchange: Record<string, AmountJson>, - tally: CoinSelectionTally, -): SelResult | undefined { - const { wireFeeAmortization } = req; - const selectedDenom: SelResult = {}; - for (const denom of candidateDenoms) { - const contributions: AmountJson[] = []; - - // Don't use this coin if depositing it is more expensive than - // the amount it would give the merchant. - if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) { - tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit); - continue; - } - - for ( - let i = 0; - i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining); - i++ - ) { - tally = tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - denom.exchangeBaseUrl, - Amounts.parseOrThrow(denom.feeDeposit), - ); - - const coinSpend = Amounts.max( - Amounts.min(tally.amountPayRemaining, denom.value), - denom.feeDeposit, - ); - - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - coinSpend, - ).amount; - - contributions.push(coinSpend); - } - - if (contributions.length) { - const avKey = makeAvailabilityKey( - denom.exchangeBaseUrl, - denom.denomPubHash, - denom.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: denom.denomPubHash, - exchangeBaseUrl: denom.exchangeBaseUrl, - maxAge: denom.maxAge, - expireDeposit: denom.stampExpireDeposit, - expireWithdraw: denom.stampExpireWithdraw, - }; - } - sd.contributions.push(...contributions); - selectedDenom[avKey] = sd; - } - } - return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined; -} - -function selectForced( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], -): SelResult | undefined { - const selectedDenom: SelResult = {}; - - const forcedSelection = req.forcedSelection; - checkLogicInvariant(!!forcedSelection); - - for (const forcedCoin of forcedSelection.coins) { - let found = false; - for (const aci of candidateDenoms) { - if (aci.numAvailable <= 0) { - continue; - } - if (Amounts.cmp(aci.value, forcedCoin.value) === 0) { - aci.numAvailable--; - const avKey = makeAvailabilityKey( - aci.exchangeBaseUrl, - aci.denomPubHash, - aci.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: aci.denomPubHash, - exchangeBaseUrl: aci.exchangeBaseUrl, - maxAge: aci.maxAge, - expireDeposit: aci.stampExpireDeposit, - expireWithdraw: aci.stampExpireWithdraw, - }; - } - sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value)); - selectedDenom[avKey] = sd; - found = true; - break; - } - } - if (!found) { - throw Error("can't find coin for forced coin selection"); - } - } - - return selectedDenom; -} - -export function checkAccountRestriction( - paytoUri: string, - restrictions: AccountRestriction[], -): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } { - for (const myRestriction of restrictions) { - switch (myRestriction.type) { - case "deny": - return { ok: false }; - case "regex": - const regex = new RegExp(myRestriction.payto_regex); - if (!regex.test(paytoUri)) { - return { - ok: false, - hint: myRestriction.human_hint, - hintI18n: myRestriction.human_hint_i18n, - }; - } - } - } - return { - ok: true, - }; -} - -export interface SelectPayCoinRequestNg { - exchanges: AllowedExchangeInfo[]; - auditors: AllowedAuditorInfo[]; - wireMethod: string; - contractTermsAmount: AmountJson; - depositFeeLimit: AmountJson; - wireFeeLimit: AmountJson; - wireFeeAmortization: number; - prevPayCoins?: PreviousPayCoins; - requiredMinimumAge?: number; - forcedSelection?: ForcedCoinSel; - - /** - * Deposit payto URI, in case we already know the account that - * will be deposited into. - * - * That is typically the case when the wallet does a deposit to - * return funds to the user's own bank account. - */ - depositPaytoUri?: string; -} - -export type AvailableDenom = DenominationInfo & { - maxAge: number; - numAvailable: number; -}; - -async function selectPayMerchantCandidates( - ws: InternalWalletState, - req: SelectPayCoinRequestNg, -): Promise<[AvailableDenom[], Record<string, AmountJson>]> { - return await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.coinAvailability, - ]) - .runReadOnly(async (tx) => { - // FIXME: Use the existing helper (from balance.ts) to - // get acceptable exchanges. - const denoms: AvailableDenom[] = []; - const exchanges = await tx.exchanges.iter().toArray(); - const wfPerExchange: Record<string, AmountJson> = {}; - for (const exchange of exchanges) { - const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl); - // 1.- exchange has same currency - if (exchangeDetails?.currency !== req.contractTermsAmount.currency) { - continue; - } - let wireMethodFee: string | undefined; - // 2.- exchange supports wire method - for (const acc of exchangeDetails.wireInfo.accounts) { - const pp = parsePaytoUri(acc.payto_uri); - checkLogicInvariant(!!pp); - if (pp.targetType !== req.wireMethod) { - continue; - } - const wireFeeStr = exchangeDetails.wireInfo.feesForType[ - req.wireMethod - ]?.find((x) => { - return AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(x.startStamp), - AbsoluteTime.fromProtocolTimestamp(x.endStamp), - ); - })?.wireFee; - let debitAccountCheckOk = false; - if (req.depositPaytoUri) { - // FIXME: We should somehow propagate the hint here! - const checkResult = checkAccountRestriction( - req.depositPaytoUri, - acc.debit_restrictions, - ); - if (checkResult.ok) { - debitAccountCheckOk = true; - } - } else { - debitAccountCheckOk = true; - } - - if (wireFeeStr) { - wireMethodFee = wireFeeStr; - } - break; - } - if (!wireMethodFee) { - break; - } - wfPerExchange[exchange.baseUrl] = Amounts.parseOrThrow(wireMethodFee); - - // 3.- exchange is trusted in the exchange list or auditor list - let accepted = false; - for (const allowedExchange of req.exchanges) { - if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { - accepted = true; - break; - } - } - for (const allowedAuditor of req.auditors) { - for (const providedAuditor of exchangeDetails.auditors) { - if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { - accepted = true; - break; - } - } - } - if (!accepted) { - continue; - } - // 4.- filter coins restricted by age - let ageLower = 0; - let ageUpper = AgeRestriction.AGE_UNRESTRICTED; - if (req.requiredMinimumAge) { - ageLower = req.requiredMinimumAge; - } - const myExchangeCoins = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeDetails.exchangeBaseUrl, ageLower, 1], - [ - exchangeDetails.exchangeBaseUrl, - ageUpper, - Number.MAX_SAFE_INTEGER, - ], - ), - ); - // 5.- save denoms with how many coins are available - // FIXME: Check that the individual denomination is audited! - // FIXME: Should we exclude denominations that are - // not spendable anymore? - for (const coinAvail of myExchangeCoins) { - const denom = await tx.denominations.get([ - coinAvail.exchangeBaseUrl, - coinAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - denoms.push({ - ...DenominationRecord.toDenomInfo(denom), - numAvailable: coinAvail.freshCoinCount ?? 0, - maxAge: coinAvail.maxAge, - }); - } - } - // Sort by available amount (descending), deposit fee (ascending) and - // denomPub (ascending) if deposit fee is the same - // (to guarantee deterministic results) - denoms.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - return [denoms, wfPerExchange]; - }); -} - -/** - * Get a list of denominations (with repetitions possible) - * whose total value is as close as possible to the available - * amount, but never larger. - */ -export function selectWithdrawalDenominations( - amountAvailable: AmountJson, - denoms: DenominationRecord[], - denomselAllowLate: boolean = false, -): DenomSelectionState { - let remaining = Amounts.copy(amountAvailable); - - const selectedDenoms: { - count: number; - denomPubHash: string; - }[] = []; - - let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency); - let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency); - - denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate)); - denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); - - for (const d of denoms) { - const cost = Amounts.add(d.value, d.fees.feeWithdraw).amount; - const res = Amounts.divmod(remaining, cost); - const count = res.quotient; - remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount; - if (count > 0) { - totalCoinValue = Amounts.add( - totalCoinValue, - Amounts.mult(d.value, count).amount, - ).amount; - totalWithdrawCost = Amounts.add( - totalWithdrawCost, - Amounts.mult(cost, count).amount, - ).amount; - selectedDenoms.push({ - count, - denomPubHash: d.denomPubHash, - }); - } - - if (Amounts.isZero(remaining)) { - break; - } - } - - if (logger.shouldLogTrace()) { - logger.trace( - `selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`, - ); - for (const sd of selectedDenoms) { - logger.trace(`denom_pub_hash=${sd.denomPubHash}, count=${sd.count}`); - } - logger.trace("(end of withdrawal denom list)"); - } - - return { - selectedDenoms, - totalCoinValue: Amounts.stringify(totalCoinValue), - totalWithdrawCost: Amounts.stringify(totalWithdrawCost), - }; -} - -export function selectForcedWithdrawalDenominations( - amountAvailable: AmountJson, - denoms: DenominationRecord[], - forcedDenomSel: ForcedDenomSel, - denomselAllowLate: boolean, -): DenomSelectionState { - const selectedDenoms: { - count: number; - denomPubHash: string; - }[] = []; - - let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency); - let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency); - - denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate)); - denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); - - for (const fds of forcedDenomSel.denoms) { - const count = fds.count; - const denom = denoms.find((x) => { - return Amounts.cmp(x.value, fds.value) == 0; - }); - if (!denom) { - throw Error( - `unable to find denom for forced selection (value ${fds.value})`, - ); - } - const cost = Amounts.add(denom.value, denom.fees.feeWithdraw).amount; - totalCoinValue = Amounts.add( - totalCoinValue, - Amounts.mult(denom.value, count).amount, - ).amount; - totalWithdrawCost = Amounts.add( - totalWithdrawCost, - Amounts.mult(cost, count).amount, - ).amount; - selectedDenoms.push({ - count, - denomPubHash: denom.denomPubHash, - }); - } - - return { - selectedDenoms, - totalCoinValue: Amounts.stringify(totalCoinValue), - totalWithdrawCost: Amounts.stringify(totalWithdrawCost), - }; -} - -export interface CoinInfo { - id: string; - value: AmountJson; - denomDeposit: AmountJson; - denomWithdraw: AmountJson; - denomRefresh: AmountJson; - totalAvailable: number | undefined; - exchangeWire: AmountJson | undefined; - exchangePurse: AmountJson | undefined; - duration: Duration; - exchangeBaseUrl: string; - maxAge: number; -} - -export interface SelectedPeerCoin { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; -} - -export interface PeerCoinSelectionDetails { - exchangeBaseUrl: string; - - /** - * Info of Coins that were selected. - */ - coins: SelectedPeerCoin[]; - - /** - * How much of the deposit fees is the customer paying? - */ - depositFees: AmountJson; - - maxExpirationDate: TalerProtocolTimestamp; -} - -export type SelectPeerCoinsResult = - | { type: "success"; result: PeerCoinSelectionDetails } - | { - type: "failure"; - insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; - }; - -export interface PeerCoinRepair { - exchangeBaseUrl: string; - coinPubs: CoinPublicKeyString[]; - contribs: AmountJson[]; -} - -export interface PeerCoinSelectionRequest { - instructedAmount: AmountJson; - - /** - * Instruct the coin selection to repair this coin - * selection instead of selecting completely new coins. - */ - repair?: PeerCoinRepair; -} - -/** - * Get coin availability information for a certain exchange. - */ -async function selectPayPeerCandidatesForExchange( - ws: InternalWalletState, - tx: WalletDbReadOnlyTransaction<"coinAvailability" | "denominations">, - exchangeBaseUrl: string, -): Promise<AvailableDenom[]> { - const denoms: AvailableDenom[] = []; - - let ageLower = 0; - let ageUpper = AgeRestriction.AGE_UNRESTRICTED; - const myExchangeCoins = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeBaseUrl, ageLower, 1], - [exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER], - ), - ); - - for (const coinAvail of myExchangeCoins) { - const denom = await tx.denominations.get([ - coinAvail.exchangeBaseUrl, - coinAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - denoms.push({ - ...DenominationRecord.toDenomInfo(denom), - numAvailable: coinAvail.freshCoinCount ?? 0, - maxAge: coinAvail.maxAge, - }); - } - // Sort by available amount (descending), deposit fee (ascending) and - // denomPub (ascending) if deposit fee is the same - // (to guarantee deterministic results) - denoms.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - - return denoms; -} - -interface PeerCoinSelectionTally { - amountAcc: AmountJson; - depositFeesAcc: AmountJson; - lastDepositFee: AmountJson; -} - -/** - * exporting for testing - */ -export function testing_greedySelectPeer( - ...args: Parameters<typeof greedySelectPeer> -): ReturnType<typeof greedySelectPeer> { - return greedySelectPeer(...args); -} - -function greedySelectPeer( - candidates: AvailableDenom[], - instructedAmount: AmountLike, - tally: PeerCoinSelectionTally, -): SelResult | undefined { - const selectedDenom: SelResult = {}; - for (const denom of candidates) { - const contributions: AmountJson[] = []; - for ( - let i = 0; - i < denom.numAvailable && - Amounts.cmp(tally.amountAcc, instructedAmount) < 0; - i++ - ) { - const amountPayRemaining = Amounts.sub( - instructedAmount, - tally.amountAcc, - ).amount; - // Maximum amount the coin could effectively contribute. - const maxCoinContrib = Amounts.sub(denom.value, denom.feeDeposit).amount; - - const coinSpend = Amounts.min( - Amounts.add(amountPayRemaining, denom.feeDeposit).amount, - maxCoinContrib, - ); - - tally.amountAcc = Amounts.add(tally.amountAcc, coinSpend).amount; - tally.amountAcc = Amounts.sub(tally.amountAcc, denom.feeDeposit).amount; - - tally.depositFeesAcc = Amounts.add( - tally.depositFeesAcc, - denom.feeDeposit, - ).amount; - - tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit); - - contributions.push(coinSpend); - } - if (contributions.length > 0) { - const avKey = makeAvailabilityKey( - denom.exchangeBaseUrl, - denom.denomPubHash, - denom.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: denom.denomPubHash, - exchangeBaseUrl: denom.exchangeBaseUrl, - maxAge: denom.maxAge, - expireDeposit: denom.stampExpireDeposit, - expireWithdraw: denom.stampExpireWithdraw, - }; - } - sd.contributions.push(...contributions); - selectedDenom[avKey] = sd; - } - if (Amounts.cmp(tally.amountAcc, instructedAmount) >= 0) { - break; - } - } - - if (Amounts.cmp(tally.amountAcc, instructedAmount) >= 0) { - return selectedDenom; - } - return undefined; -} - -export async function selectPeerCoins( - ws: InternalWalletState, - req: PeerCoinSelectionRequest, -): Promise<SelectPeerCoinsResult> { - const instructedAmount = req.instructedAmount; - if (Amounts.isZero(instructedAmount)) { - // Other parts of the code assume that we have at least - // one coin to spend. - throw new Error("amount of zero not allowed"); - } - return await ws.db - .mktx((x) => [ - x.exchanges, - x.contractTerms, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - x.peerPushDebit, - ]) - .runReadWrite(async (tx) => { - const exchanges = await tx.exchanges.iter().toArray(); - const exchangeFeeGap: { [url: string]: AmountJson } = {}; - const currency = Amounts.currencyOf(instructedAmount); - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - const candidates = await selectPayPeerCandidatesForExchange( - ws, - tx, - exch.baseUrl, - ); - const tally: PeerCoinSelectionTally = { - amountAcc: Amounts.zeroOfCurrency(currency), - depositFeesAcc: Amounts.zeroOfCurrency(currency), - lastDepositFee: Amounts.zeroOfCurrency(currency), - }; - const resCoins: { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; - }[] = []; - - if (req.repair && req.repair.exchangeBaseUrl === exch.baseUrl) { - for (let i = 0; i < req.repair.coinPubs.length; i++) { - const contrib = req.repair.contribs[i]; - const coin = await tx.coins.get(req.repair.coinPubs[i]); - if (!coin) { - throw Error("repair not possible, coin not found"); - } - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant(!!denom); - resCoins.push({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contribution: Amounts.stringify(contrib), - denomPubHash: coin.denomPubHash, - denomSig: coin.denomSig, - ageCommitmentProof: coin.ageCommitmentProof, - }); - const depositFee = Amounts.parseOrThrow(denom.feeDeposit); - tally.lastDepositFee = depositFee; - tally.amountAcc = Amounts.add( - tally.amountAcc, - Amounts.sub(contrib, depositFee).amount, - ).amount; - tally.depositFeesAcc = Amounts.add( - tally.depositFeesAcc, - depositFee, - ).amount; - } - } - - const selectedDenom = greedySelectPeer( - candidates, - instructedAmount, - tally, - ); - - if (selectedDenom) { - let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never(); - for (const dph of Object.keys(selectedDenom)) { - const selInfo = selectedDenom[dph]; - // Compute earliest time that a selected denom - // would have its coins auto-refreshed. - minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min( - minAutorefreshExecuteThreshold, - AbsoluteTime.toProtocolTimestamp( - getAutoRefreshExecuteThreshold({ - stampExpireDeposit: selInfo.expireDeposit, - stampExpireWithdraw: selInfo.expireWithdraw, - }), - ), - ); - const numRequested = selInfo.contributions.length; - const query = [ - selInfo.exchangeBaseUrl, - selInfo.denomPubHash, - selInfo.maxAge, - CoinStatus.Fresh, - ]; - logger.info(`query: ${j2s(query)}`); - const coins = - await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( - query, - numRequested, - ); - if (coins.length != numRequested) { - throw Error( - `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, - ); - } - for (let i = 0; i < selInfo.contributions.length; i++) { - resCoins.push({ - coinPriv: coins[i].coinPriv, - coinPub: coins[i].coinPub, - contribution: Amounts.stringify(selInfo.contributions[i]), - ageCommitmentProof: coins[i].ageCommitmentProof, - denomPubHash: selInfo.denomPubHash, - denomSig: coins[i].denomSig, - }); - } - } - - const res: PeerCoinSelectionDetails = { - exchangeBaseUrl: exch.baseUrl, - coins: resCoins, - depositFees: tally.depositFeesAcc, - maxExpirationDate: minAutorefreshExecuteThreshold, - }; - return { type: "success", result: res }; - } - - const diff = Amounts.sub(instructedAmount, tally.amountAcc).amount; - exchangeFeeGap[exch.baseUrl] = Amounts.add( - tally.lastDepositFee, - diff, - ).amount; - - continue; - } - - // We were unable to select coins. - // Now we need to produce error details. - - const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, { - currency, - }); - - const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; - - let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency); - - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, { - currency, - restrictExchangeTo: exch.baseUrl, - }); - let gap = - exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency); - if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) { - // Show fee gap only if we should've been able to pay with the material amount - gap = Amounts.zeroOfCurrency(currency); - } - perExchange[exch.baseUrl] = { - balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), - balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), - feeGapEstimate: Amounts.stringify(gap), - }; - - maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap); - } - - const errDetails: PayPeerInsufficientBalanceDetails = { - amountRequested: Amounts.stringify(instructedAmount), - balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable), - balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial), - feeGapEstimate: Amounts.stringify(maxFeeGapEstimate), - perExchange, - }; - - return { type: "failure", insufficientBalanceDetails: errDetails }; - }); -} diff --git a/packages/taler-wallet-core/src/util/invariants.ts b/packages/taler-wallet-core/src/util/invariants.ts deleted file mode 100644 index 3598d857c..000000000 --- a/packages/taler-wallet-core/src/util/invariants.ts +++ /dev/null @@ -1,46 +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/> - */ - -export class InvariantViolatedError extends Error { - constructor(message?: string) { - super(message); - Object.setPrototypeOf(this, InvariantViolatedError.prototype); - } -} - -/** - * Helpers for invariants. - */ - -export function checkDbInvariant(b: boolean, m?: string): asserts b { - if (!b) { - if (m) { - throw Error(`BUG: database invariant failed (${m})`); - } else { - throw Error("BUG: database invariant failed"); - } - } -} - -export function checkLogicInvariant(b: boolean, m?: string): asserts b { - if (!b) { - if (m) { - throw Error(`BUG: logic invariant failed (${m})`); - } else { - throw Error("BUG: logic invariant failed"); - } - } -} diff --git a/packages/taler-wallet-core/src/util/promiseUtils.ts b/packages/taler-wallet-core/src/util/promiseUtils.ts deleted file mode 100644 index 23f1c06a5..000000000 --- a/packages/taler-wallet-core/src/util/promiseUtils.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -export interface OpenedPromise<T> { - promise: Promise<T>; - resolve: (val: T) => void; - reject: (err: any) => void; -} - -/** - * Get an unresolved promise together with its extracted resolve / reject - * function. - * - * Recent ECMAScript proposals also call this a promise capability. - */ -export function openPromise<T>(): OpenedPromise<T> { - let resolve: ((x?: any) => void) | null = null; - let reject: ((reason?: any) => void) | null = null; - const promise = new Promise<T>((res, rej) => { - resolve = res; - reject = rej; - }); - if (!(resolve && reject)) { - // Never happens, unless JS implementation is broken - throw Error(); - } - return { resolve, reject, promise }; -} - -export class AsyncCondition { - private promCap?: OpenedPromise<void> = undefined; - constructor() {} - - wait(): Promise<void> { - if (!this.promCap) { - this.promCap = openPromise<void>(); - } - return this.promCap.promise; - } - - trigger(): void { - if (this.promCap) { - this.promCap.resolve(); - } - this.promCap = undefined; - } -} diff --git a/packages/taler-wallet-core/src/util/timer.ts b/packages/taler-wallet-core/src/util/timer.ts deleted file mode 100644 index d198e03c9..000000000 --- a/packages/taler-wallet-core/src/util/timer.ts +++ /dev/null @@ -1,213 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2017-2019 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Cross-platform timers. - * - * NodeJS and the browser use slightly different timer API, - * this abstracts over these differences. - */ - -/** - * Imports. - */ -import { Logger, Duration } from "@gnu-taler/taler-util"; - -const logger = new Logger("timer.ts"); - -/** - * Cancelable timer. - */ -export interface TimerHandle { - clear(): void; - - /** - * Make sure the event loop exits when the timer is the - * only event left. Has no effect in the browser. - */ - unref(): void; -} - -class IntervalHandle { - constructor(public h: any) {} - - clear(): void { - clearInterval(this.h); - } - - /** - * Make sure the event loop exits when the timer is the - * only event left. Has no effect in the browser. - */ - unref(): void { - if (typeof this.h === "object" && "unref" in this.h) { - this.h.unref(); - } - } -} - -class TimeoutHandle { - constructor(public h: any) {} - - clear(): void { - clearTimeout(this.h); - } - - /** - * Make sure the event loop exits when the timer is the - * only event left. Has no effect in the browser. - */ - unref(): void { - if (typeof this.h === "object" && "unref" in this.h) { - this.h.unref(); - } - } -} - -/** - * Get a performance counter in nanoseconds. - */ -export const performanceNow: () => bigint = (() => { - // @ts-ignore - if (typeof process !== "undefined" && process.hrtime) { - return () => { - return process.hrtime.bigint(); - }; - } - - // @ts-ignore - if (typeof performance !== "undefined") { - // @ts-ignore - return () => BigInt(Math.floor(performance.now() * 1000)) * BigInt(1000); - } - - return () => BigInt(0); -})(); - -const nullTimerHandle = { - clear() { - // do nothing - return; - }, - unref() { - // do nothing - return; - }, -}; - -/** - * Group of timers that can be destroyed at once. - */ -export interface TimerAPI { - after(delayMs: number, callback: () => void): TimerHandle; - every(delayMs: number, callback: () => void): TimerHandle; -} - -export class SetTimeoutTimerAPI implements TimerAPI { - /** - * Call a function every time the delay given in milliseconds passes. - */ - every(delayMs: number, callback: () => void): TimerHandle { - return new IntervalHandle(setInterval(callback, delayMs)); - } - - /** - * Call a function after the delay given in milliseconds passes. - */ - after(delayMs: number, callback: () => void): TimerHandle { - return new TimeoutHandle(setTimeout(callback, delayMs)); - } -} - -export const timer = new SetTimeoutTimerAPI(); - -/** - * Implementation of [[TimerGroup]] using setTimeout - */ -export class TimerGroup { - private stopped = false; - - private readonly timerMap: { [index: number]: TimerHandle } = {}; - - private idGen = 1; - - constructor(public readonly timerApi: TimerAPI) {} - - stopCurrentAndFutureTimers(): void { - this.stopped = true; - for (const x in this.timerMap) { - if (!this.timerMap.hasOwnProperty(x)) { - continue; - } - this.timerMap[x].clear(); - delete this.timerMap[x]; - } - } - - resolveAfter(delayMs: Duration): Promise<void> { - return new Promise<void>((resolve, reject) => { - if (delayMs.d_ms !== "forever") { - this.after(delayMs.d_ms, () => { - resolve(); - }); - } - }); - } - - after(delayMs: number, callback: () => void): TimerHandle { - if (this.stopped) { - logger.warn("dropping timer since timer group is stopped"); - return nullTimerHandle; - } - const h = this.timerApi.after(delayMs, callback); - const myId = this.idGen++; - this.timerMap[myId] = h; - - const tm = this.timerMap; - - return { - clear() { - h.clear(); - delete tm[myId]; - }, - unref() { - h.unref(); - }, - }; - } - - every(delayMs: number, callback: () => void): TimerHandle { - if (this.stopped) { - logger.warn("dropping timer since timer group is stopped"); - return nullTimerHandle; - } - const h = this.timerApi.every(delayMs, callback); - const myId = this.idGen++; - this.timerMap[myId] = h; - - const tm = this.timerMap; - - return { - clear() { - h.clear(); - delete tm[myId]; - }, - unref() { - h.unref(); - }, - }; - } -} diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts index 023cbb1ff..d33a23cdd 100644 --- a/packages/taler-wallet-core/src/versions.ts +++ b/packages/taler-wallet-core/src/versions.ts @@ -50,11 +50,9 @@ export const WALLET_COREBANK_API_PROTOCOL_VERSION = "2:0:0"; export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0"; /** - * Semver of the wallet-core API implementation. - * Will be replaced with the value from package.json in a - * post-compilation step (inside lib/). + * Libtool version of the wallet-core API. */ -export const WALLET_CORE_API_IMPLEMENTATION_VERSION = "3:0:2"; +export const WALLET_CORE_API_PROTOCOL_VERSION = "5:0:0"; /** * Libtool rules: @@ -68,3 +66,19 @@ export const WALLET_CORE_API_IMPLEMENTATION_VERSION = "3:0:2"; * If any interfaces have been removed or changed since the last public * release, then set age to 0. */ + +// Provided either by bundler or in the next lines. +declare global { + const walletCoreBuildInfo: { + implementationSemver: string; + implementationGitHash: string; + }; +} + +// Provide walletCoreBuildInfo if the bundler does not override it. +if (!("walletCoreBuildInfo" in globalThis)) { + (globalThis as any).walletCoreBuildInfo = { + implementationSemver: "unknown", + implementationGitHash: "unknown", + } satisfies typeof walletCoreBuildInfo; +} diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index a4be0f448..1bcab801c 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -29,15 +29,19 @@ import { AcceptExchangeTosRequest, AcceptManualWithdrawalRequest, AcceptManualWithdrawalResult, - AcceptRewardRequest, - AcceptTipResponse, AcceptWithdrawalResponse, AddExchangeRequest, + AddGlobalCurrencyAuditorRequest, + AddGlobalCurrencyExchangeRequest, AddKnownBankAccountsRequest, AmountResponse, ApplyDevExperimentRequest, BackupRecovery, BalancesResponse, + CanonicalizeBaseUrlRequest, + CanonicalizeBaseUrlResponse, + CheckPayTemplateReponse, + CheckPayTemplateRequest, CheckPeerPullCreditRequest, CheckPeerPullCreditResponse, CheckPeerPushDebitRequest, @@ -47,10 +51,12 @@ import { ConfirmPayResult, ConfirmPeerPullDebitRequest, ConfirmPeerPushCreditRequest, + ConfirmWithdrawalRequest, ConvertAmountRequest, CreateDepositGroupRequest, CreateDepositGroupResponse, CreateStoredBackupResponse, + DeleteExchangeRequest, DeleteStoredBackupRequest, DeleteTransactionRequest, ExchangeDetailedResponse, @@ -59,6 +65,7 @@ import { FailTransactionRequest, ForceRefreshRequest, ForgetKnownBankAccountsRequest, + GetActiveTasksResponse, GetAmountRequest, GetBalanceDetailRequest, GetContractTermsDetailsRequest, @@ -66,12 +73,16 @@ import { GetCurrencySpecificationResponse, GetExchangeEntryByUrlRequest, GetExchangeEntryByUrlResponse, + GetExchangeResourcesRequest, + GetExchangeResourcesResponse, GetExchangeTosRequest, GetExchangeTosResult, GetPlanForOperationRequest, GetPlanForOperationResponse, GetWithdrawalDetailsForAmountRequest, GetWithdrawalDetailsForUriRequest, + HintNetworkAvailabilityRequest, + ImportDbRequest, InitRequest, InitResponse, InitiatePeerPullCreditRequest, @@ -80,9 +91,14 @@ import { InitiatePeerPushDebitResponse, IntegrationTestArgs, KnownBankAccounts, + ListAssociatedRefreshesRequest, + ListAssociatedRefreshesResponse, ListExchangesForScopedCurrencyRequest, + ListGlobalCurrencyAuditorsResponse, + ListGlobalCurrencyExchangesResponse, ListKnownBankAccountsRequest, - WithdrawalDetailsForAmount, + PrepareBankIntegratedWithdrawalRequest, + PrepareBankIntegratedWithdrawalResponse, PrepareDepositRequest, PrepareDepositResponse, PreparePayRequest, @@ -93,12 +109,12 @@ import { PreparePeerPushCreditRequest, PreparePeerPushCreditResponse, PrepareRefundRequest, - PrepareRewardRequest, - PrepareTipResult as PrepareRewardResult, PrepareWithdrawExchangeRequest, PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, RecoveryLoadRequest, + RemoveGlobalCurrencyAuditorRequest, + RemoveGlobalCurrencyExchangeRequest, RetryTransactionRequest, SetCoinSuspendedRequest, SetWalletDeviceIdRequest, @@ -107,12 +123,18 @@ import { StartRefundQueryForUriResponse, StartRefundQueryRequest, StoredBackupList, + TalerMerchantApi, TestPayArgs, TestPayResult, + TestingGetDenomStatsRequest, + TestingGetDenomStatsResponse, + TestingListTasksForTransactionRequest, + TestingListTasksForTransactionsResponse, TestingSetTimetravelRequest, TestingWaitTransactionRequest, Transaction, TransactionByIdRequest, + TransactionWithdrawal, TransactionsRequest, TransactionsResponse, TxIdResponse, @@ -125,9 +147,10 @@ import { ValidateIbanResponse, WalletContractData, WalletCoreVersion, - WalletCurrencyInfo, WithdrawTestBalanceRequest, WithdrawUriInfoResponse, + WithdrawalDetailsForAmount, + WithdrawalTransactionByURIRequest, } from "@gnu-taler/taler-util"; import { AddBackupProviderRequest, @@ -135,16 +158,17 @@ import { BackupInfo, RemoveBackupProviderRequest, RunBackupCycleRequest, -} from "./operations/backup/index.js"; -import { MerchantPaymentBalanceDetails } from "./operations/balance.js"; -import { PendingOperationsResponse as PendingTasksResponse } from "./pending-types.js"; +} from "./backup/index.js"; +import { PaymentBalanceDetails } from "./balance.js"; export enum WalletApiOperation { InitWallet = "initWallet", + SetWalletRunConfig = "setWalletRunConfig", WithdrawTestkudos = "withdrawTestkudos", WithdrawTestBalance = "withdrawTestBalance", PreparePayForUri = "preparePayForUri", SharePayment = "sharePayment", + CheckPayForTemplate = "checkPayForTemplate", PreparePayForTemplate = "preparePayForTemplate", GetContractTermsDetails = "getContractTermsDetails", RunIntegrationTest = "runIntegrationTest", @@ -154,6 +178,7 @@ export enum WalletApiOperation { AddExchange = "addExchange", GetTransactions = "getTransactions", GetTransactionById = "getTransactionById", + GetWithdrawalTransactionByUri = "getWithdrawalTransactionByUri", TestingGetSampleTransactions = "testingGetSampleTransactions", ListExchanges = "listExchanges", GetExchangeEntryByUrl = "getExchangeEntryByUrl", @@ -175,9 +200,13 @@ export enum WalletApiOperation { GetUserAttentionUnreadCount = "getUserAttentionUnreadCount", MarkAttentionRequestAsRead = "markAttentionRequestAsRead", GetPendingOperations = "getPendingOperations", + GetActiveTasks = "getActiveTasks", SetExchangeTosAccepted = "setExchangeTosAccepted", + SetExchangeTosForgotten = "SetExchangeTosForgotten", StartRefundQueryForUri = "startRefundQueryForUri", StartRefundQuery = "startRefundQuery", + PrepareBankIntegratedWithdrawal = "prepareBankIntegratedWithdrawal", + ConfirmWithdrawal = "confirmWithdrawal", AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal", GetExchangeTos = "getExchangeTos", GetExchangeDetailedInfo = "getExchangeDetailedInfo", @@ -192,8 +221,6 @@ export enum WalletApiOperation { DumpCoins = "dumpCoins", SetCoinSuspended = "setCoinSuspended", ForceRefresh = "forceRefresh", - PrepareReward = "prepareReward", - AcceptReward = "acceptReward", ExportBackup = "exportBackup", AddBackupProvider = "addBackupProvider", RemoveBackupProvider = "removeBackupProvider", @@ -203,7 +230,6 @@ export enum WalletApiOperation { GetBackupInfo = "getBackupInfo", PrepareDeposit = "prepareDeposit", GetVersion = "getVersion", - ListCurrencies = "listCurrencies", GenerateDepositGroupTxId = "generateDepositGroupTxId", CreateDepositGroup = "createDepositGroup", SetWalletDeviceId = "setWalletDeviceId", @@ -221,28 +247,46 @@ export enum WalletApiOperation { Recycle = "recycle", ApplyDevExperiment = "applyDevExperiment", ValidateIban = "validateIban", - TestingWaitTransactionsFinal = "testingWaitTransactionsFinal", - TestingWaitRefreshesFinal = "testingWaitRefreshesFinal", - TestingWaitTransactionState = "testingWaitTransactionState", - TestingSetTimetravel = "testingSetTimetravel", GetCurrencySpecification = "getCurrencySpecification", ListStoredBackups = "listStoredBackups", CreateStoredBackup = "createStoredBackup", DeleteStoredBackup = "deleteStoredBackup", RecoverStoredBackup = "recoverStoredBackup", UpdateExchangeEntry = "updateExchangeEntry", - TestingWaitTasksProcessed = "testingWaitTasksProcessed", ListExchangesForScopedCurrency = "listExchangesForScopedCurrency", PrepareWithdrawExchange = "prepareWithdrawExchange", + GetExchangeResources = "getExchangeResources", + DeleteExchange = "deleteExchange", + ListGlobalCurrencyExchanges = "listGlobalCurrencyExchanges", + ListGlobalCurrencyAuditors = "listGlobalCurrencyAuditors", + AddGlobalCurrencyExchange = "addGlobalCurrencyExchange", + RemoveGlobalCurrencyExchange = "removeGlobalCurrencyExchange", + AddGlobalCurrencyAuditor = "addGlobalCurrencyAuditor", + RemoveGlobalCurrencyAuditor = "removeGlobalCurrencyAuditor", + ListAssociatedRefreshes = "listAssociatedRefreshes", + Shutdown = "shutdown", + HintNetworkAvailability = "hintNetworkAvailability", + CanonicalizeBaseUrl = "canonicalizeBaseUrl", + TestingWaitTransactionsFinal = "testingWaitTransactionsFinal", + TestingWaitRefreshesFinal = "testingWaitRefreshesFinal", + TestingWaitTransactionState = "testingWaitTransactionState", + TestingWaitTasksDone = "testingWaitTasksDone", + TestingSetTimetravel = "testingSetTimetravel", + TestingInfiniteTransactionLoop = "testingInfiniteTransactionLoop", + TestingListTaskForTransaction = "testingListTasksForTransaction", + TestingGetDenomStats = "testingGetDenomStats", + TestingPing = "testingPing", + TestingGetReserveHistory = "testingGetReserveHistory", } // group: Initialization type EmptyObject = Record<string, never>; + /** * Initialize wallet-core. * - * Must be the request before any other operations. + * Must be the first request made to wallet-core. */ export type InitWalletOp = { op: WalletApiOperation.InitWallet; @@ -250,57 +294,34 @@ export type InitWalletOp = { response: InitResponse; }; -export type GetVersionOp = { - op: WalletApiOperation.GetVersion; +export type ShutdownOp = { + op: WalletApiOperation.Shutdown; request: EmptyObject; - response: WalletCoreVersion; + response: EmptyObject; }; /** - * Configurations options for the Wallet + * Change the configuration of wallet-core. * - * All missing values of the config will be replaced with default values - * Default values are defined by Wallet.getDefaultConfig() + * Currently an alias for the initWallet request. */ -export type WalletConfigParameter = RecursivePartial<WalletConfig>; +export type SetWalletRunConfigOp = { + op: WalletApiOperation.SetWalletRunConfig; + request: InitRequest; + response: InitResponse; +}; -export interface BuiltinExchange { - exchangeBaseUrl: string; - currencyHint?: string; -} +export type GetVersionOp = { + op: WalletApiOperation.GetVersion; + request: EmptyObject; + response: WalletCoreVersion; +}; -export interface WalletConfig { - /** - * Initialization values useful for a complete startup. - * - * These are values may be overridden by different wallets - */ - builtin: { - exchanges: BuiltinExchange[]; - }; - - /** - * Unsafe options which it should only be used to create - * testing environment. - */ - testing: { - /** - * Allow withdrawal of denominations even though they are about to expire. - */ - denomselAllowLate: boolean; - devModeActive: boolean; - insecureTrustExchange: boolean; - preventThrottling: boolean; - skipDefaults: boolean; - }; - - /** - * Configurations values that may be safe to show to the user - */ - features: { - allowHttp: boolean; - }; -} +export type HintNetworkAvailabilityOp = { + op: WalletApiOperation.HintNetworkAvailability; + request: HintNetworkAvailabilityRequest; + response: EmptyObject; +}; // group: Basic Wallet Information @@ -315,7 +336,7 @@ export type GetBalancesOp = { export type GetBalancesDetailOp = { op: WalletApiOperation.GetBalanceDetail; request: GetBalanceDetailRequest; - response: MerchantPaymentBalanceDetails; + response: PaymentBalanceDetails; }; export type GetPlanForOperationOp = { @@ -362,6 +383,15 @@ export type GetTransactionsOp = { }; /** + * List refresh transactions associated with another transaction. + */ +export type ListAssociatedRefreshesOp = { + op: WalletApiOperation.ListAssociatedRefreshes; + request: ListAssociatedRefreshesRequest; + response: ListAssociatedRefreshesResponse; +}; + +/** * Get sample transactions. */ export type TestingGetSampleTransactionsOp = { @@ -376,6 +406,12 @@ export type GetTransactionByIdOp = { response: Transaction; }; +export type GetWithdrawalTransactionByUriOp = { + op: WalletApiOperation.GetWithdrawalTransactionByUri; + request: WithdrawalTransactionByURIRequest; + response: TransactionWithdrawal | undefined; +}; + export type RetryPendingNowOp = { op: WalletApiOperation.RetryPendingNow; request: EmptyObject; @@ -461,7 +497,27 @@ export type GetWithdrawalDetailsForUriOp = { }; /** + * Prepare a bank-integrated withdrawal operation. + */ +export type PrepareBankIntegratedWithdrawalOp = { + op: WalletApiOperation.PrepareBankIntegratedWithdrawal; + request: PrepareBankIntegratedWithdrawalRequest; + response: PrepareBankIntegratedWithdrawalResponse; +}; + +/** + * Confirm a withdrawal transaction. + */ +export type ConfirmWithdrawalOp = { + op: WalletApiOperation.ConfirmWithdrawal; + request: ConfirmWithdrawalRequest; + response: EmptyObject; +}; + +/** * Accept a bank-integrated withdrawal. + * + * @deprecated in favor of prepare/confirm withdrawal. */ export type AcceptBankIntegratedWithdrawalOp = { op: WalletApiOperation.AcceptBankIntegratedWithdrawal; @@ -495,6 +551,12 @@ export type SharePaymentOp = { response: SharePaymentResult; }; +export type CheckPayForTemplateOp = { + op: WalletApiOperation.CheckPayForTemplate; + request: CheckPayTemplateRequest; + response: CheckPayTemplateReponse; +}; + /** * Prepare to make a payment based on a taler://pay-template/ URI. */ @@ -535,24 +597,42 @@ export type StartRefundQueryOp = { response: EmptyObject; }; -// group: Rewards +// group: Global Currency management -/** - * Query and store information about a reward. - */ -export type PrepareTipOp = { - op: WalletApiOperation.PrepareReward; - request: PrepareRewardRequest; - response: PrepareRewardResult; +export type ListGlobalCurrencyAuditorsOp = { + op: WalletApiOperation.ListGlobalCurrencyAuditors; + request: EmptyObject; + response: ListGlobalCurrencyAuditorsResponse; }; -/** - * Accept a reward. - */ -export type AcceptTipOp = { - op: WalletApiOperation.AcceptReward; - request: AcceptRewardRequest; - response: AcceptTipResponse; +export type ListGlobalCurrencyExchangesOp = { + op: WalletApiOperation.ListGlobalCurrencyExchanges; + request: EmptyObject; + response: ListGlobalCurrencyExchangesResponse; +}; + +export type AddGlobalCurrencyExchangeOp = { + op: WalletApiOperation.AddGlobalCurrencyExchange; + request: AddGlobalCurrencyExchangeRequest; + response: EmptyObject; +}; + +export type AddGlobalCurrencyAuditorOp = { + op: WalletApiOperation.AddGlobalCurrencyAuditor; + request: AddGlobalCurrencyAuditorRequest; + response: EmptyObject; +}; + +export type RemoveGlobalCurrencyExchangeOp = { + op: WalletApiOperation.RemoveGlobalCurrencyExchange; + request: RemoveGlobalCurrencyExchangeRequest; + response: EmptyObject; +}; + +export type RemoveGlobalCurrencyAuditorOp = { + op: WalletApiOperation.RemoveGlobalCurrencyAuditor; + request: RemoveGlobalCurrencyAuditorRequest; + response: EmptyObject; }; // group: Exchange Management @@ -631,6 +711,15 @@ export type SetExchangeTosAcceptedOp = { }; /** + * Accept a particular version of the exchange terms of service. + */ +export type SetExchangeTosForgottenOp = { + op: WalletApiOperation.SetExchangeTosForgotten; + request: AcceptExchangeTosRequest; + response: EmptyObject; +}; + +/** * Get the current terms of a service of an exchange. */ export type GetExchangeTosOp = { @@ -658,12 +747,21 @@ export type GetExchangeEntryByUrlOp = { }; /** - * List currencies known to the wallet. + * Get resources associated with an exchange. */ -export type ListCurrenciesOp = { - op: WalletApiOperation.ListCurrencies; - request: EmptyObject; - response: WalletCurrencyInfo; +export type GetExchangeResourcesOp = { + op: WalletApiOperation.GetExchangeResources; + request: GetExchangeResourcesRequest; + response: GetExchangeResourcesResponse; +}; + +/** + * Get resources associated with an exchange. + */ +export type DeleteExchangeOp = { + op: WalletApiOperation.GetExchangeResources; + request: DeleteExchangeRequest; + response: EmptyObject; }; export type GetCurrencySpecificationOp = { @@ -882,6 +980,12 @@ export type ValidateIbanOp = { response: ValidateIbanResponse; }; +export type CanonicalizeBaseUrlOp = { + op: WalletApiOperation.CanonicalizeBaseUrl; + request: CanonicalizeBaseUrlRequest; + response: CanonicalizeBaseUrlResponse; +}; + // group: Database Management /** @@ -895,8 +999,8 @@ export type ExportDbOp = { export type ImportDbOp = { op: WalletApiOperation.ImportDb; - request: any; - response: any; + request: ImportDbRequest; + response: EmptyObject; }; /** @@ -1018,11 +1122,19 @@ export type GetUserAttentionsUnreadCount = { /** * Get wallet-internal pending tasks. + * + * @deprecated */ export type GetPendingTasksOp = { op: WalletApiOperation.GetPendingOperations; request: EmptyObject; - response: PendingTasksResponse; + response: any; +}; + +export type GetActiveTasksOp = { + op: WalletApiOperation.GetActiveTasks; + request: EmptyObject; + response: GetActiveTasksResponse; }; /** @@ -1044,6 +1156,15 @@ export type TestingSetTimetravelOp = { }; /** + * Add an offset to the wallet's internal time. + */ +export type TestingListTasksForTransactionOp = { + op: WalletApiOperation.TestingListTaskForTransaction; + request: TestingListTasksForTransactionRequest; + response: TestingListTasksForTransactionsResponse; +}; + +/** * Wait until all transactions are in a final state. */ export type TestingWaitTransactionsFinalOp = { @@ -1053,19 +1174,19 @@ export type TestingWaitTransactionsFinalOp = { }; /** - * Wait until all refresh transactions are in a final state. + * Wait until all transactions are in a final state. */ -export type TestingWaitRefreshesFinalOp = { - op: WalletApiOperation.TestingWaitRefreshesFinal; +export type TestingWaitTasksDoneOp = { + op: WalletApiOperation.TestingWaitTasksDone; request: EmptyObject; response: EmptyObject; }; /** - * Wait until all tasks have been processed and the wallet is idle. + * Wait until all refresh transactions are in a final state. */ -export type TestingWaitTasksProcessedOp = { - op: WalletApiOperation.TestingWaitTasksProcessed; +export type TestingWaitRefreshesFinalOp = { + op: WalletApiOperation.TestingWaitRefreshesFinal; request: EmptyObject; response: EmptyObject; }; @@ -1079,6 +1200,27 @@ export type TestingWaitTransactionStateOp = { response: EmptyObject; }; +export type TestingPingOp = { + op: WalletApiOperation.TestingPing; + request: EmptyObject; + response: EmptyObject; +}; + +export type TestingGetReserveHistoryOp = { + op: WalletApiOperation.TestingGetReserveHistory; + request: EmptyObject; + response: any; +}; + +/** + * Get stats about an exchange denomination. + */ +export type TestingGetDenomStatsOp = { + op: WalletApiOperation.TestingGetDenomStats; + request: TestingGetDenomStatsRequest; + response: TestingGetDenomStatsResponse; +}; + /** * Set a coin as (un-)suspended. * Suspended coins won't be used for payments. @@ -1101,9 +1243,11 @@ export type ForceRefreshOp = { export type WalletOperations = { [WalletApiOperation.InitWallet]: InitWalletOp; + [WalletApiOperation.SetWalletRunConfig]: SetWalletRunConfigOp; [WalletApiOperation.GetVersion]: GetVersionOp; [WalletApiOperation.PreparePayForUri]: PreparePayForUriOp; [WalletApiOperation.SharePayment]: SharePaymentOp; + [WalletApiOperation.CheckPayForTemplate]: CheckPayForTemplateOp; [WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp; [WalletApiOperation.GetContractTermsDetails]: GetContractTermsDetailsOp; [WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp; @@ -1123,8 +1267,10 @@ export type WalletOperations = { [WalletApiOperation.GetTransactions]: GetTransactionsOp; [WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp; [WalletApiOperation.GetTransactionById]: GetTransactionByIdOp; + [WalletApiOperation.GetWithdrawalTransactionByUri]: GetWithdrawalTransactionByUriOp; [WalletApiOperation.RetryPendingNow]: RetryPendingNowOp; [WalletApiOperation.GetPendingOperations]: GetPendingTasksOp; + [WalletApiOperation.GetActiveTasks]: GetActiveTasksOp; [WalletApiOperation.GetUserAttentionRequests]: GetUserAttentionRequests; [WalletApiOperation.GetUserAttentionUnreadCount]: GetUserAttentionsUnreadCount; [WalletApiOperation.MarkAttentionRequestAsRead]: MarkAttentionRequestAsRead; @@ -1133,11 +1279,8 @@ export type WalletOperations = { [WalletApiOperation.ForceRefresh]: ForceRefreshOp; [WalletApiOperation.DeleteTransaction]: DeleteTransactionOp; [WalletApiOperation.RetryTransaction]: RetryTransactionOp; - [WalletApiOperation.PrepareReward]: PrepareTipOp; - [WalletApiOperation.AcceptReward]: AcceptTipOp; [WalletApiOperation.StartRefundQueryForUri]: StartRefundQueryForUriOp; [WalletApiOperation.StartRefundQuery]: StartRefundQueryOp; - [WalletApiOperation.ListCurrencies]: ListCurrenciesOp; [WalletApiOperation.GetWithdrawalDetailsForAmount]: GetWithdrawalDetailsForAmountOp; [WalletApiOperation.GetWithdrawalDetailsForUri]: GetWithdrawalDetailsForUriOp; [WalletApiOperation.AcceptBankIntegratedWithdrawal]: AcceptBankIntegratedWithdrawalOp; @@ -1149,6 +1292,7 @@ export type WalletOperations = { [WalletApiOperation.AddKnownBankAccounts]: AddKnownBankAccountsOp; [WalletApiOperation.ForgetKnownBankAccounts]: ForgetKnownBankAccountsOp; [WalletApiOperation.SetExchangeTosAccepted]: SetExchangeTosAcceptedOp; + [WalletApiOperation.SetExchangeTosForgotten]: SetExchangeTosForgottenOp; [WalletApiOperation.GetExchangeTos]: GetExchangeTosOp; [WalletApiOperation.GetExchangeDetailedInfo]: GetExchangeDetailedInfoOp; [WalletApiOperation.GetExchangeEntryByUrl]: GetExchangeEntryByUrlOp; @@ -1184,9 +1328,9 @@ export type WalletOperations = { [WalletApiOperation.ValidateIban]: ValidateIbanOp; [WalletApiOperation.TestingWaitTransactionsFinal]: TestingWaitTransactionsFinalOp; [WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinalOp; - [WalletApiOperation.TestingWaitTasksProcessed]: TestingWaitTasksProcessedOp; [WalletApiOperation.TestingSetTimetravel]: TestingSetTimetravelOp; [WalletApiOperation.TestingWaitTransactionState]: TestingWaitTransactionStateOp; + [WalletApiOperation.TestingWaitTasksDone]: TestingWaitTasksDoneOp; [WalletApiOperation.GetCurrencySpecification]: GetCurrencySpecificationOp; [WalletApiOperation.CreateStoredBackup]: CreateStoredBackupsOp; [WalletApiOperation.ListStoredBackups]: ListStoredBackupsOp; @@ -1194,6 +1338,25 @@ export type WalletOperations = { [WalletApiOperation.RecoverStoredBackup]: RecoverStoredBackupsOp; [WalletApiOperation.UpdateExchangeEntry]: UpdateExchangeEntryOp; [WalletApiOperation.PrepareWithdrawExchange]: PrepareWithdrawExchangeOp; + [WalletApiOperation.TestingInfiniteTransactionLoop]: any; + [WalletApiOperation.DeleteExchange]: DeleteExchangeOp; + [WalletApiOperation.GetExchangeResources]: GetExchangeResourcesOp; + [WalletApiOperation.ListGlobalCurrencyAuditors]: ListGlobalCurrencyAuditorsOp; + [WalletApiOperation.ListGlobalCurrencyExchanges]: ListGlobalCurrencyExchangesOp; + [WalletApiOperation.AddGlobalCurrencyAuditor]: AddGlobalCurrencyAuditorOp; + [WalletApiOperation.RemoveGlobalCurrencyAuditor]: RemoveGlobalCurrencyAuditorOp; + [WalletApiOperation.AddGlobalCurrencyExchange]: AddGlobalCurrencyExchangeOp; + [WalletApiOperation.RemoveGlobalCurrencyExchange]: RemoveGlobalCurrencyExchangeOp; + [WalletApiOperation.ListAssociatedRefreshes]: ListAssociatedRefreshesOp; + [WalletApiOperation.TestingListTaskForTransaction]: TestingListTasksForTransactionOp; + [WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp; + [WalletApiOperation.TestingPing]: TestingPingOp; + [WalletApiOperation.Shutdown]: ShutdownOp; + [WalletApiOperation.PrepareBankIntegratedWithdrawal]: PrepareBankIntegratedWithdrawalOp; + [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp; + [WalletApiOperation.CanonicalizeBaseUrl]: CanonicalizeBaseUrlOp; + [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp; + [WalletApiOperation.HintNetworkAvailability]: HintNetworkAvailabilityOp; }; export type WalletCoreRequestType< @@ -1212,15 +1375,3 @@ export interface WalletCoreApiClient { payload: WalletCoreRequestType<Op>, ): Promise<WalletCoreResponseType<Op>>; } - -type Primitives = string | number | boolean; - -type RecursivePartial<T extends object> = { - [P in keyof T]?: T[P] extends Array<infer U extends object> - ? Array<RecursivePartial<U>> - : T[P] extends Array<infer J extends Primitives> - ? Array<J> - : T[P] extends object - ? RecursivePartial<T[P]> - : T[P]; -} & object; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 2d422e59c..68da15410 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -22,13 +22,16 @@ /** * Imports. */ -import { IDBFactory } from "@gnu-taler/idb-bridge"; +import { IDBDatabase, IDBFactory } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, + ActiveTask, + AmountJson, AmountString, Amounts, + AsyncCondition, + CancellationToken, CoinDumpJson, - CoinRefreshRequest, CoinStatus, CoreApiResponse, CreateStoredBackupResponse, @@ -40,43 +43,56 @@ import { InitResponse, KnownBankAccounts, KnownBankAccountsInfo, + ListGlobalCurrencyAuditorsResponse, + ListGlobalCurrencyExchangesResponse, Logger, - WithdrawalDetailsForAmount, - MerchantUsingTemplateDetails, NotificationType, + ObservabilityContext, + ObservabilityEventType, + ObservableHttpClientLibrary, + OpenedPromise, + PartialWalletRunConfig, PrepareWithdrawExchangeRequest, PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, - RefreshReason, - ScopeType, StoredBackupList, TalerError, TalerErrorCode, + TalerProtocolTimestamp, TalerUriAction, - TaskThrottler, + TestingGetDenomStatsResponse, + TestingListTasksForTransactionsResponse, TestingWaitTransactionRequest, - TransactionState, + TimerAPI, + TimerGroup, TransactionType, - URL, ValidateIbanResponse, WalletCoreVersion, WalletNotification, + WalletRunConfig, + canonicalizeBaseUrl, + checkDbInvariant, codecForAbortTransaction, codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, - codecForAcceptManualWithdrawalRequet, + codecForAcceptManualWithdrawalRequest, codecForAcceptPeerPullPaymentRequest, - codecForAcceptTipRequest, codecForAddExchangeRequest, + codecForAddGlobalCurrencyAuditorRequest, + codecForAddGlobalCurrencyExchangeRequest, codecForAddKnownBankAccounts, codecForAny, codecForApplyDevExperiment, + codecForCanonicalizeBaseUrlRequest, + codecForCheckPayTemplateRequest, codecForCheckPeerPullPaymentRequest, codecForCheckPeerPushDebitRequest, codecForConfirmPayRequest, codecForConfirmPeerPushPaymentRequest, + codecForConfirmWithdrawalRequestRequest, codecForConvertAmountRequest, codecForCreateDepositGroupRequest, + codecForDeleteExchangeRequest, codecForDeleteStoredBackupRequest, codecForDeleteTransactionRequest, codecForFailTransactionRequest, @@ -87,26 +103,29 @@ import { codecForGetContractTermsDetails, codecForGetCurrencyInfoRequest, codecForGetExchangeEntryByUrlRequest, + codecForGetExchangeResourcesRequest, codecForGetExchangeTosRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForImportDbRequest, + codecForInitRequest, codecForInitiatePeerPullPaymentRequest, codecForInitiatePeerPushDebitRequest, codecForIntegrationTestArgs, codecForIntegrationTestV2Args, codecForListExchangesForScopedCurrencyRequest, codecForListKnownBankAccounts, - codecForMerchantPostOrderResponse, + codecForPrepareBankIntegratedWithdrawalRequest, codecForPrepareDepositRequest, codecForPreparePayRequest, codecForPreparePayTemplateRequest, codecForPreparePeerPullPaymentRequest, codecForPreparePeerPushCreditRequest, codecForPrepareRefundRequest, - codecForPrepareRewardRequest, codecForPrepareWithdrawExchangeRequest, codecForRecoverStoredBackupRequest, + codecForRemoveGlobalCurrencyAuditorRequest, + codecForRemoveGlobalCurrencyExchangeRequest, codecForResumeTransaction, codecForRetryTransactionRequest, codecForSetCoinSuspendedRequest, @@ -115,6 +134,9 @@ import { codecForStartRefundQueryRequest, codecForSuspendTransaction, codecForTestPayArgs, + codecForTestingGetDenomStatsRequest, + codecForTestingGetReserveHistoryRequest, + codecForTestingListTasksForTransactionRequest, codecForTestingSetTimetravelRequest, codecForTransactionByIdRequest, codecForTransactionsRequest, @@ -123,53 +145,26 @@ import { codecForUserAttentionsRequest, codecForValidateIbanRequest, codecForWithdrawTestBalance, - constructPayUri, - durationFromSpec, - durationMin, getErrorDetailFromException, j2s, - parsePayTemplateUri, + openPromise, parsePaytoUri, parseTalerUri, + performanceNow, + safeStringifyException, sampleWalletCoreTransactions, setDangerousTimetravel, validateIban, } from "@gnu-taler/taler-util"; -import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; -import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; -import { - CryptoDispatcher, - CryptoWorkerFactory, -} from "./crypto/workers/crypto-dispatcher.js"; -import { - CoinSourceType, - ConfigRecordKey, - DenominationRecord, - WalletStoresV1, - clearDatabase, - exportDb, - importDb, - openStoredBackupsDatabase, - openTalerDatabase, -} from "./db.js"; -import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; import { - ActiveLongpollInfo, - CancelFn, - ExchangeOperations, - InternalWalletState, - MerchantInfo, - MerchantOperations, - NotificationListener, - RecoupOperations, - RefreshOperations, -} from "./internal-wallet-state.js"; + readSuccessResponseJsonOrThrow, + type HttpRequestLibrary, +} from "@gnu-taler/taler-util/http"; import { getUserAttentions, getUserAttentionsUnreadCount, markAttentionRequestAsRead, -} from "./operations/attention.js"; +} from "./attention.js"; import { addBackupProvider, codecForAddBackupProviderRequest, @@ -178,382 +173,187 @@ import { getBackupInfo, getBackupRecovery, loadBackupRecovery, - processBackupForProvider, removeBackupProvider, runBackupCycle, setWalletDeviceId, -} from "./operations/backup/index.js"; -import { getBalanceDetail, getBalances } from "./operations/balance.js"; +} from "./backup/index.js"; +import { getBalanceDetail, getBalances } from "./balance.js"; +import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { - TaskIdentifiers, - TaskRunResult, - TaskRunResultType, - makeExchangeListItem, - runTaskWithErrorReporting, -} from "./operations/common.js"; + CryptoDispatcher, + CryptoWorkerFactory, +} from "./crypto/workers/crypto-dispatcher.js"; import { - computeDepositTransactionStatus, + CoinSourceType, + ConfigRecordKey, + DenominationRecord, + WalletDbReadOnlyTransaction, + WalletStoresV1, + clearDatabase, + exportDb, + importDb, + openStoredBackupsDatabase, + openTalerDatabase, + timestampAbsoluteFromDb, + timestampProtocolToDb, +} from "./db.js"; +import { + checkDepositGroup, createDepositGroup, generateDepositGroupTxId, - prepareDepositGroup, - processDepositGroup, -} from "./operations/deposits.js"; +} from "./deposits.js"; +import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; import { + ReadyExchangeSummary, acceptExchangeTermsOfService, addPresetExchangeEntry, + deleteExchange, fetchFreshExchange, + forgetExchangeTermsOfService, getExchangeDetailedInfo, - getExchangeDetails, + getExchangeResources, getExchangeTos, - getExchanges, - updateExchangeFromUrlHandler, -} from "./operations/exchanges.js"; -import { getMerchantInfo } from "./operations/merchants.js"; + listExchanges, + lookupExchangeByUri, +} from "./exchanges.js"; +import { + convertDepositAmount, + convertPeerPushAmount, + convertWithdrawalAmount, + getMaxDepositAmount, + getMaxPeerPushAmount, +} from "./instructedAmountConversion.js"; import { - computePayMerchantTransactionState, - computeRefundTransactionState, + ObservableDbAccess, + ObservableTaskScheduler, + observeTalerCrypto, +} from "./observable-wrappers.js"; +import { + checkPayForTemplate, confirmPay, getContractTermsDetails, + preparePayForTemplate, preparePayForUri, - processPurchase, sharePayment, startQueryRefund, startRefundQueryForUri, -} from "./operations/pay-merchant.js"; +} from "./pay-merchant.js"; import { checkPeerPullPaymentInitiation, - computePeerPullCreditTransactionState, initiatePeerPullPayment, - processPeerPullCredit, -} from "./operations/pay-peer-pull-credit.js"; +} from "./pay-peer-pull-credit.js"; import { - computePeerPullDebitTransactionState, confirmPeerPullDebit, preparePeerPullDebit, - processPeerPullDebit, -} from "./operations/pay-peer-pull-debit.js"; +} from "./pay-peer-pull-debit.js"; import { - computePeerPushCreditTransactionState, confirmPeerPushCredit, preparePeerPushCredit, - processPeerPushCredit, -} from "./operations/pay-peer-push-credit.js"; +} from "./pay-peer-push-credit.js"; import { checkPeerPushDebit, - computePeerPushDebitTransactionState, initiatePeerPushDebit, - processPeerPushDebit, -} from "./operations/pay-peer-push-debit.js"; -import { getPendingOperations } from "./operations/pending.js"; -import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js"; +} from "./pay-peer-push-debit.js"; import { - autoRefresh, - computeRefreshTransactionState, - createRefreshGroup, - processRefreshGroup, -} from "./operations/refresh.js"; + AfterCommitInfo, + DbAccess, + DbAccessImpl, + TriggerSpec, +} from "./query.js"; +import { forceRefresh } from "./refresh.js"; import { - acceptTip, - computeRewardTransactionStatus, - prepareTip, - processTip, -} from "./operations/reward.js"; + TaskScheduler, + TaskSchedulerImpl, + convertTaskToTransactionId, + listTaskForTransactionId, +} from "./shepherd.js"; import { runIntegrationTest, runIntegrationTest2, testPay, + waitTasksDone, waitTransactionState, + waitUntilAllTransactionsFinal, waitUntilRefreshesDone, - waitUntilTasksProcessed, - waitUntilTransactionsFinal, withdrawTestBalance, -} from "./operations/testing.js"; +} from "./testing.js"; import { abortTransaction, + constructTransactionIdentifier, deleteTransaction, failTransaction, getTransactionById, getTransactions, + getWithdrawalTransactionByUri, parseTransactionIdentifier, resumeTransaction, retryTransaction, suspendTransaction, -} from "./operations/transactions.js"; -import { - acceptWithdrawalFromUri, - computeWithdrawalTransactionStatus, - createManualWithdrawal, - getExchangeWithdrawalInfo, - getWithdrawalDetailsForUri, - processWithdrawalGroup, -} from "./operations/withdraw.js"; -import { PendingTaskInfo, PendingTaskType } from "./pending-types.js"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { - convertDepositAmount, - convertPeerPushAmount, - convertWithdrawalAmount, - getMaxDepositAmount, - getMaxPeerPushAmount, -} from "./util/instructedAmountConversion.js"; -import { checkDbInvariant } from "./util/invariants.js"; -import { - AsyncCondition, - OpenedPromise, - openPromise, -} from "./util/promiseUtils.js"; -import { - DbAccess, - GetReadOnlyAccess, - GetReadWriteAccess, -} from "./util/query.js"; -import { TimerAPI, TimerGroup } from "./util/timer.js"; +} from "./transactions.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_COREBANK_API_PROTOCOL_VERSION, - WALLET_CORE_API_IMPLEMENTATION_VERSION, + WALLET_CORE_API_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_MERCHANT_PROTOCOL_VERSION, } from "./versions.js"; import { WalletApiOperation, - WalletConfig, - WalletConfigParameter, WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; +import { + acceptWithdrawalFromUri, + confirmWithdrawal, + createManualWithdrawal, + getWithdrawalDetailsForAmount, + getWithdrawalDetailsForUri, + prepareBankIntegratedWithdrawal, +} from "./withdraw.js"; const logger = new Logger("wallet.ts"); /** - * Call the right handler for a pending operation without doing - * any special error handling. - */ -async function callOperationHandler( - ws: InternalWalletState, - pending: PendingTaskInfo, -): Promise<TaskRunResult> { - switch (pending.type) { - case PendingTaskType.ExchangeUpdate: - return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl); - case PendingTaskType.Refresh: - return await processRefreshGroup(ws, pending.refreshGroupId); - case PendingTaskType.Withdraw: - return await processWithdrawalGroup(ws, pending.withdrawalGroupId); - case PendingTaskType.RewardPickup: - return await processTip(ws, pending.tipId); - case PendingTaskType.Purchase: - return await processPurchase(ws, pending.proposalId); - case PendingTaskType.Recoup: - return await processRecoupGroup(ws, pending.recoupGroupId); - case PendingTaskType.ExchangeCheckRefresh: - return await autoRefresh(ws, pending.exchangeBaseUrl); - case PendingTaskType.Deposit: - return await processDepositGroup(ws, pending.depositGroupId); - case PendingTaskType.Backup: - return await processBackupForProvider(ws, pending.backupProviderBaseUrl); - case PendingTaskType.PeerPushDebit: - return await processPeerPushDebit(ws, pending.pursePub); - case PendingTaskType.PeerPullCredit: - return await processPeerPullCredit(ws, pending.pursePub); - case PendingTaskType.PeerPullDebit: - return await processPeerPullDebit(ws, pending.peerPullDebitId); - case PendingTaskType.PeerPushCredit: - return await processPeerPushCredit(ws, pending.peerPushCreditId); - default: - return assertUnreachable(pending); - } - throw Error(`not reached ${pending.type}`); -} - -/** - * Process pending operations. - */ -export async function runPending(ws: InternalWalletState): Promise<void> { - const pendingOpsResponse = await getPendingOperations(ws); - for (const p of pendingOpsResponse.pendingOperations) { - if (!AbsoluteTime.isExpired(p.timestampDue)) { - continue; - } - await runTaskWithErrorReporting(ws, p.id, async () => { - logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`); - return await callOperationHandler(ws, p); - }); - } -} - -export interface RetryLoopOpts { - /** - * Stop when the number of retries is exceeded for any pending - * operation. - */ - maxRetries?: number; - - /** - * Stop the retry loop when all lifeness-giving pending operations - * are done. - * - * Defaults to false. - */ - stopWhenDone?: boolean; -} - -export interface TaskLoopResult { - /** - * Was the maximum number of retries exceeded in a task? - */ - retriesExceeded: boolean; -} - -/** - * Main retry loop of the wallet. + * Execution context for code that is run in the wallet. * - * Looks up pending operations from the wallet, runs them, repeat. + * Typically the execution context is either for a wallet-core + * request handler or for a shepherded task. */ -async function runTaskLoop( - ws: InternalWalletState, - opts: RetryLoopOpts = {}, -): Promise<TaskLoopResult> { - logger.trace(`running task loop opts=${j2s(opts)}`); - if (ws.isTaskLoopRunning) { - logger.warn( - "task loop already running, nesting the wallet-core task loop is deprecated and should be avoided", - ); - } - const throttler = new TaskThrottler(); - ws.isTaskLoopRunning = true; - let retriesExceeded = false; - for (let iteration = 0; !ws.stopped; iteration++) { - const pending = await getPendingOperations(ws); - logger.trace(`pending operations: ${j2s(pending)}`); - let numGivingLiveness = 0; - let numDue = 0; - let numThrottled = 0; - let minDue: AbsoluteTime = AbsoluteTime.never(); - - for (const p of pending.pendingOperations) { - const maxRetries = opts.maxRetries; - - if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) { - retriesExceeded = true; - logger.warn( - `skipping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`, - ); - continue; - } - if (p.givesLifeness) { - numGivingLiveness++; - } - if (!p.isDue) { - continue; - } - numDue++; - - const isThrottled = throttler.applyThrottle(p.id); - - if (isThrottled) { - logger.warn( - `task ${p.id} throttled, this is very likely a bug in wallet-core, please report`, - ); - numDue--; - numThrottled++; - } else { - minDue = AbsoluteTime.min(minDue, p.timestampDue); - } - } - - logger.trace( - `running task loop, iter=${iteration}, #tasks=${pending.pendingOperations.length} #lifeness=${numGivingLiveness}, #due=${numDue} #trottled=${numThrottled}`, - ); +export interface WalletExecutionContext { + readonly ws: InternalWalletState; + readonly cryptoApi: TalerCryptoInterface; + readonly cancellationToken: CancellationToken; + readonly http: HttpRequestLibrary; + readonly db: DbAccess<typeof WalletStoresV1>; + readonly oc: ObservabilityContext; + readonly taskScheduler: TaskScheduler; +} - if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) { - logger.warn(`stopping, as no pending operations have lifeness`); - ws.isTaskLoopRunning = false; - return { - retriesExceeded, - }; - } +export const EXCHANGE_COINS_LOCK = "exchange-coins-lock"; +export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock"; - if (ws.stopped) { - ws.isTaskLoopRunning = false; - return { - retriesExceeded, - }; - } +export type NotificationListener = (n: WalletNotification) => void; - // Make sure that we run tasks that don't give lifeness at least - // one time. - if (iteration !== 0 && numDue === 0) { - // We've executed pending, due operations at least one. - // Now we don't have any more operations available, - // and need to wait. - - // Wait for at most 5 seconds to the next check. - const dt = durationMin( - durationFromSpec({ - seconds: 5, - }), - Duration.getRemaining(minDue), - ); - logger.trace(`waiting for at most ${dt.d_ms} ms`); - const timeout = ws.timerGroup.resolveAfter(dt); - // Wait until either the timeout, or we are notified (via the latch) - // that more work might be available. - await Promise.race([timeout, ws.workAvailable.wait()]); - logger.trace(`done waiting for available work`); - } else { - logger.trace( - `running ${pending.pendingOperations.length} pending operations`, - ); - for (const p of pending.pendingOperations) { - if (!AbsoluteTime.isExpired(p.timestampDue)) { - continue; - } - logger.trace(`running task ${p.id}`); - const res = await runTaskWithErrorReporting(ws, p.id, async () => { - return await callOperationHandler(ws, p); - }); - if (!(ws.stopped && res.type === TaskRunResultType.Error)) { - ws.notify({ - type: NotificationType.PendingOperationProcessed, - id: p.id, - taskResultType: res.type, - }); - } - if (ws.stopped) { - ws.isTaskLoopRunning = false; - return { - retriesExceeded, - }; - } - } - } - } - logger.trace("exiting wallet task loop"); - ws.isTaskLoopRunning = false; - return { - retriesExceeded, - }; -} +type CancelFn = () => void; /** * Insert the hard-coded defaults for exchanges, coins and * auditors into the database, unless these defaults have * already been applied. */ -async function fillDefaults(ws: InternalWalletState): Promise<void> { +async function fillDefaults(wex: WalletExecutionContext): Promise<void> { const notifications: WalletNotification[] = []; - await ws.db - .mktx((x) => [x.config, x.exchanges, x.exchangeDetails]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["config", "exchanges"] }, + async (tx) => { const appliedRec = await tx.config.get("currencyDefaultsApplied"); let alreadyApplied = appliedRec ? !!appliedRec.value : false; if (alreadyApplied) { logger.trace("defaults already applied"); return; } - for (const exch of ws.config.builtin.exchanges) { + for (const exch of wex.ws.config.builtin.exchanges) { const resp = await addPresetExchangeEntry( tx, exch.exchangeBaseUrl, @@ -567,10 +367,31 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> { key: ConfigRecordKey.CurrencyDefaultsApplied, value: true, }); - }); + }, + ); for (const notif of notifications) { - ws.notify(notif); + wex.ws.notify(notif); + } +} + +export async function getDenomInfo( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["denominations"]>, + exchangeBaseUrl: string, + denomPubHash: string, +): Promise<DenominationInfo | undefined> { + const cacheKey = `${exchangeBaseUrl}:${denomPubHash}`; + const cached = wex.ws.denomInfoCache.get(cacheKey); + if (cached) { + return cached; + } + const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); + if (d) { + const denomInfo = DenominationRecord.toDenomInfo(d); + wex.ws.denomInfoCache.put(cacheKey, denomInfo); + return denomInfo; } + return undefined; } /** @@ -578,79 +399,73 @@ async function fillDefaults(ws: InternalWalletState): Promise<void> { * previous withdrawals. */ async function listKnownBankAccounts( - ws: InternalWalletState, + wex: WalletExecutionContext, currency?: string, ): Promise<KnownBankAccounts> { const accounts: KnownBankAccountsInfo[] = []; - await ws.db - .mktx((x) => [x.bankAccounts]) - .runReadOnly(async (tx) => { - const knownAccounts = await tx.bankAccounts.iter().toArray(); - for (const r of knownAccounts) { - if (currency && currency !== r.currency) { - continue; - } - const payto = parsePaytoUri(r.uri); - if (payto) { - accounts.push({ - uri: payto, - alias: r.alias, - kyc_completed: r.kycCompleted, - currency: r.currency, - }); - } + await wex.db.runReadOnlyTx({ storeNames: ["bankAccounts"] }, async (tx) => { + const knownAccounts = await tx.bankAccounts.iter().toArray(); + for (const r of knownAccounts) { + if (currency && currency !== r.currency) { + continue; } - }); + const payto = parsePaytoUri(r.uri); + if (payto) { + accounts.push({ + uri: payto, + alias: r.alias, + kyc_completed: r.kycCompleted, + currency: r.currency, + }); + } + } + }); return { accounts }; } /** */ async function addKnownBankAccounts( - ws: InternalWalletState, + wex: WalletExecutionContext, payto: string, alias: string, currency: string, ): Promise<void> { - await ws.db - .mktx((x) => [x.bankAccounts]) - .runReadWrite(async (tx) => { - tx.bankAccounts.put({ - uri: payto, - alias: alias, - currency: currency, - kycCompleted: false, - }); + await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => { + tx.bankAccounts.put({ + uri: payto, + alias: alias, + currency: currency, + kycCompleted: false, }); + }); return; } /** */ async function forgetKnownBankAccounts( - ws: InternalWalletState, + wex: WalletExecutionContext, payto: string, ): Promise<void> { - await ws.db - .mktx((x) => [x.bankAccounts]) - .runReadWrite(async (tx) => { - const account = await tx.bankAccounts.get(payto); - if (!account) { - throw Error(`account not found: ${payto}`); - } - tx.bankAccounts.delete(account.uri); - }); + await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => { + const account = await tx.bankAccounts.get(payto); + if (!account) { + throw Error(`account not found: ${payto}`); + } + tx.bankAccounts.delete(account.uri); + }); return; } async function setCoinSuspended( - ws: InternalWalletState, + wex: WalletExecutionContext, coinPub: string, suspended: boolean, ): Promise<void> { - await ws.db - .mktx((x) => [x.coins, x.coinAvailability]) - .runReadWrite(async (tx) => { + await wex.db.runReadWriteTx( + { storeNames: ["coins", "coinAvailability"] }, + async (tx) => { const c = await tx.coins.get(coinPub); if (!c) { logger.warn(`coin ${coinPub} not found, won't suspend`); @@ -682,18 +497,19 @@ async function setCoinSuspended( } await tx.coins.put(c); await tx.coinAvailability.put(coinAvailability); - }); + }, + ); } /** * Dump the public information of coins we have in an easy-to-process format. */ -async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> { +async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> { const coinsJson: CoinDumpJson = { coins: [] }; logger.info("dumping coins"); - await ws.db - .mktx((x) => [x.coins, x.denominations, x.withdrawalGroups]) - .runReadOnly(async (tx) => { + await wex.db.runReadOnlyTx( + { storeNames: ["coins", "denominations"] }, + async (tx) => { const coins = await tx.coins.iter().toArray(); for (const c of coins) { const denom = await tx.denominations.get([ @@ -713,8 +529,8 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> { if (cs.type == CoinSourceType.Withdraw) { withdrawalReservePub = cs.reservePub; } - const denomInfo = await ws.getDenomInfo( - ws, + const denomInfo = await getDenomInfo( + wex, tx, c.exchangeBaseUrl, c.denomPubHash, @@ -741,20 +557,22 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> { : undefined, }); } - }); + }, + ); return coinsJson; } /** * Get an API client from an internal wallet state object. */ -export async function getClientFromWalletState( +let id = 0; +async function getClientFromWalletState( ws: InternalWalletState, ): Promise<WalletCoreApiClient> { - let id = 0; const client: WalletCoreApiClient = { async call(op, payload): Promise<any> { - const res = await handleCoreApiRequest(ws, op, `${id++}`, payload); + id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100); + const res = await handleCoreApiRequest(ws, op, String(id), payload); switch (res.type) { case "error": throw TalerError.fromUncheckedDetail(res.error); @@ -767,12 +585,12 @@ export async function getClientFromWalletState( } async function createStoredBackup( - ws: InternalWalletState, + wex: WalletExecutionContext, ): Promise<CreateStoredBackupResponse> { - const backup = await exportDb(ws.idb); - const backupsDb = await openStoredBackupsDatabase(ws.idb); + const backup = await exportDb(wex.ws.idb); + const backupsDb = await openStoredBackupsDatabase(wex.ws.idb); const name = `backup-${new Date().getTime()}`; - await backupsDb.mktxAll().runReadWrite(async (tx) => { + await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupMeta.add({ name, }); @@ -784,13 +602,13 @@ async function createStoredBackup( } async function listStoredBackups( - ws: InternalWalletState, + wex: WalletExecutionContext, ): Promise<StoredBackupList> { const storedBackups: StoredBackupList = { storedBackups: [], }; - const backupsDb = await openStoredBackupsDatabase(ws.idb); - await backupsDb.mktxAll().runReadWrite(async (tx) => { + const backupsDb = await openStoredBackupsDatabase(wex.ws.idb); + await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupMeta.iter().forEach((x) => { storedBackups.storedBackups.push({ name: x.name, @@ -801,24 +619,24 @@ async function listStoredBackups( } async function deleteStoredBackup( - ws: InternalWalletState, + wex: WalletExecutionContext, req: DeleteStoredBackupRequest, ): Promise<void> { - const backupsDb = await openStoredBackupsDatabase(ws.idb); - await backupsDb.mktxAll().runReadWrite(async (tx) => { + const backupsDb = await openStoredBackupsDatabase(wex.ws.idb); + await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupData.delete(req.name); await tx.backupMeta.delete(req.name); }); } async function recoverStoredBackup( - ws: InternalWalletState, + wex: WalletExecutionContext, req: RecoverStoredBackupRequest, ): Promise<void> { logger.info(`Recovering stored backup ${req.name}`); const { name } = req; - const backupsDb = await openStoredBackupsDatabase(ws.idb); - const bd = await backupsDb.mktxAll().runReadWrite(async (tx) => { + const backupsDb = await openStoredBackupsDatabase(wex.ws.idb); + const bd = await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { const backupMeta = tx.backupMeta.get(name); if (!backupMeta) { throw Error("backup not found"); @@ -830,12 +648,12 @@ async function recoverStoredBackup( return backupData; }); logger.info(`backup found, now importing`); - await importDb(ws.db.idbHandle(), bd); + await importDb(wex.db.idbHandle(), bd); logger.info(`import done`); } async function handlePrepareWithdrawExchange( - ws: InternalWalletState, + wex: WalletExecutionContext, req: PrepareWithdrawExchangeRequest, ): Promise<PrepareWithdrawExchangeResponse> { const parsedUri = parseTalerUri(req.talerUri); @@ -843,10 +661,7 @@ async function handlePrepareWithdrawExchange( throw Error("expected a taler://withdraw-exchange URI"); } const exchangeBaseUrl = parsedUri.exchangeBaseUrl; - const exchange = await fetchFreshExchange(ws, exchangeBaseUrl); - if (exchange.masterPub != parsedUri.exchangePub) { - throw Error("mismatch of exchange master public key (URI vs actual)"); - } + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); if (parsedUri.amount) { const amt = Amounts.parseOrThrow(parsedUri.amount); if (amt.currency !== exchange.currency) { @@ -860,14 +675,27 @@ async function handlePrepareWithdrawExchange( } /** + * Response returned from the pending operations API. + * + * @deprecated this is a placeholder for the response type of a deprecated wallet-core request. + */ +export interface PendingOperationsResponse { + /** + * List of pending operations. + */ + pendingOperations: any[]; +} + +/** * Implementation of the "wallet-core" API. */ -async function dispatchRequestInternal<Op extends WalletApiOperation>( - ws: InternalWalletState, +async function dispatchRequestInternal( + wex: WalletExecutionContext, + cts: CancellationToken.Source, operation: WalletApiOperation, payload: unknown, ): Promise<WalletCoreResponseType<typeof operation>> { - if (!ws.initCalled && operation !== WalletApiOperation.InitWallet) { + if (!wex.ws.initCalled && operation !== WalletApiOperation.InitWallet) { throw Error( `wallet must be initialized before running operation ${operation}`, ); @@ -876,56 +704,95 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( // definitions we already have? switch (operation) { case WalletApiOperation.CreateStoredBackup: - return createStoredBackup(ws); + return createStoredBackup(wex); case WalletApiOperation.DeleteStoredBackup: { const req = codecForDeleteStoredBackupRequest().decode(payload); - await deleteStoredBackup(ws, req); + await deleteStoredBackup(wex, req); return {}; } case WalletApiOperation.ListStoredBackups: - return listStoredBackups(ws); + return listStoredBackups(wex); case WalletApiOperation.RecoverStoredBackup: { const req = codecForRecoverStoredBackupRequest().decode(payload); - await recoverStoredBackup(ws, req); + await recoverStoredBackup(wex, req); return {}; } + case WalletApiOperation.SetWalletRunConfig: case WalletApiOperation.InitWallet: { - logger.trace("initializing wallet"); - ws.initCalled = true; - if (ws.config.testing.skipDefaults) { + const req = codecForInitRequest().decode(payload); + + logger.info(`init request: ${j2s(req)}`); + + if (wex.ws.initCalled) { + logger.info("initializing wallet (repeat initialization)"); + } else { + logger.info("initializing wallet (first initialization)"); + } + + // Write to the DB to make sure that we're failing early in + // case the DB is not writeable. + try { + await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { + tx.config.put({ + key: ConfigRecordKey.LastInitInfo, + value: timestampProtocolToDb(TalerProtocolTimestamp.now()), + }); + }); + } catch (e) { + logger.error("error writing to database during initialization"); + throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, { + innerError: getErrorDetailFromException(e), + }); + } + + wex.ws.initWithConfig(applyRunConfigDefaults(req.config)); + + if (wex.ws.config.testing.skipDefaults) { logger.trace("skipping defaults"); } else { logger.trace("filling defaults"); - await fillDefaults(ws); + await fillDefaults(wex); } const resp: InitResponse = { - versionInfo: getVersion(ws), + versionInfo: getVersion(wex), }; + + // After initialization, task loop should run. + await wex.taskScheduler.ensureRunning(); + + wex.ws.initCalled = true; return resp; } case WalletApiOperation.WithdrawTestkudos: { - await withdrawTestBalance(ws, { + await withdrawTestBalance(wex, { amount: "TESTKUDOS:10" as AmountString, corebankApiBaseUrl: "https://bank.test.taler.net/", exchangeBaseUrl: "https://exchange.test.taler.net/", }); return { - versionInfo: getVersion(ws), + versionInfo: getVersion(wex), }; } case WalletApiOperation.WithdrawTestBalance: { const req = codecForWithdrawTestBalance().decode(payload); - await withdrawTestBalance(ws, req); + await withdrawTestBalance(wex, req); return {}; } + case WalletApiOperation.TestingListTaskForTransaction: { + const req = + codecForTestingListTasksForTransactionRequest().decode(payload); + return { + taskIdList: listTaskForTransactionId(req.transactionId), + } satisfies TestingListTasksForTransactionsResponse; + } case WalletApiOperation.RunIntegrationTest: { const req = codecForIntegrationTestArgs().decode(payload); - await runIntegrationTest(ws, req); + await runIntegrationTest(wex, req); return {}; } case WalletApiOperation.RunIntegrationTestV2: { const req = codecForIntegrationTestV2Args().decode(payload); - await runIntegrationTest2(ws, req); + await runIntegrationTest2(wex, req); return {}; } case WalletApiOperation.ValidateIban: { @@ -938,66 +805,73 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( } case WalletApiOperation.TestPay: { const req = codecForTestPayArgs().decode(payload); - return await testPay(ws, req); + return await testPay(wex, req); } case WalletApiOperation.GetTransactions: { const req = codecForTransactionsRequest().decode(payload); - return await getTransactions(ws, req); + return await getTransactions(wex, req); } case WalletApiOperation.GetTransactionById: { const req = codecForTransactionByIdRequest().decode(payload); - return await getTransactionById(ws, req); + return await getTransactionById(wex, req); + } + case WalletApiOperation.GetWithdrawalTransactionByUri: { + const req = codecForGetWithdrawalDetailsForUri().decode(payload); + return await getWithdrawalTransactionByUri(wex, req); } case WalletApiOperation.AddExchange: { const req = codecForAddExchangeRequest().decode(payload); - await fetchFreshExchange(ws, req.exchangeBaseUrl, { - expectedMasterPub: req.masterPub, - }); + await fetchFreshExchange(wex, req.exchangeBaseUrl, {}); + return {}; + } + case WalletApiOperation.TestingPing: { return {}; } case WalletApiOperation.UpdateExchangeEntry: { const req = codecForUpdateExchangeEntryRequest().decode(payload); - await fetchFreshExchange(ws, req.exchangeBaseUrl, { - forceUpdate: true, + await fetchFreshExchange(wex, req.exchangeBaseUrl, { + forceUpdate: !!req.force, }); return {}; } + case WalletApiOperation.TestingGetDenomStats: { + const req = codecForTestingGetDenomStatsRequest().decode(payload); + const denomStats: TestingGetDenomStatsResponse = { + numKnown: 0, + numLost: 0, + numOffered: 0, + }; + await wex.db.runReadOnlyTx( + { storeNames: ["denominations"] }, + async (tx) => { + const denoms = + await tx.denominations.indexes.byExchangeBaseUrl.getAll( + req.exchangeBaseUrl, + ); + for (const d of denoms) { + denomStats.numKnown++; + if (d.isOffered) { + denomStats.numOffered++; + } + if (d.isLost) { + denomStats.numLost++; + } + } + }, + ); + return denomStats; + } case WalletApiOperation.ListExchanges: { - return await getExchanges(ws); + return await listExchanges(wex); } case WalletApiOperation.GetExchangeEntryByUrl: { const req = codecForGetExchangeEntryByUrlRequest().decode(payload); - const exchangeEntry = await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.operationRetries, - ]) - .runReadOnly(async (tx) => { - const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl); - if (!exchangeRec) { - throw Error("exchange not found"); - } - const exchangeDetails = await getExchangeDetails( - tx, - exchangeRec.baseUrl, - ); - const opRetryRecord = await tx.operationRetries.get( - TaskIdentifiers.forExchangeUpdate(exchangeRec), - ); - return makeExchangeListItem( - exchangeRec, - exchangeDetails, - opRetryRecord?.lastError, - ); - }); - return exchangeEntry; + return lookupExchangeByUri(wex, req); } case WalletApiOperation.ListExchangesForScopedCurrency: { const req = codecForListExchangesForScopedCurrencyRequest().decode(payload); - const exchangesResp = await getExchanges(ws); + const exchangesResp = await listExchanges(wex); const result: ExchangesShortListResponse = { exchanges: [], }; @@ -1014,107 +888,132 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( } case WalletApiOperation.GetExchangeDetailedInfo: { const req = codecForAddExchangeRequest().decode(payload); - return await getExchangeDetailedInfo(ws, req.exchangeBaseUrl); + return await getExchangeDetailedInfo(wex, req.exchangeBaseUrl); } case WalletApiOperation.ListKnownBankAccounts: { const req = codecForListKnownBankAccounts().decode(payload); - return await listKnownBankAccounts(ws, req.currency); + return await listKnownBankAccounts(wex, req.currency); } case WalletApiOperation.AddKnownBankAccounts: { const req = codecForAddKnownBankAccounts().decode(payload); - await addKnownBankAccounts(ws, req.payto, req.alias, req.currency); + await addKnownBankAccounts(wex, req.payto, req.alias, req.currency); return {}; } case WalletApiOperation.ForgetKnownBankAccounts: { const req = codecForForgetKnownBankAccounts().decode(payload); - await forgetKnownBankAccounts(ws, req.payto); + await forgetKnownBankAccounts(wex, req.payto); return {}; } case WalletApiOperation.GetWithdrawalDetailsForUri: { const req = codecForGetWithdrawalDetailsForUri().decode(payload); - return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri); + return await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri); + } + case WalletApiOperation.TestingGetReserveHistory: { + const req = codecForTestingGetReserveHistoryRequest().decode(payload); + const reserve = await wex.db.runReadOnlyTx( + { storeNames: ["reserves"] }, + async (tx) => { + return tx.reserves.indexes.byReservePub.get(req.reservePub); + }, + ); + if (!reserve) { + throw Error("no reserve pub found"); + } + const sigResp = await wex.cryptoApi.signReserveHistoryReq({ + reservePriv: reserve.reservePriv, + startOffset: 0, + }); + const exchangeBaseUrl = req.exchangeBaseUrl; + const url = new URL( + `reserves/${req.reservePub}/history`, + exchangeBaseUrl, + ); + const resp = await wex.http.fetch(url.href, { + headers: { ["Taler-Reserve-History-Signature"]: sigResp.sig }, + }); + const historyJson = await readSuccessResponseJsonOrThrow( + resp, + codecForAny(), + ); + return historyJson; } case WalletApiOperation.AcceptManualWithdrawal: { - const req = codecForAcceptManualWithdrawalRequet().decode(payload); - const res = await createManualWithdrawal(ws, { + const req = codecForAcceptManualWithdrawalRequest().decode(payload); + const res = await createManualWithdrawal(wex, { amount: Amounts.parseOrThrow(req.amount), exchangeBaseUrl: req.exchangeBaseUrl, restrictAge: req.restrictAge, + forceReservePriv: req.forceReservePriv, }); return res; } case WalletApiOperation.GetWithdrawalDetailsForAmount: { const req = codecForGetWithdrawalDetailsForAmountRequest().decode(payload); - const wi = await getExchangeWithdrawalInfo( - ws, - req.exchangeBaseUrl, - Amounts.parseOrThrow(req.amount), - req.restrictAge, - ); - let numCoins = 0; - for (const x of wi.selectedDenoms.selectedDenoms) { - numCoins += x.count; - } - const amt = Amounts.parseOrThrow(req.amount); - const resp: WithdrawalDetailsForAmount = { - amountRaw: req.amount, - amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), - paytoUris: wi.exchangePaytoUris, - tosAccepted: wi.termsOfServiceAccepted, - ageRestrictionOptions: wi.ageRestrictionOptions, - withdrawalAccountsList: wi.exchangeCreditAccountDetails, - numCoins, - // FIXME: Once we have proper scope info support, return correct info here. - scopeInfo: { - type: ScopeType.Exchange, - currency: amt.currency, - url: req.exchangeBaseUrl, - }, - }; + const resp = await getWithdrawalDetailsForAmount(wex, cts, req); return resp; } case WalletApiOperation.GetBalances: { - return await getBalances(ws); + return await getBalances(wex); } case WalletApiOperation.GetBalanceDetail: { const req = codecForGetBalanceDetailRequest().decode(payload); - return await getBalanceDetail(ws, req); + return await getBalanceDetail(wex, req); } case WalletApiOperation.GetUserAttentionRequests: { const req = codecForUserAttentionsRequest().decode(payload); - return await getUserAttentions(ws, req); + return await getUserAttentions(wex, req); } case WalletApiOperation.MarkAttentionRequestAsRead: { const req = codecForUserAttentionByIdRequest().decode(payload); - return await markAttentionRequestAsRead(ws, req); + return await markAttentionRequestAsRead(wex, req); } case WalletApiOperation.GetUserAttentionUnreadCount: { const req = codecForUserAttentionsRequest().decode(payload); - return await getUserAttentionsUnreadCount(ws, req); + return await getUserAttentionsUnreadCount(wex, req); } case WalletApiOperation.GetPendingOperations: { - return await getPendingOperations(ws); + // FIXME: Eventually remove the handler after deprecation period. + return { + pendingOperations: [], + } satisfies PendingOperationsResponse; } case WalletApiOperation.SetExchangeTosAccepted: { const req = codecForAcceptExchangeTosRequest().decode(payload); - await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag); + await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl); + return {}; + } + case WalletApiOperation.SetExchangeTosForgotten: { + const req = codecForAcceptExchangeTosRequest().decode(payload); + await forgetExchangeTermsOfService(wex, req.exchangeBaseUrl); return {}; } case WalletApiOperation.AcceptBankIntegratedWithdrawal: { const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); - return await acceptWithdrawalFromUri(ws, { + return await acceptWithdrawalFromUri(wex, { selectedExchange: req.exchangeBaseUrl, talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, }); } + case WalletApiOperation.ConfirmWithdrawal: { + const req = codecForConfirmWithdrawalRequestRequest().decode(payload); + return confirmWithdrawal(wex, req); + } + case WalletApiOperation.PrepareBankIntegratedWithdrawal: { + const req = + codecForPrepareBankIntegratedWithdrawalRequest().decode(payload); + return prepareBankIntegratedWithdrawal(wex, { + talerWithdrawUri: req.talerWithdrawUri, + selectedExchange: req.selectedExchange, + }); + } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); return getExchangeTos( - ws, + wex, req.exchangeBaseUrl, req.acceptedFormat, req.acceptLanguage, @@ -1122,168 +1021,134 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( } case WalletApiOperation.GetContractTermsDetails: { const req = codecForGetContractTermsDetails().decode(payload); - return getContractTermsDetails(ws, req.proposalId); + if (req.proposalId) { + // FIXME: deprecated path + return getContractTermsDetails(wex, req.proposalId); + } + if (req.transactionId) { + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (parsedTx?.tag === TransactionType.Payment) { + return getContractTermsDetails(wex, parsedTx.proposalId); + } + throw Error("transactionId is not a payment transaction"); + } + throw Error("transactionId missing"); } case WalletApiOperation.RetryPendingNow: { - // FIXME: Should we reset all operation retries here? - await runPending(ws); + logger.error("retryPendingNow currently not implemented"); return {}; } case WalletApiOperation.SharePayment: { const req = codecForSharePaymentRequest().decode(payload); - return await sharePayment(ws, req.merchantBaseUrl, req.orderId); + return await sharePayment(wex, req.merchantBaseUrl, req.orderId); } case WalletApiOperation.PrepareWithdrawExchange: { const req = codecForPrepareWithdrawExchangeRequest().decode(payload); - return handlePrepareWithdrawExchange(ws, req); + return handlePrepareWithdrawExchange(wex, req); } case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); - return await preparePayForUri(ws, req.talerPayUri); + return await preparePayForUri(wex, req.talerPayUri); } case WalletApiOperation.PreparePayForTemplate: { const req = codecForPreparePayTemplateRequest().decode(payload); - const url = parsePayTemplateUri(req.talerPayTemplateUri); - const templateDetails: MerchantUsingTemplateDetails = {}; - if (!url) { - throw Error("invalid taler-template URI"); - } - if ( - url.templateParams.amount !== undefined && - typeof url.templateParams.amount === "string" - ) { - templateDetails.amount = (req.templateParams.amount ?? - url.templateParams.amount) as AmountString | undefined; - } - if ( - url.templateParams.summary !== undefined && - typeof url.templateParams.summary === "string" - ) { - templateDetails.summary = - req.templateParams.summary ?? url.templateParams.summary; - } - const reqUrl = new URL( - `templates/${url.templateId}`, - url.merchantBaseUrl, - ); - const httpReq = await ws.http.fetch(reqUrl.href, { - method: "POST", - body: templateDetails, - }); - const resp = await readSuccessResponseJsonOrThrow( - httpReq, - codecForMerchantPostOrderResponse(), - ); - - const payUri = constructPayUri( - url.merchantBaseUrl, - resp.order_id, - "", - resp.token, - ); - - return await preparePayForUri(ws, payUri); + return preparePayForTemplate(wex, req); + } + case WalletApiOperation.CheckPayForTemplate: { + const req = codecForCheckPayTemplateRequest().decode(payload); + return checkPayForTemplate(wex, req); } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); - let proposalId; + let transactionId; if (req.proposalId) { // legacy client support - proposalId = req.proposalId; + transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: req.proposalId, + }); } else if (req.transactionId) { - const txIdParsed = parseTransactionIdentifier(req.transactionId); - if (txIdParsed?.tag != TransactionType.Payment) { - throw Error("payment transaction ID required"); - } - proposalId = txIdParsed.proposalId; + transactionId = req.transactionId; } else { throw Error("transactionId or (deprecated) proposalId required"); } - return await confirmPay(ws, proposalId, req.sessionId); + return await confirmPay(wex, transactionId, req.sessionId); } case WalletApiOperation.AbortTransaction: { const req = codecForAbortTransaction().decode(payload); - await abortTransaction(ws, req.transactionId); + await abortTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.SuspendTransaction: { const req = codecForSuspendTransaction().decode(payload); - await suspendTransaction(ws, req.transactionId); + await suspendTransaction(wex, req.transactionId); return {}; } + case WalletApiOperation.GetActiveTasks: { + const allTasksId = wex.taskScheduler.getActiveTasks(); + + const tasksInfo = await Promise.all( + allTasksId.map(async (id) => { + return await wex.db.runReadOnlyTx( + { storeNames: ["operationRetries"] }, + async (tx) => { + return tx.operationRetries.get(id); + }, + ); + }), + ); + + const tasks = allTasksId.map((taskId, i): ActiveTask => { + const transaction = convertTaskToTransactionId(taskId); + const d = tasksInfo[i]; + + const firstTry = !d + ? undefined + : timestampAbsoluteFromDb(d.retryInfo.firstTry); + const nextTry = !d + ? undefined + : timestampAbsoluteFromDb(d.retryInfo.nextRetry); + const counter = d?.retryInfo.retryCounter; + const lastError = d?.lastError; + + return { + taskId: taskId, + retryCounter: counter, + firstTry, + nextTry, + lastError, + transaction, + }; + }); + return { tasks }; + } case WalletApiOperation.FailTransaction: { const req = codecForFailTransactionRequest().decode(payload); - await failTransaction(ws, req.transactionId); + await failTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.ResumeTransaction: { const req = codecForResumeTransaction().decode(payload); - await resumeTransaction(ws, req.transactionId); + await resumeTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.DumpCoins: { - return await dumpCoins(ws); + return await dumpCoins(wex); } case WalletApiOperation.SetCoinSuspended: { const req = codecForSetCoinSuspendedRequest().decode(payload); - await setCoinSuspended(ws, req.coinPub, req.suspended); + await setCoinSuspended(wex, req.coinPub, req.suspended); return {}; } case WalletApiOperation.TestingGetSampleTransactions: return { transactions: sampleWalletCoreTransactions }; case WalletApiOperation.ForceRefresh: { const req = codecForForceRefreshRequest().decode(payload); - if (req.coinPubList.length == 0) { - throw Error("refusing to create empty refresh group"); - } - const refreshGroupId = await ws.db - .mktx((x) => [ - x.refreshGroups, - x.coinAvailability, - x.denominations, - x.coins, - ]) - .runReadWrite(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, - ); - }); - processRefreshGroup(ws, refreshGroupId.refreshGroupId).catch((x) => { - logger.error(x); - }); - return { - refreshGroupId, - }; - } - case WalletApiOperation.PrepareReward: { - const req = codecForPrepareRewardRequest().decode(payload); - return await prepareTip(ws, req.talerRewardUri); + return await forceRefresh(wex, req); } case WalletApiOperation.StartRefundQueryForUri: { const req = codecForPrepareRefundRequest().decode(payload); - return await startRefundQueryForUri(ws, req.talerRefundUri); + return await startRefundQueryForUri(wex, req.talerRefundUri); } case WalletApiOperation.StartRefundQuery: { const req = codecForStartRefundQueryRequest().decode(payload); @@ -1294,38 +1159,30 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( if (txIdParsed.tag !== TransactionType.Payment) { throw Error("expected payment transaction ID"); } - await startQueryRefund(ws, txIdParsed.proposalId); + await startQueryRefund(wex, txIdParsed.proposalId); return {}; } - case WalletApiOperation.AcceptReward: { - const req = codecForAcceptTipRequest().decode(payload); - return await acceptTip(ws, req.walletRewardId); - } case WalletApiOperation.AddBackupProvider: { const req = codecForAddBackupProviderRequest().decode(payload); - return await addBackupProvider(ws, req); + return await addBackupProvider(wex, req); } case WalletApiOperation.RunBackupCycle: { const req = codecForRunBackupCycle().decode(payload); - await runBackupCycle(ws, req); + await runBackupCycle(wex, req); return {}; } case WalletApiOperation.RemoveBackupProvider: { const req = codecForRemoveBackupProvider().decode(payload); - await removeBackupProvider(ws, req); + await removeBackupProvider(wex, req); return {}; } case WalletApiOperation.ExportBackupRecovery: { - const resp = await getBackupRecovery(ws); + const resp = await getBackupRecovery(wex); return resp; } case WalletApiOperation.TestingWaitTransactionState: { const req = payload as TestingWaitTransactionRequest; - await waitTransactionState(ws, req.transactionId, req.txState); - return {}; - } - case WalletApiOperation.TestingWaitTasksProcessed: { - await waitUntilTasksProcessed(ws); + await waitTransactionState(wex, req.transactionId, req.txState); return {}; } case WalletApiOperation.GetCurrencySpecification: { @@ -1361,12 +1218,12 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( } const defaultResp: GetCurrencySpecificationResponse = { currencySpecification: { - name: "Unknown", + name: req.scope.currency, num_fractional_input_digits: 2, num_fractional_normal_digits: 2, num_fractional_trailing_zero_digits: 2, alt_unit_names: { - "0": "??", + "0": req.scope.currency, }, }, }; @@ -1374,7 +1231,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( } case WalletApiOperation.ImportBackupRecovery: { const req = codecForAny().decode(payload); - await loadBackupRecovery(ws, req); + await loadBackupRecovery(wex, req); return {}; } // case WalletApiOperation.GetPlanForOperation: { @@ -1383,31 +1240,31 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( // } case WalletApiOperation.ConvertDepositAmount: { const req = codecForConvertAmountRequest.decode(payload); - return await convertDepositAmount(ws, req); + return await convertDepositAmount(wex, req); } case WalletApiOperation.GetMaxDepositAmount: { const req = codecForGetAmountRequest.decode(payload); - return await getMaxDepositAmount(ws, req); + return await getMaxDepositAmount(wex, req); } case WalletApiOperation.ConvertPeerPushAmount: { const req = codecForConvertAmountRequest.decode(payload); - return await convertPeerPushAmount(ws, req); + return await convertPeerPushAmount(wex, req); } case WalletApiOperation.GetMaxPeerPushAmount: { const req = codecForGetAmountRequest.decode(payload); - return await getMaxPeerPushAmount(ws, req); + return await getMaxPeerPushAmount(wex, req); } case WalletApiOperation.ConvertWithdrawalAmount: { const req = codecForConvertAmountRequest.decode(payload); - return await convertWithdrawalAmount(ws, req); + return await convertWithdrawalAmount(wex, req); } case WalletApiOperation.GetBackupInfo: { - const resp = await getBackupInfo(ws); + const resp = await getBackupInfo(wex); return resp; } case WalletApiOperation.PrepareDeposit: { const req = codecForPrepareDepositRequest().decode(payload); - return await prepareDepositGroup(ws, req); + return await checkDepositGroup(wex, req); } case WalletApiOperation.GenerateDepositGroupTxId: return { @@ -1415,99 +1272,282 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( }; case WalletApiOperation.CreateDepositGroup: { const req = codecForCreateDepositGroupRequest().decode(payload); - return await createDepositGroup(ws, req); + return await createDepositGroup(wex, req); } case WalletApiOperation.DeleteTransaction: { const req = codecForDeleteTransactionRequest().decode(payload); - await deleteTransaction(ws, req.transactionId); + await deleteTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.RetryTransaction: { const req = codecForRetryTransactionRequest().decode(payload); - await retryTransaction(ws, req.transactionId); + await retryTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.SetWalletDeviceId: { const req = codecForSetWalletDeviceIdRequest().decode(payload); - await setWalletDeviceId(ws, req.walletDeviceId); + await setWalletDeviceId(wex, req.walletDeviceId); return {}; } - case WalletApiOperation.ListCurrencies: { - // FIXME: Remove / change to scoped currency approach. - return { - trustedAuditors: [], - trustedExchanges: [], - }; - } case WalletApiOperation.TestCrypto: { - return await ws.cryptoApi.hashString({ str: "hello world" }); + return await wex.cryptoApi.hashString({ str: "hello world" }); } - case WalletApiOperation.ClearDb: - await clearDatabase(ws.db.idbHandle()); + case WalletApiOperation.ClearDb: { + wex.ws.clearAllCaches(); + await clearDatabase(wex.db.idbHandle()); return {}; + } case WalletApiOperation.Recycle: { throw Error("not implemented"); return {}; } case WalletApiOperation.ExportDb: { - const dbDump = await exportDb(ws.idb); + const dbDump = await exportDb(wex.ws.idb); return dbDump; } + case WalletApiOperation.ListGlobalCurrencyExchanges: { + const resp: ListGlobalCurrencyExchangesResponse = { + exchanges: [], + }; + await wex.db.runReadOnlyTx( + { storeNames: ["globalCurrencyExchanges"] }, + async (tx) => { + const gceList = await tx.globalCurrencyExchanges.iter().toArray(); + for (const gce of gceList) { + resp.exchanges.push({ + currency: gce.currency, + exchangeBaseUrl: gce.exchangeBaseUrl, + exchangeMasterPub: gce.exchangeMasterPub, + }); + } + }, + ); + return resp; + } + case WalletApiOperation.ListGlobalCurrencyAuditors: { + const resp: ListGlobalCurrencyAuditorsResponse = { + auditors: [], + }; + await wex.db.runReadOnlyTx( + { storeNames: ["globalCurrencyAuditors"] }, + async (tx) => { + const gcaList = await tx.globalCurrencyAuditors.iter().toArray(); + for (const gca of gcaList) { + resp.auditors.push({ + currency: gca.currency, + auditorBaseUrl: gca.auditorBaseUrl, + auditorPub: gca.auditorPub, + }); + } + }, + ); + return resp; + } + case WalletApiOperation.AddGlobalCurrencyExchange: { + const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload); + await wex.db.runReadWriteTx( + { storeNames: ["globalCurrencyExchanges"] }, + async (tx) => { + const key = [ + req.currency, + req.exchangeBaseUrl, + req.exchangeMasterPub, + ]; + const existingRec = + await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( + key, + ); + if (existingRec) { + return; + } + wex.ws.exchangeCache.clear(); + await tx.globalCurrencyExchanges.add({ + currency: req.currency, + exchangeBaseUrl: req.exchangeBaseUrl, + exchangeMasterPub: req.exchangeMasterPub, + }); + }, + ); + return {}; + } + case WalletApiOperation.RemoveGlobalCurrencyExchange: { + const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload); + await wex.db.runReadWriteTx( + { storeNames: ["globalCurrencyExchanges"] }, + async (tx) => { + const key = [ + req.currency, + req.exchangeBaseUrl, + req.exchangeMasterPub, + ]; + const existingRec = + await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( + key, + ); + if (!existingRec) { + return; + } + wex.ws.exchangeCache.clear(); + checkDbInvariant(!!existingRec.id); + await tx.globalCurrencyExchanges.delete(existingRec.id); + }, + ); + return {}; + } + case WalletApiOperation.AddGlobalCurrencyAuditor: { + const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload); + await wex.db.runReadWriteTx( + { storeNames: ["globalCurrencyAuditors"] }, + async (tx) => { + const key = [req.currency, req.auditorBaseUrl, req.auditorPub]; + const existingRec = + await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( + key, + ); + if (existingRec) { + return; + } + await tx.globalCurrencyAuditors.add({ + currency: req.currency, + auditorBaseUrl: req.auditorBaseUrl, + auditorPub: req.auditorPub, + }); + wex.ws.exchangeCache.clear(); + }, + ); + return {}; + } + case WalletApiOperation.TestingWaitTasksDone: { + await waitTasksDone(wex); + return {}; + } + case WalletApiOperation.RemoveGlobalCurrencyAuditor: { + const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload); + await wex.db.runReadWriteTx( + { storeNames: ["globalCurrencyAuditors"] }, + async (tx) => { + const key = [req.currency, req.auditorBaseUrl, req.auditorPub]; + const existingRec = + await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( + key, + ); + if (!existingRec) { + return; + } + checkDbInvariant(!!existingRec.id); + await tx.globalCurrencyAuditors.delete(existingRec.id); + wex.ws.exchangeCache.clear(); + }, + ); + return {}; + } case WalletApiOperation.ImportDb: { const req = codecForImportDbRequest().decode(payload); - await importDb(ws.db.idbHandle(), req.dump); + await importDb(wex.db.idbHandle(), req.dump); return []; } case WalletApiOperation.CheckPeerPushDebit: { const req = codecForCheckPeerPushDebitRequest().decode(payload); - return await checkPeerPushDebit(ws, req); + return await checkPeerPushDebit(wex, req); } case WalletApiOperation.InitiatePeerPushDebit: { const req = codecForInitiatePeerPushDebitRequest().decode(payload); - return await initiatePeerPushDebit(ws, req); + return await initiatePeerPushDebit(wex, req); } case WalletApiOperation.PreparePeerPushCredit: { const req = codecForPreparePeerPushCreditRequest().decode(payload); - return await preparePeerPushCredit(ws, req); + return await preparePeerPushCredit(wex, req); } case WalletApiOperation.ConfirmPeerPushCredit: { const req = codecForConfirmPeerPushPaymentRequest().decode(payload); - return await confirmPeerPushCredit(ws, req); + return await confirmPeerPushCredit(wex, req); } case WalletApiOperation.CheckPeerPullCredit: { const req = codecForPreparePeerPullPaymentRequest().decode(payload); - return await checkPeerPullPaymentInitiation(ws, req); + return await checkPeerPullPaymentInitiation(wex, req); } case WalletApiOperation.InitiatePeerPullCredit: { const req = codecForInitiatePeerPullPaymentRequest().decode(payload); - return await initiatePeerPullPayment(ws, req); + return await initiatePeerPullPayment(wex, req); } case WalletApiOperation.PreparePeerPullDebit: { const req = codecForCheckPeerPullPaymentRequest().decode(payload); - return await preparePeerPullDebit(ws, req); + return await preparePeerPullDebit(wex, req); } case WalletApiOperation.ConfirmPeerPullDebit: { const req = codecForAcceptPeerPullPaymentRequest().decode(payload); - return await confirmPeerPullDebit(ws, req); + return await confirmPeerPullDebit(wex, req); } case WalletApiOperation.ApplyDevExperiment: { const req = codecForApplyDevExperiment().decode(payload); - await applyDevExperiment(ws, req.devExperimentUri); + await applyDevExperiment(wex, req.devExperimentUri); + return {}; + } + case WalletApiOperation.Shutdown: { + wex.ws.stop(); return {}; } case WalletApiOperation.GetVersion: { - return getVersion(ws); + return getVersion(wex); } case WalletApiOperation.TestingWaitTransactionsFinal: - return await waitUntilTransactionsFinal(ws); + return await waitUntilAllTransactionsFinal(wex); case WalletApiOperation.TestingWaitRefreshesFinal: - return await waitUntilRefreshesDone(ws); + return await waitUntilRefreshesDone(wex); case WalletApiOperation.TestingSetTimetravel: { const req = codecForTestingSetTimetravelRequest().decode(payload); setDangerousTimetravel(req.offsetMs); - ws.workAvailable.trigger(); + await wex.taskScheduler.reload(); + return {}; + } + case WalletApiOperation.DeleteExchange: { + const req = codecForDeleteExchangeRequest().decode(payload); + await deleteExchange(wex, req); return {}; } + case WalletApiOperation.GetExchangeResources: { + const req = codecForGetExchangeResourcesRequest().decode(payload); + return await getExchangeResources(wex, req.exchangeBaseUrl); + } + case WalletApiOperation.CanonicalizeBaseUrl: { + const req = codecForCanonicalizeBaseUrlRequest().decode(payload); + return { + url: canonicalizeBaseUrl(req.url), + }; + } + case WalletApiOperation.TestingInfiniteTransactionLoop: { + const myDelayMs = (payload as any).delayMs ?? 5; + const shouldFetch = !!(payload as any).shouldFetch; + const doFetch = async () => { + while (1) { + const url = + "https://exchange.demo.taler.net/reserves/01PMMB9PJN0QBWAFBXV6R0KNJJMAKXCV4D6FDG0GJFDJQXGYP32G?timeout_ms=30000"; + logger.info(`fetching ${url}`); + const res = await wex.http.fetch(url); + logger.info(`fetch result ${res.status}`); + } + }; + if (shouldFetch) { + // In the background! + doFetch(); + } + let loopCount = 0; + while (true) { + logger.info(`looping test write tx, iteration ${loopCount}`); + await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { + await tx.config.put({ + key: ConfigRecordKey.TestLoopTx, + value: loopCount, + }); + }); + if (myDelayMs != 0) { + await new Promise<void>((resolve, reject) => { + setTimeout(() => resolve(), myDelayMs); + }); + } + loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1); + } + } // default: // assertUnreachable(operation); } @@ -1520,21 +1560,62 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( ); } -export function getVersion(ws: InternalWalletState): WalletCoreVersion { +export function getVersion(wex: WalletExecutionContext): WalletCoreVersion { const result: WalletCoreVersion = { + implementationSemver: walletCoreBuildInfo.implementationSemver, + implementationGitHash: walletCoreBuildInfo.implementationGitHash, hash: undefined, - version: WALLET_CORE_API_IMPLEMENTATION_VERSION, + version: WALLET_CORE_API_PROTOCOL_VERSION, exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION, bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - devMode: false, + devMode: wex.ws.config.testing.devModeActive, }; return result; } +export function getObservedWalletExecutionContext( + ws: InternalWalletState, + cancellationToken: CancellationToken, + oc: ObservabilityContext, +): WalletExecutionContext { + const wex: WalletExecutionContext = { + ws, + cancellationToken, + cryptoApi: observeTalerCrypto(ws.cryptoApi, oc), + db: new ObservableDbAccess(ws.db, oc), + http: new ObservableHttpClientLibrary(ws.http, oc), + taskScheduler: new ObservableTaskScheduler(ws.taskScheduler, oc), + oc, + }; + return wex; +} + +export function getNormalWalletExecutionContext( + ws: InternalWalletState, + cancellationToken: CancellationToken, + oc: ObservabilityContext, +): WalletExecutionContext { + const wex: WalletExecutionContext = { + ws, + cancellationToken, + cryptoApi: ws.cryptoApi, + db: ws.db, + get http() { + if (ws.initCalled) { + return ws.http; + } + throw Error("wallet not initialized"); + }, + taskScheduler: ws.taskScheduler, + oc, + }; + return wex; +} + /** * Handle a request to the wallet-core API. */ @@ -1544,8 +1625,56 @@ async function handleCoreApiRequest( id: string, payload: unknown, ): Promise<CoreApiResponse> { + if (operation !== WalletApiOperation.InitWallet) { + if (!ws.initCalled) { + throw Error("init must be called first"); + } + // Might be lazily initialized! + await ws.taskScheduler.ensureRunning(); + } + + let wex: WalletExecutionContext; + let oc: ObservabilityContext; + + const cts = CancellationToken.create(); + + if (ws.initCalled && ws.config.testing.emitObservabilityEvents) { + oc = { + observe(evt) { + ws.notify({ + type: NotificationType.RequestObservabilityEvent, + operation, + requestId: id, + event: evt, + }); + }, + }; + + wex = getObservedWalletExecutionContext(ws, cts.token, oc); + } else { + oc = { + observe(evt) {}, + }; + wex = getNormalWalletExecutionContext(ws, cts.token, oc); + } + try { - const result = await dispatchRequestInternal(ws, operation as any, payload); + const start = performanceNow(); + await ws.ensureWalletDbOpen(); + oc.observe({ + type: ObservabilityEventType.RequestStart, + }); + const result = await dispatchRequestInternal( + wex, + cts, + operation as any, + payload, + ); + const end = performanceNow(); + oc.observe({ + type: ObservabilityEventType.RequestFinishSuccess, + durationMs: Number((end - start) / 1000n / 1000n), + }); return { type: "response", operation, @@ -1557,6 +1686,9 @@ async function handleCoreApiRequest( logger.info( `finished wallet core request ${operation} with error: ${j2s(err)}`, ); + oc.observe({ + type: ObservabilityEventType.RequestFinishError, + }); return { type: "error", operation, @@ -1566,6 +1698,35 @@ async function handleCoreApiRequest( } } +export function applyRunConfigDefaults( + wcp?: PartialWalletRunConfig, +): WalletRunConfig { + return { + builtin: { + exchanges: wcp?.builtin?.exchanges ?? [ + { + exchangeBaseUrl: "https://exchange.demo.taler.net/", + currencyHint: "KUDOS", + }, + ], + }, + features: { + allowHttp: wcp?.features?.allowHttp ?? false, + }, + testing: { + denomselAllowLate: wcp?.testing?.denomselAllowLate ?? false, + devModeActive: wcp?.testing?.devModeActive ?? false, + insecureTrustExchange: wcp?.testing?.insecureTrustExchange ?? false, + preventThrottling: wcp?.testing?.preventThrottling ?? false, + skipDefaults: wcp?.testing?.skipDefaults ?? false, + emitObservabilityEvents: wcp?.testing?.emitObservabilityEvents ?? false, + }, + lazyTaskLoop: wcp?.lazyTaskLoop ?? false, + }; +} + +export type HttpFactory = (config: WalletRunConfig) => HttpRequestLibrary; + /** * Public handle to a running wallet. */ @@ -1575,17 +1736,15 @@ export class Wallet { private constructor( idb: IDBFactory, - http: HttpRequestLibrary, + httpFactory: HttpFactory, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, - config?: WalletConfigParameter, ) { - this.ws = new InternalWalletStateImpl( + this.ws = new InternalWalletState( idb, - http, + httpFactory, timer, cryptoWorkerFactory, - Wallet.getEffectiveConfig(config), ); } @@ -1598,61 +1757,19 @@ export class Wallet { static async create( idb: IDBFactory, - http: HttpRequestLibrary, + httpFactory: HttpFactory, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, - config?: WalletConfigParameter, ): Promise<Wallet> { - const w = new Wallet(idb, http, timer, cryptoWorkerFactory, config); + const w = new Wallet(idb, httpFactory, timer, cryptoWorkerFactory); w._client = await getClientFromWalletState(w.ws); return w; } - public static defaultConfig: Readonly<WalletConfig> = { - builtin: { - exchanges: [ - { - exchangeBaseUrl: "https://exchange.demo.taler.net/", - currencyHint: "KUDOS", - }, - ], - }, - features: { - allowHttp: false, - }, - testing: { - preventThrottling: false, - devModeActive: false, - insecureTrustExchange: false, - denomselAllowLate: false, - skipDefaults: false, - }, - }; - - static getEffectiveConfig( - param?: WalletConfigParameter, - ): Readonly<WalletConfig> { - return deepMerge(Wallet.defaultConfig, param ?? {}); - } - addNotificationListener(f: (n: WalletNotification) => void): CancelFn { return this.ws.addNotificationListener(f); } - stop(): void { - this.ws.stop(); - } - - async runPending(): Promise<void> { - await this.ws.ensureWalletDbOpen(); - return runPending(this.ws); - } - - async runTaskLoop(opts?: RetryLoopOpts): Promise<TaskLoopResult> { - await this.ws.ensureWalletDbOpen(); - return runTaskLoop(this.ws, opts); - } - async handleCoreApiRequest( operation: string, id: string, @@ -1663,49 +1780,107 @@ export class Wallet { } } +export interface DevExperimentState { + blockRefreshes?: boolean; +} + +export class Cache<T> { + private map: Map<string, [AbsoluteTime, T]> = new Map(); + + constructor( + private maxCapacity: number, + private cacheDuration: Duration, + ) {} + + get(key: string): T | undefined { + const r = this.map.get(key); + if (!r) { + return undefined; + } + + if (AbsoluteTime.isExpired(r[0])) { + this.map.delete(key); + return undefined; + } + + return r[1]; + } + + clear(): void { + this.map.clear(); + } + + put(key: string, value: T): void { + if (this.map.size > this.maxCapacity) { + this.map.clear(); + } + const expiry = AbsoluteTime.addDuration( + AbsoluteTime.now(), + this.cacheDuration, + ); + this.map.set(key, [expiry, value]); + } +} + +/** + * Implementation of triggers for the wallet DB. + */ +class WalletDbTriggerSpec implements TriggerSpec { + constructor(public ws: InternalWalletState) {} + + afterCommit(info: AfterCommitInfo): void { + if (info.mode !== "readwrite") { + return; + } + logger.info( + `in after commit callback for readwrite, modified ${j2s([ + ...info.modifiedStores, + ])}`, + ); + const modified = info.accessedStores; + if ( + modified.has(WalletStoresV1.exchanges.storeName) || + modified.has(WalletStoresV1.exchangeDetails.storeName) || + modified.has(WalletStoresV1.denominations.storeName) || + modified.has(WalletStoresV1.globalCurrencyAuditors.storeName) || + modified.has(WalletStoresV1.globalCurrencyExchanges.storeName) + ) { + this.ws.clearAllCaches(); + } + } +} + /** * Internal state of the wallet. * * This ties together all the operation implementations. */ -class InternalWalletStateImpl implements InternalWalletState { - /** - * @see {@link InternalWalletState.activeLongpoll} - */ - activeLongpoll: ActiveLongpollInfo = {}; - +export class InternalWalletState { cryptoApi: TalerCryptoInterface; cryptoDispatcher: CryptoDispatcher; - merchantInfoCache: Record<string, MerchantInfo> = {}; - readonly timerGroup: TimerGroup; workAvailable = new AsyncCondition(); stopped = false; - listeners: NotificationListener[] = []; + private listeners: NotificationListener[] = []; initCalled = false; - exchangeOps: ExchangeOperations = { - getExchangeDetails, - fetchFreshExchange, - }; - - recoupOps: RecoupOperations = { - createRecoupGroup, - }; - - merchantOps: MerchantOperations = { - getMerchantInfo, - }; + refreshCostCache: Cache<AmountJson> = new Cache( + 1000, + Duration.fromSpec({ minutes: 1 }), + ); - refreshOps: RefreshOperations = { - createRefreshGroup, - }; + denomInfoCache: Cache<DenominationInfo> = new Cache( + 1000, + Duration.fromSpec({ minutes: 1 }), + ); - // FIXME: Use an LRU cache here. - private denomCache: Record<string, DenominationInfo> = {}; + exchangeCache: Cache<ReadyExchangeSummary> = new Cache( + 1000, + Duration.fromSpec({ minutes: 1 }), + ); /** * Promises that are waiting for a particular resource. @@ -1717,153 +1892,106 @@ class InternalWalletStateImpl implements InternalWalletState { */ private resourceLocks: Set<string> = new Set(); - isTaskLoopRunning: boolean = false; + taskScheduler: TaskScheduler = new TaskSchedulerImpl(this); + + private _config: Readonly<WalletRunConfig> | undefined; + + private _indexedDbHandle: IDBDatabase | undefined = undefined; - config: Readonly<WalletConfig>; + private _dbAccessHandle: DbAccess<typeof WalletStoresV1> | undefined; - private _db: DbAccess<typeof WalletStoresV1> | undefined = undefined; + private _http: HttpRequestLibrary | undefined = undefined; get db(): DbAccess<typeof WalletStoresV1> { - if (!this._db) { + if (!this._dbAccessHandle) { + this._dbAccessHandle = this.createDbAccessHandle( + CancellationToken.CONTINUE, + ); + } + return this._dbAccessHandle; + } + + devExperimentState: DevExperimentState = {}; + + clientCancellationMap: Map<string, CancellationToken.Source> = new Map(); + + clearAllCaches(): void { + this.exchangeCache.clear(); + this.denomInfoCache.clear(); + this.refreshCostCache.clear(); + } + + initWithConfig(newConfig: WalletRunConfig): void { + this._config = newConfig; + + logger.info(`setting new config to ${j2s(newConfig)}`); + + this._http = this.httpFactory(newConfig); + + if (this.config.testing.devModeActive) { + this._http = new DevExperimentHttpLib(this.http); + } + } + + createDbAccessHandle( + cancellationToken: CancellationToken, + ): DbAccess<typeof WalletStoresV1> { + if (!this._indexedDbHandle) { throw Error("db not initialized"); } - return this._db; + return new DbAccessImpl( + this._indexedDbHandle, + WalletStoresV1, + new WalletDbTriggerSpec(this), + cancellationToken, + ); + } + + get config(): WalletRunConfig { + if (!this._config) { + throw Error("config not initialized"); + } + return this._config; + } + + get http(): HttpRequestLibrary { + if (!this._http) { + throw Error("wallet not initialized"); + } + return this._http; } constructor( public idb: IDBFactory, - public http: HttpRequestLibrary, + private httpFactory: HttpFactory, public timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, - configParam: WalletConfig, ) { this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); this.cryptoApi = this.cryptoDispatcher.cryptoApi; this.timerGroup = new TimerGroup(timer); - this.config = configParam; - if (this.config.testing.devModeActive) { - this.http = new DevExperimentHttpLib(this.http); - } } async ensureWalletDbOpen(): Promise<void> { - if (this._db) { + if (this._indexedDbHandle) { return; } const myVersionChange = async (): Promise<void> => { logger.info("version change requested for Taler DB"); }; - const myDb = await openTalerDatabase(this.idb, myVersionChange); - this._db = myDb; - } - - async getTransactionState( - ws: InternalWalletState, - tx: GetReadOnlyAccess<typeof WalletStoresV1>, - transactionId: string, - ): Promise<TransactionState | undefined> { - const parsedTxId = parseTransactionIdentifier(transactionId); - if (!parsedTxId) { - throw Error("invalid tx identifier"); - } - switch (parsedTxId.tag) { - case TransactionType.Deposit: { - const rec = await tx.depositGroups.get(parsedTxId.depositGroupId); - if (!rec) { - return undefined; - } - return computeDepositTransactionStatus(rec); - } - case TransactionType.InternalWithdrawal: - case TransactionType.Withdrawal: { - const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId); - if (!rec) { - return undefined; - } - return computeWithdrawalTransactionStatus(rec); - } - case TransactionType.Payment: { - const rec = await tx.purchases.get(parsedTxId.proposalId); - if (!rec) { - return; - } - return computePayMerchantTransactionState(rec); - } - case TransactionType.Refund: { - const rec = await tx.refundGroups.get(parsedTxId.refundGroupId); - if (!rec) { - return undefined; - } - return computeRefundTransactionState(rec); - } - case TransactionType.PeerPullCredit: - const rec = await tx.peerPullCredit.get(parsedTxId.pursePub); - if (!rec) { - return undefined; - } - return computePeerPullCreditTransactionState(rec); - case TransactionType.PeerPullDebit: { - const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId); - if (!rec) { - return undefined; - } - return computePeerPullDebitTransactionState(rec); - } - case TransactionType.PeerPushCredit: { - const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId); - if (!rec) { - return undefined; - } - return computePeerPushCreditTransactionState(rec); - } - case TransactionType.PeerPushDebit: { - const rec = await tx.peerPushDebit.get(parsedTxId.pursePub); - if (!rec) { - return undefined; - } - return computePeerPushDebitTransactionState(rec); - } - case TransactionType.Refresh: { - const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId); - if (!rec) { - return undefined; - } - return computeRefreshTransactionState(rec); - } - case TransactionType.Reward: { - const rec = await tx.rewards.get(parsedTxId.walletRewardId); - if (!rec) { - return undefined; - } - return computeRewardTransactionStatus(rec); - } - default: - assertUnreachable(parsedTxId); + try { + const myDb = await openTalerDatabase(this.idb, myVersionChange); + this._indexedDbHandle = myDb; + } catch (e) { + logger.error("error writing to database during initialization"); + throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, { + innerError: getErrorDetailFromException(e), + }); } } - async getDenomInfo( - ws: InternalWalletState, - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - }>, - exchangeBaseUrl: string, - denomPubHash: string, - ): Promise<DenominationInfo | undefined> { - const key = `${exchangeBaseUrl}:${denomPubHash}`; - const cached = this.denomCache[key]; - if (cached) { - return cached; - } - const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); - if (d) { - return DenominationRecord.toDenomInfo(d); - } - return undefined; - } - notify(n: WalletNotification): void { - logger.trace("Notification", j2s(n)); + logger.trace(`Notification: ${j2s(n)}`); for (const l of this.listeners) { const nc = JSON.parse(JSON.stringify(n)); setTimeout(() => { @@ -1890,11 +2018,9 @@ class InternalWalletStateImpl implements InternalWalletState { this.stopped = true; this.timerGroup.stopCurrentAndFutureTimers(); this.cryptoDispatcher.stop(); - for (const key of Object.keys(this.activeLongpoll)) { - logger.trace(`cancelling active longpoll ${key}`); - this.activeLongpoll[key].cancel(); - delete this.activeLongpoll[key]; - } + this.taskScheduler.shutdown().catch((e) => { + logger.warn(`shutdown failed: ${safeStringifyException(e)}`); + }); } /** @@ -1929,48 +2055,11 @@ class InternalWalletStateImpl implements InternalWalletState { } finally { for (const token of tokens) { this.resourceLocks.delete(token); - let waiter = (this.resourceWaiters[token] ?? []).shift(); + const waiter = (this.resourceWaiters[token] ?? []).shift(); if (waiter) { waiter.resolve(); } } } } - - ensureTaskLoopRunning(): void { - if (this.isTaskLoopRunning) { - return; - } - runTaskLoop(this) - .catch((e) => { - logger.error("error running task loop"); - logger.error(`err: ${e}`); - }) - .then(() => { - logger.info("done running task loop"); - }); - } -} - -/** - * Take the full object as template, create a new result with all the values. - * Use the override object to change the values in the result - * return result - * @param full - * @param override - * @returns - */ -function deepMerge<T extends object>(full: T, override: object): T { - const keys = Object.keys(full); - const result = { ...full }; - for (const k of keys) { - // @ts-ignore - const newVal = override[k]; - if (newVal === undefined) continue; - // @ts-ignore - result[k] = - // @ts-ignore - typeof newVal === "object" ? deepMerge(full[k], newVal) : newVal; - } - return result; } diff --git a/packages/taler-wallet-core/src/operations/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts index 97a80ec26..2a081b481 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.test.ts +++ b/packages/taler-wallet-core/src/withdraw.test.ts @@ -20,8 +20,8 @@ import { DenominationRecord, DenominationVerificationStatus, timestampProtocolToDb, -} from "../db.js"; -import { selectWithdrawalDenominations } from "../util/coinSelection.js"; +} from "./db.js"; +import { selectWithdrawalDenominations } from "./denomSelection.js"; test("withdrawal selection bug repro", (t) => { const amount = { @@ -83,7 +83,6 @@ test("withdrawal selection bug repro", (t) => { verificationStatus: DenominationVerificationStatus.Unverified, currency: "KUDOS", value: "KUDOS:1000" as AmountString, - listIssueDate: timestampProtocolToDb({ t_s: 0 }), }, { denomPub: { @@ -138,7 +137,6 @@ test("withdrawal selection bug repro", (t) => { verificationStatus: DenominationVerificationStatus.Unverified, value: "KUDOS:10" as AmountString, currency: "KUDOS", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), }, { denomPub: { @@ -192,7 +190,6 @@ test("withdrawal selection bug repro", (t) => { verificationStatus: DenominationVerificationStatus.Unverified, value: "KUDOS:5" as AmountString, currency: "KUDOS", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), }, { denomPub: { @@ -247,7 +244,6 @@ test("withdrawal selection bug repro", (t) => { verificationStatus: DenominationVerificationStatus.Unverified, value: "KUDOS:1" as AmountString, currency: "KUDOS", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), }, { denomPub: { @@ -305,7 +301,6 @@ test("withdrawal selection bug repro", (t) => { value: 0, }), currency: "KUDOS", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), }, { denomPub: { @@ -359,7 +354,6 @@ test("withdrawal selection bug repro", (t) => { verificationStatus: DenominationVerificationStatus.Unverified, value: "KUDOS:2" as AmountString, currency: "KUDOS", - listIssueDate: timestampProtocolToDb({ t_s: 0 }), }, ]; diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts new file mode 100644 index 000000000..4a7c7873c --- /dev/null +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -0,0 +1,3604 @@ +/* + This file is part of GNU Taler + (C) 2019-2024 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * @fileoverview Implementation of Taler withdrawals, both + * bank-integrated and manual. + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AcceptManualWithdrawalResult, + AcceptWithdrawalResponse, + AgeRestriction, + Amount, + AmountJson, + AmountLike, + AmountString, + Amounts, + AsyncFlag, + BankWithdrawDetails, + CancellationToken, + CoinStatus, + ConfirmWithdrawalRequest, + CurrencySpecification, + DenomKeyType, + DenomSelItem, + DenomSelectionState, + Duration, + EddsaPrivateKeyString, + ExchangeBatchWithdrawRequest, + ExchangeUpdateStatus, + ExchangeWireAccount, + ExchangeWithdrawBatchResponse, + ExchangeWithdrawRequest, + ExchangeWithdrawResponse, + ExchangeWithdrawalDetails, + ForcedDenomSel, + GetWithdrawalDetailsForAmountRequest, + HttpStatusCode, + LibtoolVersion, + Logger, + NotificationType, + ObservabilityEventType, + PrepareBankIntegratedWithdrawalResponse, + TalerBankIntegrationHttpClient, + TalerError, + TalerErrorCode, + TalerErrorDetail, + TalerPreciseTimestamp, + Transaction, + TransactionAction, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + URL, + UnblindedSignature, + WalletNotification, + WithdrawUriInfoResponse, + WithdrawalDetailsForAmount, + WithdrawalExchangeAccountDetails, + WithdrawalType, + addPaytoQueryParams, + assertUnreachable, + checkDbInvariant, + checkLogicInvariant, + codeForBankWithdrawalOperationPostResponse, + codecForBankWithdrawalOperationStatus, + codecForCashinConversionResponse, + codecForConversionBankConfig, + codecForExchangeWithdrawBatchResponse, + codecForReserveStatus, + codecForWalletKycUuid, + codecForWithdrawOperationStatusResponse, + encodeCrock, + getErrorDetailFromException, + getRandomBytes, + j2s, + makeErrorDetail, + parseWithdrawUri, +} from "@gnu-taler/taler-util"; +import { + HttpRequestLibrary, + HttpResponse, + readSuccessResponseJsonOrErrorCode, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, + throwUnexpectedRequestError, +} from "@gnu-taler/taler-util/http"; +import { + PendingTaskType, + TaskIdStr, + TaskRunResult, + TaskRunResultType, + TombstoneTag, + TransactionContext, + TransitionResult, + TransitionResultType, + constructTaskIdentifier, + makeCoinAvailable, + makeCoinsVisible, +} from "./common.js"; +import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; +import { + CoinRecord, + CoinSourceType, + DenominationRecord, + DenominationVerificationStatus, + KycPendingInfo, + PlanchetRecord, + PlanchetStatus, + WalletDbReadOnlyTransaction, + WalletDbReadWriteTransaction, + WalletDbStoresArr, + WalletStoresV1, + WgInfo, + WithdrawalGroupRecord, + WithdrawalGroupStatus, + WithdrawalRecordType, + timestampAbsoluteFromDb, + timestampPreciseFromDb, + timestampPreciseToDb, +} from "./db.js"; +import { + selectForcedWithdrawalDenominations, + selectWithdrawalDenominations, +} from "./denomSelection.js"; +import { isWithdrawableDenom } from "./denominations.js"; +import { + ReadyExchangeSummary, + fetchFreshExchange, + getExchangePaytoUri, + getExchangeWireDetailsInTx, + listExchanges, + markExchangeUsed, +} from "./exchanges.js"; +import { DbAccess } from "./query.js"; +import { + TransitionInfo, + constructTransactionIdentifier, + isUnsuccessfulTransaction, + notifyTransition, + parseTransactionIdentifier, +} from "./transactions.js"; +import { + WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + WALLET_EXCHANGE_PROTOCOL_VERSION, +} from "./versions.js"; +import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; + +/** + * Logger for this file. + */ +const logger = new Logger("withdraw.ts"); + +/** + * Update the materialized withdrawal transaction based + * on the withdrawal group record. + */ +async function updateWithdrawalTransaction( + ctx: WithdrawTransactionContext, + tx: WalletDbReadWriteTransaction< + [ + "withdrawalGroups", + "transactions", + "operationRetries", + "exchanges", + "exchangeDetails", + ] + >, +): Promise<void> { + const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId); + if (!wgRecord) { + await tx.transactions.delete(ctx.transactionId); + return; + } + const retryRecord = await tx.operationRetries.get(ctx.taskId); + + let transactionItem: Transaction; + + if ( + !wgRecord.instructedAmount || + !wgRecord.denomsSel || + !wgRecord.exchangeBaseUrl + ) { + // withdrawal group is in preparation, nothing to update + return; + } + + if (wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) { + const txState = computeWithdrawalTransactionStatus(wgRecord); + transactionItem = { + type: TransactionType.Withdrawal, + txState, + txActions: computeWithdrawalTransactionActions(wgRecord), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount)) + : Amounts.stringify(wgRecord.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wgRecord.instructedAmount), + withdrawalDetails: { + type: WithdrawalType.TalerBankIntegrationApi, + confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed + ? true + : false, + exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts, + reservePub: wgRecord.reservePub, + bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl, + reserveIsReady: + wgRecord.status === WithdrawalGroupStatus.Done || + wgRecord.status === WithdrawalGroupStatus.PendingReady, + }, + kycUrl: wgRecord.kycUrl, + exchangeBaseUrl: wgRecord.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(wgRecord.timestampStart), + transactionId: ctx.transactionId, + }; + } else if ( + wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual + ) { + checkDbInvariant( + wgRecord.instructedAmount !== undefined, + "manual withdrawal without amount can't be created", + ); + checkDbInvariant( + wgRecord.denomsSel !== undefined, + "manual withdrawal without denoms can't be created", + ); + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + wgRecord.exchangeBaseUrl, + ); + const plainPaytoUris = + exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; + + const exchangePaytoUris = augmentPaytoUrisForWithdrawal( + plainPaytoUris, + wgRecord.reservePub, + wgRecord.instructedAmount, + ); + + const txState = computeWithdrawalTransactionStatus(wgRecord); + + transactionItem = { + type: TransactionType.Withdrawal, + txState, + txActions: computeWithdrawalTransactionActions(wgRecord), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount)) + : Amounts.stringify(wgRecord.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wgRecord.instructedAmount), + withdrawalDetails: { + type: WithdrawalType.ManualTransfer, + reservePub: wgRecord.reservePub, + exchangePaytoUris, + exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts, + reserveIsReady: + wgRecord.status === WithdrawalGroupStatus.Done || + wgRecord.status === WithdrawalGroupStatus.PendingReady, + }, + kycUrl: wgRecord.kycUrl, + exchangeBaseUrl: wgRecord.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(wgRecord.timestampStart), + transactionId: ctx.transactionId, + }; + } else { + // FIXME: If this is an orphaned withdrawal for a p2p transaction, we + // still might want to report the withdrawal. + return; + } + + if (retryRecord?.lastError) { + transactionItem.error = retryRecord.lastError; + } + + await tx.transactions.put({ + currency: Amounts.currencyOf(wgRecord.instructedAmount), + transactionItem, + exchanges: [wgRecord.exchangeBaseUrl], + }); + + // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted? +} + +export class WithdrawTransactionContext implements TransactionContext { + readonly transactionId: TransactionIdStr; + readonly taskId: TaskIdStr; + + constructor( + public wex: WalletExecutionContext, + public withdrawalGroupId: string, + ) { + this.transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId, + }); + this.taskId = constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId, + }); + } + + /** + * Transition a withdrawal transaction. + * Extra object stores may be accessed during the transition. + */ + async transition<StoreNameArray extends WalletDbStoresArr = []>( + opts: { extraStores?: StoreNameArray; transactionLabel?: string }, + f: ( + rec: WithdrawalGroupRecord | undefined, + tx: WalletDbReadWriteTransaction< + [ + "withdrawalGroups", + "transactions", + "operationRetries", + "exchanges", + "exchangeDetails", + ...StoreNameArray, + ] + >, + ) => Promise<TransitionResult<WithdrawalGroupRecord>>, + ): Promise<TransitionInfo | undefined> { + const baseStores = [ + "withdrawalGroups" as const, + "transactions" as const, + "operationRetries" as const, + "exchanges" as const, + "exchangeDetails" as const, + ]; + let stores = opts.extraStores + ? [...baseStores, ...opts.extraStores] + : baseStores; + const transitionInfo = await this.wex.db.runReadWriteTx( + { storeNames: stores }, + async (tx) => { + const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId); + let oldTxState: TransactionState; + if (wgRec) { + oldTxState = computeWithdrawalTransactionStatus(wgRec); + } else { + oldTxState = { + major: TransactionMajorState.None, + }; + } + const res = await f(wgRec, tx); + switch (res.type) { + case TransitionResultType.Transition: { + await tx.withdrawalGroups.put(res.rec); + await updateWithdrawalTransaction(this, tx); + const newTxState = computeWithdrawalTransactionStatus(res.rec); + return { + oldTxState, + newTxState, + }; + } + case TransitionResultType.Delete: + await tx.withdrawalGroups.delete(this.withdrawalGroupId); + await updateWithdrawalTransaction(this, tx); + return { + oldTxState, + newTxState: { + major: TransactionMajorState.None, + }, + }; + default: + return undefined; + } + }, + ); + notifyTransition(this.wex, this.transactionId, transitionInfo); + return transitionInfo; + } + + async deleteTransaction(): Promise<void> { + await this.transition( + { + extraStores: ["tombstones"], + transactionLabel: "delete-transaction-withdraw", + }, + async (rec, tx) => { + if (!rec) { + return TransitionResult.stay(); + } + if (rec) { + await tx.tombstones.put({ + id: + TombstoneTag.DeleteWithdrawalGroup + ":" + rec.withdrawalGroupId, + }); + } + return TransitionResult.delete(); + }, + ); + } + + async suspendTransaction(): Promise<void> { + const { withdrawalGroupId } = this; + await this.transition( + { + transactionLabel: "suspend-transaction-withdraw", + }, + async (wg, _tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.PendingReady: + newStatus = WithdrawalGroupStatus.SuspendedReady; + break; + case WithdrawalGroupStatus.AbortingBank: + newStatus = WithdrawalGroupStatus.SuspendedAbortingBank; + break; + case WithdrawalGroupStatus.PendingWaitConfirmBank: + newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank; + break; + case WithdrawalGroupStatus.PendingRegisteringBank: + newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank; + break; + case WithdrawalGroupStatus.PendingQueryingStatus: + newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus; + break; + case WithdrawalGroupStatus.PendingKyc: + newStatus = WithdrawalGroupStatus.SuspendedKyc; + break; + case WithdrawalGroupStatus.PendingAml: + newStatus = WithdrawalGroupStatus.SuspendedAml; + break; + default: + logger.warn( + `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`, + ); + return TransitionResult.stay(); + } + wg.status = newStatus; + return TransitionResult.transition(wg); + }, + ); + } + + async abortTransaction(): Promise<void> { + const { withdrawalGroupId } = this; + await this.transition( + { + transactionLabel: "abort-transaction-withdraw", + }, + async (wg, _tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedRegisteringBank: + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.PendingRegisteringBank: + newStatus = WithdrawalGroupStatus.AbortingBank; + break; + case WithdrawalGroupStatus.SuspendedAml: + case WithdrawalGroupStatus.SuspendedKyc: + case WithdrawalGroupStatus.SuspendedQueryingStatus: + case WithdrawalGroupStatus.SuspendedReady: + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingQueryingStatus: + newStatus = WithdrawalGroupStatus.AbortedExchange; + break; + case WithdrawalGroupStatus.PendingReady: + newStatus = WithdrawalGroupStatus.SuspendedReady; + break; + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.AbortingBank: + case WithdrawalGroupStatus.AbortedUserRefused: + // No transition needed, but not an error + return TransitionResult.stay(); + case WithdrawalGroupStatus.DialogProposed: + newStatus = WithdrawalGroupStatus.AbortedUserRefused; + break; + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.AbortedOtherWallet: + // Not allowed + throw Error("abort not allowed in current state"); + default: + assertUnreachable(wg.status); + } + wg.status = newStatus; + return TransitionResult.transition(wg); + }, + ); + } + + async resumeTransaction(): Promise<void> { + const { withdrawalGroupId } = this; + await this.transition( + { + transactionLabel: "resume-transaction-withdraw", + }, + async (wg, _tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedReady: + newStatus = WithdrawalGroupStatus.PendingReady; + break; + case WithdrawalGroupStatus.SuspendedAbortingBank: + newStatus = WithdrawalGroupStatus.AbortingBank; + break; + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank; + break; + case WithdrawalGroupStatus.SuspendedQueryingStatus: + newStatus = WithdrawalGroupStatus.PendingQueryingStatus; + break; + case WithdrawalGroupStatus.SuspendedRegisteringBank: + newStatus = WithdrawalGroupStatus.PendingRegisteringBank; + break; + case WithdrawalGroupStatus.SuspendedAml: + newStatus = WithdrawalGroupStatus.PendingAml; + break; + case WithdrawalGroupStatus.SuspendedKyc: + newStatus = WithdrawalGroupStatus.PendingKyc; + break; + default: + logger.warn( + `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`, + ); + return TransitionResult.stay(); + } + wg.status = newStatus; + return TransitionResult.transition(wg); + }, + ); + } + + async failTransaction(): Promise<void> { + const { withdrawalGroupId } = this; + await this.transition( + { + transactionLabel: "fail-transaction-withdraw", + }, + async (wg, _tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.AbortingBank: + newStatus = WithdrawalGroupStatus.FailedAbortingBank; + break; + default: + return TransitionResult.stay(); + } + wg.status = newStatus; + return TransitionResult.transition(wg); + }, + ); + } +} + +/** + * Compute the DD37 transaction state of a withdrawal transaction + * from the database's withdrawal group record. + */ +export function computeWithdrawalTransactionStatus( + wgRecord: WithdrawalGroupRecord, +): TransactionState { + switch (wgRecord.status) { + case WithdrawalGroupStatus.FailedBankAborted: + return { + major: TransactionMajorState.Aborted, + }; + case WithdrawalGroupStatus.Done: + return { + major: TransactionMajorState.Done, + }; + case WithdrawalGroupStatus.PendingRegisteringBank: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankRegisterReserve, + }; + case WithdrawalGroupStatus.PendingReady: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.WithdrawCoins, + }; + case WithdrawalGroupStatus.PendingQueryingStatus: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.ExchangeWaitReserve, + }; + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }; + case WithdrawalGroupStatus.AbortingBank: + return { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.Bank, + }; + case WithdrawalGroupStatus.SuspendedAbortingBank: + return { + major: TransactionMajorState.SuspendedAborting, + minor: TransactionMinorState.Bank, + }; + case WithdrawalGroupStatus.SuspendedQueryingStatus: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.ExchangeWaitReserve, + }; + case WithdrawalGroupStatus.SuspendedRegisteringBank: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.BankRegisterReserve, + }; + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.BankConfirmTransfer, + }; + case WithdrawalGroupStatus.SuspendedReady: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.WithdrawCoins, + }; + case WithdrawalGroupStatus.PendingAml: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.AmlRequired, + }; + case WithdrawalGroupStatus.PendingKyc: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.KycRequired, + }; + case WithdrawalGroupStatus.SuspendedAml: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.AmlRequired, + }; + case WithdrawalGroupStatus.SuspendedKyc: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.KycRequired, + }; + case WithdrawalGroupStatus.FailedAbortingBank: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.AbortingBank, + }; + case WithdrawalGroupStatus.AbortedExchange: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.Exchange, + }; + case WithdrawalGroupStatus.AbortedBank: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.Bank, + }; + case WithdrawalGroupStatus.AbortedUserRefused: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.Refused, + }; + case WithdrawalGroupStatus.DialogProposed: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }; + case WithdrawalGroupStatus.AbortedOtherWallet: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.CompletedByOtherWallet, + }; + } +} + +/** + * Compute DD37 transaction actions for a withdrawal transaction + * based on the database's withdrawal group record. + */ +export function computeWithdrawalTransactionActions( + wgRecord: WithdrawalGroupRecord, +): TransactionAction[] { + switch (wgRecord.status) { + case WithdrawalGroupStatus.FailedBankAborted: + return [TransactionAction.Delete]; + case WithdrawalGroupStatus.Done: + return [TransactionAction.Delete]; + case WithdrawalGroupStatus.PendingRegisteringBank: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingReady: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingQueryingStatus: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case WithdrawalGroupStatus.AbortingBank: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case WithdrawalGroupStatus.SuspendedAbortingBank: + return [TransactionAction.Resume, TransactionAction.Fail]; + case WithdrawalGroupStatus.SuspendedQueryingStatus: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedRegisteringBank: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedReady: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingAml: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingKyc: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedAml: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.SuspendedKyc: + return [TransactionAction.Resume, TransactionAction.Abort]; + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedOtherWallet: + case WithdrawalGroupStatus.AbortedUserRefused: + return [TransactionAction.Delete]; + case WithdrawalGroupStatus.DialogProposed: + return [TransactionAction.Abort]; + } +} + +async function processWithdrawalGroupDialogProposed( + ctx: WithdrawTransactionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + if ( + withdrawalGroup.wgInfo.withdrawalType !== + WithdrawalRecordType.BankIntegrated + ) { + throw new Error( + "processWithdrawalGroupDialogProposed called in unexpected state", + ); + } + + const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; + + const parsedUri = parseWithdrawUri(talerWithdrawUri); + + checkLogicInvariant(!!parsedUri); + + const wopid = parsedUri.withdrawalOperationId; + + const url = new URL( + `withdrawal-operation/${wopid}`, + parsedUri.bankIntegrationApiBaseUrl, + ); + + url.searchParams.set("old_state", "pending"); + url.searchParams.set("long_poll_ms", "30000"); + + const resp = await ctx.wex.http.fetch(url.href, { + method: "GET", + cancellationToken: ctx.wex.cancellationToken, + }); + + // If the bank claims that the withdrawal operation is already + // pending, but we're still in DialogProposed, some other wallet + // must've completed the withdrawal, we're giving up. + + switch (resp.status) { + case HttpStatusCode.Ok: { + const body = await readSuccessResponseJsonOrThrow( + resp, + codecForBankWithdrawalOperationStatus(), + ); + if (body.status !== "pending") { + await ctx.transition({}, async (rec) => { + switch (rec?.status) { + case WithdrawalGroupStatus.DialogProposed: { + rec.status = WithdrawalGroupStatus.AbortedOtherWallet; + return TransitionResult.transition(rec); + } + } + return TransitionResult.stay(); + }); + } + break; + } + } + + return TaskRunResult.longpollReturnedPending(); +} + +/** + * Get information about a withdrawal from + * a taler://withdraw URI by asking the bank. + * + * FIXME: Move into bank client. + */ +export async function getBankWithdrawalInfo( + http: HttpRequestLibrary, + talerWithdrawUri: string, +): Promise<BankWithdrawDetails> { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse URL ${talerWithdrawUri}`); + } + + const bankApi = new TalerBankIntegrationHttpClient( + uriResult.bankIntegrationApiBaseUrl, + http, + ); + + const { body: config } = await bankApi.getConfig(); + + if (!bankApi.isCompatible(config.version)) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE, + { + bankProtocolVersion: config.version, + walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + }, + "bank integration protocol version not compatible with wallet", + ); + } + + const resp = await bankApi.getWithdrawalOperationById( + uriResult.withdrawalOperationId, + ); + + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + const { body: status } = resp; + + return { + operationId: uriResult.withdrawalOperationId, + apiBaseUrl: uriResult.bankIntegrationApiBaseUrl, + amount: Amounts.parseOrThrow(status.amount), + confirmTransferUrl: status.confirm_transfer_url, + senderWire: status.sender_wire, + suggestedExchange: status.suggested_exchange, + wireTypes: status.wire_types, + status: status.status, + }; +} + +/** + * Return denominations that can potentially used for a withdrawal. + */ +async function getCandidateWithdrawalDenoms( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + currency: string, +): Promise<DenominationRecord[]> { + return await wex.db.runReadOnlyTx( + { storeNames: ["denominations"] }, + async (tx) => { + return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency); + }, + ); +} + +export async function getCandidateWithdrawalDenomsTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["denominations"]>, + exchangeBaseUrl: string, + currency: string, +): Promise<DenominationRecord[]> { + // FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations! + const allDenoms = + await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl); + return allDenoms + .filter((d) => d.currency === currency) + .filter((d) => + isWithdrawableDenom(d, wex.ws.config.testing.denomselAllowLate), + ); +} + +/** + * Generate a planchet for a coin index in a withdrawal group. + * Does not actually withdraw the coin yet. + * + * Split up so that we can parallelize the crypto, but serialize + * the exchange requests per reserve. + */ +async function processPlanchetGenerate( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, + coinIdx: number, +): Promise<void> { + checkDbInvariant( + withdrawalGroup.denomsSel !== undefined, + "can't process uninitialized exchange", + ); + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + let planchet = await wex.db.runReadOnlyTx( + { storeNames: ["planchets"] }, + async (tx) => { + return tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + }, + ); + if (planchet) { + return; + } + let ci = 0; + let isSkipped = false; + let maybeDenomPubHash: string | undefined; + for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) { + const d = withdrawalGroup.denomsSel.selectedDenoms[di]; + if (coinIdx >= ci && coinIdx < ci + d.count) { + maybeDenomPubHash = d.denomPubHash; + if (coinIdx >= ci + d.count - (d.skip ?? 0)) { + isSkipped = true; + } + break; + } + ci += d.count; + } + if (isSkipped) { + return; + } + if (!maybeDenomPubHash) { + throw Error("invariant violated"); + } + const denomPubHash = maybeDenomPubHash; + + const denom = await wex.db.runReadOnlyTx( + { storeNames: ["denominations"] }, + async (tx) => { + return getDenomInfo(wex, tx, exchangeBaseUrl, denomPubHash); + }, + ); + checkDbInvariant(!!denom); + const r = await wex.cryptoApi.createPlanchet({ + denomPub: denom.denomPub, + feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), + reservePriv: withdrawalGroup.reservePriv, + reservePub: withdrawalGroup.reservePub, + value: Amounts.parseOrThrow(denom.value), + coinIndex: coinIdx, + secretSeed: withdrawalGroup.secretSeed, + restrictAge: withdrawalGroup.restrictAge, + }); + const newPlanchet: PlanchetRecord = { + blindingKey: r.blindingKey, + coinEv: r.coinEv, + coinEvHash: r.coinEvHash, + coinIdx, + coinPriv: r.coinPriv, + coinPub: r.coinPub, + denomPubHash: r.denomPubHash, + planchetStatus: PlanchetStatus.Pending, + withdrawSig: r.withdrawSig, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + ageCommitmentProof: r.ageCommitmentProof, + lastError: undefined, + }; + await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => { + const p = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (p) { + planchet = p; + return; + } + await tx.planchets.put(newPlanchet); + planchet = newPlanchet; + }); +} + +interface WithdrawalRequestBatchArgs { + coinStartIndex: number; + + batchSize: number; +} + +interface WithdrawalBatchResult { + coinIdxs: number[]; + batchResp: ExchangeWithdrawBatchResponse; +} + +// FIXME: Move to exchange API types +enum ExchangeAmlStatus { + Normal = 0, + Pending = 1, + Frozen = 2, +} + +async function handleKycRequired( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, + resp: HttpResponse, + startIdx: number, + requestCoinIdxs: number[], +): Promise<void> { + logger.info("withdrawal requires KYC"); + const respJson = await resp.json(); + const uuidResp = codecForWalletKycUuid().decode(respJson); + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + logger.info(`kyc uuid response: ${j2s(uuidResp)}`); + const exchangeUrl = withdrawalGroup.exchangeBaseUrl; + const userType = "individual"; + const kycInfo: KycPendingInfo = { + paytoHash: uuidResp.h_payto, + requirementRow: uuidResp.requirement_row, + }; + const url = new URL( + `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, + exchangeUrl, + ); + logger.info(`kyc url ${url.href}`); + const kycStatusRes = await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + let kycUrl: string; + let amlStatus: ExchangeAmlStatus | undefined; + if ( + kycStatusRes.status === HttpStatusCode.Ok || + // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge + // remove after the exchange is fixed or clarified + kycStatusRes.status === HttpStatusCode.NoContent + ) { + logger.warn("kyc requested, but already fulfilled"); + return; + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + const kycStatus = await kycStatusRes.json(); + logger.info(`kyc status: ${j2s(kycStatus)}`); + kycUrl = kycStatus.kyc_url; + } else if ( + kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons + ) { + const kycStatus = await kycStatusRes.json(); + logger.info(`aml status: ${j2s(kycStatus)}`); + amlStatus = kycStatus.aml_status; + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } + + await ctx.transition( + { + extraStores: ["planchets"], + }, + async (wg2, tx) => { + if (!wg2) { + return TransitionResult.stay(); + } + for (let i = startIdx; i < requestCoinIdxs.length; i++) { + const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + requestCoinIdxs[i], + ]); + if (!planchet) { + continue; + } + planchet.planchetStatus = PlanchetStatus.KycRequired; + await tx.planchets.put(planchet); + } + if (wg2.status !== WithdrawalGroupStatus.PendingReady) { + return TransitionResult.stay(); + } + wg2.kycPending = { + paytoHash: uuidResp.h_payto, + requirementRow: uuidResp.requirement_row, + }; + wg2.kycUrl = kycUrl; + wg2.status = + amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined + ? WithdrawalGroupStatus.PendingKyc + : amlStatus === ExchangeAmlStatus.Pending + ? WithdrawalGroupStatus.PendingAml + : amlStatus === ExchangeAmlStatus.Frozen + ? WithdrawalGroupStatus.SuspendedAml + : assertUnreachable(amlStatus); + return TransitionResult.transition(wg2); + }, + ); +} + +/** + * Send the withdrawal request for a generated planchet to the exchange. + * + * The verification of the response is done asynchronously to enable parallelism. + */ +async function processPlanchetExchangeBatchRequest( + wex: WalletExecutionContext, + wgContext: WithdrawalGroupStatusInfo, + args: WithdrawalRequestBatchArgs, +): Promise<WithdrawalBatchResult> { + const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord; + logger.info( + `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`, + ); + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + + const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] }; + // Indices of coins that are included in the batch request + const requestCoinIdxs: number[] = []; + + await wex.db.runReadOnlyTx( + { storeNames: ["planchets", "denominations"] }, + async (tx) => { + for ( + let coinIdx = args.coinStartIndex; + coinIdx < args.coinStartIndex + args.batchSize && + coinIdx < wgContext.numPlanchets; + coinIdx++ + ) { + const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + continue; + } + if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) { + logger.warn("processPlanchet: planchet already withdrawn"); + continue; + } + if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) { + continue; + } + const denom = await getDenomInfo( + wex, + tx, + exchangeBaseUrl, + planchet.denomPubHash, + ); + + if (!denom) { + logger.error("db inconsistent: denom for planchet not found"); + continue; + } + + const planchetReq: ExchangeWithdrawRequest = { + denom_pub_hash: planchet.denomPubHash, + reserve_sig: planchet.withdrawSig, + coin_ev: planchet.coinEv, + }; + batchReq.planchets.push(planchetReq); + requestCoinIdxs.push(coinIdx); + } + }, + ); + + if (batchReq.planchets.length == 0) { + logger.warn("empty withdrawal batch"); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + + async function storeCoinError( + errDetail: TalerErrorDetail, + coinIdx: number, + ): Promise<void> { + logger.trace(`withdrawal request failed: ${j2s(errDetail)}`); + await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => { + const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + return; + } + planchet.lastError = errDetail; + await tx.planchets.put(planchet); + }); + } + + // FIXME: handle individual error codes better! + + const reqUrl = new URL( + `reserves/${withdrawalGroup.reservePub}/batch-withdraw`, + withdrawalGroup.exchangeBaseUrl, + ).href; + + try { + const resp = await wex.http.fetch(reqUrl, { + method: "POST", + body: batchReq, + cancellationToken: wex.cancellationToken, + timeout: Duration.fromSpec({ seconds: 40 }), + }); + if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { + await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + if (resp.status === HttpStatusCode.Gone) { + const e = await readTalerErrorResponse(resp); + // FIXME: Store in place of the planchet that is actually affected! + await storeCoinError(e, requestCoinIdxs[0]); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + const r = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeWithdrawBatchResponse(), + ); + return { + coinIdxs: requestCoinIdxs, + batchResp: r, + }; + } catch (e) { + const errDetail = getErrorDetailFromException(e); + // We don't know which coin is affected, so we store the error + // with the first coin of the batch. + await storeCoinError(errDetail, requestCoinIdxs[0]); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } +} + +async function processPlanchetVerifyAndStoreCoin( + wex: WalletExecutionContext, + wgContext: WithdrawalGroupStatusInfo, + coinIdx: number, + resp: ExchangeWithdrawResponse, +): Promise<void> { + const withdrawalGroup = wgContext.wgRecord; + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + + logger.trace(`checking and storing planchet idx=${coinIdx}`); + const d = await wex.db.runReadOnlyTx( + { storeNames: ["planchets", "denominations"] }, + async (tx) => { + const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + return; + } + if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) { + logger.warn("processPlanchet: planchet already withdrawn"); + return; + } + const denomInfo = await getDenomInfo( + wex, + tx, + exchangeBaseUrl, + planchet.denomPubHash, + ); + if (!denomInfo) { + return; + } + return { + planchet, + denomInfo, + exchangeBaseUrl: 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`); + } + + const evSig = resp.ev_sig; + if (!(evSig.cipher === DenomKeyType.Rsa)) { + throw Error("unsupported cipher"); + } + + const denomSigRsa = await wex.cryptoApi.rsaUnblind({ + bk: planchet.blindingKey, + blindedSig: evSig.blinded_rsa_signature, + pk: planchetDenomPub.rsa_public_key, + }); + + const isValid = await wex.cryptoApi.rsaVerify({ + hm: planchet.coinPub, + pk: planchetDenomPub.rsa_public_key, + sig: denomSigRsa.sig, + }); + + if (!isValid) { + await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => { + const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + return; + } + planchet.lastError = makeErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID, + {}, + "invalid signature from the exchange after unblinding", + ); + await tx.planchets.put(planchet); + }); + return; + } + + let denomSig: UnblindedSignature; + if (planchetDenomPub.cipher === DenomKeyType.Rsa) { + denomSig = { + cipher: planchetDenomPub.cipher, + rsa_signature: denomSigRsa.sig, + }; + } else { + throw Error("unsupported cipher"); + } + + const coin: CoinRecord = { + blindingKey: planchet.blindingKey, + coinPriv: planchet.coinPriv, + coinPub: planchet.coinPub, + denomPubHash: planchet.denomPubHash, + denomSig, + coinEvHash: planchet.coinEvHash, + exchangeBaseUrl: d.exchangeBaseUrl, + status: CoinStatus.Fresh, + coinSource: { + type: CoinSourceType.Withdraw, + coinIndex: coinIdx, + reservePub: withdrawalGroup.reservePub, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + }, + sourceTransactionId: transactionId, + maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED, + ageCommitmentProof: planchet.ageCommitmentProof, + spendAllocation: undefined, + }; + + const planchetCoinPub = planchet.coinPub; + + wgContext.planchetsFinished.add(planchet.coinPub); + + await wex.db.runReadWriteTx( + { storeNames: ["planchets", "coins", "coinAvailability", "denominations"] }, + async (tx) => { + const p = await tx.planchets.get(planchetCoinPub); + if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) { + return; + } + p.planchetStatus = PlanchetStatus.WithdrawalDone; + p.lastError = undefined; + await tx.planchets.put(p); + await makeCoinAvailable(wex, tx, coin); + }, + ); +} + +/** + * Make sure that denominations that currently can be used for withdrawal + * are validated, and the result of validation is stored in the database. + */ +export async function updateWithdrawalDenoms( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<void> { + logger.trace( + `updating denominations used for withdrawal for ${exchangeBaseUrl}`, + ); + const exchangeDetails = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges", "exchangeDetails"] }, + async (tx) => { + return getExchangeWireDetailsInTx(tx, exchangeBaseUrl); + }, + ); + if (!exchangeDetails) { + logger.error("exchange details not available"); + throw Error(`exchange ${exchangeBaseUrl} details not available`); + } + // First do a pass where the validity of candidate denominations + // is checked and the result is stored in the database. + logger.trace("getting candidate denominations"); + const denominations = await getCandidateWithdrawalDenoms( + wex, + exchangeBaseUrl, + exchangeDetails.currency, + ); + logger.trace(`got ${denominations.length} candidate denominations`); + const batchSize = 500; + let current = 0; + + while (current < denominations.length) { + const updatedDenominations: DenominationRecord[] = []; + // Do a batch of batchSize + for ( + let batchIdx = 0; + batchIdx < batchSize && current < denominations.length; + batchIdx++, current++ + ) { + const denom = denominations[current]; + if ( + denom.verificationStatus === DenominationVerificationStatus.Unverified + ) { + logger.trace( + `Validating denomination (${current + 1}/${ + denominations.length + }) signature of ${denom.denomPubHash}`, + ); + let valid = false; + if (wex.ws.config.testing.insecureTrustExchange) { + valid = true; + } else { + const res = await wex.cryptoApi.isValidDenom({ + denom, + masterPub: exchangeDetails.masterPublicKey, + }); + valid = res.valid; + } + logger.trace(`Done validating ${denom.denomPubHash}`); + if (!valid) { + logger.warn( + `Signature check for denomination h=${denom.denomPubHash} failed`, + ); + denom.verificationStatus = DenominationVerificationStatus.VerifiedBad; + } else { + denom.verificationStatus = + DenominationVerificationStatus.VerifiedGood; + } + updatedDenominations.push(denom); + } + } + if (updatedDenominations.length > 0) { + logger.trace("writing denomination batch to db"); + await wex.db.runReadWriteTx( + { storeNames: ["denominations"] }, + async (tx) => { + for (let i = 0; i < updatedDenominations.length; i++) { + const denom = updatedDenominations[i]; + await tx.denominations.put(denom); + } + }, + ); + wex.ws.denomInfoCache.clear(); + logger.trace("done with DB write"); + } + } +} + +/** + * Update the information about a reserve that is stored in the wallet + * by querying the reserve's exchange. + * + * If the reserve have funds that are not allocated in a withdrawal group yet + * and are big enough to withdraw with available denominations, + * create a new withdrawal group for the remaining amount. + */ +async function processQueryReserve( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, { + withdrawalGroupId, + }); + if (!withdrawalGroup) { + return TaskRunResult.finished(); + } + if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) { + return TaskRunResult.backoff(); + } + checkDbInvariant( + withdrawalGroup.denomsSel !== undefined, + "can't process uninitialized exchange", + ); + checkDbInvariant( + withdrawalGroup.instructedAmount !== undefined, + "can't process uninitialized exchange", + ); + + const reservePub = withdrawalGroup.reservePub; + + const reserveUrl = new URL( + `reserves/${reservePub}`, + withdrawalGroup.exchangeBaseUrl, + ); + reserveUrl.searchParams.set("timeout_ms", "30000"); + + logger.trace(`querying reserve status via ${reserveUrl.href}`); + + const resp = await wex.http.fetch(reserveUrl.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + + logger.trace(`reserve status code: HTTP ${resp.status}`); + + const result = await readSuccessResponseJsonOrErrorCode( + resp, + codecForReserveStatus(), + ); + + if (result.isError) { + logger.trace( + `got reserve status error, EC=${result.talerErrorResponse.code}`, + ); + if (resp.status === HttpStatusCode.NotFound) { + return TaskRunResult.longpollReturnedPending(); + } else { + throwUnexpectedRequestError(resp, result.talerErrorResponse); + } + } + + logger.trace(`got reserve status ${j2s(result.response)}`); + + let amountChanged = false; + if ( + Amounts.cmp( + result.response.balance, + withdrawalGroup.denomsSel.totalWithdrawCost, + ) === -1 + ) { + amountChanged = true; + } + console.log(`amount change ${j2s(result.response)}`); + console.log( + `amount change ${j2s(withdrawalGroup.denomsSel.totalWithdrawCost)}`, + ); + + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount); + + const transitionResult = await ctx.transition( + { + extraStores: ["denominations"], + }, + async (wg, tx) => { + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TransitionResult.stay(); + } + if (wg.status !== WithdrawalGroupStatus.PendingQueryingStatus) { + return TransitionResult.stay(); + } + if (amountChanged) { + const candidates = await getCandidateWithdrawalDenomsTx( + wex, + tx, + exchangeBaseUrl, + currency, + ); + wg.denomsSel = selectWithdrawalDenominations( + Amounts.parseOrThrow(result.response.balance), + candidates, + ); + } + wg.status = WithdrawalGroupStatus.PendingReady; + wg.reserveBalanceAmount = Amounts.stringify(result.response.balance); + return TransitionResult.transition(wg); + }, + ); + + if (transitionResult) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } +} + +/** + * Withdrawal context that is kept in-memory. + * + * Used to store some cached info during a withdrawal operation. + */ +interface WithdrawalGroupStatusInfo { + numPlanchets: number; + planchetsFinished: Set<string>; + + /** + * Cached withdrawal group record from the database. + */ + wgRecord: WithdrawalGroupRecord; +} + +async function processWithdrawalGroupAbortingBank( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const { withdrawalGroupId } = withdrawalGroup; + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + const wgInfo = withdrawalGroup.wgInfo; + if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) { + throw Error("invalid state (aborting(bank) without bank info"); + } + const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri); + logger.info(`aborting withdrawal at ${abortUrl}`); + const abortResp = await wex.http.fetch(abortUrl, { + method: "POST", + body: {}, + cancellationToken: wex.cancellationToken, + }); + logger.info(`abort response status: ${abortResp.status}`); + + await ctx.transition({}, async (wg) => { + if (!wg) { + return TransitionResult.stay(); + } + wg.status = WithdrawalGroupStatus.AbortedBank; + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + return TransitionResult.transition(wg); + }); + return TaskRunResult.finished(); +} + +async function processWithdrawalGroupPendingKyc( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + const userType = "individual"; + const kycInfo = withdrawalGroup.kycPending; + if (!kycInfo) { + throw Error("no kyc info available in pending(kyc)"); + } + const exchangeUrl = withdrawalGroup.exchangeBaseUrl; + const url = new URL( + `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`, + exchangeUrl, + ); + url.searchParams.set("timeout_ms", "30000"); + logger.info(`long-polling for withdrawal KYC status via ${url.href}`); + const kycStatusRes = await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + }); + logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`); + if ( + kycStatusRes.status === HttpStatusCode.Ok || + // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge + // remove after the exchange is fixed or clarified + kycStatusRes.status === HttpStatusCode.NoContent + ) { + await ctx.transition({}, async (rec) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.status) { + case WithdrawalGroupStatus.PendingKyc: { + delete rec.kycPending; + delete rec.kycUrl; + rec.status = WithdrawalGroupStatus.PendingReady; + return TransitionResult.transition(rec); + } + default: + return TransitionResult.stay(); + } + }); + } else if (kycStatusRes.status === HttpStatusCode.Accepted) { + const kycStatus = await kycStatusRes.json(); + logger.info(`kyc status: ${j2s(kycStatus)}`); + const kycUrl = kycStatus.kyc_url; + if (typeof kycUrl === "string") { + await ctx.transition({}, async (rec) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.status) { + case WithdrawalGroupStatus.PendingReady: { + rec.kycUrl = kycUrl; + return TransitionResult.transition(rec); + } + } + return TransitionResult.stay(); + }); + } + } else if ( + kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons + ) { + const kycStatus = await kycStatusRes.json(); + logger.info(`aml status: ${j2s(kycStatus)}`); + } else { + throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + } + return TaskRunResult.backoff(); +} + +/** + * Select new denominations for a withdrawal group. + * Necessary when denominations expired or got revoked + * before the withdrawal could complete. + */ +async function redenominateWithdrawal( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<void> { + logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`); + await wex.db.runReadWriteTx( + { storeNames: ["withdrawalGroups", "planchets", "denominations"] }, + async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + return; + } + checkDbInvariant( + wg.denomsSel !== undefined, + "can't process uninitialized exchange", + ); + const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost); + const exchangeBaseUrl = wg.exchangeBaseUrl; + + const candidates = await getCandidateWithdrawalDenomsTx( + wex, + tx, + exchangeBaseUrl, + currency, + ); + + const oldSel = wg.denomsSel; + + if (logger.shouldLogTrace()) { + logger.trace(`old denom sel: ${j2s(oldSel)}`); + } + + const zero = Amount.zeroOfCurrency(currency); + let amountRemaining = zero; + let prevTotalCoinValue = zero; + let prevTotalWithdrawalCost = zero; + let prevHasDenomWithAgeRestriction = false; + let prevEarliestDepositExpiration = AbsoluteTime.never(); + const prevDenoms: DenomSelItem[] = []; + let coinIndex = 0; + for (let i = 0; i < oldSel.selectedDenoms.length; i++) { + const sel = wg.denomsSel.selectedDenoms[i]; + const denom = await tx.denominations.get([ + exchangeBaseUrl, + sel.denomPubHash, + ]); + if (!denom) { + throw Error("denom in use but not not found"); + } + // FIXME: Also check planchet if there was a different error or planchet already withdrawn + const denomOkay = isWithdrawableDenom( + denom, + wex.ws.config.testing.denomselAllowLate, + ); + const numCoins = sel.count - (sel.skip ?? 0); + const denomValue = Amount.from(denom.value).mult(numCoins); + const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult( + numCoins, + ); + if (denomOkay) { + prevTotalCoinValue = prevTotalCoinValue.add(denomValue); + prevTotalWithdrawalCost = prevTotalWithdrawalCost.add( + denomValue, + denomFeeWithdraw, + ); + prevDenoms.push({ + count: sel.count, + denomPubHash: sel.denomPubHash, + skip: sel.skip, + }); + prevHasDenomWithAgeRestriction = + prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0; + prevEarliestDepositExpiration = AbsoluteTime.min( + prevEarliestDepositExpiration, + timestampAbsoluteFromDb(denom.stampExpireDeposit), + ); + } else { + amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw); + prevDenoms.push({ + count: sel.count, + denomPubHash: sel.denomPubHash, + skip: (sel.skip ?? 0) + numCoins, + }); + + for (let j = 0; j < sel.count; j++) { + const ci = coinIndex + j; + const p = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroupId, + ci, + ]); + if (!p) { + // Maybe planchet wasn't yet generated. + // No problem! + logger.info( + `not aborting planchet #${coinIndex}, planchet not found`, + ); + continue; + } + logger.info(`aborting planchet #${coinIndex}`); + p.planchetStatus = PlanchetStatus.AbortedReplaced; + await tx.planchets.put(p); + } + } + + coinIndex += sel.count; + } + + const newSel = selectWithdrawalDenominations( + amountRemaining.toJson(), + candidates, + ); + + if (logger.shouldLogTrace()) { + logger.trace(`new denom sel: ${j2s(newSel)}`); + } + + const mergedSel: DenomSelectionState = { + selectedDenoms: [...prevDenoms, ...newSel.selectedDenoms], + totalCoinValue: zero + .add(prevTotalCoinValue, newSel.totalCoinValue) + .toString(), + totalWithdrawCost: zero + .add(prevTotalWithdrawalCost, newSel.totalWithdrawCost) + .toString(), + hasDenomWithAgeRestriction: + prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction, + earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.min( + prevEarliestDepositExpiration, + AbsoluteTime.fromProtocolTimestamp( + newSel.earliestDepositExpiration, + ), + ), + ), + }; + wg.denomsSel = mergedSel; + if (logger.shouldLogTrace()) { + logger.trace(`merged denom sel: ${j2s(mergedSel)}`); + } + await tx.withdrawalGroups.put(wg); + }, + ); +} + +async function processWithdrawalGroupPendingReady( + wex: WalletExecutionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const { withdrawalGroupId } = withdrawalGroup; + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + checkDbInvariant( + withdrawalGroup.denomsSel !== undefined, + "can't process uninitialized exchange", + ); + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl); + + if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { + logger.warn("Finishing empty withdrawal group (no denoms)"); + await ctx.transition({}, async (wg) => { + if (!wg) { + return TransitionResult.stay(); + } + wg.status = WithdrawalGroupStatus.Done; + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + return TransitionResult.transition(wg); + }); + return TaskRunResult.finished(); + } + + const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms + .map((x) => x.count) + .reduce((a, b) => a + b); + + const wgContext: WithdrawalGroupStatusInfo = { + numPlanchets: numTotalCoins, + planchetsFinished: new Set<string>(), + wgRecord: withdrawalGroup, + }; + + await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => { + const planchets = + await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + for (const p of planchets) { + if (p.planchetStatus === PlanchetStatus.WithdrawalDone) { + wgContext.planchetsFinished.add(p.coinPub); + } + } + }); + + // We sequentially generate planchets, so that + // large withdrawal groups don't make the wallet unresponsive. + for (let i = 0; i < numTotalCoins; i++) { + await processPlanchetGenerate(wex, withdrawalGroup, i); + } + + const maxBatchSize = 100; + + for (let i = 0; i < numTotalCoins; i += maxBatchSize) { + const resp = await processPlanchetExchangeBatchRequest(wex, wgContext, { + batchSize: maxBatchSize, + coinStartIndex: i, + }); + let work: Promise<void>[] = []; + work = []; + for (let j = 0; j < resp.coinIdxs.length; j++) { + if (!resp.batchResp.ev_sigs[j]) { + // response may not be available when there is kyc needed + continue; + } + work.push( + processPlanchetVerifyAndStoreCoin( + wex, + wgContext, + resp.coinIdxs[j], + resp.batchResp.ev_sigs[j], + ), + ); + } + await Promise.all(work); + } + + let redenomRequired = false; + + await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => { + const planchets = + await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + for (const p of planchets) { + if (p.planchetStatus !== PlanchetStatus.Pending) { + continue; + } + if (!p.lastError) { + continue; + } + switch (p.lastError.code) { + case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED: + case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED: + redenomRequired = true; + return; + } + } + }); + + if (redenomRequired) { + logger.warn(`withdrawal ${withdrawalGroupId} requires redenomination`); + await fetchFreshExchange(wex, exchangeBaseUrl, { + forceUpdate: true, + }); + await updateWithdrawalDenoms(wex, exchangeBaseUrl); + await redenominateWithdrawal(wex, withdrawalGroupId); + return TaskRunResult.backoff(); + } + + const errorsPerCoin: Record<number, TalerErrorDetail> = {}; + let numPlanchetErrors = 0; + let numActive = 0; + const maxReportedErrors = 5; + + const res = await ctx.transition( + { + extraStores: ["coins", "coinAvailability", "planchets"], + }, + async (wg, tx) => { + if (!wg) { + return TransitionResult.stay(); + } + + const groupPlanchets = + await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + for (const x of groupPlanchets) { + switch (x.planchetStatus) { + case PlanchetStatus.KycRequired: + case PlanchetStatus.Pending: + numActive++; + break; + case PlanchetStatus.WithdrawalDone: + break; + } + if (x.lastError) { + numPlanchetErrors++; + if (numPlanchetErrors < maxReportedErrors) { + errorsPerCoin[x.coinIdx] = x.lastError; + } + } + } + + if (wg.timestampFinish === undefined && numActive === 0) { + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + wg.status = WithdrawalGroupStatus.Done; + await makeCoinsVisible(wex, tx, ctx.transactionId); + } + return TransitionResult.transition(wg); + }, + ); + + if (!res) { + throw Error("withdrawal group does not exist anymore"); + } + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + if (numPlanchetErrors > 0) { + return { + type: TaskRunResultType.Error, + errorDetail: makeErrorDetail( + TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE, + { + errorsPerCoin, + numErrors: numPlanchetErrors, + }, + ), + }; + } + + return TaskRunResult.backoff(); +} + +export async function processWithdrawalGroup( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + logger.trace("processing withdrawal group", withdrawalGroupId); + const withdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return tx.withdrawalGroups.get(withdrawalGroupId); + }, + ); + + if (!withdrawalGroup) { + throw Error(`withdrawal group ${withdrawalGroupId} not found`); + } + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + switch (withdrawalGroup.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + return await processBankRegisterReserve(wex, withdrawalGroupId); + case WithdrawalGroupStatus.PendingQueryingStatus: + return processQueryReserve(wex, withdrawalGroupId); + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return await processReserveBankStatus(wex, withdrawalGroupId); + case WithdrawalGroupStatus.PendingAml: + // FIXME: Handle this case, withdrawal doesn't support AML yet. + return TaskRunResult.backoff(); + case WithdrawalGroupStatus.PendingKyc: + return processWithdrawalGroupPendingKyc(wex, withdrawalGroup); + case WithdrawalGroupStatus.PendingReady: + // Continue with the actual withdrawal! + return await processWithdrawalGroupPendingReady(wex, withdrawalGroup); + case WithdrawalGroupStatus.AbortingBank: + return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup); + case WithdrawalGroupStatus.DialogProposed: + return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup); + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.SuspendedAml: + case WithdrawalGroupStatus.SuspendedKyc: + case WithdrawalGroupStatus.SuspendedQueryingStatus: + case WithdrawalGroupStatus.SuspendedReady: + case WithdrawalGroupStatus.SuspendedRegisteringBank: + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedUserRefused: + case WithdrawalGroupStatus.AbortedOtherWallet: + // Nothing to do. + return TaskRunResult.finished(); + default: + assertUnreachable(withdrawalGroup.status); + } +} + +const AGE_MASK_GROUPS = "8:10:12:14:16:18" + .split(":") + .map((n) => parseInt(n, 10)); + +export async function getExchangeWithdrawalInfo( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + instructedAmount: AmountJson, + ageRestricted: number | undefined, +): Promise<ExchangeWithdrawalDetails> { + logger.trace("updating exchange"); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, {}); + + wex.cancellationToken.throwIfCancelled(); + + if (exchange.currency != instructedAmount.currency) { + // Specifying the amount in the conversion input currency is not yet supported. + // We might add support for it later. + throw new Error( + `withdrawal only supported when specifying target currency ${exchange.currency}`, + ); + } + + const withdrawalAccountsList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount, + }, + wex.cancellationToken, + ); + + logger.trace("updating withdrawal denoms"); + await updateWithdrawalDenoms(wex, exchangeBaseUrl); + + wex.cancellationToken.throwIfCancelled(); + + logger.trace("getting candidate denoms"); + const candidateDenoms = await getCandidateWithdrawalDenoms( + wex, + exchangeBaseUrl, + instructedAmount.currency, + ); + + wex.cancellationToken.throwIfCancelled(); + + logger.trace("selecting withdrawal denoms"); + // FIXME: Why not in a transaction? + const selectedDenoms = selectWithdrawalDenominations( + instructedAmount, + candidateDenoms, + wex.ws.config.testing.denomselAllowLate, + ); + + logger.trace("selection done"); + + if (selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify( + instructedAmount, + )}`, + ); + } + + const exchangeWireAccounts: string[] = []; + + for (const account of exchange.wireInfo.accounts) { + exchangeWireAccounts.push(account.payto_uri); + } + + let versionMatch; + if (exchange.protocolVersionRange) { + versionMatch = LibtoolVersion.compare( + WALLET_EXCHANGE_PROTOCOL_VERSION, + exchange.protocolVersionRange, + ); + + if ( + versionMatch && + !versionMatch.compatible && + versionMatch.currentCmp === -1 + ) { + logger.warn( + `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` + + `(exchange has ${exchange.protocolVersionRange}), checking for updates`, + ); + } + } + + let tosAccepted = false; + if (exchange.tosAcceptedTimestamp) { + if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) { + tosAccepted = true; + } + } + + const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri); + if (!paytoUris) { + throw Error("exchange is in invalid state"); + } + + const ret: ExchangeWithdrawalDetails = { + earliestDepositExpiration: selectedDenoms.earliestDepositExpiration, + exchangePaytoUris: paytoUris, + exchangeWireAccounts, + exchangeCreditAccountDetails: withdrawalAccountsList, + exchangeVersion: exchange.protocolVersionRange || "unknown", + selectedDenoms, + versionMatch, + walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, + termsOfServiceAccepted: tosAccepted, + withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue), + withdrawalAmountRaw: Amounts.stringify(instructedAmount), + // TODO: remove hardcoding, this should be calculated from the denominations info + // force enabled for testing + ageRestrictionOptions: selectedDenoms.hasDenomWithAgeRestriction + ? AGE_MASK_GROUPS + : undefined, + scopeInfo: exchange.scopeInfo, + }; + return ret; +} + +export interface GetWithdrawalDetailsForUriOpts { + restrictAge?: number; + notifyChangeFromPendingTimeoutMs?: number; +} + +/** + * Get more information about a taler://withdraw URI. + * + * As side effects, the bank (via the bank integration API) is queried + * and the exchange suggested by the bank is ephemerally added + * to the wallet's list of known exchanges. + */ +export async function getWithdrawalDetailsForUri( + wex: WalletExecutionContext, + talerWithdrawUri: string, +): Promise<WithdrawUriInfoResponse> { + logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); + const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri); + logger.trace(`got bank info`); + if (info.suggestedExchange) { + try { + // If the exchange entry doesn't exist yet, + // it'll be created as an ephemeral entry. + await fetchFreshExchange(wex, info.suggestedExchange); + } catch (e) { + // We still continued if it failed, as other exchanges might be available. + // We don't want to fail if the bank-suggested exchange is broken/offline. + logger.trace( + `querying bank-suggested exchange (${info.suggestedExchange}) failed`, + ); + } + } + + const currency = Amounts.currencyOf(info.amount); + + const listExchangesResp = await listExchanges(wex); + const possibleExchanges = listExchangesResp.exchanges.filter((x) => { + return ( + x.currency === currency && + (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || + x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) + ); + }); + + return { + operationId: info.operationId, + confirmTransferUrl: info.confirmTransferUrl, + status: info.status, + amount: Amounts.stringify(info.amount), + defaultExchangeBaseUrl: info.suggestedExchange, + possibleExchanges, + }; +} + +export function augmentPaytoUrisForWithdrawal( + plainPaytoUris: string[], + reservePub: string, + instructedAmount: AmountLike, +): string[] { + return plainPaytoUris.map((x) => + addPaytoQueryParams(x, { + amount: Amounts.stringify(instructedAmount), + message: `Taler ${reservePub}`, + }), + ); +} + +/** + * Get payto URIs that can be used to fund a withdrawal operation. + */ +export async function getFundingPaytoUris( + tx: WalletDbReadOnlyTransaction< + ["withdrawalGroups", "exchanges", "exchangeDetails"] + >, + withdrawalGroupId: string, +): Promise<string[]> { + const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId); + checkDbInvariant(!!withdrawalGroup); + checkDbInvariant( + withdrawalGroup.instructedAmount !== undefined, + "can't get funding uri from uninitialized wg", + ); + const exchangeDetails = await getExchangeWireDetailsInTx( + tx, + withdrawalGroup.exchangeBaseUrl, + ); + if (!exchangeDetails) { + logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`); + return []; + } + const plainPaytoUris = + exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; + if (!plainPaytoUris) { + logger.error( + `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`, + ); + return []; + } + return augmentPaytoUrisForWithdrawal( + plainPaytoUris, + withdrawalGroup.reservePub, + withdrawalGroup.instructedAmount, + ); +} + +async function getWithdrawalGroupRecordTx( + db: DbAccess<typeof WalletStoresV1>, + req: { + withdrawalGroupId: string; + }, +): Promise<WithdrawalGroupRecord | undefined> { + return await db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return tx.withdrawalGroups.get(req.withdrawalGroupId); + }, + ); +} + +export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration { + return { d_ms: 60000 }; +} + +export function getBankStatusUrl(talerWithdrawUri: string): string { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`); + } + const url = new URL( + `withdrawal-operation/${uriResult.withdrawalOperationId}`, + uriResult.bankIntegrationApiBaseUrl, + ); + return url.href; +} + +export function getBankAbortUrl(talerWithdrawUri: string): string { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`); + } + const url = new URL( + `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`, + uriResult.bankIntegrationApiBaseUrl, + ); + return url.href; +} + +async function registerReserveWithBank( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<void> { + const withdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.get(withdrawalGroupId); + }, + ); + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + switch (withdrawalGroup?.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: + return; + } + if ( + withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated + ) { + throw Error("expecting withdrawal type = bank integrated"); + } + const bankInfo = withdrawalGroup.wgInfo.bankInfo; + if (!bankInfo) { + return; + } + const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri); + const reqBody = { + reserve_pub: withdrawalGroup.reservePub, + selected_exchange: bankInfo.exchangePaytoUri, + }; + logger.info(`registering reserve with bank: ${j2s(reqBody)}`); + const httpResp = await wex.http.fetch(bankStatusUrl, { + method: "POST", + body: reqBody, + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + const status = await readSuccessResponseJsonOrThrow( + httpResp, + codeForBankWithdrawalOperationPostResponse(), + ); + + await ctx.transition({}, async (r) => { + if (!r) { + return TransitionResult.stay(); + } + switch (r.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return TransitionResult.stay(); + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()), + ); + r.status = WithdrawalGroupStatus.PendingWaitConfirmBank; + r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url; + return TransitionResult.transition(r); + }); +} + +async function transitionBankAborted( + ctx: WithdrawTransactionContext, +): Promise<TaskRunResult> { + logger.info("bank aborted the withdrawal"); + await ctx.transition({}, async (r) => { + if (!r) { + return TransitionResult.stay(); + } + switch (r.status) { + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return TransitionResult.stay(); + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); + r.status = WithdrawalGroupStatus.FailedBankAborted; + return TransitionResult.transition(r); + }); + return TaskRunResult.progress(); +} + +async function processBankRegisterReserve( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, { + withdrawalGroupId, + }); + if (!withdrawalGroup) { + return TaskRunResult.finished(); + } + + if ( + withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated + ) { + throw Error("wrong withdrawal record type"); + } + const bankInfo = withdrawalGroup.wgInfo.bankInfo; + if (!bankInfo) { + throw Error("no bank info in bank-integrated withdrawal"); + } + + const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`); + } + const url = new URL( + `withdrawal-operation/${uriResult.withdrawalOperationId}`, + uriResult.bankIntegrationApiBaseUrl, + ); + + const statusResp = await wex.http.fetch(url.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + + const status = await readSuccessResponseJsonOrThrow( + statusResp, + codecForWithdrawOperationStatusResponse(), + ); + + if (status.aborted) { + return transitionBankAborted(ctx); + } + + // FIXME: Put confirm transfer URL in the DB! + + await registerReserveWithBank(wex, withdrawalGroupId); + return TaskRunResult.progress(); +} + +async function processReserveBankStatus( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<TaskRunResult> { + const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, { + withdrawalGroupId, + }); + + if (!withdrawalGroup) { + return TaskRunResult.finished(); + } + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + if ( + withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated + ) { + throw Error("wrong withdrawal record type"); + } + const bankInfo = withdrawalGroup.wgInfo.bankInfo; + if (!bankInfo) { + throw Error("no bank info in bank-integrated withdrawal"); + } + + const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri); + if (!uriResult) { + throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`); + } + const bankStatusUrl = new URL( + `withdrawal-operation/${uriResult.withdrawalOperationId}`, + uriResult.bankIntegrationApiBaseUrl, + ); + bankStatusUrl.searchParams.set("long_poll_ms", "30000"); + + logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`); + const statusResp = await wex.http.fetch(bankStatusUrl.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + cancellationToken: wex.cancellationToken, + }); + logger.info( + `long-polling for withdrawal operation returned status ${statusResp.status}`, + ); + + const status = await readSuccessResponseJsonOrThrow( + statusResp, + codecForWithdrawOperationStatusResponse(), + ); + + if (logger.shouldLogTrace()) { + logger.trace(`response body: ${j2s(status)}`); + } + + if (status.aborted) { + return transitionBankAborted(ctx); + } + + if (!status.transfer_done) { + return TaskRunResult.longpollReturnedPending(); + } + + const transitionInfo = await ctx.transition({}, async (r) => { + if (!r) { + return TransitionResult.stay(); + } + // Re-check reserve status within transaction + switch (r.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return TransitionResult.stay(); + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + if (status.transfer_done) { + logger.info("withdrawal: transfer confirmed by bank."); + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); + r.status = WithdrawalGroupStatus.PendingQueryingStatus; + return TransitionResult.transition(r); + } else { + return TransitionResult.stay(); + } + }); + + if (transitionInfo) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } +} + +export interface PrepareCreateWithdrawalGroupResult { + withdrawalGroup: WithdrawalGroupRecord; + transactionId: string; + creationInfo?: { + amount: AmountJson; + canonExchange: string; + }; +} + +async function getInitialDenomsSelection( + wex: WalletExecutionContext, + exchange: string, + amount: AmountJson, + forcedDenoms: ForcedDenomSel | undefined, +): Promise<DenomSelectionState> { + const currency = Amounts.currencyOf(amount); + await updateWithdrawalDenoms(wex, exchange); + const denoms = await getCandidateWithdrawalDenoms(wex, exchange, currency); + + if (forcedDenoms) { + logger.warn("using forced denom selection"); + const initialDenomSel = selectForcedWithdrawalDenominations( + amount, + denoms, + forcedDenoms, + wex.ws.config.testing.denomselAllowLate, + ); + return initialDenomSel; + } else { + const initialDenomSel = selectWithdrawalDenominations( + amount, + denoms, + wex.ws.config.testing.denomselAllowLate, + ); + return initialDenomSel; + } +} + +export async function internalPrepareCreateWithdrawalGroup( + wex: WalletExecutionContext, + args: { + reserveStatus: WithdrawalGroupStatus; + amount?: AmountJson; + exchangeBaseUrl: string; + forcedWithdrawalGroupId?: string; + forcedDenomSel?: ForcedDenomSel; + reserveKeyPair?: EddsaKeypair; + restrictAge?: number; + wgInfo: WgInfo; + }, +): Promise<PrepareCreateWithdrawalGroupResult> { + const reserveKeyPair = + args.reserveKeyPair ?? (await wex.cryptoApi.createEddsaKeypair({})); + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + const secretSeed = encodeCrock(getRandomBytes(32)); + const exchangeBaseUrl = args.exchangeBaseUrl; + const amount = args.amount; + + let withdrawalGroupId: string; + + if (args.forcedWithdrawalGroupId) { + withdrawalGroupId = args.forcedWithdrawalGroupId; + const wgId = withdrawalGroupId; + const existingWg = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return tx.withdrawalGroups.get(wgId); + }, + ); + + if (existingWg) { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: existingWg.withdrawalGroupId, + }); + return { withdrawalGroup: existingWg, transactionId }; + } + } else { + withdrawalGroupId = encodeCrock(getRandomBytes(32)); + } + + let initialDenomSel: DenomSelectionState | undefined; + const denomSelUid = encodeCrock(getRandomBytes(16)); + + if (amount !== undefined) { + initialDenomSel = await getInitialDenomsSelection( + wex, + exchangeBaseUrl, + amount, + args.forcedDenomSel, + ); + } + + const withdrawalGroup: WithdrawalGroupRecord = { + denomSelUid, + // next fields will be undefined if exchange or amount is not specified + denomsSel: initialDenomSel, + exchangeBaseUrl: exchangeBaseUrl, + instructedAmount: + amount === undefined ? undefined : Amounts.stringify(amount), + rawWithdrawalAmount: initialDenomSel?.totalWithdrawCost, + effectiveWithdrawalAmount: initialDenomSel?.totalCoinValue, + // end of optional fields + timestampStart: timestampPreciseToDb(now), + secretSeed, + reservePriv: reserveKeyPair.priv, + reservePub: reserveKeyPair.pub, + status: args.reserveStatus, + withdrawalGroupId, + restrictAge: args.restrictAge, + senderWire: undefined, + timestampFinish: undefined, + wgInfo: args.wgInfo, + }; + + await fetchFreshExchange(wex, exchangeBaseUrl); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + }); + + return { + withdrawalGroup, + transactionId, + creationInfo: !amount + ? undefined + : { + amount, + canonExchange: exchangeBaseUrl, + }, + }; +} + +export interface PerformCreateWithdrawalGroupResult { + withdrawalGroup: WithdrawalGroupRecord; + transitionInfo: TransitionInfo | undefined; + + /** + * Notification for the exchange state transition. + * + * Should be emitted after the transaction has succeeded. + */ + exchangeNotif: WalletNotification | undefined; +} + +export async function internalPerformCreateWithdrawalGroup( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction< + ["withdrawalGroups", "reserves", "exchanges"] + >, + prep: PrepareCreateWithdrawalGroupResult, +): Promise<PerformCreateWithdrawalGroupResult> { + const { withdrawalGroup } = prep; + 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, + }); + + if (!prep.creationInfo) { + return { + withdrawalGroup, + transitionInfo: undefined, + exchangeNotif: undefined, + }; + } + const exchange = await tx.exchanges.get(prep.creationInfo.canonExchange); + if (exchange) { + exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now()); + await tx.exchanges.put(exchange); + } + + const oldTxState = { + major: TransactionMajorState.None, + minor: undefined, + }; + const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup); + const transitionInfo = { + oldTxState, + newTxState, + }; + + const exchangeUsedRes = await markExchangeUsed( + wex, + tx, + prep.creationInfo.canonExchange, + ); + + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + withdrawalGroup, + transitionInfo, + exchangeNotif: exchangeUsedRes.notif, + }; +} + +/** + * Create a withdrawal group. + * + * If a forcedWithdrawalGroupId is given and a + * withdrawal group with this ID already exists, + * the existing one is returned. No conflict checking + * of the other arguments is done in that case. + */ +export async function internalCreateWithdrawalGroup( + wex: WalletExecutionContext, + args: { + reserveStatus: WithdrawalGroupStatus; + exchangeBaseUrl: string; + amount?: AmountJson; + forcedWithdrawalGroupId?: string; + forcedDenomSel?: ForcedDenomSel; + reserveKeyPair?: EddsaKeypair; + restrictAge?: number; + wgInfo: WgInfo; + }, +): Promise<WithdrawalGroupRecord> { + const prep = await internalPrepareCreateWithdrawalGroup(wex, args); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId, + }); + const ctx = new WithdrawTransactionContext( + wex, + prep.withdrawalGroup.withdrawalGroupId, + ); + const res = await wex.db.runReadWriteTx( + { + storeNames: [ + "withdrawalGroups", + "reserves", + "exchanges", + "exchangeDetails", + "transactions", + "operationRetries", + ], + }, + async (tx) => { + const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep); + await updateWithdrawalTransaction(ctx, tx); + return res; + }, + ); + if (res.exchangeNotif) { + wex.ws.notify(res.exchangeNotif); + } + notifyTransition(wex, transactionId, res.transitionInfo); + return res.withdrawalGroup; +} + +export async function prepareBankIntegratedWithdrawal( + wex: WalletExecutionContext, + req: { + talerWithdrawUri: string; + selectedExchange?: string; + }, +): Promise<PrepareBankIntegratedWithdrawalResponse> { + const existingWithdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + req.talerWithdrawUri, + ); + }, + ); + + if (existingWithdrawalGroup) { + const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri); + return { + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId, + }), + info, + }; + } + const withdrawInfo = await getBankWithdrawalInfo( + wex.http, + req.talerWithdrawUri, + ); + + const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri); + + const exchangeBaseUrl = + req.selectedExchange ?? withdrawInfo.suggestedExchange; + if (!exchangeBaseUrl) { + return { info }; + } + + /** + * Withdrawal group without exchange and amount + * this is an special case when the user haven't yet + * choose. We are still tracking this object since the state + * can change from the bank side or another wallet with the + * same URI + */ + const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { + exchangeBaseUrl, + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { + talerWithdrawUri: req.talerWithdrawUri, + confirmUrl: withdrawInfo.confirmTransferUrl, + timestampBankConfirmed: undefined, + timestampReserveInfoPosted: undefined, + wireTypes: withdrawInfo.wireTypes, + }, + }, + reserveStatus: WithdrawalGroupStatus.DialogProposed, + }); + + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + transactionId: ctx.transactionId, + info, + }; +} + +export async function confirmWithdrawal( + wex: WalletExecutionContext, + req: ConfirmWithdrawalRequest, +): Promise<void> { + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (parsedTx?.tag !== TransactionType.Withdrawal) { + throw Error("invalid withdrawal transaction ID"); + } + const withdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.get(parsedTx.withdrawalGroupId); + }, + ); + + if (!withdrawalGroup) { + throw Error("withdrawal group not found"); + } + + if ( + withdrawalGroup.wgInfo.withdrawalType !== + WithdrawalRecordType.BankIntegrated + ) { + throw Error("not a bank integrated withdrawal"); + } + + const selectedExchange = req.exchangeBaseUrl; + const exchange = await fetchFreshExchange(wex, selectedExchange); + + const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; + const confirmUrl = withdrawalGroup.wgInfo.bankInfo.confirmUrl; + + /** + * The only reasong this to be undefined is because it is an old wallet + * database before adding the wireType field was added + */ + let wtypes: string[]; + if (withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined) { + const withdrawInfo = await getBankWithdrawalInfo( + wex.http, + talerWithdrawUri, + ); + wtypes = withdrawInfo.wireTypes; + } else { + wtypes = withdrawalGroup.wgInfo.bankInfo.wireTypes; + } + + const exchangePaytoUri = await getExchangePaytoUri( + wex, + selectedExchange, + wtypes, + ); + + const withdrawalAccountList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount: Amounts.parseOrThrow(req.amount), + }, + wex.cancellationToken, + ); + + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + const initalDenoms = await getInitialDenomsSelection( + wex, + req.exchangeBaseUrl, + Amounts.parseOrThrow(req.amount), + req.forcedDenomSel, + ); + + ctx.transition({}, async (rec) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.status) { + case WithdrawalGroupStatus.DialogProposed: { + rec.exchangeBaseUrl = req.exchangeBaseUrl; + rec.instructedAmount = req.amount; + rec.denomsSel = initalDenoms; + rec.rawWithdrawalAmount = initalDenoms.totalWithdrawCost; + rec.effectiveWithdrawalAmount = initalDenoms.totalCoinValue; + rec.restrictAge = req.restrictAge; + + rec.wgInfo = { + withdrawalType: WithdrawalRecordType.BankIntegrated, + exchangeCreditAccounts: withdrawalAccountList, + bankInfo: { + exchangePaytoUri, + talerWithdrawUri, + confirmUrl: confirmUrl, + timestampBankConfirmed: undefined, + timestampReserveInfoPosted: undefined, + wireTypes: wtypes, + }, + }; + + rec.status = WithdrawalGroupStatus.PendingRegisteringBank; + return TransitionResult.transition(rec); + } + default: + throw Error("unable to confirm withdrawal in current state"); + } + }); + + await wex.taskScheduler.resetTaskRetries(ctx.taskId); + wex.taskScheduler.startShepherdTask(ctx.taskId); +} + +/** + * Accept a bank-integrated withdrawal. + * + * Before returning, the wallet tries to register the reserve with the bank. + * + * Thus after this call returns, the withdrawal operation can be confirmed + * with the bank. + * + * @deprecated in favor of prepare/accept + */ +export async function acceptWithdrawalFromUri( + wex: WalletExecutionContext, + req: { + talerWithdrawUri: string; + selectedExchange: string; + forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; + }, +): Promise<AcceptWithdrawalResponse> { + const selectedExchange = req.selectedExchange; + logger.info( + `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, + ); + const existingWithdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + req.talerWithdrawUri, + ); + }, + ); + + if (existingWithdrawalGroup) { + let url: string | undefined; + if ( + existingWithdrawalGroup.wgInfo.withdrawalType === + WithdrawalRecordType.BankIntegrated + ) { + url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl; + } + return { + reservePub: existingWithdrawalGroup.reservePub, + confirmTransferUrl: url, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId, + }), + }; + } + + const exchange = await fetchFreshExchange(wex, selectedExchange); + const withdrawInfo = await getBankWithdrawalInfo( + wex.http, + req.talerWithdrawUri, + ); + const exchangePaytoUri = await getExchangePaytoUri( + wex, + selectedExchange, + withdrawInfo.wireTypes, + ); + + const withdrawalAccountList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount: withdrawInfo.amount, + }, + CancellationToken.CONTINUE, + ); + + const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { + amount: withdrawInfo.amount, + exchangeBaseUrl: req.selectedExchange, + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + exchangeCreditAccounts: withdrawalAccountList, + bankInfo: { + exchangePaytoUri, + talerWithdrawUri: req.talerWithdrawUri, + confirmUrl: withdrawInfo.confirmTransferUrl, + timestampBankConfirmed: undefined, + timestampReserveInfoPosted: undefined, + wireTypes: withdrawInfo.wireTypes, + }, + }, + restrictAge: req.restrictAge, + forcedDenomSel: req.forcedDenomSel, + reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank, + }); + + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + await waitWithdrawalRegistered(wex, ctx); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + reservePub: withdrawalGroup.reservePub, + confirmTransferUrl: withdrawInfo.confirmTransferUrl, + transactionId: ctx.transactionId, + }; +} + +async function internalWaitWithdrawalRegistered( + wex: WalletExecutionContext, + ctx: WithdrawTransactionContext, + withdrawalNotifFlag: AsyncFlag, +): Promise<void> { + while (true) { + const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "operationRetries"] }, + async (tx) => { + return { + withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), + retryRec: await tx.operationRetries.get(ctx.taskId), + }; + }, + ); + + if (!withdrawalRec) { + throw Error("withdrawal not found anymore"); + } + + switch (withdrawalRec.status) { + case WithdrawalGroupStatus.FailedBankAborted: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return; + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: { + if (retryRec) { + if (retryRec.lastError) { + throw TalerError.fromUncheckedDetail(retryRec.lastError); + } else { + throw Error("withdrawal unexpectedly pending"); + } + } + } + } + + await withdrawalNotifFlag.wait(); + withdrawalNotifFlag.reset(); + } +} + +async function waitWithdrawalRegistered( + wex: WalletExecutionContext, + ctx: WithdrawTransactionContext, +): Promise<void> { + // FIXME: Doesn't support cancellation yet + // FIXME: We should use Symbol.dispose magic here for cleanup! + + const withdrawalNotifFlag = new AsyncFlag(); + // Raise exchangeNotifFlag whenever we get a notification + // about our exchange. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ) { + logger.info(`raising update notification: ${j2s(notif)}`); + withdrawalNotifFlag.raise(); + } + }); + + try { + const res = await internalWaitWithdrawalRegistered( + wex, + ctx, + withdrawalNotifFlag, + ); + logger.info("done waiting for ready exchange"); + return res; + } finally { + cancelNotif(); + } +} + +async function fetchAccount( + wex: WalletExecutionContext, + instructedAmount: AmountJson, + acct: ExchangeWireAccount, + reservePub: string | undefined, + cancellationToken: CancellationToken, +): Promise<WithdrawalExchangeAccountDetails> { + let paytoUri: string; + let transferAmount: AmountString | undefined = undefined; + let currencySpecification: CurrencySpecification | undefined = undefined; + if (acct.conversion_url != null) { + const reqUrl = new URL("cashin-rate", acct.conversion_url); + reqUrl.searchParams.set( + "amount_credit", + Amounts.stringify(instructedAmount), + ); + const httpResp = await wex.http.fetch(reqUrl.href, { + cancellationToken, + }); + const respOrErr = await readSuccessResponseJsonOrErrorCode( + httpResp, + codecForCashinConversionResponse(), + ); + if (respOrErr.isError) { + return { + status: "error", + paytoUri: acct.payto_uri, + conversionError: respOrErr.talerErrorResponse, + }; + } + const resp = respOrErr.response; + paytoUri = acct.payto_uri; + transferAmount = resp.amount_debit; + const configUrl = new URL("config", acct.conversion_url); + const configResp = await wex.http.fetch(configUrl.href, { + cancellationToken, + }); + const configRespOrError = await readSuccessResponseJsonOrErrorCode( + configResp, + codecForConversionBankConfig(), + ); + if (configRespOrError.isError) { + return { + status: "error", + paytoUri: acct.payto_uri, + conversionError: configRespOrError.talerErrorResponse, + }; + } + const configParsed = configRespOrError.response; + currencySpecification = configParsed.fiat_currency_specification; + } else { + paytoUri = acct.payto_uri; + transferAmount = Amounts.stringify(instructedAmount); + } + paytoUri = addPaytoQueryParams(paytoUri, { + amount: Amounts.stringify(transferAmount), + }); + if (reservePub != null) { + paytoUri = addPaytoQueryParams(paytoUri, { + message: `Taler ${reservePub}`, + }); + } + const acctInfo: WithdrawalExchangeAccountDetails = { + status: "ok", + paytoUri, + transferAmount, + bankLabel: acct.bank_label, + priority: acct.priority, + currencySpecification, + creditRestrictions: acct.credit_restrictions, + }; + if (transferAmount != null) { + acctInfo.transferAmount = transferAmount; + } + return acctInfo; +} + +/** + * Gather information about bank accounts that can be used for + * withdrawals. This includes accounts that are in a different + * currency and require conversion. + */ +async function fetchWithdrawalAccountInfo( + wex: WalletExecutionContext, + req: { + exchange: ReadyExchangeSummary; + instructedAmount: AmountJson; + reservePub?: string; + }, + cancellationToken: CancellationToken, +): Promise<WithdrawalExchangeAccountDetails[]> { + const { exchange } = req; + const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = []; + for (let acct of exchange.wireInfo.accounts) { + const acctInfo = await fetchAccount( + wex, + req.instructedAmount, + acct, + req.reservePub, + cancellationToken, + ); + withdrawalAccounts.push(acctInfo); + } + withdrawalAccounts.sort((x1, x2) => { + // Accounts without explicit priority have prio 0. + const n1 = x1.priority ?? 0; + const n2 = x2.priority ?? 0; + return Math.sign(n2 - n1); + }); + return withdrawalAccounts; +} + +/** + * Create a manual withdrawal operation. + * + * Adds the corresponding exchange as a trusted exchange if it is neither + * audited nor trusted already. + * + * Asynchronously starts the withdrawal. + */ +export async function createManualWithdrawal( + wex: WalletExecutionContext, + req: { + exchangeBaseUrl: string; + amount: AmountLike; + restrictAge?: number; + forcedDenomSel?: ForcedDenomSel; + forceReservePriv?: EddsaPrivateKeyString; + }, +): Promise<AcceptManualWithdrawalResult> { + const { exchangeBaseUrl } = req; + const amount = Amounts.parseOrThrow(req.amount); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + + if (exchange.currency != amount.currency) { + throw Error( + "manual withdrawal with conversion from foreign currency is not yet supported", + ); + } + + let reserveKeyPair: EddsaKeypair; + if (req.forceReservePriv) { + const pubResp = await wex.cryptoApi.eddsaGetPublic({ + priv: req.forceReservePriv, + }); + + reserveKeyPair = { + priv: req.forceReservePriv, + pub: pubResp.pub, + }; + } else { + reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({}); + } + + const withdrawalAccountsList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount: amount, + reservePub: reserveKeyPair.pub, + }, + CancellationToken.CONTINUE, + ); + + const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { + amount: Amounts.jsonifyAmount(req.amount), + wgInfo: { + withdrawalType: WithdrawalRecordType.BankManual, + exchangeCreditAccounts: withdrawalAccountsList, + }, + exchangeBaseUrl: req.exchangeBaseUrl, + forcedDenomSel: req.forcedDenomSel, + restrictAge: req.restrictAge, + reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus, + reserveKeyPair, + }); + + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + + const exchangePaytoUris = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] }, + async (tx) => { + return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId); + }, + ); + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + reservePub: withdrawalGroup.reservePub, + exchangePaytoUris: exchangePaytoUris, + withdrawalAccountsList: withdrawalAccountsList, + transactionId: ctx.transactionId, + }; +} + +/** + * Wait until a refresh operation is final. + */ +export async function waitWithdrawalFinal( + wex: WalletExecutionContext, + withdrawalGroupId: string, +): Promise<void> { + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + wex.taskScheduler.startShepherdTask(ctx.taskId); + + // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. + const withdrawalNotifFlag = new AsyncFlag(); + // Raise purchaseNotifFlag whenever we get a notification + // about our refresh. + const cancelNotif = wex.ws.addNotificationListener((notif) => { + if ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ) { + withdrawalNotifFlag.raise(); + } + }); + const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { + cancelNotif(); + withdrawalNotifFlag.raise(); + }); + + try { + await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag); + } catch (e) { + unregisterOnCancelled(); + cancelNotif(); + } +} + +async function internalWaitWithdrawalFinal( + ctx: WithdrawTransactionContext, + flag: AsyncFlag, +): Promise<void> { + while (true) { + if (ctx.wex.cancellationToken.isCancelled) { + throw Error("cancelled"); + } + + // Check if refresh is final + const res = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "operationRetries"] }, + async (tx) => { + return { + wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), + }; + }, + ); + const { wg } = res; + if (!wg) { + // Must've been deleted, we consider that final. + return; + } + switch (wg.status) { + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.FailedBankAborted: + // Transaction is final + return; + } + + // Wait for the next transition + await flag.wait(); + flag.reset(); + } +} + +export async function getWithdrawalDetailsForAmount( + wex: WalletExecutionContext, + cts: CancellationToken.Source, + req: GetWithdrawalDetailsForAmountRequest, +): Promise<WithdrawalDetailsForAmount> { + const clientCancelKey = req.clientCancellationId + ? `ccid:getWithdrawalDetailsForAmount:${req.clientCancellationId}` + : undefined; + if (clientCancelKey) { + const prevCts = wex.ws.clientCancellationMap.get(clientCancelKey); + if (prevCts) { + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `Cancelling previous key ${clientCancelKey}`, + }); + prevCts.cancel(); + } else { + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `No previous key ${clientCancelKey}`, + }); + } + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `Setting clientCancelKey ${clientCancelKey} to ${cts}`, + }); + wex.ws.clientCancellationMap.set(clientCancelKey, cts); + } + try { + return await internalGetWithdrawalDetailsForAmount(wex, req); + } finally { + wex.oc.observe({ + type: ObservabilityEventType.Message, + contents: `Deleting clientCancelKey ${clientCancelKey} to ${cts}`, + }); + if (clientCancelKey && !cts.token.isCancelled) { + wex.ws.clientCancellationMap.delete(clientCancelKey); + } + } +} + +async function internalGetWithdrawalDetailsForAmount( + wex: WalletExecutionContext, + req: GetWithdrawalDetailsForAmountRequest, +): Promise<WithdrawalDetailsForAmount> { + const wi = await getExchangeWithdrawalInfo( + wex, + req.exchangeBaseUrl, + Amounts.parseOrThrow(req.amount), + req.restrictAge, + ); + let numCoins = 0; + for (const x of wi.selectedDenoms.selectedDenoms) { + numCoins += x.count; + } + const resp: WithdrawalDetailsForAmount = { + amountRaw: req.amount, + amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), + paytoUris: wi.exchangePaytoUris, + tosAccepted: wi.termsOfServiceAccepted, + ageRestrictionOptions: wi.ageRestrictionOptions, + withdrawalAccountsList: wi.exchangeCreditAccountDetails, + numCoins, + scopeInfo: wi.scopeInfo, + }; + return resp; +} diff --git a/packages/taler-wallet-core/tsconfig.json b/packages/taler-wallet-core/tsconfig.json index 663a4dd98..7a1a0fcce 100644 --- a/packages/taler-wallet-core/tsconfig.json +++ b/packages/taler-wallet-core/tsconfig.json @@ -15,7 +15,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "strict": true, - "strictPropertyInitialization": false, + "strictPropertyInitialization": true, "outDir": "lib", "noImplicitAny": true, "noImplicitThis": true, @@ -33,5 +33,5 @@ "path": "../taler-util/" } ], - "include": ["src/**/*", "src/*.json", "../taler-util/src/bank-api-client.ts"] + "include": ["src/**/*", "src/*.json"] } |