summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2024-02-19 18:05:48 +0100
committerFlorian Dold <florian@dold.me>2024-02-19 18:05:48 +0100
commite951075d2ef52fa8e9e7489c62031777c3a7e66b (patch)
tree64208c09a9162f3a99adccf30edc36de1ef884ef /packages/taler-wallet-core/src/operations
parente975740ac4e9ba4bc531226784d640a018c00833 (diff)
downloadwallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.gz
wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.tar.bz2
wallet-core-e951075d2ef52fa8e9e7489c62031777c3a7e66b.zip
wallet-core: flatten directory structure
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
-rw-r--r--packages/taler-wallet-core/src/operations/attention.ts133
-rw-r--r--packages/taler-wallet-core/src/operations/backup/index.ts1059
-rw-r--r--packages/taler-wallet-core/src/operations/backup/state.ts15
-rw-r--r--packages/taler-wallet-core/src/operations/balance.ts730
-rw-r--r--packages/taler-wallet-core/src/operations/common.ts693
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts1598
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.ts2007
-rw-r--r--packages/taler-wallet-core/src/operations/merchants.ts66
-rw-r--r--packages/taler-wallet-core/src/operations/pay-merchant.ts3232
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-common.ts172
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts1204
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts883
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts1037
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts1150
-rw-r--r--packages/taler-wallet-core/src/operations/recoup.ts535
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts1430
-rw-r--r--packages/taler-wallet-core/src/operations/reward.ts321
-rw-r--r--packages/taler-wallet-core/src/operations/testing.ts913
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.ts2007
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.test.ts370
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts2754
21 files changed, 0 insertions, 22309 deletions
diff --git a/packages/taler-wallet-core/src/operations/attention.ts b/packages/taler-wallet-core/src/operations/attention.ts
deleted file mode 100644
index 7d8b11e79..000000000
--- a/packages/taler-wallet-core/src/operations/attention.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AttentionInfo,
- Logger,
- TalerPreciseTimestamp,
- UserAttentionByIdRequest,
- UserAttentionPriority,
- UserAttentionUnreadList,
- UserAttentionsCountResponse,
- UserAttentionsRequest,
- UserAttentionsResponse,
-} from "@gnu-taler/taler-util";
-import { timestampPreciseFromDb, timestampPreciseToDb } from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-
-const logger = new Logger("operations/attention.ts");
-
-export async function getUserAttentionsUnreadCount(
- ws: InternalWalletState,
- req: UserAttentionsRequest,
-): Promise<UserAttentionsCountResponse> {
- const total = await ws.db.runReadOnlyTx(["userAttention"], async (tx) => {
- let count = 0;
- await tx.userAttention.iter().forEach((x) => {
- if (
- req.priority !== undefined &&
- UserAttentionPriority[x.info.type] !== req.priority
- )
- return;
- if (x.read !== undefined) return;
- count++;
- });
-
- return count;
- });
-
- return { total };
-}
-
-export async function getUserAttentions(
- ws: InternalWalletState,
- req: UserAttentionsRequest,
-): Promise<UserAttentionsResponse> {
- return await ws.db.runReadOnlyTx(["userAttention"], async (tx) => {
- const pending: UserAttentionUnreadList = [];
- await tx.userAttention.iter().forEach((x) => {
- if (
- req.priority !== undefined &&
- UserAttentionPriority[x.info.type] !== req.priority
- )
- return;
- pending.push({
- info: x.info,
- when: timestampPreciseFromDb(x.created),
- read: x.read !== undefined,
- });
- });
-
- return { pending };
- });
-}
-
-export async function markAttentionRequestAsRead(
- ws: InternalWalletState,
- req: UserAttentionByIdRequest,
-): Promise<void> {
- await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
- const ua = await tx.userAttention.get([req.entityId, req.type]);
- if (!ua) throw Error("attention request not found");
- tx.userAttention.put({
- ...ua,
- read: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- });
- });
-}
-
-/**
- * the wallet need the user attention to complete a task
- * internal API
- *
- * @param ws
- * @param info
- */
-export async function addAttentionRequest(
- ws: InternalWalletState,
- info: AttentionInfo,
- entityId: string,
-): Promise<void> {
- await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
- await tx.userAttention.put({
- info,
- entityId,
- created: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- read: undefined,
- });
- });
-}
-
-/**
- * user completed the task, attention request is not needed
- * internal API
- *
- * @param ws
- * @param created
- */
-export async function removeAttentionRequest(
- ws: InternalWalletState,
- req: UserAttentionByIdRequest,
-): Promise<void> {
- await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
- const ua = await tx.userAttention.get([req.entityId, req.type]);
- if (!ua) throw Error("attention request not found");
- await tx.userAttention.delete([req.entityId, req.type]);
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts
deleted file mode 100644
index 948b8eb85..000000000
--- a/packages/taler-wallet-core/src/operations/backup/index.ts
+++ /dev/null
@@ -1,1059 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of wallet backups (export/import/upload) and sync
- * server management.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AmountString,
- AttentionType,
- BackupRecovery,
- Codec,
- EddsaKeyPair,
- HttpStatusCode,
- Logger,
- PreparePayResult,
- RecoveryLoadRequest,
- RecoveryMergeStrategy,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- URL,
- buildCodecForObject,
- buildCodecForUnion,
- bytesToString,
- canonicalJson,
- canonicalizeBaseUrl,
- codecForAmountString,
- codecForBoolean,
- codecForConstString,
- codecForList,
- codecForNumber,
- codecForString,
- codecOptional,
- decodeCrock,
- durationFromSpec,
- eddsaGetPublic,
- encodeCrock,
- getRandomBytes,
- hash,
- j2s,
- kdf,
- notEmpty,
- secretbox,
- secretbox_open,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import {
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "@gnu-taler/taler-util/http";
-import { gunzipSync, gzipSync } from "fflate";
-import {
- BackupProviderRecord,
- BackupProviderState,
- BackupProviderStateTag,
- BackupProviderTerms,
- ConfigRecord,
- ConfigRecordKey,
- WalletBackupConfState,
- WalletDbReadOnlyTransaction,
- timestampOptionalPreciseFromDb,
- timestampPreciseToDb,
-} from "../../db.js";
-import { InternalWalletState } from "../../internal-wallet-state.js";
-import {
- checkDbInvariant,
- checkLogicInvariant,
-} from "../../util/invariants.js";
-import { addAttentionRequest, removeAttentionRequest } from "../attention.js";
-import {
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
-} from "../common.js";
-import { preparePayForUri } from "../pay-merchant.js";
-
-const logger = new Logger("operations/backup.ts");
-
-function concatArrays(xs: Uint8Array[]): Uint8Array {
- let len = 0;
- for (const x of xs) {
- len += x.byteLength;
- }
- const out = new Uint8Array(len);
- let offset = 0;
- for (const x of xs) {
- out.set(x, offset);
- offset += x.length;
- }
- return out;
-}
-
-const magic = "TLRWBK01";
-
-/**
- * Encrypt the backup.
- *
- * Blob format:
- * Magic "TLRWBK01" (8 bytes)
- * Nonce (24 bytes)
- * Compressed JSON blob (rest)
- */
-export async function encryptBackup(
- config: WalletBackupConfState,
- blob: any,
-): Promise<Uint8Array> {
- const chunks: Uint8Array[] = [];
- chunks.push(stringToBytes(magic));
- const nonceStr = config.lastBackupNonce;
- checkLogicInvariant(!!nonceStr);
- const nonce = decodeCrock(nonceStr).slice(0, 24);
- chunks.push(nonce);
- const backupJsonContent = canonicalJson(blob);
- logger.trace("backup JSON size", backupJsonContent.length);
- const compressedContent = gzipSync(stringToBytes(backupJsonContent), {
- mtime: 0,
- });
- const secret = deriveBlobSecret(config);
- const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
- chunks.push(encrypted);
- return concatArrays(chunks);
-}
-
-function deriveAccountKeyPair(
- bc: WalletBackupConfState,
- providerUrl: string,
-): EddsaKeyPair {
- const privateKey = kdf(
- 32,
- decodeCrock(bc.walletRootPriv),
- stringToBytes("taler-sync-account-key-salt"),
- stringToBytes(providerUrl),
- );
- return {
- eddsaPriv: privateKey,
- eddsaPub: eddsaGetPublic(privateKey),
- };
-}
-
-function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
- return kdf(
- 32,
- decodeCrock(bc.walletRootPriv),
- stringToBytes("taler-sync-blob-secret-salt"),
- stringToBytes("taler-sync-blob-secret-info"),
- );
-}
-
-interface BackupForProviderArgs {
- backupProviderBaseUrl: string;
-}
-
-function getNextBackupTimestamp(): TalerPreciseTimestamp {
- // FIXME: Randomize!
- return AbsoluteTime.toPreciseTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- durationFromSpec({ minutes: 5 }),
- ),
- );
-}
-
-async function runBackupCycleForProvider(
- ws: InternalWalletState,
- args: BackupForProviderArgs,
-): Promise<TaskRunResult> {
- const provider = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- return tx.backupProviders.get(args.backupProviderBaseUrl);
- },
- );
-
- if (!provider) {
- logger.warn("provider disappeared");
- return TaskRunResult.finished();
- }
-
- //const backupJson = await exportBackup(ws);
- // FIXME: re-implement backup
- const backupJson = {};
- const backupConfig = await provideBackupState(ws);
- const encBackup = await encryptBackup(backupConfig, backupJson);
- const currentBackupHash = hash(encBackup);
-
- const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
-
- const newHash = encodeCrock(currentBackupHash);
- const oldHash = provider.lastBackupHash;
-
- logger.trace(`trying to upload backup to ${provider.baseUrl}`);
- logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
-
- const syncSigResp = await ws.cryptoApi.makeSyncSignature({
- newHash: encodeCrock(currentBackupHash),
- oldHash: provider.lastBackupHash,
- accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
- });
-
- logger.trace(`sync signature is ${syncSigResp}`);
-
- const accountBackupUrl = new URL(
- `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
- provider.baseUrl,
- );
-
- if (provider.shouldRetryFreshProposal) {
- accountBackupUrl.searchParams.set("fresh", "yes");
- }
-
- const resp = await ws.http.fetch(accountBackupUrl.href, {
- method: "POST",
- body: encBackup,
- headers: {
- "content-type": "application/octet-stream",
- "sync-signature": syncSigResp.sig,
- "if-none-match": newHash,
- ...(provider.lastBackupHash
- ? {
- "if-match": provider.lastBackupHash,
- }
- : {}),
- },
- });
-
- logger.trace(`sync response status: ${resp.status}`);
-
- if (resp.status === HttpStatusCode.NotModified) {
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupCycleTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
- };
- await tx.backupProviders.put(prov);
- });
-
- removeAttentionRequest(ws, {
- entityId: provider.baseUrl,
- type: AttentionType.BackupUnpaid,
- });
-
- return TaskRunResult.finished();
- }
-
- if (resp.status === HttpStatusCode.PaymentRequired) {
- logger.trace("payment required for backup");
- logger.trace(`headers: ${j2s(resp.headers)}`);
- const talerUri = resp.headers.get("taler");
- if (!talerUri) {
- throw Error("no taler URI available to pay provider");
- }
-
- //We can't delay downloading the proposal since we need the id
- //FIXME: check download errors
- let res: PreparePayResult | undefined = undefined;
- try {
- res = await preparePayForUri(ws, talerUri);
- } catch (e) {
- const error = TalerError.fromException(e);
- if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) {
- throw error;
- }
- }
-
- if (res === undefined) {
- //claimed
-
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- prov.shouldRetryFreshProposal = true;
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- };
- await tx.backupProviders.put(prov);
- });
-
- throw Error("not implemented");
- // return {
- // type: TaskRunResultType.Pending,
- // };
- }
- const result = res;
-
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- // const opId = TaskIdentifiers.forBackup(prov);
- // await scheduleRetryInTx(ws, tx, opId);
- prov.currentPaymentProposalId = result.proposalId;
- prov.shouldRetryFreshProposal = false;
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- };
- await tx.backupProviders.put(prov);
- });
-
- addAttentionRequest(
- ws,
- {
- type: AttentionType.BackupUnpaid,
- provider_base_url: provider.baseUrl,
- talerUri,
- },
- provider.baseUrl,
- );
-
- throw Error("not implemented");
- // return {
- // type: TaskRunResultType.Pending,
- // };
- }
-
- if (resp.status === HttpStatusCode.NoContent) {
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupHash = encodeCrock(currentBackupHash);
- prov.lastBackupCycleTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
- };
- await tx.backupProviders.put(prov);
- });
-
- removeAttentionRequest(ws, {
- entityId: provider.baseUrl,
- type: AttentionType.BackupUnpaid,
- });
-
- return {
- type: TaskRunResultType.Finished,
- };
- }
-
- if (resp.status === HttpStatusCode.Conflict) {
- logger.info("conflicting backup found");
- const backupEnc = new Uint8Array(await resp.bytes());
- const backupConfig = await provideBackupState(ws);
- // const blob = await decryptBackup(backupConfig, backupEnc);
- // FIXME: Re-implement backup import with merging
- // await importBackup(ws, blob, cryptoData);
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- prov.lastBackupHash = encodeCrock(hash(backupEnc));
- // FIXME: Allocate error code for this situation?
- // FIXME: Add operation retry record!
- const opId = TaskIdentifiers.forBackup(prov);
- //await scheduleRetryInTx(ws, tx, opId);
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- };
- await tx.backupProviders.put(prov);
- });
- logger.info("processed existing backup");
- // Now upload our own, merged backup.
- return await runBackupCycleForProvider(ws, args);
- }
-
- // Some other response that we did not expect!
-
- logger.error("parsing error response");
-
- const err = await readTalerErrorResponse(resp);
- logger.error(`got error response from backup provider: ${j2s(err)}`);
- return {
- type: TaskRunResultType.Error,
- errorDetail: err,
- };
-}
-
-export async function processBackupForProvider(
- ws: InternalWalletState,
- backupProviderBaseUrl: string,
-): Promise<TaskRunResult> {
- const provider = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- return await tx.backupProviders.get(backupProviderBaseUrl);
- },
- );
- if (!provider) {
- throw Error("unknown backup provider");
- }
-
- logger.info(`running backup for provider ${backupProviderBaseUrl}`);
-
- return await runBackupCycleForProvider(ws, {
- backupProviderBaseUrl: provider.baseUrl,
- });
-}
-
-export interface RemoveBackupProviderRequest {
- provider: string;
-}
-
-export const codecForRemoveBackupProvider =
- (): Codec<RemoveBackupProviderRequest> =>
- buildCodecForObject<RemoveBackupProviderRequest>()
- .property("provider", codecForString())
- .build("RemoveBackupProviderRequest");
-
-export async function removeBackupProvider(
- ws: InternalWalletState,
- req: RemoveBackupProviderRequest,
-): Promise<void> {
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- await tx.backupProviders.delete(req.provider);
- });
-}
-
-export interface RunBackupCycleRequest {
- /**
- * List of providers to backup or empty for all known providers.
- */
- providers?: Array<string>;
-}
-
-export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
- buildCodecForObject<RunBackupCycleRequest>()
- .property("providers", codecOptional(codecForList(codecForString())))
- .build("RunBackupCycleRequest");
-
-/**
- * Do one backup cycle that consists of:
- * 1. Exporting a backup and try to upload it.
- * Stop if this step succeeds.
- * 2. Download, verify and import backups from connected sync accounts.
- * 3. Upload the updated backup blob.
- */
-export async function runBackupCycle(
- ws: InternalWalletState,
- req: RunBackupCycleRequest,
-): Promise<void> {
- const providers = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- if (req.providers) {
- const rs = await Promise.all(
- req.providers.map((id) => tx.backupProviders.get(id)),
- );
- return rs.filter(notEmpty);
- }
- return await tx.backupProviders.iter().toArray();
- },
- );
-
- for (const provider of providers) {
- await runBackupCycleForProvider(ws, {
- backupProviderBaseUrl: provider.baseUrl,
- });
- }
-}
-
-export interface SyncTermsOfServiceResponse {
- // maximum backup size supported
- storage_limit_in_megabytes: number;
-
- // Fee for an account, per year.
- annual_fee: AmountString;
-
- // protocol version supported by the server,
- // for now always "0.0".
- version: string;
-}
-
-export const codecForSyncTermsOfServiceResponse =
- (): Codec<SyncTermsOfServiceResponse> =>
- buildCodecForObject<SyncTermsOfServiceResponse>()
- .property("storage_limit_in_megabytes", codecForNumber())
- .property("annual_fee", codecForAmountString())
- .property("version", codecForString())
- .build("SyncTermsOfServiceResponse");
-
-export interface AddBackupProviderRequest {
- backupProviderBaseUrl: string;
-
- name: string;
- /**
- * Activate the provider. Should only be done after
- * the user has reviewed the provider.
- */
- activate?: boolean;
-}
-
-export const codecForAddBackupProviderRequest =
- (): Codec<AddBackupProviderRequest> =>
- buildCodecForObject<AddBackupProviderRequest>()
- .property("backupProviderBaseUrl", codecForString())
- .property("name", codecForString())
- .property("activate", codecOptional(codecForBoolean()))
- .build("AddBackupProviderRequest");
-
-export type AddBackupProviderResponse =
- | AddBackupProviderOk
- | AddBackupProviderPaymentRequired;
-
-interface AddBackupProviderOk {
- status: "ok";
-}
-interface AddBackupProviderPaymentRequired {
- status: "payment-required";
- talerUri?: string;
-}
-
-export const codecForAddBackupProviderOk = (): Codec<AddBackupProviderOk> =>
- buildCodecForObject<AddBackupProviderOk>()
- .property("status", codecForConstString("ok"))
- .build("AddBackupProviderOk");
-
-export const codecForAddBackupProviderPaymenrRequired =
- (): Codec<AddBackupProviderPaymentRequired> =>
- buildCodecForObject<AddBackupProviderPaymentRequired>()
- .property("status", codecForConstString("payment-required"))
- .property("talerUri", codecOptional(codecForString()))
- .build("AddBackupProviderPaymentRequired");
-
-export const codecForAddBackupProviderResponse =
- (): Codec<AddBackupProviderResponse> =>
- buildCodecForUnion<AddBackupProviderResponse>()
- .discriminateOn("status")
- .alternative("ok", codecForAddBackupProviderOk())
- .alternative(
- "payment-required",
- codecForAddBackupProviderPaymenrRequired(),
- )
- .build("AddBackupProviderResponse");
-
-export async function addBackupProvider(
- ws: InternalWalletState,
- req: AddBackupProviderRequest,
-): Promise<AddBackupProviderResponse> {
- logger.info(`adding backup provider ${j2s(req)}`);
- await provideBackupState(ws);
- const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const oldProv = await tx.backupProviders.get(canonUrl);
- if (oldProv) {
- logger.info("old backup provider found");
- if (req.activate) {
- oldProv.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- ),
- };
- logger.info("setting existing backup provider to active");
- await tx.backupProviders.put(oldProv);
- }
- return;
- }
- });
- const termsUrl = new URL("config", canonUrl);
- const resp = await ws.http.fetch(termsUrl.href);
- const terms = await readSuccessResponseJsonOrThrow(
- resp,
- codecForSyncTermsOfServiceResponse(),
- );
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- let state: BackupProviderState;
- //FIXME: what is the difference provisional and ready?
- if (req.activate) {
- state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
- } else {
- state = {
- tag: BackupProviderStateTag.Provisional,
- };
- }
- await tx.backupProviders.put({
- state,
- name: req.name,
- terms: {
- annualFee: terms.annual_fee,
- storageLimitInMegabytes: terms.storage_limit_in_megabytes,
- supportedProtocolVersion: terms.version,
- },
- shouldRetryFreshProposal: false,
- paymentProposalIds: [],
- baseUrl: canonUrl,
- uids: [encodeCrock(getRandomBytes(32))],
- });
- });
-
- return await runFirstBackupCycleForProvider(ws, {
- backupProviderBaseUrl: canonUrl,
- });
-}
-
-async function runFirstBackupCycleForProvider(
- ws: InternalWalletState,
- args: BackupForProviderArgs,
-): Promise<AddBackupProviderResponse> {
- throw Error("not implemented");
- // const resp = await runBackupCycleForProvider(ws, args);
- // switch (resp.type) {
- // case TaskRunResultType.Error:
- // throw TalerError.fromDetail(
- // TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- // resp.errorDetail as any, //FIXME create an error for backup problems
- // );
- // case TaskRunResultType.Finished:
- // return {
- // status: "ok",
- // };
- // case TaskRunResultType.Pending:
- // return {
- // status: "payment-required",
- // talerUri: "FIXME",
- // //talerUri: resp.result.talerUri,
- // };
- // default:
- // assertUnreachable(resp);
- // }
-}
-
-export async function restoreFromRecoverySecret(): Promise<void> {
- return;
-}
-
-/**
- * Information about one provider.
- *
- * We don't store the account key here,
- * as that's derived from the wallet root key.
- */
-export interface ProviderInfo {
- active: boolean;
- syncProviderBaseUrl: string;
- name: string;
- terms?: BackupProviderTerms;
- /**
- * Last communication issue with the provider.
- */
- lastError?: TalerErrorDetail;
- lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp;
- lastAttemptedBackupTimestamp?: TalerPreciseTimestamp;
- paymentProposalIds: string[];
- backupProblem?: BackupProblem;
- paymentStatus: ProviderPaymentStatus;
-}
-
-export type BackupProblem =
- | BackupUnreadableProblem
- | BackupConflictingDeviceProblem;
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupConflictingDeviceProblem {
- type: "backup-conflicting-device";
- otherDeviceId: string;
- myDeviceId: string;
- backupTimestamp: AbsoluteTime;
-}
-
-export type ProviderPaymentStatus =
- | ProviderPaymentTermsChanged
- | ProviderPaymentPaid
- | ProviderPaymentInsufficientBalance
- | ProviderPaymentUnpaid
- | ProviderPaymentPending;
-
-export interface BackupInfo {
- walletRootPub: string;
- deviceId: string;
- providers: ProviderInfo[];
-}
-
-export enum ProviderPaymentType {
- Unpaid = "unpaid",
- Pending = "pending",
- InsufficientBalance = "insufficient-balance",
- Paid = "paid",
- TermsChanged = "terms-changed",
-}
-
-export interface ProviderPaymentUnpaid {
- type: ProviderPaymentType.Unpaid;
-}
-
-export interface ProviderPaymentInsufficientBalance {
- type: ProviderPaymentType.InsufficientBalance;
- amount: AmountString;
-}
-
-export interface ProviderPaymentPending {
- type: ProviderPaymentType.Pending;
- talerUri?: string;
-}
-
-export interface ProviderPaymentPaid {
- type: ProviderPaymentType.Paid;
- paidUntil: AbsoluteTime;
-}
-
-export interface ProviderPaymentTermsChanged {
- type: ProviderPaymentType.TermsChanged;
- paidUntil: AbsoluteTime;
- oldTerms: BackupProviderTerms;
- newTerms: BackupProviderTerms;
-}
-
-async function getProviderPaymentInfo(
- ws: InternalWalletState,
- provider: BackupProviderRecord,
-): Promise<ProviderPaymentStatus> {
- throw Error("not implemented");
- // if (!provider.currentPaymentProposalId) {
- // return {
- // type: ProviderPaymentType.Unpaid,
- // };
- // }
- // const status = await checkPaymentByProposalId(
- // ws,
- // provider.currentPaymentProposalId,
- // ).catch(() => undefined);
-
- // if (!status) {
- // return {
- // type: ProviderPaymentType.Unpaid,
- // };
- // }
-
- // switch (status.status) {
- // case PreparePayResultType.InsufficientBalance:
- // return {
- // type: ProviderPaymentType.InsufficientBalance,
- // amount: status.amountRaw,
- // };
- // case PreparePayResultType.PaymentPossible:
- // return {
- // type: ProviderPaymentType.Pending,
- // talerUri: status.talerUri,
- // };
- // case PreparePayResultType.AlreadyConfirmed:
- // if (status.paid) {
- // return {
- // type: ProviderPaymentType.Paid,
- // paidUntil: AbsoluteTime.addDuration(
- // AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp),
- // durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
- // ),
- // };
- // } else {
- // return {
- // type: ProviderPaymentType.Pending,
- // talerUri: status.talerUri,
- // };
- // }
- // default:
- // assertUnreachable(status);
- // }
-}
-
-/**
- * Get information about the current state of wallet backups.
- */
-export async function getBackupInfo(
- ws: InternalWalletState,
-): Promise<BackupInfo> {
- const backupConfig = await provideBackupState(ws);
- const providerRecords = await ws.db.runReadOnlyTx(
- ["backupProviders", "operationRetries"],
- async (tx) => {
- return await tx.backupProviders.iter().mapAsync(async (bp) => {
- const opId = TaskIdentifiers.forBackup(bp);
- const retryRecord = await tx.operationRetries.get(opId);
- return {
- provider: bp,
- retryRecord,
- };
- });
- },
- );
- const providers: ProviderInfo[] = [];
- for (const x of providerRecords) {
- providers.push({
- active: x.provider.state.tag !== BackupProviderStateTag.Provisional,
- syncProviderBaseUrl: x.provider.baseUrl,
- lastSuccessfulBackupTimestamp: timestampOptionalPreciseFromDb(
- x.provider.lastBackupCycleTimestamp,
- ),
- paymentProposalIds: x.provider.paymentProposalIds,
- lastError:
- x.provider.state.tag === BackupProviderStateTag.Retrying
- ? x.retryRecord?.lastError
- : undefined,
- paymentStatus: await getProviderPaymentInfo(ws, x.provider),
- terms: x.provider.terms,
- name: x.provider.name,
- });
- }
- return {
- deviceId: backupConfig.deviceId,
- walletRootPub: backupConfig.walletRootPub,
- providers,
- };
-}
-
-/**
- * Get backup recovery information, including the wallet's
- * private key.
- */
-export async function getBackupRecovery(
- ws: InternalWalletState,
-): Promise<BackupRecovery> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- return await tx.backupProviders.iter().toArray();
- },
- );
- return {
- providers: providers
- .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
- .map((x) => {
- return {
- name: x.name,
- url: x.baseUrl,
- };
- }),
- walletRootPriv: bs.walletRootPriv,
- };
-}
-
-async function backupRecoveryTheirs(
- ws: InternalWalletState,
- br: BackupRecovery,
-) {
- await ws.db.runReadWriteTx(["backupProviders", "config"], async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- checkDbInvariant(!!backupStateEntry);
- checkDbInvariant(
- backupStateEntry.key === ConfigRecordKey.WalletBackupState,
- );
- backupStateEntry.value.lastBackupNonce = undefined;
- backupStateEntry.value.lastBackupTimestamp = undefined;
- backupStateEntry.value.lastBackupCheckTimestamp = undefined;
- backupStateEntry.value.lastBackupPlainHash = undefined;
- backupStateEntry.value.walletRootPriv = br.walletRootPriv;
- backupStateEntry.value.walletRootPub = encodeCrock(
- eddsaGetPublic(decodeCrock(br.walletRootPriv)),
- );
- await tx.config.put(backupStateEntry);
- for (const prov of br.providers) {
- const existingProv = await tx.backupProviders.get(prov.url);
- if (!existingProv) {
- await tx.backupProviders.put({
- baseUrl: prov.url,
- name: prov.name,
- paymentProposalIds: [],
- shouldRetryFreshProposal: false,
- state: {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- ),
- },
- uids: [encodeCrock(getRandomBytes(32))],
- });
- }
- }
- const providers = await tx.backupProviders.iter().toArray();
- for (const prov of providers) {
- prov.lastBackupCycleTimestamp = undefined;
- prov.lastBackupHash = undefined;
- await tx.backupProviders.put(prov);
- }
- });
-}
-
-async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) {
- throw Error("not implemented");
-}
-
-export async function loadBackupRecovery(
- ws: InternalWalletState,
- br: RecoveryLoadRequest,
-): Promise<void> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- return await tx.backupProviders.iter().toArray();
- },
- );
- let strategy = br.strategy;
- if (
- br.recovery.walletRootPriv != bs.walletRootPriv &&
- providers.length > 0 &&
- !strategy
- ) {
- throw Error(
- "recovery load strategy must be specified for wallet with existing providers",
- );
- } else if (!strategy) {
- // Default to using the new key if we don't have providers yet.
- strategy = RecoveryMergeStrategy.Theirs;
- }
- if (strategy === RecoveryMergeStrategy.Theirs) {
- return backupRecoveryTheirs(ws, br.recovery);
- } else {
- return backupRecoveryOurs(ws, br.recovery);
- }
-}
-
-export async function decryptBackup(
- backupConfig: WalletBackupConfState,
- data: Uint8Array,
-): Promise<any> {
- const rMagic = bytesToString(data.slice(0, 8));
- if (rMagic != magic) {
- throw Error("invalid backup file (magic tag mismatch)");
- }
-
- const nonce = data.slice(8, 8 + 24);
- const box = data.slice(8 + 24);
- const secret = deriveBlobSecret(backupConfig);
- const dataCompressed = secretbox_open(box, nonce, secret);
- if (!dataCompressed) {
- throw Error("decryption failed");
- }
- return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
-}
-
-export async function provideBackupState(
- ws: InternalWalletState,
-): Promise<WalletBackupConfState> {
- const bs: ConfigRecord | undefined = await ws.db.runReadOnlyTx(
- ["config"],
- async (tx) => {
- return await tx.config.get(ConfigRecordKey.WalletBackupState);
- },
- );
- if (bs) {
- checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
- return bs.value;
- }
- // We need to generate the key outside of the transaction
- // due to how IndexedDB works.
- const k = await ws.cryptoApi.createEddsaKeypair({});
- const d = getRandomBytes(5);
- // FIXME: device ID should be configured when wallet is initialized
- // and be based on hostname
- const deviceId = `wallet-core-${encodeCrock(d)}`;
- return await ws.db.runReadWriteTx(["config"], async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- if (!backupStateEntry) {
- backupStateEntry = {
- key: ConfigRecordKey.WalletBackupState,
- value: {
- deviceId,
- walletRootPub: k.pub,
- walletRootPriv: k.priv,
- lastBackupPlainHash: undefined,
- },
- };
- await tx.config.put(backupStateEntry);
- }
- checkDbInvariant(
- backupStateEntry.key === ConfigRecordKey.WalletBackupState,
- );
- return backupStateEntry.value;
- });
-}
-
-export async function getWalletBackupState(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<["config"]>,
-): Promise<WalletBackupConfState> {
- const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
- checkDbInvariant(!!bs, "wallet backup state should be in DB");
- checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
- return bs.value;
-}
-
-export async function setWalletDeviceId(
- ws: InternalWalletState,
- deviceId: string,
-): Promise<void> {
- await provideBackupState(ws);
- await ws.db.runReadWriteTx(["config"], async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- if (
- !backupStateEntry ||
- backupStateEntry.key !== ConfigRecordKey.WalletBackupState
- ) {
- return;
- }
- backupStateEntry.value.deviceId = deviceId;
- await tx.config.put(backupStateEntry);
- });
-}
-
-export async function getWalletDeviceId(
- ws: InternalWalletState,
-): Promise<string> {
- const bs = await provideBackupState(ws);
- return bs.deviceId;
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts b/packages/taler-wallet-core/src/operations/backup/state.ts
deleted file mode 100644
index 72f850b25..000000000
--- a/packages/taler-wallet-core/src/operations/backup/state.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts
deleted file mode 100644
index 12f9795b2..000000000
--- a/packages/taler-wallet-core/src/operations/balance.ts
+++ /dev/null
@@ -1,730 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Functions to compute the wallet's balance.
- *
- * There are multiple definition of the wallet's balance.
- * We use the following terminology:
- *
- * - "available": Balance that is available
- * for spending from transactions in their final state and
- * expected to be available from pending refreshes.
- *
- * - "pending-incoming": Expected (positive!) delta
- * to the available balance that we expect to have
- * after pending operations reach the "done" state.
- *
- * - "pending-outgoing": Amount that is currently allocated
- * to be spent, but the spend operation could still be aborted
- * and part of the pending-outgoing amount could be recovered.
- *
- * - "material": Balance that the wallet believes it could spend *right now*,
- * without waiting for any operations to complete.
- * This balance type is important when showing "insufficient balance" error messages.
- *
- * - "age-acceptable": Subset of the material balance that can be spent
- * with age restrictions applied.
- *
- * - "merchant-acceptable": Subset of the material balance that can be spent with a particular
- * merchant (restricted via min age, exchange, auditor, wire_method).
- *
- * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
- * can accept via their supported wire methods.
- */
-
-/**
- * Imports.
- */
-import { GlobalIDB } from "@gnu-taler/idb-bridge";
-import {
- AllowedAuditorInfo,
- AllowedExchangeInfo,
- AmountJson,
- AmountLike,
- Amounts,
- BalanceFlag,
- BalancesResponse,
- canonicalizeBaseUrl,
- GetBalanceDetailRequest,
- Logger,
- parsePaytoUri,
- ScopeInfo,
- ScopeType,
-} from "@gnu-taler/taler-util";
-import {
- DepositOperationStatus,
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- RefreshGroupRecord,
- RefreshOperationStatus,
- WalletDbReadOnlyTransaction,
- WithdrawalGroupStatus,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import {
- getExchangeScopeInfo,
- getExchangeWireDetailsInTx,
-} from "./exchanges.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("operations/balance.ts");
-
-interface WalletBalance {
- scopeInfo: ScopeInfo;
- available: AmountJson;
- pendingIncoming: AmountJson;
- pendingOutgoing: AmountJson;
- flagIncomingKyc: boolean;
- flagIncomingAml: boolean;
- flagIncomingConfirmation: boolean;
- flagOutgoingKyc: boolean;
-}
-
-/**
- * Compute the available amount that the wallet expects to get
- * out of a refresh group.
- */
-function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
- // Don't count finished refreshes, since the refresh already resulted
- // in coins being added to the wallet.
- let available = Amounts.zeroOfCurrency(r.currency);
- if (r.timestampFinished) {
- return available;
- }
- for (let i = 0; i < r.oldCoinPubs.length; i++) {
- available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount;
- }
- return available;
-}
-
-function getBalanceKey(scopeInfo: ScopeInfo): string {
- switch (scopeInfo.type) {
- case ScopeType.Auditor:
- return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
- case ScopeType.Exchange:
- return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
- case ScopeType.Global:
- return `${scopeInfo.type};${scopeInfo.currency}`;
- }
-}
-
-class BalancesStore {
- private exchangeScopeCache: Record<string, ScopeInfo> = {};
- private balanceStore: Record<string, WalletBalance> = {};
-
- constructor(
- private ws: InternalWalletState,
- private tx: WalletDbReadOnlyTransaction<
- [
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- "exchanges",
- "exchangeDetails",
- ]
- >,
- ) {}
-
- /**
- * Add amount to a balance field, both for
- * the slicing by exchange and currency.
- */
- private async initBalance(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<WalletBalance> {
- let scopeInfo: ScopeInfo | undefined =
- this.exchangeScopeCache[exchangeBaseUrl];
- if (!scopeInfo) {
- scopeInfo = await getExchangeScopeInfo(
- this.tx,
- exchangeBaseUrl,
- currency,
- );
- this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo;
- }
- const balanceKey = getBalanceKey(scopeInfo);
- const b = this.balanceStore[balanceKey];
- if (!b) {
- const zero = Amounts.zeroOfCurrency(currency);
- this.balanceStore[balanceKey] = {
- scopeInfo,
- available: zero,
- pendingIncoming: zero,
- pendingOutgoing: zero,
- flagIncomingAml: false,
- flagIncomingConfirmation: false,
- flagIncomingKyc: false,
- flagOutgoingKyc: false,
- };
- }
- return this.balanceStore[balanceKey];
- }
-
- async addAvailable(
- currency: string,
- exchangeBaseUrl: string,
- amount: AmountLike,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.available = Amounts.add(b.available, amount).amount;
- }
-
- async addPendingIncoming(
- currency: string,
- exchangeBaseUrl: string,
- amount: AmountLike,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.pendingIncoming = Amounts.add(b.available, amount).amount;
- }
-
- async setFlagIncomingAml(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.flagIncomingAml = true;
- }
-
- async setFlagIncomingKyc(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.flagIncomingKyc = true;
- }
-
- async setFlagIncomingConfirmation(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.flagIncomingConfirmation = true;
- }
-
- async setFlagOutgoingKyc(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.flagOutgoingKyc = true;
- }
-
- toBalancesResponse(): BalancesResponse {
- const balancesResponse: BalancesResponse = {
- balances: [],
- };
-
- const balanceStore = this.balanceStore;
-
- Object.keys(balanceStore)
- .sort()
- .forEach((c) => {
- const v = balanceStore[c];
- const flags: BalanceFlag[] = [];
- if (v.flagIncomingAml) {
- flags.push(BalanceFlag.IncomingAml);
- }
- if (v.flagIncomingKyc) {
- flags.push(BalanceFlag.IncomingKyc);
- }
- if (v.flagIncomingConfirmation) {
- flags.push(BalanceFlag.IncomingConfirmation);
- }
- if (v.flagOutgoingKyc) {
- flags.push(BalanceFlag.OutgoingKyc);
- }
- balancesResponse.balances.push({
- scopeInfo: v.scopeInfo,
- available: Amounts.stringify(v.available),
- pendingIncoming: Amounts.stringify(v.pendingIncoming),
- pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
- // FIXME: This field is basically not implemented, do we even need it?
- hasPendingTransactions: false,
- // FIXME: This field is basically not implemented, do we even need it?
- requiresUserInput: false,
- flags,
- });
- });
- return balancesResponse;
- }
-}
-
-/**
- * Get balance information.
- */
-export async function getBalancesInsideTransaction(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<
- [
- "exchanges",
- "exchangeDetails",
- "coinAvailability",
- "refreshGroups",
- "depositGroups",
- "withdrawalGroups",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ]
- >,
-): Promise<BalancesResponse> {
- const balanceStore: BalancesStore = new BalancesStore(ws, tx);
-
- const keyRangeActive = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
-
- await tx.coinAvailability.iter().forEachAsync(async (ca) => {
- const count = ca.visibleCoinCount ?? 0;
- for (let i = 0; i < count; i++) {
- await balanceStore.addAvailable(
- ca.currency,
- ca.exchangeBaseUrl,
- ca.value,
- );
- }
- });
-
- await tx.refreshGroups.iter().forEachAsync(async (r) => {
- switch (r.operationStatus) {
- case RefreshOperationStatus.Pending:
- case RefreshOperationStatus.Suspended:
- break;
- default:
- return;
- }
- const perExchange = r.infoPerExchange;
- if (!perExchange) {
- return;
- }
- for (const [e, x] of Object.entries(perExchange)) {
- await balanceStore.addAvailable(r.currency, e, x.outputEffective);
- }
- });
-
- await tx.withdrawalGroups.indexes.byStatus
- .iter(keyRangeActive)
- .forEachAsync(async (wgRecord) => {
- const currency = Amounts.currencyOf(wgRecord.denomsSel.totalCoinValue);
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.FailedAbortingBank:
- case WithdrawalGroupStatus.FailedBankAborted:
- case WithdrawalGroupStatus.Done:
- // Does not count as pendingIncoming
- return;
- case WithdrawalGroupStatus.PendingReady:
- case WithdrawalGroupStatus.AbortingBank:
- case WithdrawalGroupStatus.PendingQueryingStatus:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- // Pending, but no special flag.
- break;
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.PendingKyc:
- await balanceStore.setFlagIncomingKyc(
- currency,
- wgRecord.exchangeBaseUrl,
- );
- break;
- case WithdrawalGroupStatus.PendingAml:
- case WithdrawalGroupStatus.SuspendedAml:
- await balanceStore.setFlagIncomingAml(
- currency,
- wgRecord.exchangeBaseUrl,
- );
- break;
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- await balanceStore.setFlagIncomingConfirmation(
- currency,
- wgRecord.exchangeBaseUrl,
- );
- break;
- default:
- assertUnreachable(wgRecord.status);
- }
- await balanceStore.addPendingIncoming(
- currency,
- wgRecord.exchangeBaseUrl,
- wgRecord.denomsSel.totalCoinValue,
- );
- });
-
- await tx.depositGroups.indexes.byStatus
- .iter(keyRangeActive)
- .forEachAsync(async (dgRecord) => {
- const perExchange = dgRecord.infoPerExchange;
- if (!perExchange) {
- return;
- }
- for (const [e, x] of Object.entries(perExchange)) {
- const currency = Amounts.currencyOf(dgRecord.amount);
- switch (dgRecord.operationStatus) {
- case DepositOperationStatus.SuspendedKyc:
- case DepositOperationStatus.PendingKyc:
- await balanceStore.setFlagOutgoingKyc(currency, e);
- }
- }
- });
-
- return balanceStore.toBalancesResponse();
-}
-
-/**
- * Get detailed balance information, sliced by exchange and by currency.
- */
-export async function getBalances(
- ws: InternalWalletState,
-): Promise<BalancesResponse> {
- logger.trace("starting to compute balance");
-
- const wbal = await ws.db.runReadWriteTx(
- [
- "coinAvailability",
- "coins",
- "depositGroups",
- "exchangeDetails",
- "exchanges",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- "purchases",
- "refreshGroups",
- "withdrawalGroups",
- ],
- async (tx) => {
- return getBalancesInsideTransaction(ws, tx);
- },
- );
-
- logger.trace("finished computing wallet balance");
-
- return wbal;
-}
-
-/**
- * Information about the balance for a particular payment to a particular
- * merchant.
- */
-export interface MerchantPaymentBalanceDetails {
- balanceAvailable: AmountJson;
-}
-
-export interface MerchantPaymentRestrictionsForBalance {
- currency: string;
- minAge: number;
- acceptedExchanges: AllowedExchangeInfo[];
- acceptedAuditors: AllowedAuditorInfo[];
- acceptedWireMethods: string[];
-}
-
-export interface AcceptableExchanges {
- /**
- * Exchanges accepted by the merchant, but wire method might not match.
- */
- acceptableExchanges: string[];
-
- /**
- * Exchanges accepted by the merchant, including a matching
- * wire method, i.e. the merchant can deposit coins there.
- */
- depositableExchanges: string[];
-}
-
-/**
- * Get all exchanges that are acceptable for a particular payment.
- */
-export async function getAcceptableExchangeBaseUrls(
- ws: InternalWalletState,
- req: MerchantPaymentRestrictionsForBalance,
-): Promise<AcceptableExchanges> {
- const acceptableExchangeUrls = new Set<string>();
- const depositableExchangeUrls = new Set<string>();
- await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
- // FIXME: We should have a DB index to look up all exchanges
- // for a particular auditor ...
-
- const canonExchanges = new Set<string>();
- const canonAuditors = new Set<string>();
-
- for (const exchangeHandle of req.acceptedExchanges) {
- const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl);
- canonExchanges.add(normUrl);
- }
-
- for (const auditorHandle of req.acceptedAuditors) {
- const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl);
- canonAuditors.add(normUrl);
- }
-
- await tx.exchanges.iter().forEachAsync(async (exchange) => {
- const dp = exchange.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([
- exchange.baseUrl,
- currency,
- masterPublicKey,
- ]);
- if (!exchangeDetails) {
- return;
- }
-
- let acceptable = false;
-
- if (canonExchanges.has(exchange.baseUrl)) {
- acceptableExchangeUrls.add(exchange.baseUrl);
- acceptable = true;
- }
- for (const exchangeAuditor of exchangeDetails.auditors) {
- if (canonAuditors.has(exchangeAuditor.auditor_url)) {
- acceptableExchangeUrls.add(exchange.baseUrl);
- acceptable = true;
- break;
- }
- }
-
- if (!acceptable) {
- return;
- }
- // FIXME: Also consider exchange and auditor public key
- // instead of just base URLs?
-
- let wireMethodSupported = false;
- for (const acc of exchangeDetails.wireInfo.accounts) {
- const pp = parsePaytoUri(acc.payto_uri);
- checkLogicInvariant(!!pp);
- for (const wm of req.acceptedWireMethods) {
- if (pp.targetType === wm) {
- wireMethodSupported = true;
- break;
- }
- if (wireMethodSupported) {
- break;
- }
- }
- }
-
- acceptableExchangeUrls.add(exchange.baseUrl);
- if (wireMethodSupported) {
- depositableExchangeUrls.add(exchange.baseUrl);
- }
- });
- });
- return {
- acceptableExchanges: [...acceptableExchangeUrls],
- depositableExchanges: [...depositableExchangeUrls],
- };
-}
-
-export interface MerchantPaymentBalanceDetails {
- /**
- * Balance of type "available" (see balance.ts for definition).
- */
- balanceAvailable: AmountJson;
-
- /**
- * Balance of type "material" (see balance.ts for definition).
- */
- balanceMaterial: AmountJson;
-
- /**
- * Balance of type "age-acceptable" (see balance.ts for definition).
- */
- balanceAgeAcceptable: AmountJson;
-
- /**
- * Balance of type "merchant-acceptable" (see balance.ts for definition).
- */
- balanceMerchantAcceptable: AmountJson;
-
- /**
- * Balance of type "merchant-depositable" (see balance.ts for definition).
- */
- balanceMerchantDepositable: AmountJson;
-}
-
-export async function getMerchantPaymentBalanceDetails(
- ws: InternalWalletState,
- req: MerchantPaymentRestrictionsForBalance,
-): Promise<MerchantPaymentBalanceDetails> {
- const acceptability = await getAcceptableExchangeBaseUrls(ws, req);
-
- const d: MerchantPaymentBalanceDetails = {
- balanceAvailable: Amounts.zeroOfCurrency(req.currency),
- balanceMaterial: Amounts.zeroOfCurrency(req.currency),
- balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
- balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency),
- balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency),
- };
-
- await ws.db.runReadOnlyTx(
- ["coinAvailability", "refreshGroups"],
- async (tx) => {
- await tx.coinAvailability.iter().forEach((ca) => {
- if (ca.currency != req.currency) {
- return;
- }
- const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
- const coinAmount: AmountJson = Amounts.mult(
- singleCoinAmount,
- ca.freshCoinCount,
- ).amount;
- d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
- d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
- if (ca.maxAge === 0 || ca.maxAge > req.minAge) {
- d.balanceAgeAcceptable = Amounts.add(
- d.balanceAgeAcceptable,
- coinAmount,
- ).amount;
- if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) {
- d.balanceMerchantAcceptable = Amounts.add(
- d.balanceMerchantAcceptable,
- coinAmount,
- ).amount;
- if (
- acceptability.depositableExchanges.includes(ca.exchangeBaseUrl)
- ) {
- d.balanceMerchantDepositable = Amounts.add(
- d.balanceMerchantDepositable,
- coinAmount,
- ).amount;
- }
- }
- }
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- if (r.currency != req.currency) {
- return;
- }
- d.balanceAvailable = Amounts.add(
- d.balanceAvailable,
- computeRefreshGroupAvailableAmount(r),
- ).amount;
- });
- },
- );
-
- return d;
-}
-
-export async function getBalanceDetail(
- ws: InternalWalletState,
- req: GetBalanceDetailRequest,
-): Promise<MerchantPaymentBalanceDetails> {
- const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
- const wires = new Array<string>();
- await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || req.currency !== details.currency) {
- continue;
- }
- details.wireInfo.accounts.forEach((a) => {
- const payto = parsePaytoUri(a.payto_uri);
- if (payto && !wires.includes(payto.targetType)) {
- wires.push(payto.targetType);
- }
- });
- exchanges.push({
- exchangePub: details.masterPublicKey,
- exchangeBaseUrl: e.baseUrl,
- });
- }
- });
-
- return await getMerchantPaymentBalanceDetails(ws, {
- currency: req.currency,
- acceptedAuditors: [],
- acceptedExchanges: exchanges,
- acceptedWireMethods: wires,
- minAge: 0,
- });
-}
-
-export interface PeerPaymentRestrictionsForBalance {
- currency: string;
- restrictExchangeTo?: string;
-}
-
-export interface PeerPaymentBalanceDetails {
- /**
- * Balance of type "available" (see balance.ts for definition).
- */
- balanceAvailable: AmountJson;
-
- /**
- * Balance of type "material" (see balance.ts for definition).
- */
- balanceMaterial: AmountJson;
-}
-
-export async function getPeerPaymentBalanceDetailsInTx(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<["coinAvailability", "refreshGroups"]>,
- req: PeerPaymentRestrictionsForBalance,
-): Promise<PeerPaymentBalanceDetails> {
- let balanceAvailable = Amounts.zeroOfCurrency(req.currency);
- let balanceMaterial = Amounts.zeroOfCurrency(req.currency);
-
- await tx.coinAvailability.iter().forEach((ca) => {
- if (ca.currency != req.currency) {
- return;
- }
- if (
- req.restrictExchangeTo &&
- req.restrictExchangeTo !== ca.exchangeBaseUrl
- ) {
- return;
- }
- const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
- const coinAmount: AmountJson = Amounts.mult(
- singleCoinAmount,
- ca.freshCoinCount,
- ).amount;
- balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount;
- balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount;
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- if (r.currency != req.currency) {
- return;
- }
- balanceAvailable = Amounts.add(
- balanceAvailable,
- computeRefreshGroupAvailableAmount(r),
- ).amount;
- });
-
- return {
- balanceAvailable,
- balanceMaterial,
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts
deleted file mode 100644
index 6bafa632e..000000000
--- a/packages/taler-wallet-core/src/operations/common.ts
+++ /dev/null
@@ -1,693 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AmountJson,
- Amounts,
- CoinRefreshRequest,
- CoinStatus,
- Duration,
- ExchangeEntryState,
- ExchangeEntryStatus,
- ExchangeTosStatus,
- ExchangeUpdateStatus,
- Logger,
- RefreshReason,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TombstoneIdStr,
- TransactionIdStr,
- durationMul,
-} from "@gnu-taler/taler-util";
-import {
- BackupProviderRecord,
- CoinRecord,
- DbPreciseTimestamp,
- DepositGroupRecord,
- ExchangeEntryDbRecordStatus,
- ExchangeEntryDbUpdateStatus,
- ExchangeEntryRecord,
- PeerPullCreditRecord,
- PeerPullPaymentIncomingRecord,
- PeerPushDebitRecord,
- PeerPushPaymentIncomingRecord,
- PurchaseRecord,
- RecoupGroupRecord,
- RefreshGroupRecord,
- RewardRecord,
- WalletDbReadWriteTransaction,
- WithdrawalGroupRecord,
- timestampPreciseToDb,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import { createRefreshGroup } from "./refresh.js";
-
-const logger = new Logger("operations/common.ts");
-
-export interface CoinsSpendInfo {
- coinPubs: string[];
- contributions: AmountJson[];
- refreshReason: RefreshReason;
- /**
- * Identifier for what the coin has been spent for.
- */
- allocationId: TransactionIdStr;
-}
-
-export async function makeCoinsVisible(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>,
- transactionId: string,
-): Promise<void> {
- const coins =
- await tx.coins.indexes.bySourceTransactionId.getAll(transactionId);
- for (const coinRecord of coins) {
- if (!coinRecord.visible) {
- coinRecord.visible = 1;
- await tx.coins.put(coinRecord);
- const ageRestriction = coinRecord.maxAge;
- const car = await tx.coinAvailability.get([
- coinRecord.exchangeBaseUrl,
- coinRecord.denomPubHash,
- ageRestriction,
- ]);
- if (!car) {
- logger.error("missing coin availability record");
- continue;
- }
- const visCount = car.visibleCoinCount ?? 0;
- car.visibleCoinCount = visCount + 1;
- await tx.coinAvailability.put(car);
- }
- }
-}
-
-export async function makeCoinAvailable(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["coins", "coinAvailability", "denominations"]
- >,
- coinRecord: CoinRecord,
-): Promise<void> {
- checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
- const existingCoin = await tx.coins.get(coinRecord.coinPub);
- if (existingCoin) {
- return;
- }
- const denom = await tx.denominations.get([
- coinRecord.exchangeBaseUrl,
- coinRecord.denomPubHash,
- ]);
- checkDbInvariant(!!denom);
- const ageRestriction = coinRecord.maxAge;
- let car = await tx.coinAvailability.get([
- coinRecord.exchangeBaseUrl,
- coinRecord.denomPubHash,
- ageRestriction,
- ]);
- if (!car) {
- car = {
- maxAge: ageRestriction,
- value: denom.value,
- currency: denom.currency,
- denomPubHash: denom.denomPubHash,
- exchangeBaseUrl: denom.exchangeBaseUrl,
- freshCoinCount: 0,
- visibleCoinCount: 0,
- };
- }
- car.freshCoinCount++;
- await tx.coins.put(coinRecord);
- await tx.coinAvailability.put(car);
-}
-
-export async function spendCoins(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["coins", "coinAvailability", "refreshGroups", "denominations"]
- >,
- csi: CoinsSpendInfo,
-): Promise<void> {
- if (csi.coinPubs.length != csi.contributions.length) {
- throw Error("assertion failed");
- }
- if (csi.coinPubs.length === 0) {
- return;
- }
- let refreshCoinPubs: CoinRefreshRequest[] = [];
- for (let i = 0; i < csi.coinPubs.length; i++) {
- const coin = await tx.coins.get(csi.coinPubs[i]);
- if (!coin) {
- throw Error("coin allocated for payment doesn't exist anymore");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(!!denom);
- const coinAvailability = await tx.coinAvailability.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- coin.maxAge,
- ]);
- checkDbInvariant(!!coinAvailability);
- const contrib = csi.contributions[i];
- if (coin.status !== CoinStatus.Fresh) {
- const alloc = coin.spendAllocation;
- if (!alloc) {
- continue;
- }
- if (alloc.id !== csi.allocationId) {
- // FIXME: assign error code
- logger.info("conflicting coin allocation ID");
- logger.info(`old ID: ${alloc.id}, new ID: ${csi.allocationId}`);
- throw Error("conflicting coin allocation (id)");
- }
- if (0 !== Amounts.cmp(alloc.amount, contrib)) {
- // FIXME: assign error code
- throw Error("conflicting coin allocation (contrib)");
- }
- continue;
- }
- coin.status = CoinStatus.Dormant;
- coin.spendAllocation = {
- id: csi.allocationId,
- amount: Amounts.stringify(contrib),
- };
- const remaining = Amounts.sub(denom.value, contrib);
- if (remaining.saturated) {
- throw Error("not enough remaining balance on coin for payment");
- }
- refreshCoinPubs.push({
- amount: Amounts.stringify(remaining.amount),
- coinPub: coin.coinPub,
- });
- checkDbInvariant(!!coinAvailability);
- if (coinAvailability.freshCoinCount === 0) {
- throw Error(
- `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
- );
- }
- coinAvailability.freshCoinCount--;
- if (coin.visible) {
- if (!coinAvailability.visibleCoinCount) {
- logger.error("coin availability inconsistent");
- } else {
- coinAvailability.visibleCoinCount--;
- }
- }
- await tx.coins.put(coin);
- await tx.coinAvailability.put(coinAvailability);
- }
-
- await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(csi.contributions[0]),
- refreshCoinPubs,
- csi.refreshReason,
- csi.allocationId,
- );
-}
-
-export enum TombstoneTag {
- DeleteWithdrawalGroup = "delete-withdrawal-group",
- DeleteReserve = "delete-reserve",
- DeletePayment = "delete-payment",
- DeleteReward = "delete-reward",
- DeleteRefreshGroup = "delete-refresh-group",
- DeleteDepositGroup = "delete-deposit-group",
- DeleteRefund = "delete-refund",
- DeletePeerPullDebit = "delete-peer-pull-debit",
- DeletePeerPushDebit = "delete-peer-push-debit",
- DeletePeerPullCredit = "delete-peer-pull-credit",
- DeletePeerPushCredit = "delete-peer-push-credit",
-}
-
-export function getExchangeTosStatusFromRecord(
- exchange: ExchangeEntryRecord,
-): ExchangeTosStatus {
- if (!exchange.tosAcceptedEtag) {
- return ExchangeTosStatus.Proposed;
- }
- if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) {
- return ExchangeTosStatus.Accepted;
- }
- return ExchangeTosStatus.Proposed;
-}
-
-export function getExchangeUpdateStatusFromRecord(
- r: ExchangeEntryRecord,
-): ExchangeUpdateStatus {
- switch (r.updateStatus) {
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- return ExchangeUpdateStatus.UnavailableUpdate;
- case ExchangeEntryDbUpdateStatus.Initial:
- return ExchangeUpdateStatus.Initial;
- case ExchangeEntryDbUpdateStatus.InitialUpdate:
- return ExchangeUpdateStatus.InitialUpdate;
- case ExchangeEntryDbUpdateStatus.Ready:
- return ExchangeUpdateStatus.Ready;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- return ExchangeUpdateStatus.ReadyUpdate;
- case ExchangeEntryDbUpdateStatus.Suspended:
- return ExchangeUpdateStatus.Suspended;
- }
-}
-
-export function getExchangeEntryStatusFromRecord(
- r: ExchangeEntryRecord,
-): ExchangeEntryStatus {
- switch (r.entryStatus) {
- case ExchangeEntryDbRecordStatus.Ephemeral:
- return ExchangeEntryStatus.Ephemeral;
- case ExchangeEntryDbRecordStatus.Preset:
- return ExchangeEntryStatus.Preset;
- case ExchangeEntryDbRecordStatus.Used:
- return ExchangeEntryStatus.Used;
- }
-}
-
-/**
- * Compute the state of an exchange entry from the DB
- * record.
- */
-export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState {
- return {
- exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
- exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
- tosStatus: getExchangeTosStatusFromRecord(r),
- };
-}
-
-export type ParsedTombstone =
- | {
- tag: TombstoneTag.DeleteWithdrawalGroup;
- withdrawalGroupId: string;
- }
- | { tag: TombstoneTag.DeleteRefund; refundGroupId: string }
- | { tag: TombstoneTag.DeleteReserve; reservePub: string }
- | { tag: TombstoneTag.DeleteRefreshGroup; refreshGroupId: string }
- | { tag: TombstoneTag.DeleteReward; walletTipId: string }
- | { tag: TombstoneTag.DeletePayment; proposalId: string };
-
-export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
- switch (p.tag) {
- case TombstoneTag.DeleteWithdrawalGroup:
- return `tmb:${p.tag}:${p.withdrawalGroupId}` as TombstoneIdStr;
- case TombstoneTag.DeleteRefund:
- return `tmb:${p.tag}:${p.refundGroupId}` as TombstoneIdStr;
- case TombstoneTag.DeleteReserve:
- return `tmb:${p.tag}:${p.reservePub}` as TombstoneIdStr;
- case TombstoneTag.DeletePayment:
- return `tmb:${p.tag}:${p.proposalId}` as TombstoneIdStr;
- case TombstoneTag.DeleteRefreshGroup:
- return `tmb:${p.tag}:${p.refreshGroupId}` as TombstoneIdStr;
- case TombstoneTag.DeleteReward:
- return `tmb:${p.tag}:${p.walletTipId}` as TombstoneIdStr;
- default:
- assertUnreachable(p);
- }
-}
-
-/**
- * Uniform interface for a particular wallet transaction.
- */
-export interface TransactionManager {
- get taskId(): TaskId;
- get transactionId(): TransactionIdStr;
- fail(): Promise<void>;
- abort(): Promise<void>;
- suspend(): Promise<void>;
- resume(): Promise<void>;
- process(): Promise<TaskRunResult>;
-}
-
-export enum TaskRunResultType {
- Finished = "finished",
- Backoff = "backoff",
- Progress = "progress",
- Error = "error",
- ScheduleLater = "schedule-later",
-}
-
-export type TaskRunResult =
- | TaskRunFinishedResult
- | TaskRunErrorResult
- | TaskRunBackoffResult
- | TaskRunProgressResult
- | TaskRunScheduleLaterResult;
-
-export namespace TaskRunResult {
- /**
- * Task is finished and does not need to be processed again.
- */
- export function finished(): TaskRunResult {
- return {
- type: TaskRunResultType.Finished,
- };
- }
- /**
- * Task is waiting for something, should be invoked
- * again with exponentiall back-off until some other
- * result is returned.
- */
- export function backoff(): TaskRunResult {
- return {
- type: TaskRunResultType.Backoff,
- };
- }
- /**
- * Task made progress and should be processed again.
- */
- export function progress(): TaskRunResult {
- return {
- type: TaskRunResultType.Progress,
- };
- }
- /**
- * Run the task again at a fixed time in the future.
- */
- export function runAgainAt(runAt: AbsoluteTime): TaskRunResult {
- return {
- type: TaskRunResultType.ScheduleLater,
- runAt,
- };
- }
-}
-
-export interface TaskRunFinishedResult {
- type: TaskRunResultType.Finished;
-}
-
-export interface TaskRunBackoffResult {
- type: TaskRunResultType.Backoff;
-}
-
-export interface TaskRunProgressResult {
- type: TaskRunResultType.Progress;
-}
-
-export interface TaskRunScheduleLaterResult {
- type: TaskRunResultType.ScheduleLater;
- runAt: AbsoluteTime;
-}
-
-export interface TaskRunErrorResult {
- type: TaskRunResultType.Error;
- errorDetail: TalerErrorDetail;
-}
-
-export interface DbRetryInfo {
- firstTry: DbPreciseTimestamp;
- nextRetry: DbPreciseTimestamp;
- retryCounter: number;
-}
-
-export interface RetryPolicy {
- readonly backoffDelta: Duration;
- readonly backoffBase: number;
- readonly maxTimeout: Duration;
-}
-
-const defaultRetryPolicy: RetryPolicy = {
- backoffBase: 1.5,
- backoffDelta: Duration.fromSpec({ seconds: 1 }),
- maxTimeout: Duration.fromSpec({ minutes: 2 }),
-};
-
-function updateTimeout(
- r: DbRetryInfo,
- p: RetryPolicy = defaultRetryPolicy,
-): void {
- const now = AbsoluteTime.now();
- if (now.t_ms === "never") {
- throw Error("assertion failed");
- }
- if (p.backoffDelta.d_ms === "forever") {
- r.nextRetry = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- );
- return;
- }
-
- const nextIncrement =
- p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
-
- const t =
- now.t_ms +
- (p.maxTimeout.d_ms === "forever"
- ? nextIncrement
- : Math.min(p.maxTimeout.d_ms, nextIncrement));
- r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
-}
-
-export namespace DbRetryInfo {
- export function getDuration(
- r: DbRetryInfo | undefined,
- p: RetryPolicy = defaultRetryPolicy,
- ): Duration {
- if (!r) {
- // If we don't have any retry info, run immediately.
- return { d_ms: 0 };
- }
- if (p.backoffDelta.d_ms === "forever") {
- return { d_ms: "forever" };
- }
- const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
- return {
- d_ms:
- p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t),
- };
- }
-
- export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo {
- const now = TalerPreciseTimestamp.now();
- const info: DbRetryInfo = {
- firstTry: timestampPreciseToDb(now),
- nextRetry: timestampPreciseToDb(now),
- retryCounter: 0,
- };
- updateTimeout(info, p);
- return info;
- }
-
- export function increment(
- r: DbRetryInfo | undefined,
- p: RetryPolicy = defaultRetryPolicy,
- ): DbRetryInfo {
- if (!r) {
- return reset(p);
- }
- const r2 = { ...r };
- r2.retryCounter++;
- updateTimeout(r2, p);
- return r2;
- }
-}
-
-/**
- * Timestamp after which the wallet would do an auto-refresh.
- */
-export function getAutoRefreshExecuteThreshold(d: {
- stampExpireWithdraw: TalerProtocolTimestamp;
- stampExpireDeposit: TalerProtocolTimestamp;
-}): AbsoluteTime {
- const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
- d.stampExpireWithdraw,
- );
- const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
- d.stampExpireDeposit,
- );
- const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
- const deltaDiv = durationMul(delta, 0.5);
- return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
-}
-
-/**
- * Parsed representation of task identifiers.
- */
-export type ParsedTaskIdentifier =
- | {
- tag: PendingTaskType.Withdraw;
- withdrawalGroupId: string;
- }
- | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
- | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
- | { tag: PendingTaskType.Deposit; depositGroupId: string }
- | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string }
- | { tag: PendingTaskType.PeerPullCredit; pursePub: string }
- | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string }
- | { tag: PendingTaskType.PeerPushDebit; pursePub: string }
- | { tag: PendingTaskType.Purchase; proposalId: string }
- | { tag: PendingTaskType.Recoup; recoupGroupId: string }
- | { tag: PendingTaskType.RewardPickup; walletRewardId: string }
- | { tag: PendingTaskType.Refresh; refreshGroupId: string };
-
-export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
- const task = x.split(":");
-
- if (task.length < 2) {
- throw Error("task id should have al least 2 parts separated by ':'");
- }
-
- const [type, ...rest] = task;
- switch (type) {
- case PendingTaskType.Backup:
- return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) };
- case PendingTaskType.Deposit:
- return { tag: type, depositGroupId: rest[0] };
- case PendingTaskType.ExchangeUpdate:
- return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
- case PendingTaskType.PeerPullCredit:
- return { tag: type, pursePub: rest[0] };
- case PendingTaskType.PeerPullDebit:
- return { tag: type, peerPullDebitId: rest[0] };
- case PendingTaskType.PeerPushCredit:
- return { tag: type, peerPushCreditId: rest[0] };
- case PendingTaskType.PeerPushDebit:
- return { tag: type, pursePub: rest[0] };
- case PendingTaskType.Purchase:
- return { tag: type, proposalId: rest[0] };
- case PendingTaskType.Recoup:
- return { tag: type, recoupGroupId: rest[0] };
- case PendingTaskType.Refresh:
- return { tag: type, refreshGroupId: rest[0] };
- case PendingTaskType.RewardPickup:
- return { tag: type, walletRewardId: rest[0] };
- case PendingTaskType.Withdraw:
- return { tag: type, withdrawalGroupId: rest[0] };
- default:
- throw Error("invalid task identifier");
- }
-}
-
-export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskId {
- switch (p.tag) {
- case PendingTaskType.Backup:
- return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId;
- case PendingTaskType.Deposit:
- return `${p.tag}:${p.depositGroupId}` as TaskId;
- case PendingTaskType.ExchangeUpdate:
- return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskId;
- case PendingTaskType.PeerPullDebit:
- return `${p.tag}:${p.peerPullDebitId}` as TaskId;
- case PendingTaskType.PeerPushCredit:
- return `${p.tag}:${p.peerPushCreditId}` as TaskId;
- case PendingTaskType.PeerPullCredit:
- return `${p.tag}:${p.pursePub}` as TaskId;
- case PendingTaskType.PeerPushDebit:
- return `${p.tag}:${p.pursePub}` as TaskId;
- case PendingTaskType.Purchase:
- return `${p.tag}:${p.proposalId}` as TaskId;
- case PendingTaskType.Recoup:
- return `${p.tag}:${p.recoupGroupId}` as TaskId;
- case PendingTaskType.Refresh:
- return `${p.tag}:${p.refreshGroupId}` as TaskId;
- case PendingTaskType.RewardPickup:
- return `${p.tag}:${p.walletRewardId}` as TaskId;
- case PendingTaskType.Withdraw:
- return `${p.tag}:${p.withdrawalGroupId}` as TaskId;
- default:
- assertUnreachable(p);
- }
-}
-
-export namespace TaskIdentifiers {
- export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId {
- return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId;
- }
- export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId {
- return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
- exch.baseUrl,
- )}` as TaskId;
- }
- export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId {
- return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
- exchBaseUrl,
- )}` as TaskId;
- }
- export function forTipPickup(tipRecord: RewardRecord): TaskId {
- return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId;
- }
- export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId {
- return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId;
- }
- export function forPay(purchaseRecord: PurchaseRecord): TaskId {
- return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId;
- }
- export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId {
- return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId;
- }
- export function forDeposit(depositRecord: DepositGroupRecord): TaskId {
- return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId;
- }
- export function forBackup(backupRecord: BackupProviderRecord): TaskId {
- return `${PendingTaskType.Backup}:${encodeURIComponent(
- backupRecord.baseUrl,
- )}` as TaskId;
- }
- export function forPeerPushPaymentInitiation(
- ppi: PeerPushDebitRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId;
- }
- export function forPeerPullPaymentInitiation(
- ppi: PeerPullCreditRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId;
- }
- export function forPeerPullPaymentDebit(
- ppi: PeerPullPaymentIncomingRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskId;
- }
- export function forPeerPushCredit(
- ppi: PeerPushPaymentIncomingRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId;
- }
-}
-
-/**
- * Result of a transaction transition.
- */
-export enum TransitionResult {
- Transition = 1,
- Stay = 2,
-}
-
-/**
- * Transaction context.
- * Uniform interface to all transactions.
- */
-export interface TransactionContext {
- abortTransaction(): Promise<void>;
- suspendTransaction(): Promise<void>;
- resumeTransaction(): Promise<void>;
- failTransaction(): Promise<void>;
- deleteTransaction(): Promise<void>;
-}
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts
deleted file mode 100644
index 415f3cd72..000000000
--- a/packages/taler-wallet-core/src/operations/deposits.ts
+++ /dev/null
@@ -1,1598 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the deposit transaction.
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AmountJson,
- Amounts,
- BatchDepositRequestCoin,
- CancellationToken,
- CoinRefreshRequest,
- CreateDepositGroupRequest,
- CreateDepositGroupResponse,
- DepositGroupFees,
- Duration,
- ExchangeBatchDepositRequest,
- ExchangeRefundRequest,
- HttpStatusCode,
- Logger,
- MerchantContractTerms,
- NotificationType,
- PayCoinSelection,
- PrepareDepositRequest,
- PrepareDepositResponse,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TrackTransaction,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- WireFee,
- canonicalJson,
- codecForBatchDepositSuccess,
- codecForTackTransactionAccepted,
- codecForTackTransactionWired,
- durationFromSpec,
- encodeCrock,
- getRandomBytes,
- hashTruncate32,
- hashWire,
- j2s,
- parsePaytoUri,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import { DepositElementStatus, DepositGroupRecord } from "../db.js";
-import {
- DepositOperationStatus,
- DepositTrackingInfo,
- KycPendingInfo,
- PendingTaskType,
- RefreshOperationStatus,
- TaskId,
- createRefreshGroup,
- getCandidateWithdrawalDenomsTx,
- getTotalRefreshCost,
- timestampPreciseToDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { selectPayCoinsNew } from "../util/coinSelection.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
- spendCoins,
-} from "./common.js";
-import { getExchangeWireDetailsInTx } from "./exchanges.js";
-import {
- extractContractData,
- generateDepositPermissions,
- getTotalPaymentCost,
-} from "./pay-merchant.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
-} from "./transactions.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("deposits.ts");
-
-export class DepositTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
- constructor(
- public ws: InternalWalletState,
- public depositGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const depositGroupId = this.depositGroupId;
- const ws = this.ws;
- // FIXME: We should check first if we are in a final state
- // where deletion is allowed.
- await ws.db.runReadWriteTx(["depositGroups", "tombstones"], async (tx) => {
- const tipRecord = await tx.depositGroups.get(depositGroupId);
- if (tipRecord) {
- await tx.depositGroups.delete(depositGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
- });
- }
- });
- return;
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- let newOpStatus: DepositOperationStatus | undefined;
- switch (dg.operationStatus) {
- case DepositOperationStatus.PendingDeposit:
- newOpStatus = DepositOperationStatus.SuspendedDeposit;
- break;
- case DepositOperationStatus.PendingKyc:
- newOpStatus = DepositOperationStatus.SuspendedKyc;
- break;
- case DepositOperationStatus.PendingTrack:
- newOpStatus = DepositOperationStatus.SuspendedTrack;
- break;
- case DepositOperationStatus.Aborting:
- newOpStatus = DepositOperationStatus.SuspendedAborting;
- break;
- }
- if (!newOpStatus) {
- return undefined;
- }
- dg.operationStatus = newOpStatus;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- switch (dg.operationStatus) {
- case DepositOperationStatus.Finished:
- return undefined;
- case DepositOperationStatus.PendingDeposit: {
- dg.operationStatus = DepositOperationStatus.Aborting;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- }
- case DepositOperationStatus.SuspendedDeposit:
- // FIXME: Can we abort a suspended transaction?!
- return undefined;
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return;
- }
- const oldState = computeDepositTransactionStatus(dg);
- let newOpStatus: DepositOperationStatus | undefined;
- switch (dg.operationStatus) {
- case DepositOperationStatus.SuspendedDeposit:
- newOpStatus = DepositOperationStatus.PendingDeposit;
- break;
- case DepositOperationStatus.SuspendedAborting:
- newOpStatus = DepositOperationStatus.Aborting;
- break;
- case DepositOperationStatus.SuspendedKyc:
- newOpStatus = DepositOperationStatus.PendingKyc;
- break;
- case DepositOperationStatus.SuspendedTrack:
- newOpStatus = DepositOperationStatus.PendingTrack;
- break;
- }
- if (!newOpStatus) {
- return undefined;
- }
- dg.operationStatus = newOpStatus;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- switch (dg.operationStatus) {
- case DepositOperationStatus.SuspendedAborting:
- case DepositOperationStatus.Aborting: {
- dg.operationStatus = DepositOperationStatus.Failed;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- }
- }
- return undefined;
- },
- );
- // FIXME: Also cancel ongoing work (via cancellation token, once implemented)
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-}
-
-/**
- * Get the (DD37-style) transaction status based on the
- * database record of a deposit group.
- */
-export function computeDepositTransactionStatus(
- dg: DepositGroupRecord,
-): TransactionState {
- switch (dg.operationStatus) {
- case DepositOperationStatus.Finished:
- return {
- major: TransactionMajorState.Done,
- };
- case DepositOperationStatus.PendingDeposit:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Deposit,
- };
- case DepositOperationStatus.PendingKyc:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.KycRequired,
- };
- case DepositOperationStatus.PendingTrack:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Track,
- };
- case DepositOperationStatus.SuspendedKyc:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.KycRequired,
- };
- case DepositOperationStatus.SuspendedTrack:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Track,
- };
- case DepositOperationStatus.SuspendedDeposit:
- return {
- major: TransactionMajorState.Suspended,
- };
- case DepositOperationStatus.Aborting:
- return {
- major: TransactionMajorState.Aborting,
- };
- case DepositOperationStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case DepositOperationStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case DepositOperationStatus.SuspendedAborting:
- return {
- major: TransactionMajorState.SuspendedAborting,
- };
- default:
- assertUnreachable(dg.operationStatus);
- }
-}
-
-/**
- * Compute the possible actions possible on a deposit transaction
- * based on the current transaction state.
- */
-export function computeDepositTransactionActions(
- dg: DepositGroupRecord,
-): TransactionAction[] {
- switch (dg.operationStatus) {
- case DepositOperationStatus.Finished:
- return [TransactionAction.Delete];
- case DepositOperationStatus.PendingDeposit:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case DepositOperationStatus.SuspendedDeposit:
- return [TransactionAction.Resume];
- case DepositOperationStatus.Aborting:
- return [TransactionAction.Fail, TransactionAction.Suspend];
- case DepositOperationStatus.Aborted:
- return [TransactionAction.Delete];
- case DepositOperationStatus.Failed:
- return [TransactionAction.Delete];
- case DepositOperationStatus.SuspendedAborting:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case DepositOperationStatus.PendingKyc:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case DepositOperationStatus.PendingTrack:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case DepositOperationStatus.SuspendedKyc:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case DepositOperationStatus.SuspendedTrack:
- return [TransactionAction.Resume, TransactionAction.Abort];
- default:
- assertUnreachable(dg.operationStatus);
- }
-}
-
-/**
- * Check whether the refresh associated with the
- * aborting deposit group is done.
- *
- * If done, mark the deposit transaction as aborted.
- *
- * Otherwise continue waiting.
- *
- * FIXME: Wait for the refresh group notifications instead of periodically
- * checking the refresh group status.
- * FIXME: This is just one transaction, can't we do this in the initial
- * transaction of processDepositGroup?
- */
-async function waitForRefreshOnDepositGroup(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
-): Promise<TaskRunResult> {
- const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: depositGroup.depositGroupId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups", "refreshGroups"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: DepositOperationStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into aborted.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = DepositOperationStatus.Aborted;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = DepositOperationStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = DepositOperationStatus.Aborted;
- }
- }
- if (newOpState) {
- const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
- if (!newDg) {
- return;
- }
- const oldTxState = computeDepositTransactionStatus(newDg);
- newDg.operationStatus = newOpState;
- const newTxState = computeDepositTransactionStatus(newDg);
- await tx.depositGroups.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
-}
-
-async function refundDepositGroup(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
-): Promise<TaskRunResult> {
- const newTxPerCoin = [...depositGroup.statusPerCoin];
- logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`);
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- const st = depositGroup.statusPerCoin[i];
- switch (st) {
- case DepositElementStatus.RefundFailed:
- case DepositElementStatus.RefundSuccess:
- break;
- default: {
- const coinPub = depositGroup.payCoinSelection.coinPubs[i];
- const coinExchange = await ws.db.runReadOnlyTx(
- ["coins"],
- async (tx) => {
- const coinRecord = await tx.coins.get(coinPub);
- checkDbInvariant(!!coinRecord);
- return coinRecord.exchangeBaseUrl;
- },
- );
- const refundAmount = depositGroup.payCoinSelection.coinContributions[i];
- // We use a constant refund transaction ID, since there can
- // only be one refund.
- const rtid = 1;
- const sig = await ws.cryptoApi.signRefund({
- coinPub,
- contractTermsHash: depositGroup.contractTermsHash,
- merchantPriv: depositGroup.merchantPriv,
- merchantPub: depositGroup.merchantPub,
- refundAmount: refundAmount,
- rtransactionId: rtid,
- });
- const refundReq: ExchangeRefundRequest = {
- h_contract_terms: depositGroup.contractTermsHash,
- merchant_pub: depositGroup.merchantPub,
- merchant_sig: sig.sig,
- refund_amount: refundAmount,
- rtransaction_id: rtid,
- };
- const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
- const httpResp = await ws.http.fetch(refundUrl.href, {
- method: "POST",
- body: refundReq,
- });
- logger.info(
- `coin ${i} refund HTTP status for coin: ${httpResp.status}`,
- );
- let newStatus: DepositElementStatus;
- if (httpResp.status === 200) {
- // FIXME: validate response
- newStatus = DepositElementStatus.RefundSuccess;
- } else {
- // FIXME: Store problem somewhere!
- newStatus = DepositElementStatus.RefundFailed;
- }
- // FIXME: Handle case where refund request needs to be tried again
- newTxPerCoin[i] = newStatus;
- break;
- }
- }
- }
- let isDone = true;
- for (let i = 0; i < newTxPerCoin.length; i++) {
- if (
- newTxPerCoin[i] != DepositElementStatus.RefundFailed &&
- newTxPerCoin[i] != DepositElementStatus.RefundSuccess
- ) {
- isDone = false;
- }
- }
-
- const currency = Amounts.currencyOf(depositGroup.totalPayCost);
-
- await ws.db.runReadWriteTx(
- [
- "depositGroups",
- "refreshGroups",
- "coins",
- "denominations",
- "coinAvailability",
- ],
- async (tx) => {
- const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
- if (!newDg) {
- return;
- }
- newDg.statusPerCoin = newTxPerCoin;
- const refreshCoins: CoinRefreshRequest[] = [];
- for (let i = 0; i < newTxPerCoin.length; i++) {
- refreshCoins.push({
- amount: depositGroup.payCoinSelection.coinContributions[i],
- coinPub: depositGroup.payCoinSelection.coinPubs[i],
- });
- }
- if (isDone) {
- const rgid = await createRefreshGroup(
- ws,
- tx,
- currency,
- refreshCoins,
- RefreshReason.AbortDeposit,
- constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: newDg.depositGroupId,
- }),
- );
- newDg.abortRefreshGroupId = rgid.refreshGroupId;
- }
- await tx.depositGroups.put(newDg);
- },
- );
-
- return TaskRunResult.backoff();
-}
-
-async function processDepositGroupAborting(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
-): Promise<TaskRunResult> {
- logger.info("processing deposit tx in 'aborting'");
- const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
- if (!abortRefreshGroupId) {
- logger.info("refunding deposit group");
- return refundDepositGroup(ws, depositGroup);
- }
- logger.info("waiting for refresh");
- return waitForRefreshOnDepositGroup(ws, depositGroup);
-}
-
-async function processDepositGroupPendingKyc(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const { depositGroupId } = depositGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId,
- });
-
- const kycInfo = depositGroup.kycInfo;
- const userType = "individual";
-
- if (!kycInfo) {
- throw Error("invalid DB state, in pending(kyc), but no kycInfo present");
- }
-
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- kycInfo.exchangeBaseUrl,
- );
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const newDg = await tx.depositGroups.get(depositGroupId);
- if (!newDg) {
- return;
- }
- if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) {
- return;
- }
- const oldTxState = computeDepositTransactionStatus(newDg);
- newDg.operationStatus = DepositOperationStatus.PendingTrack;
- const newTxState = computeDepositTransactionStatus(newDg);
- await tx.depositGroups.put(newDg);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-/**
- * Tracking information from the exchange indicated that
- * KYC is required. We need to check the KYC info
- * and transition the transaction to the KYC required state.
- */
-async function transitionToKycRequired(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- kycInfo: KycPendingInfo,
- exchangeUrl: string,
-): Promise<TaskRunResult> {
- const { depositGroupId } = depositGroup;
- const userType = "individual";
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
-
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- logger.info(`kyc url ${url.href}`);
- const kycStatusReq = await ws.http.fetch(url.href, {
- method: "GET",
- });
- if (kycStatusReq.status === HttpStatusCode.Ok) {
- logger.warn("kyc requested, but already fulfilled");
- return TaskRunResult.backoff();
- } else if (kycStatusReq.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusReq.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return undefined;
- }
- if (dg.operationStatus !== DepositOperationStatus.PendingTrack) {
- return undefined;
- }
- const oldTxState = computeDepositTransactionStatus(dg);
- dg.kycInfo = {
- exchangeBaseUrl: exchangeUrl,
- kycUrl: kycStatus.kyc_url,
- paytoHash: kycInfo.paytoHash,
- requirementRow: kycInfo.requirementRow,
- };
- await tx.depositGroups.put(dg);
- const newTxState = computeDepositTransactionStatus(dg);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
- }
-}
-
-async function processDepositGroupPendingTrack(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- cancellationToken?: CancellationToken,
-): Promise<TaskRunResult> {
- const { depositGroupId } = depositGroup;
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- const coinPub = depositGroup.payCoinSelection.coinPubs[i];
- // FIXME: Make the URL part of the coin selection?
- const exchangeBaseUrl = await ws.db.runReadWriteTx(
- ["coins"],
- async (tx) => {
- const coinRecord = await tx.coins.get(coinPub);
- checkDbInvariant(!!coinRecord);
- return coinRecord.exchangeBaseUrl;
- },
- );
-
- let updatedTxStatus: DepositElementStatus | undefined = undefined;
- let newWiredCoin:
- | {
- id: string;
- value: DepositTrackingInfo;
- }
- | undefined;
-
- if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) {
- const track = await trackDeposit(
- ws,
- depositGroup,
- coinPub,
- exchangeBaseUrl,
- );
-
- if (track.type === "accepted") {
- if (!track.kyc_ok && track.requirement_row !== undefined) {
- const paytoHash = encodeCrock(
- hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")),
- );
- const { requirement_row: requirementRow } = track;
- const kycInfo: KycPendingInfo = {
- paytoHash,
- requirementRow,
- };
- return transitionToKycRequired(
- ws,
- depositGroup,
- kycInfo,
- exchangeBaseUrl,
- );
- } else {
- updatedTxStatus = DepositElementStatus.Tracking;
- }
- } else if (track.type === "wired") {
- updatedTxStatus = DepositElementStatus.Wired;
-
- const payto = parsePaytoUri(depositGroup.wire.payto_uri);
- if (!payto) {
- throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`);
- }
-
- const fee = await getExchangeWireFee(
- ws,
- payto.targetType,
- exchangeBaseUrl,
- track.execution_time,
- );
- const raw = Amounts.parseOrThrow(track.coin_contribution);
- const wireFee = Amounts.parseOrThrow(fee.wireFee);
-
- newWiredCoin = {
- value: {
- amountRaw: Amounts.stringify(raw),
- wireFee: Amounts.stringify(wireFee),
- exchangePub: track.exchange_pub,
- timestampExecuted: timestampProtocolToDb(track.execution_time),
- wireTransferId: track.wtid,
- },
- id: track.exchange_sig,
- };
- } else {
- updatedTxStatus = DepositElementStatus.DepositPending;
- }
- }
-
- if (updatedTxStatus !== undefined) {
- await ws.db.runReadWriteTx(["depositGroups"], async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- if (updatedTxStatus !== undefined) {
- dg.statusPerCoin[i] = updatedTxStatus;
- }
- if (newWiredCoin) {
- /**
- * FIXME: if there is a new wire information from the exchange
- * it should add up to the previous tracking states.
- *
- * This may loose information by overriding prev state.
- *
- * And: add checks to integration tests
- */
- if (!dg.trackingState) {
- dg.trackingState = {};
- }
-
- dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
- }
- await tx.depositGroups.put(dg);
- });
- }
- }
-
- let allWired = true;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return undefined;
- }
- const oldTxState = computeDepositTransactionStatus(dg);
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) {
- allWired = false;
- break;
- }
- }
- if (allWired) {
- dg.timestampFinished = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- dg.operationStatus = DepositOperationStatus.Finished;
- await tx.depositGroups.put(dg);
- }
- const newTxState = computeDepositTransactionStatus(dg);
- return { oldTxState, newTxState };
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- if (allWired) {
- return TaskRunResult.finished();
- } else {
- // FIXME: Use long-polling.
- return TaskRunResult.backoff();
- }
-}
-
-async function processDepositGroupPendingDeposit(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- cancellationToken?: CancellationToken,
-): Promise<TaskRunResult> {
- logger.info("processing deposit group in pending(deposit)");
- const depositGroupId = depositGroup.depositGroupId;
- const contractTermsRec = await ws.db.runReadOnlyTx(
- ["contractTerms"],
- async (tx) => {
- return tx.contractTerms.get(depositGroup.contractTermsHash);
- },
- );
- if (!contractTermsRec) {
- throw Error("contract terms for deposit not found in database");
- }
- const contractTerms: MerchantContractTerms =
- contractTermsRec.contractTermsRaw;
- const contractData = extractContractData(
- contractTermsRec.contractTermsRaw,
- depositGroup.contractTermsHash,
- "",
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
-
- // Check for cancellation before expensive operations.
- cancellationToken?.throwIfCancelled();
-
- // FIXME: Cache these!
- const depositPermissions = await generateDepositPermissions(
- ws,
- depositGroup.payCoinSelection,
- contractData,
- );
-
- // Exchanges involved in the deposit
- const exchanges: Set<string> = new Set();
-
- for (const dp of depositPermissions) {
- exchanges.add(dp.exchange_url);
- }
-
- // We need to do one batch per exchange.
- for (const exchangeUrl of exchanges.values()) {
- const coins: BatchDepositRequestCoin[] = [];
- const batchIndexes: number[] = [];
-
- const batchReq: ExchangeBatchDepositRequest = {
- coins,
- h_contract_terms: depositGroup.contractTermsHash,
- merchant_payto_uri: depositGroup.wire.payto_uri,
- merchant_pub: contractTerms.merchant_pub,
- timestamp: contractTerms.timestamp,
- wire_salt: depositGroup.wire.salt,
- wire_transfer_deadline: contractTerms.wire_transfer_deadline,
- refund_deadline: contractTerms.refund_deadline,
- };
-
- for (let i = 0; i < depositPermissions.length; i++) {
- const perm = depositPermissions[i];
- if (perm.exchange_url != exchangeUrl) {
- continue;
- }
- coins.push({
- coin_pub: perm.coin_pub,
- coin_sig: perm.coin_sig,
- contribution: Amounts.stringify(perm.contribution),
- denom_pub_hash: perm.h_denom,
- ub_sig: perm.ub_sig,
- h_age_commitment: perm.h_age_commitment,
- });
- batchIndexes.push(i);
- }
-
- // Check for cancellation before making network request.
- cancellationToken?.throwIfCancelled();
- const url = new URL(`batch-deposit`, exchangeUrl);
- logger.info(`depositing to ${url.href}`);
- logger.trace(`deposit request: ${j2s(batchReq)}`);
- const httpResp = await ws.http.fetch(url.href, {
- method: "POST",
- body: batchReq,
- cancellationToken: cancellationToken,
- });
- await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForBatchDepositSuccess(),
- );
-
- await ws.db.runReadWriteTx(["depositGroups"], async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- for (const batchIndex of batchIndexes) {
- const coinStatus = dg.statusPerCoin[batchIndex];
- switch (coinStatus) {
- case DepositElementStatus.DepositPending:
- dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
- await tx.depositGroups.put(dg);
- }
- }
- });
- }
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return undefined;
- }
- const oldTxState = computeDepositTransactionStatus(dg);
- dg.operationStatus = DepositOperationStatus.PendingTrack;
- await tx.depositGroups.put(dg);
- const newTxState = computeDepositTransactionStatus(dg);
- return { oldTxState, newTxState };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.progress();
-}
-
-/**
- * Process a deposit group that is not in its final state yet.
- */
-export async function processDepositGroup(
- ws: InternalWalletState,
- depositGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const depositGroup = await ws.db.runReadOnlyTx(
- ["depositGroups"],
- async (tx) => {
- return tx.depositGroups.get(depositGroupId);
- },
- );
- if (!depositGroup) {
- logger.warn(`deposit group ${depositGroupId} not found`);
- return TaskRunResult.finished();
- }
-
- switch (depositGroup.operationStatus) {
- case DepositOperationStatus.PendingTrack:
- return processDepositGroupPendingTrack(
- ws,
- depositGroup,
- cancellationToken,
- );
- case DepositOperationStatus.PendingKyc:
- return processDepositGroupPendingKyc(ws, depositGroup, cancellationToken);
- case DepositOperationStatus.PendingDeposit:
- return processDepositGroupPendingDeposit(
- ws,
- depositGroup,
- cancellationToken,
- );
- case DepositOperationStatus.Aborting:
- return processDepositGroupAborting(ws, depositGroup);
- }
-
- return TaskRunResult.finished();
-}
-
-/**
- * FIXME: Consider moving this to exchanges.ts.
- */
-async function getExchangeWireFee(
- ws: InternalWalletState,
- wireType: string,
- baseUrl: string,
- time: TalerProtocolTimestamp,
-): Promise<WireFee> {
- const exchangeDetails = await ws.db.runReadOnlyTx(
- ["exchangeDetails", "exchanges"],
- async (tx) => {
- const ex = await tx.exchanges.get(baseUrl);
- if (!ex || !ex.detailsPointer) return undefined;
- return await tx.exchangeDetails.indexes.byPointer.get([
- baseUrl,
- ex.detailsPointer.currency,
- ex.detailsPointer.masterPublicKey,
- ]);
- },
- );
-
- if (!exchangeDetails) {
- throw Error(`exchange missing: ${baseUrl}`);
- }
-
- const fees = exchangeDetails.wireInfo.feesForType[wireType];
- if (!fees || fees.length === 0) {
- throw Error(
- `exchange ${baseUrl} doesn't have fees for wire type ${wireType}`,
- );
- }
- const fee = fees.find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.fromProtocolTimestamp(time),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- });
- if (!fee) {
- throw Error(
- `exchange ${exchangeDetails.exchangeBaseUrl} doesn't have fees for wire type ${wireType} at ${time.t_s}`,
- );
- }
-
- return fee;
-}
-
-async function trackDeposit(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- coinPub: string,
- exchangeUrl: string,
-): Promise<TrackTransaction> {
- const wireHash = hashWire(
- depositGroup.wire.payto_uri,
- depositGroup.wire.salt,
- );
-
- const url = new URL(
- `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
- exchangeUrl,
- );
- const sigResp = await ws.cryptoApi.signTrackTransaction({
- coinPub,
- contractTermsHash: depositGroup.contractTermsHash,
- merchantPriv: depositGroup.merchantPriv,
- merchantPub: depositGroup.merchantPub,
- wireHash,
- });
- url.searchParams.set("merchant_sig", sigResp.sig);
- const httpResp = await ws.http.fetch(url.href, { method: "GET" });
- logger.trace(`deposits response status: ${httpResp.status}`);
- switch (httpResp.status) {
- case HttpStatusCode.Accepted: {
- const accepted = await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForTackTransactionAccepted(),
- );
- return { type: "accepted", ...accepted };
- }
- case HttpStatusCode.Ok: {
- const wired = await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForTackTransactionWired(),
- );
- return { type: "wired", ...wired };
- }
- default: {
- throw Error(
- `unexpected response from track-transaction (${httpResp.status})`,
- );
- }
- }
-}
-
-/**
- * Check if creating a deposit group is possible and calculate
- * the associated fees.
- *
- * FIXME: This should be renamed to checkDepositGroup,
- * as it doesn't prepare anything
- */
-export async function prepareDepositGroup(
- ws: InternalWalletState,
- req: PrepareDepositRequest,
-): Promise<PrepareDepositResponse> {
- const p = parsePaytoUri(req.depositPaytoUri);
- if (!p) {
- throw Error("invalid payto URI");
- }
- const amount = Amounts.parseOrThrow(req.amount);
-
- const exchangeInfos: { url: string; master_pub: string }[] = [];
-
- await ws.db.runReadOnlyTx(["exchangeDetails", "exchanges"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || amount.currency !== details.currency) {
- continue;
- }
- exchangeInfos.push({
- master_pub: details.masterPublicKey,
- url: e.baseUrl,
- });
- }
- });
-
- const now = AbsoluteTime.now();
- const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
- const contractTerms: MerchantContractTerms = {
- exchanges: exchangeInfos,
- amount: req.amount,
- max_fee: Amounts.stringify(amount),
- max_wire_fee: Amounts.stringify(amount),
- wire_method: p.targetType,
- timestamp: nowRounded,
- merchant_base_url: "",
- summary: "",
- nonce: "",
- wire_transfer_deadline: nowRounded,
- order_id: "",
- h_wire: "",
- pay_deadline: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
- ),
- merchant: {
- name: "(wallet)",
- },
- merchant_pub: "",
- refund_deadline: TalerProtocolTimestamp.zero(),
- };
-
- const { h: contractTermsHash } = await ws.cryptoApi.hashString({
- str: canonicalJson(contractTerms),
- });
-
- const contractData = extractContractData(
- contractTerms,
- contractTermsHash,
- "",
- );
-
- const payCoinSel = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins: [],
- });
-
- if (payCoinSel.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
- }
-
- const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
-
- const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
- ws,
- p.targetType,
- payCoinSel.coinSel,
- );
-
- const fees = await getTotalFeesForDepositAmount(
- ws,
- p.targetType,
- amount,
- payCoinSel.coinSel,
- );
-
- return {
- totalDepositCost: Amounts.stringify(totalDepositCost),
- effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
- fees,
- };
-}
-
-export function generateDepositGroupTxId(): string {
- const depositGroupId = encodeCrock(getRandomBytes(32));
- return constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: depositGroupId,
- });
-}
-
-export async function createDepositGroup(
- ws: InternalWalletState,
- req: CreateDepositGroupRequest,
-): Promise<CreateDepositGroupResponse> {
- const p = parsePaytoUri(req.depositPaytoUri);
- if (!p) {
- throw Error("invalid payto URI");
- }
-
- const amount = Amounts.parseOrThrow(req.amount);
-
- const exchangeInfos: { url: string; master_pub: string }[] = [];
-
- await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || amount.currency !== details.currency) {
- continue;
- }
- exchangeInfos.push({
- master_pub: details.masterPublicKey,
- url: e.baseUrl,
- });
- }
- });
-
- const now = AbsoluteTime.now();
- const wireDeadline = AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
- );
- const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
- const noncePair = await ws.cryptoApi.createEddsaKeypair({});
- const merchantPair = await ws.cryptoApi.createEddsaKeypair({});
- const wireSalt = encodeCrock(getRandomBytes(16));
- const wireHash = hashWire(req.depositPaytoUri, wireSalt);
- const contractTerms: MerchantContractTerms = {
- exchanges: exchangeInfos,
- amount: req.amount,
- max_fee: Amounts.stringify(amount),
- max_wire_fee: Amounts.stringify(amount),
- wire_method: p.targetType,
- timestamp: nowRounded,
- merchant_base_url: "",
- summary: "",
- nonce: noncePair.pub,
- wire_transfer_deadline: wireDeadline,
- order_id: "",
- h_wire: wireHash,
- pay_deadline: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
- ),
- merchant: {
- name: "(wallet)",
- },
- merchant_pub: merchantPair.pub,
- refund_deadline: TalerProtocolTimestamp.zero(),
- };
-
- const { h: contractTermsHash } = await ws.cryptoApi.hashString({
- str: canonicalJson(contractTerms),
- });
-
- const contractData = extractContractData(
- contractTerms,
- contractTermsHash,
- "",
- );
-
- const payCoinSel = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins: [],
- });
-
- if (payCoinSel.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
- }
-
- const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
-
- let depositGroupId: string;
- if (req.transactionId) {
- const txId = parseTransactionIdentifier(req.transactionId);
- if (!txId || txId.tag !== TransactionType.Deposit) {
- throw Error("invalid transaction ID");
- }
- depositGroupId = txId.depositGroupId;
- } else {
- depositGroupId = encodeCrock(getRandomBytes(32));
- }
-
- const counterpartyEffectiveDepositAmount =
- await getCounterpartyEffectiveDepositAmount(
- ws,
- p.targetType,
- payCoinSel.coinSel,
- );
-
- const depositGroup: DepositGroupRecord = {
- contractTermsHash,
- depositGroupId,
- currency: Amounts.currencyOf(totalDepositCost),
- amount: contractData.amount,
- noncePriv: noncePair.priv,
- noncePub: noncePair.pub,
- timestampCreated: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(now),
- ),
- timestampFinished: undefined,
- statusPerCoin: payCoinSel.coinSel.coinPubs.map(
- () => DepositElementStatus.DepositPending,
- ),
- payCoinSelection: payCoinSel.coinSel,
- payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
- merchantPriv: merchantPair.priv,
- merchantPub: merchantPair.pub,
- totalPayCost: Amounts.stringify(totalDepositCost),
- counterpartyEffectiveDepositAmount: Amounts.stringify(
- counterpartyEffectiveDepositAmount,
- ),
- wireTransferDeadline: timestampProtocolToDb(
- contractTerms.wire_transfer_deadline,
- ),
- wire: {
- payto_uri: req.depositPaytoUri,
- salt: wireSalt,
- },
- operationStatus: DepositOperationStatus.PendingDeposit,
- };
-
- const ctx = new DepositTransactionContext(ws, depositGroupId);
- const transactionId = ctx.transactionId;
-
- const newTxState = await ws.db.runReadWriteTx(
- [
- "depositGroups",
- "coins",
- "recoupGroups",
- "denominations",
- "refreshGroups",
- "coinAvailability",
- "contractTerms",
- ],
- async (tx) => {
- await spendCoins(ws, tx, {
- allocationId: transactionId,
- coinPubs: payCoinSel.coinSel.coinPubs,
- contributions: payCoinSel.coinSel.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
- ),
- refreshReason: RefreshReason.PayDeposit,
- });
- await tx.depositGroups.put(depositGroup);
- await tx.contractTerms.put({
- contractTermsRaw: contractTerms,
- h: contractTermsHash,
- });
- return computeDepositTransactionStatus(depositGroup);
- },
- );
-
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- transactionId,
- oldTxState: {
- major: TransactionMajorState.None,
- },
- newTxState,
- });
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- depositGroupId,
- transactionId,
- };
-}
-
-/**
- * Get the amount that will be deposited on the users bank
- * account after depositing, not considering aggregation.
- */
-export async function getCounterpartyEffectiveDepositAmount(
- ws: InternalWalletState,
- wireType: string,
- pcs: PayCoinSelection,
-): Promise<AmountJson> {
- const amt: AmountJson[] = [];
- const fees: AmountJson[] = [];
- const exchangeSet: Set<string> = new Set();
-
- await ws.db.runReadOnlyTx(
- ["coins", "denominations", "exchangeDetails", "exchanges"],
- async (tx) => {
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate deposit amount, coin not found");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("can't find denomination to calculate deposit amount");
- }
- amt.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
- fees.push(Amounts.parseOrThrow(denom.feeDeposit));
- exchangeSet.add(coin.exchangeBaseUrl);
- }
-
- for (const exchangeUrl of exchangeSet.values()) {
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- exchangeUrl,
- );
- if (!exchangeDetails) {
- continue;
- }
-
- // FIXME/NOTE: the line below _likely_ throws exception
- // about "find method not found on undefined" when the wireType
- // is not supported by the Exchange.
- const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- })?.wireFee;
- if (fee) {
- fees.push(Amounts.parseOrThrow(fee));
- }
- }
- },
- );
- return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
-}
-
-/**
- * Get the fee amount that will be charged when trying to deposit the
- * specified amount using the selected coins and the wire method.
- */
-async function getTotalFeesForDepositAmount(
- ws: InternalWalletState,
- wireType: string,
- total: AmountJson,
- pcs: PayCoinSelection,
-): Promise<DepositGroupFees> {
- const wireFee: AmountJson[] = [];
- const coinFee: AmountJson[] = [];
- const refreshFee: AmountJson[] = [];
- const exchangeSet: Set<string> = new Set();
- const currency = Amounts.currencyOf(total);
-
- await ws.db.runReadOnlyTx(
- ["coins", "denominations", "exchanges", "exchangeDetails"],
- async (tx) => {
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate deposit amount, coin not found");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("can't find denomination to calculate deposit amount");
- }
- coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
- exchangeSet.add(coin.exchangeBaseUrl);
-
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coinContributions[i],
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- ws.config.testing.denomselAllowLate,
- );
- refreshFee.push(refreshCost);
- }
-
- for (const exchangeUrl of exchangeSet.values()) {
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- exchangeUrl,
- );
- if (!exchangeDetails) {
- continue;
- }
- const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
- (x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- },
- )?.wireFee;
- if (fee) {
- wireFee.push(Amounts.parseOrThrow(fee));
- }
- }
- },
- );
-
- return {
- coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
- wire: Amounts.stringify(Amounts.sumOrZero(total.currency, wireFee).amount),
- refresh: Amounts.stringify(
- Amounts.sumOrZero(total.currency, refreshFee).amount,
- ),
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
deleted file mode 100644
index 678e48fb9..000000000
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ /dev/null
@@ -1,2007 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * @fileoverview
- * Implementation of exchange entry management in wallet-core.
- * The details of exchange entry management are specified in DD48.
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AgeRestriction,
- Amounts,
- AsyncFlag,
- CancellationToken,
- CoinRefreshRequest,
- CoinStatus,
- DeleteExchangeRequest,
- DenomKeyType,
- DenomOperationMap,
- DenominationInfo,
- DenominationPubKey,
- Duration,
- EddsaPublicKeyString,
- ExchangeAuditor,
- ExchangeDetailedResponse,
- ExchangeGlobalFees,
- ExchangeListItem,
- ExchangeSignKeyJson,
- ExchangeTosStatus,
- ExchangeWireAccount,
- ExchangesListResponse,
- FeeDescription,
- GetExchangeEntryByUrlRequest,
- GetExchangeResourcesResponse,
- GetExchangeTosResult,
- GlobalFees,
- LibtoolVersion,
- Logger,
- NotificationType,
- OperationErrorInfo,
- Recoup,
- RefreshReason,
- ScopeInfo,
- ScopeType,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolDuration,
- TalerProtocolTimestamp,
- URL,
- WalletNotification,
- WireFee,
- WireFeeMap,
- WireFeesJson,
- WireInfo,
- assertUnreachable,
- canonicalizeBaseUrl,
- codecForExchangeKeysJson,
- durationFromSpec,
- durationMul,
- encodeCrock,
- hashDenomPub,
- j2s,
- makeErrorDetail,
- parsePaytoUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- getExpiry,
- readSuccessResponseJsonOrThrow,
- readSuccessResponseTextOrThrow,
-} from "@gnu-taler/taler-util/http";
-import {
- DenominationRecord,
- DenominationVerificationStatus,
- ExchangeDetailsRecord,
- ExchangeEntryRecord,
- WalletStoresV1,
-} from "../db.js";
-import {
- ExchangeEntryDbRecordStatus,
- ExchangeEntryDbUpdateStatus,
- PendingTaskType,
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
- createRefreshGroup,
- createTimeline,
- isWithdrawableDenom,
- selectBestForOverlappingDenominations,
- selectMinimumFee,
- timestampAbsoluteFromDb,
- timestampOptionalPreciseFromDb,
- timestampPreciseFromDb,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { DbReadOnlyTransaction } from "../util/query.js";
-import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
-import {
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- getAutoRefreshExecuteThreshold,
- getExchangeEntryStatusFromRecord,
- getExchangeState,
- getExchangeTosStatusFromRecord,
- getExchangeUpdateStatusFromRecord,
-} from "./common.js";
-
-const logger = new Logger("exchanges.ts");
-
-function getExchangeRequestTimeout(): Duration {
- return Duration.fromSpec({
- seconds: 15,
- });
-}
-
-interface ExchangeTosDownloadResult {
- tosText: string;
- tosEtag: string;
- tosContentType: string;
- tosContentLanguage: string | undefined;
- tosAvailableLanguages: string[];
-}
-
-async function downloadExchangeWithTermsOfService(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
- acceptFormat: string,
- acceptLanguage: string | undefined,
-): Promise<ExchangeTosDownloadResult> {
- logger.trace(`downloading exchange tos (type ${acceptFormat})`);
- const reqUrl = new URL("terms", exchangeBaseUrl);
- const headers: {
- Accept: string;
- "Accept-Language"?: string;
- } = {
- Accept: acceptFormat,
- };
-
- if (acceptLanguage) {
- headers["Accept-Language"] = acceptLanguage;
- }
-
- const resp = await http.fetch(reqUrl.href, {
- headers,
- timeout,
- });
- const tosText = await readSuccessResponseTextOrThrow(resp);
- const tosEtag = resp.headers.get("etag") || "unknown";
- const tosContentLanguage = resp.headers.get("content-language") || undefined;
- const tosContentType = resp.headers.get("content-type") || "text/plain";
- const availLangStr = resp.headers.get("avail-languages") || "";
- // Work around exchange bug that reports the same language multiple times.
- const availLangSet = new Set<string>(
- availLangStr.split(",").map((x) => x.trim()),
- );
- const tosAvailableLanguages = [...availLangSet];
-
- return {
- tosText,
- tosEtag,
- tosContentType,
- tosContentLanguage,
- tosAvailableLanguages,
- };
-}
-
-/**
- * Get exchange details from the database.
- */
-async function getExchangeRecordsInternal(
- tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
- exchangeBaseUrl: string,
-): Promise<ExchangeDetailsRecord | undefined> {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- return;
- }
- const dp = r.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- return await tx.exchangeDetails.indexes.byPointer.get([
- r.baseUrl,
- currency,
- masterPublicKey,
- ]);
-}
-
-export async function getExchangeScopeInfo(
- tx: WalletDbReadOnlyTransaction<
- [
- "exchanges",
- "exchangeDetails",
- "globalCurrencyExchanges",
- "globalCurrencyAuditors",
- ]
- >,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<ScopeInfo> {
- const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
- if (!det) {
- return {
- type: ScopeType.Exchange,
- currency: currency,
- url: exchangeBaseUrl,
- };
- }
- return internalGetExchangeScopeInfo(tx, det);
-}
-
-async function internalGetExchangeScopeInfo(
- tx: WalletDbReadOnlyTransaction<
- ["globalCurrencyExchanges", "globalCurrencyAuditors"]
- >,
- exchangeDetails: ExchangeDetailsRecord,
-): Promise<ScopeInfo> {
- const globalExchangeRec =
- await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
- exchangeDetails.currency,
- exchangeDetails.exchangeBaseUrl,
- exchangeDetails.masterPublicKey,
- ]);
- if (globalExchangeRec) {
- return {
- currency: exchangeDetails.currency,
- type: ScopeType.Global,
- };
- } else {
- for (const aud of exchangeDetails.auditors) {
- const globalAuditorRec =
- await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
- exchangeDetails.currency,
- aud.auditor_url,
- aud.auditor_pub,
- ]);
- if (globalAuditorRec) {
- return {
- currency: exchangeDetails.currency,
- type: ScopeType.Auditor,
- url: aud.auditor_url,
- };
- }
- }
- }
- return {
- currency: exchangeDetails.currency,
- type: ScopeType.Exchange,
- url: exchangeDetails.exchangeBaseUrl,
- };
-}
-
-async function makeExchangeListItem(
- tx: WalletDbReadOnlyTransaction<
- ["globalCurrencyExchanges", "globalCurrencyAuditors"]
- >,
- r: ExchangeEntryRecord,
- exchangeDetails: ExchangeDetailsRecord | undefined,
- lastError: TalerErrorDetail | undefined,
-): Promise<ExchangeListItem> {
- const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
- ? {
- error: lastError,
- }
- : undefined;
-
- let scopeInfo: ScopeInfo | undefined = undefined;
-
- if (exchangeDetails) {
- scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
- }
-
- return {
- exchangeBaseUrl: r.baseUrl,
- currency: exchangeDetails?.currency ?? r.presetCurrencyHint,
- exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
- exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
- tosStatus: getExchangeTosStatusFromRecord(r),
- ageRestrictionOptions: exchangeDetails?.ageMask
- ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
- : [],
- paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
- lastUpdateErrorInfo,
- scopeInfo,
- };
-}
-
-export interface ExchangeWireDetails {
- currency: string;
- masterPublicKey: EddsaPublicKeyString;
- wireInfo: WireInfo;
- exchangeBaseUrl: string;
- auditors: ExchangeAuditor[];
- globalFees: ExchangeGlobalFees[];
-}
-
-export async function getExchangeWireDetailsInTx(
- tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
- exchangeBaseUrl: string,
-): Promise<ExchangeWireDetails | undefined> {
- const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
- if (!det) {
- return undefined;
- }
- return {
- currency: det.currency,
- masterPublicKey: det.masterPublicKey,
- wireInfo: det.wireInfo,
- exchangeBaseUrl: det.exchangeBaseUrl,
- auditors: det.auditors,
- globalFees: det.globalFees,
- };
-}
-
-export async function lookupExchangeByUri(
- ws: InternalWalletState,
- req: GetExchangeEntryByUrlRequest,
-): Promise<ExchangeListItem> {
- return await ws.db.runReadOnlyTx(
- [
- "exchanges",
- "exchangeDetails",
- "operationRetries",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
- async (tx) => {
- const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
- if (!exchangeRec) {
- throw Error("exchange not found");
- }
- const exchangeDetails = await getExchangeRecordsInternal(
- tx,
- exchangeRec.baseUrl,
- );
- const opRetryRecord = await tx.operationRetries.get(
- TaskIdentifiers.forExchangeUpdate(exchangeRec),
- );
- return await makeExchangeListItem(
- tx,
- exchangeRec,
- exchangeDetails,
- opRetryRecord?.lastError,
- );
- },
- );
-}
-
-/**
- * Mark the current ToS version as accepted by the user.
- */
-export async function acceptExchangeTermsOfService(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- const notif = await ws.db.runReadWriteTx(
- ["exchangeDetails", "exchanges"],
- async (tx) => {
- const exch = await tx.exchanges.get(exchangeBaseUrl);
- if (exch && exch.tosCurrentEtag) {
- const oldExchangeState = getExchangeState(exch);
- exch.tosAcceptedEtag = exch.tosCurrentEtag;
- exch.tosAcceptedTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- await tx.exchanges.put(exch);
- const newExchangeState = getExchangeState(exch);
- return {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl,
- newExchangeState: newExchangeState,
- oldExchangeState: oldExchangeState,
- } satisfies WalletNotification;
- }
- return undefined;
- },
- );
- if (notif) {
- ws.notify(notif);
- }
-}
-
-/**
- * Validate wire fees and wire accounts.
- *
- * Throw an exception if they are invalid.
- */
-async function validateWireInfo(
- ws: InternalWalletState,
- versionCurrent: number,
- wireInfo: ExchangeKeysDownloadResult,
- masterPublicKey: string,
-): Promise<WireInfo> {
- for (const a of wireInfo.accounts) {
- logger.trace("validating exchange acct");
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidWireAccount({
- masterPub: masterPublicKey,
- paytoUri: a.payto_uri,
- sig: a.master_sig,
- versionCurrent,
- conversionUrl: a.conversion_url,
- creditRestrictions: a.credit_restrictions,
- debitRestrictions: a.debit_restrictions,
- });
- isValid = v;
- }
- if (!isValid) {
- throw Error("exchange acct signature invalid");
- }
- }
- logger.trace("account validation done");
- const feesForType: WireFeeMap = {};
- for (const wireMethod of Object.keys(wireInfo.wireFees)) {
- const feeList: WireFee[] = [];
- for (const x of wireInfo.wireFees[wireMethod]) {
- const startStamp = x.start_date;
- const endStamp = x.end_date;
- const fee: WireFee = {
- closingFee: Amounts.stringify(x.closing_fee),
- endStamp,
- sig: x.sig,
- startStamp,
- wireFee: Amounts.stringify(x.wire_fee),
- };
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidWireFee({
- masterPub: masterPublicKey,
- type: wireMethod,
- wf: fee,
- });
- isValid = v;
- }
- if (!isValid) {
- throw Error("exchange wire fee signature invalid");
- }
- feeList.push(fee);
- }
- feesForType[wireMethod] = feeList;
- }
-
- return {
- accounts: wireInfo.accounts,
- feesForType,
- };
-}
-
-/**
- * Validate global fees.
- *
- * Throw an exception if they are invalid.
- */
-async function validateGlobalFees(
- ws: InternalWalletState,
- fees: GlobalFees[],
- masterPub: string,
-): Promise<ExchangeGlobalFees[]> {
- const egf: ExchangeGlobalFees[] = [];
- for (const gf of fees) {
- logger.trace("validating exchange global fees");
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidGlobalFees({
- masterPub,
- gf,
- });
- isValid = v;
- }
-
- if (!isValid) {
- throw Error("exchange global fees signature invalid: " + gf.master_sig);
- }
- egf.push({
- accountFee: Amounts.stringify(gf.account_fee),
- historyFee: Amounts.stringify(gf.history_fee),
- purseFee: Amounts.stringify(gf.purse_fee),
- startDate: gf.start_date,
- endDate: gf.end_date,
- signature: gf.master_sig,
- historyTimeout: gf.history_expiration,
- purseLimit: gf.purse_account_limit,
- purseTimeout: gf.purse_timeout,
- });
- }
-
- return egf;
-}
-
-/**
- * Add an exchange entry to the wallet database in the
- * entry state "preset".
- *
- * Returns the notification to the caller that should be emitted
- * if the DB transaction succeeds.
- */
-export async function addPresetExchangeEntry(
- tx: WalletDbReadWriteTransaction<["exchanges"]>,
- exchangeBaseUrl: string,
- currencyHint?: string,
-): Promise<{ notification?: WalletNotification }> {
- let exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange) {
- const r: ExchangeEntryRecord = {
- entryStatus: ExchangeEntryDbRecordStatus.Preset,
- updateStatus: ExchangeEntryDbUpdateStatus.Initial,
- baseUrl: exchangeBaseUrl,
- presetCurrencyHint: currencyHint,
- detailsPointer: undefined,
- lastUpdate: undefined,
- lastKeysEtag: undefined,
- nextRefreshCheckStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- nextUpdateStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- tosAcceptedEtag: undefined,
- tosAcceptedTimestamp: undefined,
- tosCurrentEtag: undefined,
- };
- await tx.exchanges.put(r);
- return {
- notification: {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: exchangeBaseUrl,
- // Exchange did not exist yet
- oldExchangeState: undefined,
- newExchangeState: getExchangeState(r),
- },
- };
- }
- return {};
-}
-
-async function provideExchangeRecordInTx(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
- baseUrl: string,
-): Promise<{
- exchange: ExchangeEntryRecord;
- exchangeDetails: ExchangeDetailsRecord | undefined;
- notification?: WalletNotification;
-}> {
- let notification: WalletNotification | undefined = undefined;
- let exchange = await tx.exchanges.get(baseUrl);
- if (!exchange) {
- const r: ExchangeEntryRecord = {
- entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
- updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
- baseUrl: baseUrl,
- detailsPointer: undefined,
- lastUpdate: undefined,
- nextUpdateStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- nextRefreshCheckStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- lastKeysEtag: undefined,
- tosAcceptedEtag: undefined,
- tosAcceptedTimestamp: undefined,
- tosCurrentEtag: undefined,
- };
- await tx.exchanges.put(r);
- exchange = r;
- notification = {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: r.baseUrl,
- oldExchangeState: undefined,
- newExchangeState: getExchangeState(r),
- };
- }
- const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
- return { exchange, exchangeDetails, notification };
-}
-
-export interface ExchangeKeysDownloadResult {
- baseUrl: string;
- masterPublicKey: string;
- currency: string;
- auditors: ExchangeAuditor[];
- currentDenominations: DenominationRecord[];
- protocolVersion: string;
- signingKeys: ExchangeSignKeyJson[];
- reserveClosingDelay: TalerProtocolDuration;
- expiry: TalerProtocolTimestamp;
- recoup: Recoup[];
- listIssueDate: TalerProtocolTimestamp;
- globalFees: GlobalFees[];
- accounts: ExchangeWireAccount[];
- wireFees: { [methodName: string]: WireFeesJson[] };
-}
-
-/**
- * Download and validate an exchange's /keys data.
- */
-async function downloadExchangeKeysInfo(
- baseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
- cancellationToken: CancellationToken,
-): Promise<ExchangeKeysDownloadResult> {
- const keysUrl = new URL("keys", baseUrl);
-
- const resp = await http.fetch(keysUrl.href, {
- timeout,
- cancellationToken,
- });
-
- logger.info("got response to /keys request");
-
- // We must make sure to parse out the protocol version
- // before we validate the body.
- // Otherwise the parser might complain with a hard to understand
- // message about some other field, when it is just a version
- // incompatibility.
-
- const keysJson = await resp.json();
-
- const protocolVersion = keysJson.version;
- if (typeof protocolVersion !== "string") {
- throw Error("bad exchange, does not even specify protocol version");
- }
-
- const versionRes = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- protocolVersion,
- );
- if (!versionRes) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- {
- requestUrl: resp.requestUrl,
- httpStatusCode: resp.status,
- requestMethod: resp.requestMethod,
- },
- "exchange protocol version malformed",
- );
- }
- if (!versionRes.compatible) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- exchangeProtocolVersion: protocolVersion,
- walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- },
- "exchange protocol version not compatible with wallet",
- );
- }
-
- const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeKeysJson(),
- );
-
- if (exchangeKeysJsonUnchecked.denominations.length === 0) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
- {
- exchangeBaseUrl: baseUrl,
- },
- "exchange doesn't offer any denominations",
- );
- }
-
- const currency = exchangeKeysJsonUnchecked.currency;
-
- const currentDenominations: DenominationRecord[] = [];
-
- for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
- switch (denomGroup.cipher) {
- case "RSA":
- case "RSA+age_restricted": {
- let ageMask = 0;
- if (denomGroup.cipher === "RSA+age_restricted") {
- ageMask = denomGroup.age_mask;
- }
- for (const denomIn of denomGroup.denoms) {
- const denomPub: DenominationPubKey = {
- age_mask: ageMask,
- cipher: DenomKeyType.Rsa,
- rsa_public_key: denomIn.rsa_pub,
- };
- const denomPubHash = encodeCrock(hashDenomPub(denomPub));
- const value = Amounts.parseOrThrow(denomGroup.value);
- const rec: DenominationRecord = {
- denomPub,
- denomPubHash,
- exchangeBaseUrl: baseUrl,
- exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
- isOffered: true,
- isRevoked: false,
- value: Amounts.stringify(value),
- currency: value.currency,
- stampExpireDeposit: timestampProtocolToDb(
- denomIn.stamp_expire_deposit,
- ),
- stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
- stampExpireWithdraw: timestampProtocolToDb(
- denomIn.stamp_expire_withdraw,
- ),
- stampStart: timestampProtocolToDb(denomIn.stamp_start),
- verificationStatus: DenominationVerificationStatus.Unverified,
- masterSig: denomIn.master_sig,
- listIssueDate: timestampProtocolToDb(
- exchangeKeysJsonUnchecked.list_issue_date,
- ),
- fees: {
- feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
- feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
- feeRefund: Amounts.stringify(denomGroup.fee_refund),
- feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
- },
- };
- currentDenominations.push(rec);
- }
- break;
- }
- case "CS+age_restricted":
- case "CS":
- logger.warn("Clause-Schnorr denominations not supported");
- continue;
- default:
- logger.warn(
- `denomination type ${(denomGroup as any).cipher} not supported`,
- );
- continue;
- }
- }
-
- return {
- masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
- currency,
- baseUrl: exchangeKeysJsonUnchecked.base_url,
- auditors: exchangeKeysJsonUnchecked.auditors,
- currentDenominations,
- protocolVersion: exchangeKeysJsonUnchecked.version,
- signingKeys: exchangeKeysJsonUnchecked.signkeys,
- reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
- expiry: AbsoluteTime.toProtocolTimestamp(
- getExpiry(resp, {
- minDuration: Duration.fromSpec({ hours: 1 }),
- }),
- ),
- recoup: exchangeKeysJsonUnchecked.recoup ?? [],
- listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
- globalFees: exchangeKeysJsonUnchecked.global_fees,
- accounts: exchangeKeysJsonUnchecked.accounts,
- wireFees: exchangeKeysJsonUnchecked.wire_fees,
- };
-}
-
-async function downloadTosFromAcceptedFormat(
- ws: InternalWalletState,
- baseUrl: string,
- timeout: Duration,
- acceptedFormat?: string[],
- acceptLanguage?: string,
-): Promise<ExchangeTosDownloadResult> {
- let tosFound: ExchangeTosDownloadResult | undefined;
- // Remove this when exchange supports multiple content-type in accept header
- if (acceptedFormat)
- for (const format of acceptedFormat) {
- const resp = await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- format,
- acceptLanguage,
- );
- if (resp.tosContentType === format) {
- tosFound = resp;
- break;
- }
- }
- if (tosFound !== undefined) {
- return tosFound;
- }
- // If none of the specified format was found try text/plain
- return await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- "text/plain",
- acceptLanguage,
- );
-}
-
-/**
- * Transition an exchange into an updating state.
- *
- * If the update is forced, the exchange is put into an updating state
- * even if the old information should still be up to date.
- *
- * If the exchange entry doesn't exist,
- * a new ephemeral entry is created.
- */
-async function startUpdateExchangeEntry(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- options: { forceUpdate?: boolean } = {},
-): Promise<void> {
- const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
- logger.info(
- `starting update of exchange entry ${canonBaseUrl}, forced=${
- options.forceUpdate ?? false
- }`,
- );
-
- const { notification } = await ws.db.runReadWriteTx(
- ["exchanges", "exchangeDetails"],
- async (tx) => {
- return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl);
- },
- );
-
- if (notification) {
- ws.notify(notification);
- }
-
- const { oldExchangeState, newExchangeState, taskId } =
- await ws.db.runReadWriteTx(
- ["exchanges", "operationRetries"],
- async (tx) => {
- const r = await tx.exchanges.get(canonBaseUrl);
- if (!r) {
- throw Error("exchange not found");
- }
- const oldExchangeState = getExchangeState(r);
- switch (r.updateStatus) {
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- break;
- case ExchangeEntryDbUpdateStatus.Suspended:
- break;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- break;
- case ExchangeEntryDbUpdateStatus.Ready: {
- const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
- timestampPreciseFromDb(r.nextUpdateStamp),
- );
- // Only update if entry is outdated or update is forced.
- if (
- options.forceUpdate ||
- AbsoluteTime.isExpired(nextUpdateTimestamp)
- ) {
- r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
- }
- break;
- }
- case ExchangeEntryDbUpdateStatus.Initial:
- r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
- break;
- }
- await tx.exchanges.put(r);
- const newExchangeState = getExchangeState(r);
- // Reset retries for updating the exchange entry.
- const taskId = TaskIdentifiers.forExchangeUpdate(r);
- await tx.operationRetries.delete(taskId);
- return { oldExchangeState, newExchangeState, taskId };
- },
- );
- ws.notify({
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: canonBaseUrl,
- newExchangeState: newExchangeState,
- oldExchangeState: oldExchangeState,
- });
- await ws.taskScheduler.resetTaskRetries(taskId);
-}
-
-/**
- * Basic information about an exchange in a ready state.
- */
-export interface ReadyExchangeSummary {
- exchangeBaseUrl: string;
- currency: string;
- masterPub: string;
- tosStatus: ExchangeTosStatus;
- tosAcceptedEtag: string | undefined;
- tosCurrentEtag: string | undefined;
- wireInfo: WireInfo;
- protocolVersionRange: string;
- tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
- scopeInfo: ScopeInfo;
-}
-
-async function internalWaitReadyExchange(
- ws: InternalWalletState,
- canonUrl: string,
- exchangeNotifFlag: AsyncFlag,
- options: {
- cancellationToken?: CancellationToken;
- forceUpdate?: boolean;
- expectedMasterPub?: string;
- } = {},
-): Promise<ReadyExchangeSummary> {
- const operationId = constructTaskIdentifier({
- tag: PendingTaskType.ExchangeUpdate,
- exchangeBaseUrl: canonUrl,
- });
- while (true) {
- logger.info(`waiting for ready exchange ${canonUrl}`);
- const { exchange, exchangeDetails, retryInfo, scopeInfo } =
- await ws.db.runReadOnlyTx(
- [
- "exchanges",
- "exchangeDetails",
- "operationRetries",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
- async (tx) => {
- const exchange = await tx.exchanges.get(canonUrl);
- const exchangeDetails = await getExchangeRecordsInternal(
- tx,
- canonUrl,
- );
- const retryInfo = await tx.operationRetries.get(operationId);
- let scopeInfo: ScopeInfo | undefined = undefined;
- if (exchange && exchangeDetails) {
- scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
- }
- return { exchange, exchangeDetails, retryInfo, scopeInfo };
- },
- );
-
- if (!exchange) {
- throw Error("exchange entry does not exist anymore");
- }
-
- let ready = false;
-
- switch (exchange.updateStatus) {
- case ExchangeEntryDbUpdateStatus.Ready:
- ready = true;
- break;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- // If the update is forced,
- // we wait until we're in a full "ready" state,
- // as we're not happy with the stale information.
- if (!options.forceUpdate) {
- ready = true;
- }
- break;
- default: {
- if (retryInfo) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
- {
- exchangeBaseUrl: canonUrl,
- innerError: retryInfo?.lastError,
- },
- );
- }
- }
- }
-
- if (!ready) {
- logger.info("waiting for exchange update notification");
- await exchangeNotifFlag.wait();
- logger.info("done waiting for exchange update notification");
- exchangeNotifFlag.reset();
- continue;
- }
-
- if (!exchangeDetails) {
- throw Error("invariant failed");
- }
-
- if (!scopeInfo) {
- throw Error("invariant failed");
- }
-
- const res: ReadyExchangeSummary = {
- currency: exchangeDetails.currency,
- exchangeBaseUrl: canonUrl,
- masterPub: exchangeDetails.masterPublicKey,
- tosStatus: getExchangeTosStatusFromRecord(exchange),
- tosAcceptedEtag: exchange.tosAcceptedEtag,
- wireInfo: exchangeDetails.wireInfo,
- protocolVersionRange: exchangeDetails.protocolVersionRange,
- tosCurrentEtag: exchange.tosCurrentEtag,
- tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
- exchange.tosAcceptedTimestamp,
- ),
- scopeInfo,
- };
-
- if (options.expectedMasterPub) {
- if (res.masterPub !== options.expectedMasterPub) {
- throw Error(
- "public key of the exchange does not match expected public key",
- );
- }
- }
- return res;
- }
-}
-
-/**
- * Ensure that a fresh exchange entry exists for the given
- * exchange base URL.
- *
- * The cancellation token can be used to abort waiting for the
- * updated exchange entry.
- *
- * If an exchange entry for the database doesn't exist in the
- * DB, it will be added ephemerally.
- *
- * If the expectedMasterPub is given and does not match the actual
- * master pub, an exception will be thrown. However, the exchange
- * will still have been added as an ephemeral exchange entry.
- */
-export async function fetchFreshExchange(
- ws: InternalWalletState,
- baseUrl: string,
- options: {
- cancellationToken?: CancellationToken;
- forceUpdate?: boolean;
- expectedMasterPub?: string;
- } = {},
-): Promise<ReadyExchangeSummary> {
- const canonUrl = canonicalizeBaseUrl(baseUrl);
-
- ws.ensureTaskLoopRunning();
-
- await startUpdateExchangeEntry(ws, canonUrl, {
- forceUpdate: options.forceUpdate,
- });
-
- return waitReadyExchange(ws, canonUrl, options);
-}
-
-async function waitReadyExchange(
- ws: InternalWalletState,
- canonUrl: string,
- options: {
- cancellationToken?: CancellationToken;
- forceUpdate?: boolean;
- expectedMasterPub?: string;
- } = {},
-): Promise<ReadyExchangeSummary> {
- // FIXME: We should use Symbol.dispose magic here for cleanup!
-
- const exchangeNotifFlag = new AsyncFlag();
- // Raise exchangeNotifFlag whenever we get a notification
- // about our exchange.
- const cancelNotif = ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.ExchangeStateTransition &&
- notif.exchangeBaseUrl === canonUrl
- ) {
- logger.info(`raising update notification: ${j2s(notif)}`);
- exchangeNotifFlag.raise();
- }
- });
-
- try {
- const res = await internalWaitReadyExchange(
- ws,
- canonUrl,
- exchangeNotifFlag,
- options,
- );
- logger.info("done waiting for ready exchange");
- return res;
- } finally {
- cancelNotif();
- }
-}
-
-/**
- * Update an exchange entry in the wallet's database
- * by fetching the /keys and /wire information.
- * Optionally link the reserve entry to the new or existing
- * exchange entry in then DB.
- */
-export async function updateExchangeFromUrlHandler(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
- exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
- const oldExchangeRec = await ws.db.runReadOnlyTx(
- ["exchanges"],
- async (tx) => {
- return tx.exchanges.get(exchangeBaseUrl);
- },
- );
-
- if (!oldExchangeRec) {
- logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
- return TaskRunResult.finished();
- }
-
- let updateRequestedExplicitly = false;
-
- switch (oldExchangeRec.updateStatus) {
- case ExchangeEntryDbUpdateStatus.Suspended:
- logger.info(`not updating exchange in status "suspended"`);
- return TaskRunResult.finished();
- case ExchangeEntryDbUpdateStatus.Initial:
- logger.info(`not updating exchange in status "initial"`);
- return TaskRunResult.finished();
- case ExchangeEntryDbUpdateStatus.InitialUpdate:
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- updateRequestedExplicitly = true;
- break;
- case ExchangeEntryDbUpdateStatus.Ready:
- break;
- default:
- assertUnreachable(oldExchangeRec.updateStatus);
- }
-
- let refreshCheckNecessary = true;
-
- if (!updateRequestedExplicitly) {
- // If the update wasn't requested explicitly,
- // check if we really need to update.
-
- let nextUpdateStamp = timestampAbsoluteFromDb(
- oldExchangeRec.nextUpdateStamp,
- );
-
- let nextRefreshCheckStamp = timestampAbsoluteFromDb(
- oldExchangeRec.nextRefreshCheckStamp,
- );
-
- let updateNecessary = true;
-
- if (
- !AbsoluteTime.isNever(nextUpdateStamp) &&
- !AbsoluteTime.isExpired(nextUpdateStamp)
- ) {
- logger.info(
- `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
- nextUpdateStamp,
- )}`,
- );
- updateNecessary = false;
- }
-
- if (
- !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
- !AbsoluteTime.isExpired(nextRefreshCheckStamp)
- ) {
- logger.info(
- `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
- nextRefreshCheckStamp,
- )}`,
- );
- refreshCheckNecessary = false;
- }
-
- if (!(updateNecessary || refreshCheckNecessary)) {
- return TaskRunResult.runAgainAt(
- AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
- );
- }
- }
-
- // When doing the auto-refresh check, we always update
- // the key info before that.
-
- logger.trace("updating exchange /keys info");
-
- const timeout = getExchangeRequestTimeout();
-
- const keysInfo = await downloadExchangeKeysInfo(
- exchangeBaseUrl,
- ws.http,
- timeout,
- cancellationToken,
- );
-
- logger.trace("validating exchange wire info");
-
- const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
- if (!version) {
- // Should have been validated earlier.
- throw Error("unexpected invalid version");
- }
-
- const wireInfo = await validateWireInfo(
- ws,
- version.current,
- keysInfo,
- keysInfo.masterPublicKey,
- );
-
- const globalFees = await validateGlobalFees(
- ws,
- keysInfo.globalFees,
- keysInfo.masterPublicKey,
- );
- if (keysInfo.baseUrl != exchangeBaseUrl) {
- logger.warn("exchange base URL mismatch");
- const errorDetail: TalerErrorDetail = makeErrorDetail(
- TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
- {
- urlWallet: exchangeBaseUrl,
- urlExchange: keysInfo.baseUrl,
- },
- );
- return {
- type: TaskRunResultType.Error,
- errorDetail,
- };
- }
-
- logger.trace("finished validating exchange /wire info");
-
- // We download the text/plain version here,
- // because that one needs to exist, and we
- // will get the current etag from the response.
- const tosDownload = await downloadTosFromAcceptedFormat(
- ws,
- exchangeBaseUrl,
- timeout,
- ["text/plain"],
- );
-
- let recoupGroupId: string | undefined;
-
- logger.trace("updating exchange info in database");
-
- let detailsPointerChanged = false;
-
- let ageMask = 0;
- for (const x of keysInfo.currentDenominations) {
- if (
- isWithdrawableDenom(x, ws.config.testing.denomselAllowLate) &&
- x.denomPub.age_mask != 0
- ) {
- ageMask = x.denomPub.age_mask;
- break;
- }
- }
-
- const updated = await ws.db.runReadWriteTx(
- [
- "exchanges",
- "exchangeDetails",
- "exchangeSignKeys",
- "denominations",
- "coins",
- "refreshGroups",
- "recoupGroups",
- ],
- async (tx) => {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
- return;
- }
- const oldExchangeState = getExchangeState(r);
- const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
- if (!existingDetails) {
- detailsPointerChanged = true;
- }
- if (existingDetails) {
- if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
- detailsPointerChanged = true;
- }
- if (existingDetails.currency !== keysInfo.currency) {
- detailsPointerChanged = true;
- }
- // FIXME: We need to do some consistency checks!
- }
- const newDetails: ExchangeDetailsRecord = {
- auditors: keysInfo.auditors,
- currency: keysInfo.currency,
- masterPublicKey: keysInfo.masterPublicKey,
- protocolVersionRange: keysInfo.protocolVersion,
- reserveClosingDelay: keysInfo.reserveClosingDelay,
- globalFees,
- exchangeBaseUrl: r.baseUrl,
- wireInfo,
- ageMask,
- };
- r.tosCurrentEtag = tosDownload.tosEtag;
- if (existingDetails?.rowId) {
- newDetails.rowId = existingDetails.rowId;
- }
- r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
- r.nextUpdateStamp = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(
- AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
- ),
- );
- // New denominations might be available.
- r.nextRefreshCheckStamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- if (detailsPointerChanged) {
- r.detailsPointer = {
- currency: newDetails.currency,
- masterPublicKey: newDetails.masterPublicKey,
- updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
- }
- r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
- await tx.exchanges.put(r);
- const drRowId = await tx.exchangeDetails.put(newDetails);
- checkDbInvariant(typeof drRowId.key === "number");
-
- for (const sk of keysInfo.signingKeys) {
- // FIXME: validate signing keys before inserting them
- await tx.exchangeSignKeys.put({
- exchangeDetailsRowId: drRowId.key,
- masterSig: sk.master_sig,
- signkeyPub: sk.key,
- stampEnd: timestampProtocolToDb(sk.stamp_end),
- stampExpire: timestampProtocolToDb(sk.stamp_expire),
- stampStart: timestampProtocolToDb(sk.stamp_start),
- });
- }
-
- logger.trace("updating denominations in database");
- const currentDenomSet = new Set<string>(
- keysInfo.currentDenominations.map((x) => x.denomPubHash),
- );
- for (const currentDenom of keysInfo.currentDenominations) {
- const oldDenom = await tx.denominations.get([
- exchangeBaseUrl,
- currentDenom.denomPubHash,
- ]);
- if (oldDenom) {
- // FIXME: Do consistency check, report to auditor if necessary.
- } else {
- await tx.denominations.put(currentDenom);
- }
- }
-
- // Update list issue date for all denominations,
- // and mark non-offered denominations as such.
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(r.baseUrl)
- .forEachAsync(async (x) => {
- if (!currentDenomSet.has(x.denomPubHash)) {
- // FIXME: Here, an auditor report should be created, unless
- // the denomination is really legally expired.
- if (x.isOffered) {
- x.isOffered = false;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=false`,
- );
- }
- } else {
- x.listIssueDate = timestampProtocolToDb(keysInfo.listIssueDate);
- if (!x.isOffered) {
- x.isOffered = true;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=true`,
- );
- }
- }
- await tx.denominations.put(x);
- });
-
- logger.trace("done updating denominations in database");
-
- // Handle recoup
- const recoupDenomList = keysInfo.recoup;
- const newlyRevokedCoinPubs: string[] = [];
- logger.trace("recoup list from exchange", recoupDenomList);
- for (const recoupInfo of recoupDenomList) {
- const oldDenom = await tx.denominations.get([
- r.baseUrl,
- recoupInfo.h_denom_pub,
- ]);
- if (!oldDenom) {
- // We never even knew about the revoked denomination, all good.
- continue;
- }
- if (oldDenom.isRevoked) {
- // We already marked the denomination as revoked,
- // this implies we revoked all coins
- logger.trace("denom already revoked");
- continue;
- }
- logger.info("revoking denom", recoupInfo.h_denom_pub);
- oldDenom.isRevoked = true;
- await tx.denominations.put(oldDenom);
- const affectedCoins = await tx.coins.indexes.byDenomPubHash
- .iter(recoupInfo.h_denom_pub)
- .toArray();
- for (const ac of affectedCoins) {
- newlyRevokedCoinPubs.push(ac.coinPub);
- }
- }
- if (newlyRevokedCoinPubs.length != 0) {
- logger.info("recouping coins", newlyRevokedCoinPubs);
- recoupGroupId = await ws.recoupOps.createRecoupGroup(
- ws,
- tx,
- exchangeBaseUrl,
- newlyRevokedCoinPubs,
- );
- }
-
- const newExchangeState = getExchangeState(r);
-
- return {
- exchange: r,
- exchangeDetails: newDetails,
- oldExchangeState,
- newExchangeState,
- };
- },
- );
-
- if (recoupGroupId) {
- const recoupTaskId = constructTaskIdentifier({
- tag: PendingTaskType.Recoup,
- recoupGroupId,
- });
- // Asynchronously start recoup. This doesn't need to finish
- // for the exchange update to be considered finished.
- ws.taskScheduler.startShepherdTask(recoupTaskId);
- }
-
- if (!updated) {
- throw Error("something went wrong with updating the exchange");
- }
-
- logger.trace("done updating exchange info in database");
-
- logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
-
- let minCheckThreshold = AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- durationFromSpec({ days: 1 }),
- );
-
- if (refreshCheckNecessary) {
- // Do auto-refresh.
- await ws.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "coinAvailability",
- "refreshGroups",
- "exchanges",
- ],
- async (tx) => {
- const exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange || !exchange.detailsPointer) {
- return;
- }
- const coins = await tx.coins.indexes.byBaseUrl
- .iter(exchangeBaseUrl)
- .toArray();
- const refreshCoins: CoinRefreshRequest[] = [];
- for (const coin of coins) {
- if (coin.status !== CoinStatus.Fresh) {
- continue;
- }
- const denom = await tx.denominations.get([
- exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination not in database");
- continue;
- }
- const executeThreshold =
- getAutoRefreshExecuteThresholdForDenom(denom);
- if (AbsoluteTime.isExpired(executeThreshold)) {
- refreshCoins.push({
- coinPub: coin.coinPub,
- amount: denom.value,
- });
- } else {
- const checkThreshold = getAutoRefreshCheckThreshold(denom);
- minCheckThreshold = AbsoluteTime.min(
- minCheckThreshold,
- checkThreshold,
- );
- }
- }
- if (refreshCoins.length > 0) {
- const res = await createRefreshGroup(
- ws,
- tx,
- exchange.detailsPointer?.currency,
- refreshCoins,
- RefreshReason.Scheduled,
- undefined,
- );
- logger.trace(
- `created refresh group for auto-refresh (${res.refreshGroupId})`,
- );
- }
- logger.trace(
- `next refresh check at ${AbsoluteTime.toIsoString(
- minCheckThreshold,
- )}`,
- );
- exchange.nextRefreshCheckStamp = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
- );
- await tx.exchanges.put(exchange);
- },
- );
- }
-
- ws.notify({
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl,
- newExchangeState: updated.newExchangeState,
- oldExchangeState: updated.oldExchangeState,
- });
-
- // Next invocation will cause the task to be run again
- // at the necessary time.
- return TaskRunResult.progress();
-}
-
-function getAutoRefreshExecuteThresholdForDenom(
- d: DenominationRecord,
-): AbsoluteTime {
- return getAutoRefreshExecuteThreshold({
- stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
- stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
- });
-}
-
-/**
- * Timestamp after which the wallet would do the next check for an auto-refresh.
- */
-function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
- const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(d.stampExpireWithdraw),
- );
- const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(d.stampExpireDeposit),
- );
- const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
- const deltaDiv = durationMul(delta, 0.75);
- return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
-}
-
-/**
- * Find a payto:// URI of the exchange that is of one
- * of the given target types.
- *
- * Throws if no matching account was found.
- */
-export async function getExchangePaytoUri(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- supportedTargetTypes: string[],
-): Promise<string> {
- // We do the update here, since the exchange might not even exist
- // yet in our database.
- const details = await ws.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails"],
- async (tx) => {
- return getExchangeRecordsInternal(tx, exchangeBaseUrl);
- },
- );
- const accounts = details?.wireInfo.accounts ?? [];
- for (const account of accounts) {
- const res = parsePaytoUri(account.payto_uri);
- if (!res) {
- continue;
- }
- if (supportedTargetTypes.includes(res.targetType)) {
- return account.payto_uri;
- }
- }
- throw Error(
- `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
- supportedTargetTypes,
- )}`,
- );
-}
-
-/**
- * Get the exchange ToS in the requested format.
- * Try to download in the accepted format not cached.
- */
-export async function getExchangeTos(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- acceptedFormat?: string[],
- acceptLanguage?: string,
-): Promise<GetExchangeTosResult> {
- const exch = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- const tosDownload = await downloadTosFromAcceptedFormat(
- ws,
- exchangeBaseUrl,
- getExchangeRequestTimeout(),
- acceptedFormat,
- acceptLanguage,
- );
-
- await ws.db.runReadWriteTx(["exchanges"], async (tx) => {
- const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
- if (updateExchangeEntry) {
- updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
- await tx.exchanges.put(updateExchangeEntry);
- }
- });
-
- return {
- acceptedEtag: exch.tosAcceptedEtag,
- currentEtag: tosDownload.tosEtag,
- content: tosDownload.tosText,
- contentType: tosDownload.tosContentType,
- contentLanguage: tosDownload.tosContentLanguage,
- tosStatus: exch.tosStatus,
- tosAvailableLanguages: tosDownload.tosAvailableLanguages,
- };
-}
-
-/**
- * Parsed information about an exchange,
- * obtained by requesting /keys.
- */
-export interface ExchangeInfo {
- keys: ExchangeKeysDownloadResult;
-}
-
-/**
- * Helper function to download the exchange /keys info.
- *
- * Only used for testing / dbless wallet.
- */
-export async function downloadExchangeInfo(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
-): Promise<ExchangeInfo> {
- const keysInfo = await downloadExchangeKeysInfo(
- exchangeBaseUrl,
- http,
- Duration.getForever(),
- CancellationToken.CONTINUE,
- );
- return {
- keys: keysInfo,
- };
-}
-
-/**
- * List all exchange entries known to the wallet.
- */
-export async function listExchanges(
- ws: InternalWalletState,
-): Promise<ExchangesListResponse> {
- const exchanges: ExchangeListItem[] = [];
- await ws.db.runReadOnlyTx(
- [
- "exchanges",
- "operationRetries",
- "exchangeDetails",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
- async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.ExchangeUpdate,
- exchangeBaseUrl: r.baseUrl,
- });
- const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
- const opRetryRecord = await tx.operationRetries.get(taskId);
- exchanges.push(
- await makeExchangeListItem(
- tx,
- r,
- exchangeDetails,
- opRetryRecord?.lastError,
- ),
- );
- }
- },
- );
- return { exchanges };
-}
-
-/**
- * Transition an exchange to the "used" entry state if necessary.
- *
- * Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
- *
- * The caller should emit the returned notification iff the current transaction
- * succeeded.
- */
-export async function markExchangeUsed(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<["exchanges"]>,
- exchangeBaseUrl: string,
-): Promise<{ notif: WalletNotification | undefined }> {
- exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
- logger.info(`marking exchange ${exchangeBaseUrl} as used`);
- const exch = await tx.exchanges.get(exchangeBaseUrl);
- if (!exch) {
- return {
- notif: undefined,
- };
- }
- const oldExchangeState = getExchangeState(exch);
- switch (exch.entryStatus) {
- case ExchangeEntryDbRecordStatus.Ephemeral:
- case ExchangeEntryDbRecordStatus.Preset: {
- exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
- await tx.exchanges.put(exch);
- const newExchangeState = getExchangeState(exch);
- return {
- notif: {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl,
- newExchangeState: newExchangeState,
- oldExchangeState: oldExchangeState,
- } satisfies WalletNotification,
- };
- }
- default:
- return {
- notif: undefined,
- };
- }
-}
-
-/**
- * Get detailed information about the exchange including a timeline
- * for the fees charged by the exchange.
- */
-export async function getExchangeDetailedInfo(
- ws: InternalWalletState,
- exchangeBaseurl: string,
-): Promise<ExchangeDetailedResponse> {
- const exchange = await ws.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails", "denominations"],
- async (tx) => {
- const ex = await tx.exchanges.get(exchangeBaseurl);
- const dp = ex?.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency } = dp;
- const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
- if (!exchangeDetails) {
- return;
- }
- const denominationRecords =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
-
- if (!denominationRecords) {
- return;
- }
-
- const denominations: DenominationInfo[] = denominationRecords.map((x) =>
- DenominationRecord.toDenomInfo(x),
- );
-
- return {
- info: {
- exchangeBaseUrl: ex.baseUrl,
- currency,
- paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
- auditors: exchangeDetails.auditors,
- wireInfo: exchangeDetails.wireInfo,
- globalFees: exchangeDetails.globalFees,
- },
- denominations,
- };
- },
- );
-
- if (!exchange) {
- throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
- }
-
- const denoms = exchange.denominations.map((d) => ({
- ...d,
- group: Amounts.stringifyValue(d.value),
- }));
- const denomFees: DenomOperationMap<FeeDescription[]> = {
- deposit: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireDeposit",
- "feeDeposit",
- "group",
- selectBestForOverlappingDenominations,
- ),
- refresh: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeRefresh",
- "group",
- selectBestForOverlappingDenominations,
- ),
- refund: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeRefund",
- "group",
- selectBestForOverlappingDenominations,
- ),
- withdraw: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeWithdraw",
- "group",
- selectBestForOverlappingDenominations,
- ),
- };
-
- const transferFees = Object.entries(
- exchange.info.wireInfo.feesForType,
- ).reduce(
- (prev, [wireType, infoForType]) => {
- const feesByGroup = [
- ...infoForType.map((w) => ({
- ...w,
- fee: Amounts.stringify(w.closingFee),
- group: "closing",
- })),
- ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
- ];
- prev[wireType] = createTimeline(
- feesByGroup,
- "sig",
- "startStamp",
- "endStamp",
- "fee",
- "group",
- selectMinimumFee,
- );
- return prev;
- },
- {} as Record<string, FeeDescription[]>,
- );
-
- const globalFeesByGroup = [
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.accountFee,
- group: "account",
- })),
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.historyFee,
- group: "history",
- })),
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.purseFee,
- group: "purse",
- })),
- ];
-
- const globalFees = createTimeline(
- globalFeesByGroup,
- "signature",
- "startDate",
- "endDate",
- "fee",
- "group",
- selectMinimumFee,
- );
-
- return {
- exchange: {
- ...exchange.info,
- denomFees,
- transferFees,
- globalFees,
- },
- };
-}
-
-async function internalGetExchangeResources(
- ws: InternalWalletState,
- tx: DbReadOnlyTransaction<
- typeof WalletStoresV1,
- ["exchanges", "coins", "withdrawalGroups"]
- >,
- exchangeBaseUrl: string,
-): Promise<GetExchangeResourcesResponse> {
- let numWithdrawals = 0;
- let numCoins = 0;
- numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
- numWithdrawals =
- await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
- const total = numWithdrawals + numCoins;
- return {
- hasResources: total != 0,
- };
-}
-
-export async function deleteExchange(
- ws: InternalWalletState,
- req: DeleteExchangeRequest,
-): Promise<void> {
- let inUse: boolean = false;
- const exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl);
- await ws.db.runReadWriteTx(
- ["exchanges", "coins", "withdrawalGroups", "exchangeDetails"],
- async (tx) => {
- const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchangeRec) {
- // Nothing to delete!
- logger.info("no exchange found to delete");
- return;
- }
- const res = await internalGetExchangeResources(ws, tx, exchangeBaseUrl);
- if (res.hasResources) {
- if (req.purge) {
- const detRecs =
- await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
- for (const r of detRecs) {
- if (r.rowId == null) {
- // Should never happen, as rowId is the primary key.
- continue;
- }
- await tx.exchangeDetails.delete(r.rowId);
- }
- // FIXME: Also remove records related to transactions?
- } else {
- inUse = true;
- return;
- }
- }
- await tx.exchanges.delete(exchangeBaseUrl);
- },
- );
-
- if (inUse) {
- throw TalerError.fromUncheckedDetail({
- code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
- hint: "Exchange in use.",
- });
- }
-}
-
-export async function getExchangeResources(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<GetExchangeResourcesResponse> {
- // Withdrawals include internal withdrawals from peer transactions
- const res = await ws.db.runReadOnlyTx(
- ["exchanges", "withdrawalGroups", "coins"],
- async (tx) => {
- const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchangeRecord) {
- return undefined;
- }
- return internalGetExchangeResources(ws, tx, exchangeBaseUrl);
- },
- );
- if (!res) {
- throw Error("exchange not found");
- }
- return res;
-}
diff --git a/packages/taler-wallet-core/src/operations/merchants.ts b/packages/taler-wallet-core/src/operations/merchants.ts
deleted file mode 100644
index a148953f0..000000000
--- a/packages/taler-wallet-core/src/operations/merchants.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A..
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- canonicalizeBaseUrl,
- Logger,
- URL,
- codecForMerchantConfigResponse,
- LibtoolVersion,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-
-const logger = new Logger("taler-wallet-core:merchants.ts");
-
-export async function getMerchantInfo(
- ws: InternalWalletState,
- merchantBaseUrl: string,
-): Promise<MerchantInfo> {
- const canonBaseUrl = canonicalizeBaseUrl(merchantBaseUrl);
-
- const existingInfo = ws.merchantInfoCache[canonBaseUrl];
- if (existingInfo) {
- return existingInfo;
- }
-
- const configUrl = new URL("config", canonBaseUrl);
- const resp = await ws.http.fetch(configUrl.href);
-
- const configResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantConfigResponse(),
- );
-
- logger.info(
- `merchant "${canonBaseUrl}" reports protocol ${configResp.version}"`,
- );
-
- const parsedVersion = LibtoolVersion.parseVersion(configResp.version);
- if (!parsedVersion) {
- throw Error("invalid merchant version");
- }
-
- const merchantInfo: MerchantInfo = {
- protocolVersionCurrent: parsedVersion.current,
- };
-
- ws.merchantInfoCache[canonBaseUrl] = merchantInfo;
- return merchantInfo;
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts
deleted file mode 100644
index 260fc815a..000000000
--- a/packages/taler-wallet-core/src/operations/pay-merchant.ts
+++ /dev/null
@@ -1,3232 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the payment operation, including downloading and
- * claiming of proposals.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
- AbortingCoin,
- AbortRequest,
- AbsoluteTime,
- AmountJson,
- Amounts,
- AmountString,
- AsyncFlag,
- codecForAbortResponse,
- codecForMerchantContractTerms,
- codecForMerchantOrderRefundPickupResponse,
- codecForMerchantOrderStatusPaid,
- codecForMerchantPayResponse,
- codecForMerchantPostOrderResponse,
- codecForProposal,
- CoinDepositPermission,
- CoinRefreshRequest,
- ConfirmPayResult,
- ConfirmPayResultType,
- ContractTermsUtil,
- Duration,
- encodeCrock,
- ForcedCoinSel,
- getRandomBytes,
- HttpStatusCode,
- j2s,
- Logger,
- makeErrorDetail,
- makePendingOperationFailedError,
- MerchantCoinRefundStatus,
- MerchantContractTerms,
- MerchantPayResponse,
- MerchantUsingTemplateDetails,
- NotificationType,
- parsePayTemplateUri,
- parsePayUri,
- parseTalerUri,
- PayCoinSelection,
- PreparePayResult,
- PreparePayResultType,
- PreparePayTemplateRequest,
- randomBytes,
- RefreshReason,
- SharePaymentResult,
- StartRefundQueryForUriResponse,
- stringifyPayUri,
- stringifyTalerUri,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolViolationError,
- TalerUriAction,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- WalletContractData,
-} from "@gnu-taler/taler-util";
-import {
- getHttpResponseErrorDetails,
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
- readUnexpectedResponseDetails,
- throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
-import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
-import {
- CoinRecord,
- DenominationRecord,
- PurchaseRecord,
- PurchaseStatus,
- RefundReason,
- WalletStoresV1,
-} from "../db.js";
-import {
- getCandidateWithdrawalDenomsTx,
- PendingTaskType,
- RefundGroupRecord,
- RefundGroupStatus,
- RefundItemRecord,
- RefundItemStatus,
- TaskId,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
-} from "../index.js";
-import {
- EXCHANGE_COINS_LOCK,
- InternalWalletState,
-} from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { DbReadWriteTransaction, StoreNames } from "../util/query.js";
-import {
- constructTaskIdentifier,
- DbRetryInfo,
- spendCoins,
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- TransitionResult,
-} from "./common.js";
-import {
- calculateRefreshOutput,
- createRefreshGroup,
- getTotalRefreshCost,
-} from "./refresh.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
-} from "./transactions.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("pay-merchant.ts");
-
-export class PayMerchantTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public proposalId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
- }
-
- /**
- * Transition a payment transition.
- */
- async transition(
- f: (rec: PurchaseRecord) => Promise<TransitionResult>,
- ): Promise<void> {
- return this.transitionExtra(
- {
- extraStores: [],
- },
- f,
- );
- }
-
- /**
- * Transition a payment transition.
- * Extra object stores may be accessed during the transition.
- */
- async transitionExtra<
- StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
- >(
- opts: { extraStores: StoreNameArray },
- f: (
- rec: PurchaseRecord,
- tx: DbReadWriteTransaction<
- typeof WalletStoresV1,
- ["purchases", ...StoreNameArray]
- >,
- ) => Promise<TransitionResult>,
- ): Promise<void> {
- const ws = this.ws;
- const extraStores = opts.extraStores ?? [];
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases", ...extraStores],
- async (tx) => {
- const purchaseRec = await tx.purchases.get(this.proposalId);
- if (!purchaseRec) {
- throw Error("purchase not found anymore");
- }
- const oldTxState = computePayMerchantTransactionState(purchaseRec);
- const res = await f(purchaseRec, tx);
- switch (res) {
- case TransitionResult.Transition: {
- await tx.purchases.put(purchaseRec);
- const newTxState = computePayMerchantTransactionState(purchaseRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- notifyTransition(ws, this.transactionId, transitionInfo);
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, proposalId } = this;
- await ws.db.runReadWriteTx(["purchases", "tombstones"], async (tx) => {
- let found = false;
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- found = true;
- await tx.purchases.delete(proposalId);
- }
- if (found) {
- await tx.tombstones.put({
- id: TombstoneTag.DeletePayment + ":" + proposalId,
- });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, proposalId, transactionId } = this;
- ws.taskScheduler.stopShepherdTask(this.taskId);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newStatus = transitionSuspend[purchase.purchaseStatus];
- if (!newStatus) {
- return undefined;
- }
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, proposalId, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "purchases",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- "operationRetries",
- ],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- const oldStatus = purchase.purchaseStatus;
- if (purchase.timestampFirstSuccessfulPay) {
- // No point in aborting it. We don't even report an error.
- logger.warn(`tried to abort successful payment`);
- return;
- }
- switch (oldStatus) {
- case PurchaseStatus.Done:
- return;
- case PurchaseStatus.PendingPaying:
- case PurchaseStatus.SuspendedPaying: {
- purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
- if (purchase.payInfo) {
- const coinSel = purchase.payInfo.payCoinSelection;
- const currency = Amounts.currencyOf(
- purchase.payInfo.totalPayCost,
- );
- const refreshCoins: CoinRefreshRequest[] = [];
- for (let i = 0; i < coinSel.coinPubs.length; i++) {
- refreshCoins.push({
- amount: coinSel.coinContributions[i],
- coinPub: coinSel.coinPubs[i],
- });
- }
- await createRefreshGroup(
- ws,
- tx,
- currency,
- refreshCoins,
- RefreshReason.AbortPay,
- this.transactionId,
- );
- }
- break;
- }
- case PurchaseStatus.DialogProposed:
- purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
- break;
- }
- await tx.purchases.put(purchase);
- await tx.operationRetries.delete(this.taskId);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- ws.taskScheduler.stopShepherdTask(this.taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, proposalId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newStatus = transitionResume[purchase.purchaseStatus];
- if (!newStatus) {
- return undefined;
- }
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, proposalId, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "purchases",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- "operationRetries",
- ],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newState: PurchaseStatus | undefined = undefined;
- switch (purchase.purchaseStatus) {
- case PurchaseStatus.AbortingWithRefund:
- newState = PurchaseStatus.FailedAbort;
- break;
- }
- if (newState) {
- purchase.purchaseStatus = newState;
- await tx.purchases.put(purchase);
- }
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.stopShepherdTask(this.taskId);
- }
-}
-
-export class RefundTransactionContext implements TransactionContext {
- public transactionId: string;
- constructor(
- public ws: InternalWalletState,
- public refundGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refund,
- refundGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, refundGroupId, transactionId } = this;
- await ws.db.runReadWriteTx(["refundGroups", "tombstones"], async (tx) => {
- const refundRecord = await tx.refundGroups.get(refundGroupId);
- if (!refundRecord) {
- return;
- }
- await tx.refundGroups.delete(refundGroupId);
- await tx.tombstones.put({ id: transactionId });
- // FIXME: Also tombstone the refund items, so that they won't reappear.
- });
- }
-
- suspendTransaction(): Promise<void> {
- throw new Error("Unsupported operation");
- }
-
- abortTransaction(): Promise<void> {
- throw new Error("Unsupported operation");
- }
-
- resumeTransaction(): Promise<void> {
- throw new Error("Unsupported operation");
- }
-
- failTransaction(): Promise<void> {
- throw new Error("Unsupported operation");
- }
-}
-
-/**
- * Compute the total cost of a payment to the customer.
- *
- * This includes the amount taken by the merchant, fees (wire/deposit) contributed
- * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
- * of coins that are too small to spend.
- */
-export async function getTotalPaymentCost(
- ws: InternalWalletState,
- pcs: PayCoinSelection,
-): Promise<AmountJson> {
- const currency = Amounts.currencyOf(pcs.paymentAmount);
- return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
- );
- }
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coinContributions[i],
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- ws.config.testing.denomselAllowLate,
- );
- costs.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
- costs.push(refreshCost);
- }
- const zero = Amounts.zeroOfAmount(pcs.paymentAmount);
- return Amounts.sum([zero, ...costs]).amount;
- });
-}
-
-async function failProposalPermanently(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetail,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- // FIXME: We don't store the error detail here?!
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-function getProposalRequestTimeout(retryInfo?: DbRetryInfo): Duration {
- return Duration.clamp({
- lower: Duration.fromSpec({ seconds: 1 }),
- upper: Duration.fromSpec({ seconds: 60 }),
- value: retryInfo
- ? DbRetryInfo.getDuration(retryInfo)
- : Duration.fromSpec({}),
- });
-}
-
-function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
- return Duration.multiply(
- { d_ms: 15000 },
- 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
- );
-}
-
-/**
- * Return the proposal download data for a purchase, throw if not available.
- */
-export async function expectProposalDownload(
- ws: InternalWalletState,
- p: PurchaseRecord,
- parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>,
-): Promise<{
- contractData: WalletContractData;
- contractTermsRaw: any;
-}> {
- if (!p.download) {
- throw Error("expected proposal to be downloaded");
- }
- const download = p.download;
-
- async function getFromTransaction(
- tx: Exclude<typeof parentTx, undefined>,
- ): Promise<ReturnType<typeof expectProposalDownload>> {
- const contractTerms = await tx.contractTerms.get(
- download.contractTermsHash,
- );
- if (!contractTerms) {
- throw Error("contract terms not found");
- }
- return {
- contractData: extractContractData(
- contractTerms.contractTermsRaw,
- download.contractTermsHash,
- download.contractTermsMerchantSig,
- ),
- contractTermsRaw: contractTerms.contractTermsRaw,
- };
- }
-
- if (parentTx) {
- return getFromTransaction(parentTx);
- }
- return await ws.db.runReadOnlyTx(["contractTerms"], getFromTransaction);
-}
-
-export function extractContractData(
- parsedContractTerms: MerchantContractTerms,
- contractTermsHash: string,
- merchantSig: string,
-): WalletContractData {
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.zeroOfCurrency(amount.currency);
- }
- return {
- amount: Amounts.stringify(amount),
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee: Amounts.stringify(maxWireFee),
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- summaryI18n: parsedContractTerms.summary_i18n,
- minimumAge: parsedContractTerms.minimum_age,
- };
-}
-
-async function processDownloadProposal(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<TaskRunResult> {
- const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return await tx.purchases.get(proposalId);
- });
-
- if (!proposal) {
- return TaskRunResult.finished();
- }
-
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
-
- if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
- logger.error(
- `unexpected state ${proposal.purchaseStatus}/${
- PurchaseStatus[proposal.purchaseStatus]
- } for ${ctx.transactionId} in processDownloadProposal`,
- );
- return TaskRunResult.finished();
- }
-
- const transactionId = ctx.transactionId;
-
- const orderClaimUrl = new URL(
- `orders/${proposal.orderId}/claim`,
- proposal.merchantBaseUrl,
- ).href;
- logger.trace("downloading contract from '" + orderClaimUrl + "'");
-
- const requestBody: {
- nonce: string;
- token?: string;
- } = {
- nonce: proposal.noncePub,
- };
- if (proposal.claimToken) {
- requestBody.token = proposal.claimToken;
- }
-
- const opId = TaskIdentifiers.forPay(proposal);
- const retryRecord = await ws.db.runReadOnlyTx(
- ["operationRetries"],
- async (tx) => {
- return tx.operationRetries.get(opId);
- },
- );
-
- const httpResponse = await ws.http.fetch(orderClaimUrl, {
- method: "POST",
- body: requestBody,
- timeout: getProposalRequestTimeout(retryRecord?.retryInfo),
- });
- const r = await readSuccessResponseJsonOrErrorCode(
- httpResponse,
- codecForProposal(),
- );
- if (r.isError) {
- switch (r.talerErrorResponse.code) {
- case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
- {
- orderId: proposal.orderId,
- claimUrl: orderClaimUrl,
- },
- "order already claimed (likely by other wallet)",
- );
- default:
- throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
- }
- }
- const proposalResp = r.response;
-
- // The proposalResp contains the contract terms as raw JSON,
- // as the code to parse them doesn't necessarily round-trip.
- // We need this raw JSON to compute the contract terms hash.
-
- // FIXME: Do better error handling, check if the
- // contract terms have all their forgettable information still
- // present. The wallet should never accept contract terms
- // with missing information from the merchant.
-
- const isWellFormed = ContractTermsUtil.validateForgettable(
- proposalResp.contract_terms,
- );
-
- if (!isWellFormed) {
- logger.trace(
- `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
- );
- const err = makeErrorDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
- {},
- "validation for well-formedness failed",
- );
- await failProposalPermanently(ws, proposalId, err);
- throw makePendingOperationFailedError(
- err,
- TransactionType.Payment,
- proposalId,
- );
- }
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(
- proposalResp.contract_terms,
- );
-
- logger.info(`Contract terms hash: ${contractTermsHash}`);
-
- let parsedContractTerms: MerchantContractTerms;
-
- try {
- parsedContractTerms = codecForMerchantContractTerms().decode(
- proposalResp.contract_terms,
- );
- } catch (e) {
- const err = makeErrorDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
- {},
- `schema validation failed: ${e}`,
- );
- await failProposalPermanently(ws, proposalId, err);
- throw makePendingOperationFailedError(
- err,
- TransactionType.Payment,
- proposalId,
- );
- }
-
- const sigValid = await ws.cryptoApi.isValidContractTermsSignature({
- contractTermsHash,
- merchantPub: parsedContractTerms.merchant_pub,
- sig: proposalResp.sig,
- });
-
- if (!sigValid) {
- const err = makeErrorDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
- {
- merchantPub: parsedContractTerms.merchant_pub,
- orderId: parsedContractTerms.order_id,
- },
- "merchant's signature on contract terms is invalid",
- );
- await failProposalPermanently(ws, proposalId, err);
- throw makePendingOperationFailedError(
- err,
- TransactionType.Payment,
- proposalId,
- );
- }
-
- const fulfillmentUrl = parsedContractTerms.fulfillment_url;
-
- const baseUrlForDownload = proposal.merchantBaseUrl;
- const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
-
- if (baseUrlForDownload !== baseUrlFromContractTerms) {
- const err = makeErrorDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
- {
- baseUrlForDownload,
- baseUrlFromContractTerms,
- },
- "merchant base URL mismatch",
- );
- await failProposalPermanently(ws, proposalId, err);
- throw makePendingOperationFailedError(
- err,
- TransactionType.Payment,
- proposalId,
- );
- }
-
- const contractData = extractContractData(
- parsedContractTerms,
- contractTermsHash,
- proposalResp.sig,
- );
-
- logger.trace(`extracted contract data: ${j2s(contractData)}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases", "contractTerms"],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.download = {
- contractTermsHash,
- contractTermsMerchantSig: contractData.merchantSig,
- currency: Amounts.currencyOf(contractData.amount),
- fulfillmentUrl: contractData.fulfillmentUrl,
- };
- await tx.contractTerms.put({
- h: contractTermsHash,
- contractTermsRaw: proposalResp.contract_terms,
- });
- const isResourceFulfillmentUrl =
- fulfillmentUrl &&
- (fulfillmentUrl.startsWith("http://") ||
- fulfillmentUrl.startsWith("https://"));
- let otherPurchase: PurchaseRecord | undefined;
- if (isResourceFulfillmentUrl) {
- otherPurchase =
- await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
- }
- // FIXME: Adjust this to account for refunds, don't count as repurchase
- // if original order is refunded.
- if (otherPurchase && otherPurchase.refundAmountAwaiting === undefined) {
- logger.warn("repurchase detected");
- p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
- p.repurchaseProposalId = otherPurchase.proposalId;
- await tx.purchases.put(p);
- } else {
- p.purchaseStatus = p.shared
- ? PurchaseStatus.DialogShared
- : PurchaseStatus.DialogProposed;
- await tx.purchases.put(p);
- }
- const newTxState = computePayMerchantTransactionState(p);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- return TaskRunResult.progress();
-}
-
-/**
- * Create a new purchase transaction if necessary. If a purchase
- * record for the provided arguments already exists,
- * return the old proposal ID.
- */
-async function createOrReusePurchase(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- orderId: string,
- sessionId: string | undefined,
- claimToken: string | undefined,
- noncePriv: string | undefined,
-): Promise<string> {
- const oldProposals = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.getAll([
- merchantBaseUrl,
- orderId,
- ]);
- });
-
- const oldProposal = oldProposals.find((p) => {
- return (
- p.downloadSessionId === sessionId &&
- (!noncePriv || p.noncePriv === noncePriv) &&
- p.claimToken === claimToken
- );
- });
- // If we have already claimed this proposal with the same sessionId
- // nonce and claim token, reuse it. */
- if (
- oldProposal &&
- oldProposal.downloadSessionId === sessionId &&
- (!noncePriv || oldProposal.noncePriv === noncePriv) &&
- oldProposal.claimToken === claimToken
- ) {
- logger.info(
- `Found old proposal (status=${
- PurchaseStatus[oldProposal.purchaseStatus]
- }) for order ${orderId} at ${merchantBaseUrl}`,
- );
- if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) {
- const download = await expectProposalDownload(ws, oldProposal);
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
- logger.info(`old proposal paid: ${paid}`);
- if (paid) {
- // if this transaction was shared and the order is paid then it
- // means that another wallet already paid the proposal
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(oldProposal.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: oldProposal.proposalId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- }
- }
- return oldProposal.proposalId;
- }
-
- let noncePair: EddsaKeypair;
- let shared = false;
- if (noncePriv) {
- shared = true;
- noncePair = {
- priv: noncePriv,
- pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
- };
- } else {
- noncePair = await ws.cryptoApi.createEddsaKeypair({});
- }
-
- const { priv, pub } = noncePair;
- const proposalId = encodeCrock(getRandomBytes(32));
-
- const proposalRecord: PurchaseRecord = {
- download: undefined,
- noncePriv: priv,
- noncePub: pub,
- claimToken,
- timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- merchantBaseUrl,
- orderId,
- proposalId: proposalId,
- purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
- repurchaseProposalId: undefined,
- downloadSessionId: sessionId,
- autoRefundDeadline: undefined,
- lastSessionId: undefined,
- merchantPaySig: undefined,
- payInfo: undefined,
- refundAmountAwaiting: undefined,
- timestampAccept: undefined,
- timestampFirstSuccessfulPay: undefined,
- timestampLastRefundStatus: undefined,
- pendingRemovedCoinPubs: undefined,
- posConfirmation: undefined,
- shared: shared,
- };
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- await tx.purchases.put(proposalRecord);
- const oldTxState: TransactionState = {
- major: TransactionMajorState.None,
- };
- const newTxState = computePayMerchantTransactionState(proposalRecord);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return proposalId;
-}
-
-async function storeFirstPaySuccess(
- ws: InternalWalletState,
- proposalId: string,
- sessionId: string | undefined,
- payResponse: MerchantPayResponse,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const transitionInfo = await ws.db.runReadWriteTx(
- ["contractTerms", "purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
-
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (!isFirst) {
- logger.warn("payment success already stored");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) {
- purchase.purchaseStatus = PurchaseStatus.Done;
- }
- purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(now);
- purchase.lastSessionId = sessionId;
- purchase.merchantPaySig = payResponse.sig;
- purchase.posConfirmation = payResponse.pos_confirmation;
- const dl = purchase.download;
- checkDbInvariant(!!dl);
- const contractTermsRecord = await tx.contractTerms.get(
- dl.contractTermsHash,
- );
- checkDbInvariant(!!contractTermsRecord);
- const contractData = extractContractData(
- contractTermsRecord.contractTermsRaw,
- dl.contractTermsHash,
- dl.contractTermsMerchantSig,
- );
- const protoAr = contractData.autoRefund;
- if (protoAr) {
- const ar = Duration.fromTalerProtocolDuration(protoAr);
- logger.info("auto_refund present");
- purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
- purchase.autoRefundDeadline = timestampProtocolToDb(
- AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
- ),
- );
- }
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function storePayReplaySuccess(
- ws: InternalWalletState,
- proposalId: string,
- sessionId: string | undefined,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
-
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (isFirst) {
- throw Error("invalid payment state");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- if (
- purchase.purchaseStatus === PurchaseStatus.PendingPaying ||
- purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay
- ) {
- purchase.purchaseStatus = PurchaseStatus.Done;
- }
- purchase.lastSessionId = sessionId;
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-/**
- * Handle a 409 Conflict response from the merchant.
- *
- * We do this by going through the coin history provided by the exchange and
- * (1) verifying the signatures from the exchange
- * (2) adjusting the remaining coin value and refreshing it
- * (3) re-do coin selection with the bad coin removed
- */
-async function handleInsufficientFunds(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetail,
-): Promise<void> {
- logger.trace("handling insufficient funds, trying to re-select coins");
-
- const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!proposal) {
- return;
- }
-
- logger.trace(`got error details: ${j2s(err)}`);
-
- const exchangeReply = (err as any).exchange_reply;
- if (
- exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
- ) {
- // FIXME: set as failed
- if (logger.shouldLogTrace()) {
- logger.trace("got exchange error reply (see below)");
- logger.trace(j2s(exchangeReply));
- }
- throw Error(`unable to handle /pay error response (${exchangeReply.code})`);
- }
-
- const brokenCoinPub = (exchangeReply as any).coin_pub;
- logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
-
- if (!brokenCoinPub) {
- throw new TalerProtocolViolationError();
- }
-
- const { contractData } = await expectProposalDownload(ws, proposal);
-
- const prevPayCoins: PreviousPayCoins = [];
-
- const payInfo = proposal.payInfo;
- if (!payInfo) {
- return;
- }
-
- const payCoinSelection = payInfo.payCoinSelection;
-
- await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- if (coinPub === brokenCoinPub) {
- continue;
- }
- const contrib = payCoinSelection.coinContributions[i];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- continue;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- continue;
- }
- prevPayCoins.push({
- coinPub,
- contribution: Amounts.parseOrThrow(contrib),
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
- });
- }
- });
-
- const res = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins,
- requiredMinimumAge: contractData.minimumAge,
- });
-
- if (res.type !== "success") {
- logger.trace("insufficient funds for coin re-selection");
- return;
- }
-
- logger.trace("re-selected coins");
-
- await ws.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- ],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- const payInfo = p.payInfo;
- if (!payInfo) {
- return;
- }
- payInfo.payCoinSelection = res.coinSel;
- payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
- await tx.purchases.put(p);
- await spendCoins(ws, tx, {
- // allocationId: `txn:proposal:${p.proposalId}`,
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: proposalId,
- }),
- coinPubs: payInfo.payCoinSelection.coinPubs,
- contributions: payInfo.payCoinSelection.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
- ),
- refreshReason: RefreshReason.PayMerchant,
- });
- },
- );
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- }),
- });
-}
-
-// FIXME: Should take a transaction ID instead of a proposal ID
-// FIXME: Does way more than checking the payment
-// FIXME: Should return immediately.
-async function checkPaymentByProposalId(
- ws: InternalWalletState,
- proposalId: string,
- sessionId?: string,
-): Promise<PreparePayResult> {
- let proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!proposal) {
- throw Error(`could not get proposal ${proposalId}`);
- }
- if (proposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) {
- const existingProposalId = proposal.repurchaseProposalId;
- if (existingProposalId) {
- logger.trace("using existing purchase for same product");
- const oldProposal = await ws.db.runReadOnlyTx(
- ["purchases"],
- async (tx) => {
- return tx.purchases.get(existingProposalId);
- },
- );
- if (oldProposal) {
- proposal = oldProposal;
- }
- }
- }
- const d = await expectProposalDownload(ws, proposal);
- const contractData = d.contractData;
- const merchantSig = d.contractData.merchantSig;
- if (!merchantSig) {
- throw Error("BUG: proposal is in invalid state");
- }
-
- proposalId = proposal.proposalId;
-
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
-
- const transactionId = ctx.transactionId;
-
- const talerUri = stringifyTalerUri({
- type: TalerUriAction.Pay,
- merchantBaseUrl: proposal.merchantBaseUrl,
- orderId: proposal.orderId,
- sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
- claimToken: proposal.claimToken,
- });
-
- // First check if we already paid for it.
- const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (
- !purchase ||
- purchase.purchaseStatus === PurchaseStatus.DialogProposed ||
- purchase.purchaseStatus === PurchaseStatus.DialogShared
- ) {
- // If not already paid, check if we could pay for it.
- const res = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins: [],
- requiredMinimumAge: contractData.minimumAge,
- wireMethod: contractData.wireMethod,
- });
-
- if (res.type !== "success") {
- logger.info("not allowing payment, insufficient coins");
- logger.info(
- `insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`,
- );
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- transactionId,
- amountRaw: Amounts.stringify(d.contractData.amount),
- talerUri,
- balanceDetails: res.insufficientBalanceDetails,
- };
- }
-
- const totalCost = await getTotalPaymentCost(ws, res.coinSel);
- logger.trace("costInfo", totalCost);
- logger.trace("coinsForPayment", res);
-
- return {
- status: PreparePayResultType.PaymentPossible,
- contractTerms: d.contractTermsRaw,
- transactionId,
- proposalId: proposal.proposalId,
- amountEffective: Amounts.stringify(totalCost),
- amountRaw: Amounts.stringify(res.coinSel.paymentAmount),
- contractTermsHash: d.contractData.contractTermsHash,
- talerUri,
- };
- }
-
- if (
- purchase.purchaseStatus === PurchaseStatus.Done &&
- purchase.lastSessionId !== sessionId
- ) {
- logger.trace(
- "automatically re-submitting payment with different session ID",
- );
- logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.lastSessionId = sessionId;
- p.purchaseStatus = PurchaseStatus.PendingPayingReplay;
- await tx.purchases.put(p);
- const newTxState = computePayMerchantTransactionState(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- // FIXME: Consider changing the API here so that we don't have to
- // wait inline for the repurchase.
-
- await waitPaymentResult(ws, proposalId, sessionId);
- const download = await expectProposalDownload(ws, purchase);
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: download.contractTermsRaw,
- contractTermsHash: download.contractData.contractTermsHash,
- paid: true,
- amountRaw: Amounts.stringify(download.contractData.amount),
- amountEffective: purchase.payInfo
- ? Amounts.stringify(purchase.payInfo.totalPayCost)
- : undefined,
- transactionId,
- proposalId,
- talerUri,
- };
- } else if (!purchase.timestampFirstSuccessfulPay) {
- const download = await expectProposalDownload(ws, purchase);
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: download.contractTermsRaw,
- contractTermsHash: download.contractData.contractTermsHash,
- paid: false,
- amountRaw: Amounts.stringify(download.contractData.amount),
- amountEffective: purchase.payInfo
- ? Amounts.stringify(purchase.payInfo.totalPayCost)
- : undefined,
- transactionId,
- proposalId,
- talerUri,
- };
- } else {
- const paid =
- purchase.purchaseStatus === PurchaseStatus.Done ||
- purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
- purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
- const download = await expectProposalDownload(ws, purchase);
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: download.contractTermsRaw,
- contractTermsHash: download.contractData.contractTermsHash,
- paid,
- amountRaw: Amounts.stringify(download.contractData.amount),
- amountEffective: purchase.payInfo
- ? Amounts.stringify(purchase.payInfo.totalPayCost)
- : undefined,
- ...(paid ? { nextUrl: download.contractData.orderId } : {}),
- transactionId,
- proposalId,
- talerUri,
- };
- }
-}
-
-export async function getContractTermsDetails(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<WalletContractData> {
- const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!proposal) {
- throw Error(`proposal with id ${proposalId} not found`);
- }
-
- const d = await expectProposalDownload(ws, proposal);
-
- return d.contractData;
-}
-
-/**
- * Check if a payment for the given taler://pay/ URI is possible.
- *
- * If the payment is possible, the signature are already generated but not
- * yet send to the merchant.
- */
-export async function preparePayForUri(
- ws: InternalWalletState,
- talerPayUri: string,
-): Promise<PreparePayResult> {
- const uriResult = parsePayUri(talerPayUri);
-
- if (!uriResult) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
- {
- talerPayUri,
- },
- `invalid taler://pay URI (${talerPayUri})`,
- );
- }
-
- const proposalId = await createOrReusePurchase(
- ws,
- uriResult.merchantBaseUrl,
- uriResult.orderId,
- uriResult.sessionId,
- uriResult.claimToken,
- uriResult.noncePriv,
- );
-
- await waitProposalDownloaded(ws, proposalId);
-
- return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
-}
-
-/**
- * Wait until a proposal is at least downloaded.
- */
-async function waitProposalDownloaded(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
-
- logger.info(`waiting for ${ctx.transactionId} to be downloaded`);
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- // FIXME: We should use Symbol.dispose magic here for cleanup!
-
- const payNotifFlag = new AsyncFlag();
- // Raise exchangeNotifFlag whenever we get a notification
- // about our exchange.
- const cancelNotif = ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.TransactionStateTransition &&
- notif.transactionId === ctx.transactionId
- ) {
- logger.info(`raising update notification: ${j2s(notif)}`);
- payNotifFlag.raise();
- }
- });
-
- try {
- await internalWaitProposalDownloaded(ctx, payNotifFlag);
- logger.info(`done waiting for ${ctx.transactionId} to be downloaded`);
- } finally {
- cancelNotif();
- }
-}
-
-async function internalWaitProposalDownloaded(
- ctx: PayMerchantTransactionContext,
- payNotifFlag: AsyncFlag,
-): Promise<void> {
- while (true) {
- const { purchase, retryInfo } = await ctx.ws.db.runReadOnlyTx(
- ["purchases", "operationRetries"],
- async (tx) => {
- return {
- purchase: await tx.purchases.get(ctx.proposalId),
- retryInfo: await tx.operationRetries.get(ctx.taskId),
- };
- },
- );
- if (!purchase) {
- throw Error("purchase does not exist anymore");
- }
- if (purchase.download) {
- return;
- }
- if (retryInfo) {
- if (retryInfo.lastError) {
- throw TalerError.fromUncheckedDetail(retryInfo.lastError);
- } else {
- throw Error("transient error while waiting for proposal download");
- }
- }
- await payNotifFlag.wait();
- payNotifFlag.reset();
- }
-}
-
-export async function preparePayForTemplate(
- ws: InternalWalletState,
- req: PreparePayTemplateRequest,
-): Promise<PreparePayResult> {
- const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
- const templateDetails: MerchantUsingTemplateDetails = {};
- if (!parsedUri) {
- throw Error("invalid taler-template URI");
- }
- logger.trace(`parsed URI: ${j2s(parsedUri)}`);
-
- const amountFromUri = parsedUri.templateParams.amount;
- if (amountFromUri != null) {
- const templateParamsAmount = req.templateParams?.amount;
- if (templateParamsAmount != null) {
- templateDetails.amount = templateParamsAmount as AmountString;
- } else {
- if (Amounts.isCurrency(amountFromUri)) {
- throw Error(
- "Amount from template URI only has a currency without value. The value must be provided in the templateParams.",
- );
- } else {
- templateDetails.amount = amountFromUri as AmountString;
- }
- }
- }
- if (
- parsedUri.templateParams.summary !== undefined &&
- typeof parsedUri.templateParams.summary === "string"
- ) {
- templateDetails.summary =
- req.templateParams?.summary ?? parsedUri.templateParams.summary;
- }
- const reqUrl = new URL(
- `templates/${parsedUri.templateId}`,
- parsedUri.merchantBaseUrl,
- );
- const httpReq = await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: templateDetails,
- });
- const resp = await readSuccessResponseJsonOrThrow(
- httpReq,
- codecForMerchantPostOrderResponse(),
- );
-
- const payUri = stringifyPayUri({
- merchantBaseUrl: parsedUri.merchantBaseUrl,
- orderId: resp.order_id,
- sessionId: "",
- claimToken: resp.token,
- });
-
- return await preparePayForUri(ws, payUri);
-}
-
-/**
- * Generate deposit permissions for a purchase.
- *
- * Accesses the database and the crypto worker.
- */
-export async function generateDepositPermissions(
- ws: InternalWalletState,
- payCoinSel: PayCoinSelection,
- contractData: WalletContractData,
-): Promise<CoinDepositPermission[]> {
- const depositPermissions: CoinDepositPermission[] = [];
- const coinWithDenom: Array<{
- coin: CoinRecord;
- denom: DenominationRecord;
- }> = [];
- await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
- const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
- if (!coin) {
- throw Error("can't pay, allocated coin not found anymore");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't pay, denomination of allocated coin not found anymore",
- );
- }
- coinWithDenom.push({ coin, denom });
- }
- });
-
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
- const { coin, denom } = coinWithDenom[i];
- let wireInfoHash: string;
- wireInfoHash = contractData.wireInfoHash;
- logger.trace(
- `signing deposit permission for coin with ageRestriction=${j2s(
- coin.ageCommitmentProof,
- )}`,
- );
- const dp = await ws.cryptoApi.signDepositPermission({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contractTermsHash: contractData.contractTermsHash,
- denomPubHash: coin.denomPubHash,
- denomKeyType: denom.denomPub.cipher,
- denomSig: coin.denomSig,
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
- merchantPub: contractData.merchantPub,
- refundDeadline: contractData.refundDeadline,
- spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
- timestamp: contractData.timestamp,
- wireInfoHash,
- ageCommitmentProof: coin.ageCommitmentProof,
- requiredMinimumAge: contractData.minimumAge,
- });
- depositPermissions.push(dp);
- }
- return depositPermissions;
-}
-
-async function internalWaitPaymentResult(
- ctx: PayMerchantTransactionContext,
- purchaseNotifFlag: AsyncFlag,
- waitSessionId?: string,
-): Promise<ConfirmPayResult> {
- while (true) {
- const txRes = await ctx.ws.db.runReadOnlyTx(
- ["purchases", "operationRetries"],
- async (tx) => {
- const purchase = await tx.purchases.get(ctx.proposalId);
- const retryRecord = await tx.operationRetries.get(ctx.taskId);
- return { purchase, retryRecord };
- },
- );
-
- if (!txRes.purchase) {
- throw Error("purchase gone");
- }
-
- const purchase = txRes.purchase;
-
- logger.info(
- `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
- );
-
- const d = await expectProposalDownload(ctx.ws, purchase);
-
- if (txRes.purchase.timestampFirstSuccessfulPay) {
- if (
- waitSessionId == null ||
- txRes.purchase.lastSessionId === waitSessionId
- ) {
- return {
- type: ConfirmPayResultType.Done,
- contractTerms: d.contractTermsRaw,
- transactionId: ctx.transactionId,
- };
- }
- }
-
- if (txRes.retryRecord) {
- return {
- type: ConfirmPayResultType.Pending,
- lastError: txRes.retryRecord.lastError,
- transactionId: ctx.transactionId,
- };
- }
-
- if (txRes.purchase.purchaseStatus > PurchaseStatus.Done) {
- return {
- type: ConfirmPayResultType.Done,
- contractTerms: d.contractTermsRaw,
- transactionId: ctx.transactionId,
- };
- }
-
- await purchaseNotifFlag.wait();
- purchaseNotifFlag.reset();
- }
-}
-
-/**
- * Wait until either:
- * a) the payment succeeded (if provided under the {@param waitSessionId}), or
- * b) the attempt to pay failed (merchant unavailable, etc.)
- */
-async function waitPaymentResult(
- ws: InternalWalletState,
- proposalId: string,
- waitSessionId?: string,
-): Promise<ConfirmPayResult> {
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
-
- ws.ensureTaskLoopRunning();
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
- const purchaseNotifFlag = new AsyncFlag();
- // Raise purchaseNotifFlag whenever we get a notification
- // about our purchase.
- const cancelNotif = ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.TransactionStateTransition &&
- notif.transactionId === ctx.transactionId
- ) {
- purchaseNotifFlag.raise();
- }
- });
-
- try {
- logger.info(`waiting for first payment success on ${ctx.transactionId}`);
- const res = await internalWaitPaymentResult(
- ctx,
- purchaseNotifFlag,
- waitSessionId,
- );
- logger.info(
- `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`,
- );
- return res;
- } finally {
- cancelNotif();
- }
-}
-
-/**
- * Confirm payment for a proposal previously claimed by the wallet.
- */
-export async function confirmPay(
- ws: InternalWalletState,
- transactionId: string,
- sessionIdOverride?: string,
- forcedCoinSel?: ForcedCoinSel,
-): Promise<ConfirmPayResult> {
- const parsedTx = parseTransactionIdentifier(transactionId);
- if (parsedTx?.tag !== TransactionType.Payment) {
- throw Error("expected payment transaction ID");
- }
- const proposalId = parsedTx.proposalId;
- logger.trace(
- `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
- );
- const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!proposal) {
- throw Error(`proposal with id ${proposalId} not found`);
- }
-
- const d = await expectProposalDownload(ws, proposal);
- if (!d) {
- throw Error("proposal is in invalid state");
- }
-
- const existingPurchase = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (
- purchase &&
- sessionIdOverride !== undefined &&
- sessionIdOverride != purchase.lastSessionId
- ) {
- logger.trace(`changing session ID to ${sessionIdOverride}`);
- purchase.lastSessionId = sessionIdOverride;
- if (purchase.purchaseStatus === PurchaseStatus.Done) {
- purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
- }
- await tx.purchases.put(purchase);
- }
- return purchase;
- },
- );
-
- if (existingPurchase && existingPurchase.payInfo) {
- logger.trace("confirmPay: submitting payment for existing purchase");
- const ctx = new PayMerchantTransactionContext(
- ws,
- existingPurchase.proposalId,
- );
- await ws.taskScheduler.resetTaskRetries(ctx.taskId);
- return waitPaymentResult(ws, proposalId);
- }
-
- logger.trace("confirmPay: purchase record does not exist yet");
-
- const contractData = d.contractData;
-
- const selectCoinsResult = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins: [],
- requiredMinimumAge: contractData.minimumAge,
- forcedSelection: forcedCoinSel,
- });
-
- logger.trace("coin selection result", selectCoinsResult);
-
- if (selectCoinsResult.type === "failure") {
- // Should not happen, since checkPay should be called first
- // FIXME: Actually, this should be handled gracefully,
- // and the status should be stored in the DB.
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
- }
-
- const coinSelection = selectCoinsResult.coinSel;
- const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
-
- let sessionId: string | undefined;
- if (sessionIdOverride) {
- sessionId = sessionIdOverride;
- } else {
- sessionId = proposal.downloadSessionId;
- }
-
- logger.trace(
- `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
- );
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- ],
- async (tx) => {
- const p = await tx.purchases.get(proposal.proposalId);
- if (!p) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- switch (p.purchaseStatus) {
- case PurchaseStatus.DialogShared:
- case PurchaseStatus.DialogProposed:
- p.payInfo = {
- payCoinSelection: coinSelection,
- payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
- totalPayCost: Amounts.stringify(payCostInfo),
- };
- p.lastSessionId = sessionId;
- p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
- p.purchaseStatus = PurchaseStatus.PendingPaying;
- await tx.purchases.put(p);
- await spendCoins(ws, tx, {
- //`txn:proposal:${p.proposalId}`
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: proposalId,
- }),
- coinPubs: coinSelection.coinPubs,
- contributions: coinSelection.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
- ),
- refreshReason: RefreshReason.PayMerchant,
- });
- break;
- case PurchaseStatus.Done:
- case PurchaseStatus.PendingPaying:
- default:
- break;
- }
- const newTxState = computePayMerchantTransactionState(p);
- return { oldTxState, newTxState };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- // Wait until we have completed the first attempt to pay.
- return waitPaymentResult(ws, proposalId);
-}
-
-export async function processPurchase(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<TaskRunResult> {
- const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: {
- // FIXME: allocate more specific error code
- code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- when: AbsoluteTime.now(),
- hint: `trying to pay for purchase that is not in the database`,
- proposalId: proposalId,
- },
- };
- }
-
- switch (purchase.purchaseStatus) {
- case PurchaseStatus.PendingDownloadingProposal:
- return processDownloadProposal(ws, proposalId);
- case PurchaseStatus.PendingPaying:
- case PurchaseStatus.PendingPayingReplay:
- return processPurchasePay(ws, proposalId);
- case PurchaseStatus.PendingQueryingRefund:
- return processPurchaseQueryRefund(ws, purchase);
- case PurchaseStatus.PendingQueryingAutoRefund:
- return processPurchaseAutoRefund(ws, purchase);
- case PurchaseStatus.AbortingWithRefund:
- return processPurchaseAbortingRefund(ws, purchase);
- case PurchaseStatus.PendingAcceptRefund:
- return processPurchaseAcceptRefund(ws, purchase);
- case PurchaseStatus.DialogShared:
- return processPurchaseDialogShared(ws, purchase);
- case PurchaseStatus.FailedClaim:
- case PurchaseStatus.Done:
- case PurchaseStatus.DoneRepurchaseDetected:
- case PurchaseStatus.DialogProposed:
- case PurchaseStatus.AbortedProposalRefused:
- case PurchaseStatus.AbortedIncompletePayment:
- case PurchaseStatus.AbortedOrderDeleted:
- case PurchaseStatus.AbortedRefunded:
- case PurchaseStatus.SuspendedAbortingWithRefund:
- case PurchaseStatus.SuspendedDownloadingProposal:
- case PurchaseStatus.SuspendedPaying:
- case PurchaseStatus.SuspendedPayingReplay:
- case PurchaseStatus.SuspendedPendingAcceptRefund:
- case PurchaseStatus.SuspendedQueryingAutoRefund:
- case PurchaseStatus.SuspendedQueryingRefund:
- case PurchaseStatus.FailedAbort:
- return TaskRunResult.finished();
- default:
- assertUnreachable(purchase.purchaseStatus);
- // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`);
- }
-}
-
-async function processPurchasePay(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<TaskRunResult> {
- const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: {
- // FIXME: allocate more specific error code
- code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- when: AbsoluteTime.now(),
- hint: `trying to pay for purchase that is not in the database`,
- proposalId: proposalId,
- },
- };
- }
- switch (purchase.purchaseStatus) {
- case PurchaseStatus.PendingPaying:
- case PurchaseStatus.PendingPayingReplay:
- break;
- default:
- return TaskRunResult.finished();
- }
- logger.trace(`processing purchase pay ${proposalId}`);
-
- const sessionId = purchase.lastSessionId;
-
- logger.trace(`paying with session ID ${sessionId}`);
- const payInfo = purchase.payInfo;
- checkDbInvariant(!!payInfo, "payInfo");
-
- const download = await expectProposalDownload(ws, purchase);
-
- if (purchase.shared) {
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
-
- if (paid) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, {
- orderId: purchase.orderId,
- fulfillmentUrl: download.contractData.fulfillmentUrl,
- }),
- };
- }
- }
-
- if (!purchase.merchantPaySig) {
- const payUrl = new URL(
- `orders/${download.contractData.orderId}/pay`,
- download.contractData.merchantBaseUrl,
- ).href;
-
- let depositPermissions: CoinDepositPermission[];
- // FIXME: Cache!
- depositPermissions = await generateDepositPermissions(
- ws,
- payInfo.payCoinSelection,
- download.contractData,
- );
-
- const reqBody = {
- coins: depositPermissions,
- session_id: purchase.lastSessionId,
- };
-
- logger.trace(
- "making pay request ... ",
- JSON.stringify(reqBody, undefined, 2),
- );
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.fetch(payUrl, {
- method: "POST",
- body: reqBody,
- timeout: getPayRequestTimeout(purchase),
- }),
- );
-
- logger.trace(`got resp ${JSON.stringify(resp)}`);
-
- if (resp.status >= 500 && resp.status <= 599) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
- {
- requestError: errDetails,
- },
- ),
- };
- }
-
- if (resp.status === HttpStatusCode.Conflict) {
- const err = await readTalerErrorResponse(resp);
- if (
- err.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
- ) {
- // Do this in the background, as it might take some time
- handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
- logger.error("handling insufficient funds failed");
- logger.error(`${e.toString()}`);
- });
-
- // FIXME: Should we really consider this to be pending?
-
- return TaskRunResult.backoff();
- }
- }
-
- if (resp.status >= 400 && resp.status <= 499) {
- logger.trace("got generic 4xx from merchant");
- const err = await readTalerErrorResponse(resp);
- throwUnexpectedRequestError(resp, err);
- }
-
- const merchantResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantPayResponse(),
- );
-
- logger.trace("got success from pay URL", merchantResp);
-
- const merchantPub = download.contractData.merchantPub;
- const { valid } = await ws.cryptoApi.isValidPaymentSignature({
- contractHash: download.contractData.contractTermsHash,
- merchantPub,
- sig: merchantResp.sig,
- });
-
- if (!valid) {
- logger.error("merchant payment signature invalid");
- // FIXME: properly display error
- throw Error("merchant payment signature invalid");
- }
-
- await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp);
- } else {
- const payAgainUrl = new URL(
- `orders/${download.contractData.orderId}/paid`,
- download.contractData.merchantBaseUrl,
- ).href;
- const reqBody = {
- sig: purchase.merchantPaySig,
- h_contract: download.contractData.contractTermsHash,
- session_id: sessionId ?? "",
- };
- logger.trace(`/paid request body: ${j2s(reqBody)}`);
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.fetch(payAgainUrl, { method: "POST", body: reqBody }),
- );
- logger.trace(`/paid response status: ${resp.status}`);
- if (
- resp.status !== HttpStatusCode.NoContent &&
- resp.status != HttpStatusCode.Ok
- ) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- getHttpResponseErrorDetails(resp),
- "/paid failed",
- );
- }
- await storePayReplaySuccess(ws, proposalId, sessionId);
- }
-
- return TaskRunResult.progress();
-}
-
-export async function refuseProposal(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const proposal = await tx.purchases.get(proposalId);
- if (!proposal) {
- logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
- return undefined;
- }
- if (
- proposal.purchaseStatus !== PurchaseStatus.DialogProposed &&
- proposal.purchaseStatus !== PurchaseStatus.DialogShared
- ) {
- return undefined;
- }
- const oldTxState = computePayMerchantTransactionState(proposal);
- proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
- const newTxState = computePayMerchantTransactionState(proposal);
- await tx.purchases.put(proposal);
- return { oldTxState, newTxState };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-const transitionSuspend: {
- [x in PurchaseStatus]?: {
- next: PurchaseStatus | undefined;
- };
-} = {
- [PurchaseStatus.PendingDownloadingProposal]: {
- next: PurchaseStatus.SuspendedDownloadingProposal,
- },
- [PurchaseStatus.AbortingWithRefund]: {
- next: PurchaseStatus.SuspendedAbortingWithRefund,
- },
- [PurchaseStatus.PendingPaying]: {
- next: PurchaseStatus.SuspendedPaying,
- },
- [PurchaseStatus.PendingPayingReplay]: {
- next: PurchaseStatus.SuspendedPayingReplay,
- },
- [PurchaseStatus.PendingQueryingAutoRefund]: {
- next: PurchaseStatus.SuspendedQueryingAutoRefund,
- },
-};
-
-const transitionResume: {
- [x in PurchaseStatus]?: {
- next: PurchaseStatus | undefined;
- };
-} = {
- [PurchaseStatus.SuspendedDownloadingProposal]: {
- next: PurchaseStatus.PendingDownloadingProposal,
- },
- [PurchaseStatus.SuspendedAbortingWithRefund]: {
- next: PurchaseStatus.AbortingWithRefund,
- },
- [PurchaseStatus.SuspendedPaying]: {
- next: PurchaseStatus.PendingPaying,
- },
- [PurchaseStatus.SuspendedPayingReplay]: {
- next: PurchaseStatus.PendingPayingReplay,
- },
- [PurchaseStatus.SuspendedQueryingAutoRefund]: {
- next: PurchaseStatus.PendingQueryingAutoRefund,
- },
-};
-
-export function computePayMerchantTransactionState(
- purchaseRecord: PurchaseRecord,
-): TransactionState {
- switch (purchaseRecord.purchaseStatus) {
- // Pending States
- case PurchaseStatus.PendingDownloadingProposal:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.PendingPaying:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.SubmitPayment,
- };
- case PurchaseStatus.PendingPayingReplay:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.RebindSession,
- };
- case PurchaseStatus.PendingQueryingAutoRefund:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AutoRefund,
- };
- case PurchaseStatus.PendingQueryingRefund:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CheckRefund,
- };
- case PurchaseStatus.PendingAcceptRefund:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AcceptRefund,
- };
- // Suspended Pending States
- case PurchaseStatus.SuspendedDownloadingProposal:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.SuspendedPaying:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.SubmitPayment,
- };
- case PurchaseStatus.SuspendedPayingReplay:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.RebindSession,
- };
- case PurchaseStatus.SuspendedQueryingAutoRefund:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.AutoRefund,
- };
- case PurchaseStatus.SuspendedQueryingRefund:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.CheckRefund,
- };
- case PurchaseStatus.SuspendedPendingAcceptRefund:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.AcceptRefund,
- };
- // Aborting States
- case PurchaseStatus.AbortingWithRefund:
- return {
- major: TransactionMajorState.Aborting,
- };
- // Suspended Aborting States
- case PurchaseStatus.SuspendedAbortingWithRefund:
- return {
- major: TransactionMajorState.SuspendedAborting,
- };
- // Dialog States
- case PurchaseStatus.DialogProposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.MerchantOrderProposed,
- };
- case PurchaseStatus.DialogShared:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.MerchantOrderProposed,
- };
- // Final States
- case PurchaseStatus.AbortedProposalRefused:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.Refused,
- };
- case PurchaseStatus.AbortedOrderDeleted:
- case PurchaseStatus.AbortedRefunded:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PurchaseStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PurchaseStatus.DoneRepurchaseDetected:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.Repurchase,
- };
- case PurchaseStatus.AbortedIncompletePayment:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PurchaseStatus.FailedClaim:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.FailedAbort:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.AbortingBank,
- };
- }
-}
-
-export function computePayMerchantTransactionActions(
- purchaseRecord: PurchaseRecord,
-): TransactionAction[] {
- switch (purchaseRecord.purchaseStatus) {
- // Pending States
- case PurchaseStatus.PendingDownloadingProposal:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingPaying:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingPayingReplay:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingQueryingAutoRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingQueryingRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingAcceptRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Suspend, TransactionAction.Abort];
- // Suspended Pending States
- case PurchaseStatus.SuspendedDownloadingProposal:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedPaying:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedPayingReplay:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedQueryingAutoRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedQueryingRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedPendingAcceptRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Resume, TransactionAction.Abort];
- // Aborting States
- case PurchaseStatus.AbortingWithRefund:
- return [TransactionAction.Fail, TransactionAction.Suspend];
- case PurchaseStatus.SuspendedAbortingWithRefund:
- return [TransactionAction.Fail, TransactionAction.Resume];
- // Dialog States
- case PurchaseStatus.DialogProposed:
- return [];
- case PurchaseStatus.DialogShared:
- return [];
- // Final States
- case PurchaseStatus.AbortedProposalRefused:
- case PurchaseStatus.AbortedOrderDeleted:
- case PurchaseStatus.AbortedRefunded:
- return [TransactionAction.Delete];
- case PurchaseStatus.Done:
- return [TransactionAction.Delete];
- case PurchaseStatus.DoneRepurchaseDetected:
- return [TransactionAction.Delete];
- case PurchaseStatus.AbortedIncompletePayment:
- return [TransactionAction.Delete];
- case PurchaseStatus.FailedClaim:
- return [TransactionAction.Delete];
- case PurchaseStatus.FailedAbort:
- return [TransactionAction.Delete];
- }
-}
-
-export async function sharePayment(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- orderId: string,
-): Promise<SharePaymentResult> {
- const result = await ws.db.runReadWriteTx(["purchases"], async (tx) => {
- const p = await tx.purchases.indexes.byUrlAndOrderId.get([
- merchantBaseUrl,
- orderId,
- ]);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return undefined;
- }
- if (
- p.purchaseStatus !== PurchaseStatus.DialogProposed &&
- p.purchaseStatus !== PurchaseStatus.DialogShared
- ) {
- // FIXME: purchase can be shared before being paid
- return undefined;
- }
- if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
- p.purchaseStatus = PurchaseStatus.DialogShared;
- p.shared = true;
- tx.purchases.put(p);
- }
-
- return {
- nonce: p.noncePriv,
- session: p.lastSessionId ?? p.downloadSessionId,
- token: p.claimToken,
- };
- });
-
- if (result === undefined) {
- throw Error("This purchase can't be shared");
- }
- const privatePayUri = stringifyPayUri({
- merchantBaseUrl,
- orderId,
- sessionId: result.session ?? "",
- noncePriv: result.nonce,
- claimToken: result.token,
- });
- return { privatePayUri };
-}
-
-async function checkIfOrderIsAlreadyPaid(
- ws: InternalWalletState,
- contract: WalletContractData,
-) {
- const requestUrl = new URL(
- `orders/${contract.orderId}`,
- contract.merchantBaseUrl,
- );
- requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
-
- requestUrl.searchParams.set("timeout_ms", "1000");
-
- const resp = await ws.http.fetch(requestUrl.href);
- if (
- resp.status === HttpStatusCode.Ok ||
- resp.status === HttpStatusCode.Accepted ||
- resp.status === HttpStatusCode.Found
- ) {
- return true;
- } else if (resp.status === HttpStatusCode.PaymentRequired) {
- return false;
- }
- //forbidden, not found, not acceptable
- throw Error(`this order cant be paid: ${resp.status}`);
-}
-
-async function processPurchaseDialogShared(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
- logger.trace(`processing dialog-shared for proposal ${proposalId}`);
- const download = await expectProposalDownload(ws, purchase);
-
- if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) {
- return TaskRunResult.finished();
- }
-
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
- if (paid) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- return TaskRunResult.backoff();
-}
-
-async function processPurchaseAutoRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
- logger.trace(`processing auto-refund for proposal ${proposalId}`);
-
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- const download = await expectProposalDownload(ws, purchase);
-
- if (
- !purchase.autoRefundDeadline ||
- AbsoluteTime.isExpired(
- AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(purchase.autoRefundDeadline),
- ),
- )
- ) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.Done;
- p.refundAmountAwaiting = undefined;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}`,
- download.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- download.contractData.contractTermsHash,
- );
-
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
-
- const resp = await ws.http.fetch(requestUrl.href);
-
- // FIXME: Check other status codes!
-
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
-
- if (orderStatus.refund_pending) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- return TaskRunResult.backoff();
-}
-
-async function processPurchaseAbortingRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
- const download = await expectProposalDownload(ws, purchase);
- logger.trace(`processing aborting-refund for proposal ${proposalId}`);
-
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}/abort`,
- download.contractData.merchantBaseUrl,
- );
-
- const abortingCoins: AbortingCoin[] = [];
-
- const payCoinSelection = purchase.payInfo?.payCoinSelection;
- if (!payCoinSelection) {
- throw Error("can't abort, no coins selected");
- }
-
- await ws.db.runReadOnlyTx(["coins"], async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- const coin = await tx.coins.get(coinPub);
- checkDbInvariant(!!coin, "expected coin to be present");
- abortingCoins.push({
- coin_pub: coinPub,
- contribution: Amounts.stringify(payCoinSelection.coinContributions[i]),
- exchange_url: coin.exchangeBaseUrl,
- });
- }
- });
-
- const abortReq: AbortRequest = {
- h_contract: download.contractData.contractTermsHash,
- coins: abortingCoins,
- };
-
- logger.trace(`making order abort request to ${requestUrl.href}`);
-
- const abortHttpResp = await ws.http.fetch(requestUrl.href, {
- method: "POST",
- body: abortReq,
- });
-
- if (abortHttpResp.status === HttpStatusCode.NotFound) {
- const err = await readTalerErrorResponse(abortHttpResp);
- if (
- err.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
- ) {
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
- await ctx.transition(async (rec) => {
- if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
- rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
- return TransitionResult.Transition;
- }
- return TransitionResult.Stay;
- });
- }
- }
-
- const abortResp = await readSuccessResponseJsonOrThrow(
- abortHttpResp,
- codecForAbortResponse(),
- );
-
- const refunds: MerchantCoinRefundStatus[] = [];
-
- if (abortResp.refunds.length != abortingCoins.length) {
- // FIXME: define error code!
- throw Error("invalid order abort response");
- }
-
- for (let i = 0; i < abortResp.refunds.length; i++) {
- const r = abortResp.refunds[i];
- refunds.push({
- ...r,
- coin_pub: payCoinSelection.coinPubs[i],
- refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
- rtransaction_id: 0,
- execution_time: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.fromProtocolTimestamp(download.contractData.timestamp),
- Duration.fromSpec({ seconds: 1 }),
- ),
- ),
- });
- }
- return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund);
-}
-
-async function processPurchaseQueryRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
- logger.trace(`processing query-refund for proposal ${proposalId}`);
-
- const download = await expectProposalDownload(ws, purchase);
-
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}`,
- download.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- download.contractData.contractTermsHash,
- );
-
- const resp = await ws.http.fetch(requestUrl.href);
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- if (!orderStatus.refund_pending) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return undefined;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
- return undefined;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.Done;
- p.refundAmountAwaiting = undefined;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.progress();
- } else {
- const refundAwaiting = Amounts.sub(
- Amounts.parseOrThrow(orderStatus.refund_amount),
- Amounts.parseOrThrow(orderStatus.refund_taken),
- ).amount;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
- p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.progress();
- }
-}
-
-async function processPurchaseAcceptRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const download = await expectProposalDownload(ws, purchase);
-
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}/refund`,
- download.contractData.merchantBaseUrl,
- );
-
- logger.trace(`making refund request to ${requestUrl.href}`);
-
- const request = await ws.http.fetch(requestUrl.href, {
- method: "POST",
- body: {
- h_contract: download.contractData.contractTermsHash,
- },
- });
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderRefundPickupResponse(),
- );
- return await storeRefunds(
- ws,
- purchase,
- refundResponse.refunds,
- RefundReason.AbortRefund,
- );
-}
-
-export async function startRefundQueryForUri(
- ws: InternalWalletState,
- talerUri: string,
-): Promise<StartRefundQueryForUriResponse> {
- const parsedUri = parseTalerUri(talerUri);
- if (!parsedUri) {
- throw Error("invalid taler:// URI");
- }
- if (parsedUri.type !== TalerUriAction.Refund) {
- throw Error("expected taler://refund URI");
- }
- const purchaseRecord = await ws.db.runReadOnlyTx(
- ["purchases"],
- async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.get([
- parsedUri.merchantBaseUrl,
- parsedUri.orderId,
- ]);
- },
- );
- if (!purchaseRecord) {
- logger.error(
- `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`,
- );
- throw Error("no purchase found, can't refund");
- }
- const proposalId = purchaseRecord.proposalId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- await startQueryRefund(ws, proposalId);
- return {
- transactionId,
- };
-}
-
-export async function startQueryRefund(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.warn(`purchase ${proposalId} does not exist anymore`);
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.Done) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.PendingQueryingRefund;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, ctx.transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-}
-
-async function computeRefreshRequest(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<["coins", "denominations"]>,
- items: RefundItemRecord[],
-): Promise<CoinRefreshRequest[]> {
- const refreshCoins: CoinRefreshRequest[] = [];
- for (const item of items) {
- const coin = await tx.coins.get(item.coinPub);
- if (!coin) {
- throw Error("coin not found");
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denomInfo) {
- throw Error("denom not found");
- }
- if (item.status === RefundItemStatus.Done) {
- const refundedAmount = Amounts.sub(
- item.refundAmount,
- denomInfo.feeRefund,
- ).amount;
- refreshCoins.push({
- amount: Amounts.stringify(refundedAmount),
- coinPub: item.coinPub,
- });
- }
- }
- return refreshCoins;
-}
-
-/**
- * Compute the refund item status based on the merchant's response.
- */
-function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
- if (rf.type === "success") {
- return RefundItemStatus.Done;
- } else {
- if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
- return RefundItemStatus.Pending;
- } else {
- return RefundItemStatus.Failed;
- }
- }
-}
-
-/**
- * Store refunds, possibly creating a new refund group.
- */
-async function storeRefunds(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
- refunds: MerchantCoinRefundStatus[],
- reason: RefundReason,
-): Promise<TaskRunResult> {
- logger.info(`storing refunds: ${j2s(refunds)}`);
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: purchase.proposalId,
- });
-
- const newRefundGroupId = encodeCrock(randomBytes(32));
- const now = TalerPreciseTimestamp.now();
-
- const download = await expectProposalDownload(ws, purchase);
- const currency = Amounts.currencyOf(download.contractData.amount);
-
- const result = await ws.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "purchases",
- "refundItems",
- "refundGroups",
- "denominations",
- "coins",
- "coinAvailability",
- "refreshGroups",
- ],
- async (tx) => {
- const myPurchase = await tx.purchases.get(purchase.proposalId);
- if (!myPurchase) {
- logger.warn("purchase group not found anymore");
- return;
- }
- let isAborting: boolean;
- switch (myPurchase.purchaseStatus) {
- case PurchaseStatus.PendingAcceptRefund:
- isAborting = false;
- break;
- case PurchaseStatus.AbortingWithRefund:
- isAborting = true;
- break;
- default:
- logger.warn("wrong state, not accepting refund");
- return;
- }
-
- let newGroup: RefundGroupRecord | undefined = undefined;
- // Pending, but not part of an aborted refund group.
- let numPendingItemsTotal = 0;
- const newGroupRefunds: RefundItemRecord[] = [];
-
- for (const rf of refunds) {
- const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
- rf.coin_pub,
- rf.rtransaction_id,
- ]);
- if (oldItem) {
- logger.info("already have refund in database");
- if (oldItem.status === RefundItemStatus.Done) {
- continue;
- }
- if (rf.type === "success") {
- oldItem.status = RefundItemStatus.Done;
- } else {
- if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
- oldItem.status = RefundItemStatus.Pending;
- numPendingItemsTotal += 1;
- } else {
- oldItem.status = RefundItemStatus.Failed;
- }
- }
- await tx.refundItems.put(oldItem);
- } else {
- // Put refund item into a new group!
- if (!newGroup) {
- newGroup = {
- proposalId: purchase.proposalId,
- refundGroupId: newRefundGroupId,
- status: RefundGroupStatus.Pending,
- timestampCreated: timestampPreciseToDb(now),
- amountEffective: Amounts.stringify(
- Amounts.zeroOfCurrency(currency),
- ),
- amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
- };
- }
- const status: RefundItemStatus = getItemStatus(rf);
- const newItem: RefundItemRecord = {
- coinPub: rf.coin_pub,
- executionTime: timestampProtocolToDb(rf.execution_time),
- obtainedTime: timestampPreciseToDb(now),
- refundAmount: rf.refund_amount,
- refundGroupId: newGroup.refundGroupId,
- rtxid: rf.rtransaction_id,
- status,
- };
- if (status === RefundItemStatus.Pending) {
- numPendingItemsTotal += 1;
- }
- newGroupRefunds.push(newItem);
- await tx.refundItems.put(newItem);
- }
- }
-
- // Now that we know all the refunds for the new refund group,
- // we can compute the raw/effective amounts.
- if (newGroup) {
- const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
- const refreshCoins = await computeRefreshRequest(
- ws,
- tx,
- newGroupRefunds,
- );
- const outInfo = await calculateRefreshOutput(
- ws,
- tx,
- currency,
- refreshCoins,
- );
- newGroup.amountEffective = Amounts.stringify(
- Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
- );
- newGroup.amountRaw = Amounts.stringify(
- Amounts.sumOrZero(currency, amountsRaw).amount,
- );
- await tx.refundGroups.put(newGroup);
- }
-
- const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
- myPurchase.proposalId,
- );
-
- for (const refundGroup of refundGroups) {
- switch (refundGroup.status) {
- case RefundGroupStatus.Aborted:
- case RefundGroupStatus.Expired:
- case RefundGroupStatus.Failed:
- case RefundGroupStatus.Done:
- continue;
- case RefundGroupStatus.Pending:
- break;
- default:
- assertUnreachable(refundGroup.status);
- }
- const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
- refundGroup.refundGroupId,
- ]);
- let numPending = 0;
- let numFailed = 0;
- for (const item of items) {
- if (item.status === RefundItemStatus.Pending) {
- numPending++;
- }
- if (item.status === RefundItemStatus.Failed) {
- numFailed++;
- }
- }
- if (numPending === 0) {
- // We're done for this refund group!
- if (numFailed === 0) {
- refundGroup.status = RefundGroupStatus.Done;
- } else {
- refundGroup.status = RefundGroupStatus.Failed;
- }
- await tx.refundGroups.put(refundGroup);
- const refreshCoins = await computeRefreshRequest(ws, tx, items);
- await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(download.contractData.amount),
- refreshCoins,
- RefreshReason.Refund,
- // Since refunds are really just pseudo-transactions,
- // the originating transaction for the refresh is the payment transaction.
- constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: myPurchase.proposalId,
- }),
- );
- }
- }
-
- const oldTxState = computePayMerchantTransactionState(myPurchase);
- if (numPendingItemsTotal === 0) {
- if (isAborting) {
- myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
- } else {
- myPurchase.purchaseStatus = PurchaseStatus.Done;
- }
- myPurchase.refundAmountAwaiting = undefined;
- }
- await tx.purchases.put(myPurchase);
- const newTxState = computePayMerchantTransactionState(myPurchase);
-
- return {
- numPendingItemsTotal,
- transitionInfo: {
- oldTxState,
- newTxState,
- },
- };
- },
- );
-
- if (!result) {
- return TaskRunResult.finished();
- }
-
- notifyTransition(ws, transactionId, result.transitionInfo);
-
- if (result.numPendingItemsTotal > 0) {
- return TaskRunResult.backoff();
- } else {
- return TaskRunResult.progress();
- }
-}
-
-export function computeRefundTransactionState(
- refundGroupRecord: RefundGroupRecord,
-): TransactionState {
- switch (refundGroupRecord.status) {
- case RefundGroupStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case RefundGroupStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case RefundGroupStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case RefundGroupStatus.Pending:
- return {
- major: TransactionMajorState.Pending,
- };
- case RefundGroupStatus.Expired:
- return {
- major: TransactionMajorState.Expired,
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts
deleted file mode 100644
index ae6f98ccd..000000000
--- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AmountJson,
- AmountString,
- Amounts,
- Codec,
- Logger,
- TalerProtocolTimestamp,
- buildCodecForObject,
- codecForAmountString,
- codecForTimestamp,
- codecOptional,
-} from "@gnu-taler/taler-util";
-import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
-import { PeerPushPaymentCoinSelection, ReserveRecord } from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import type { SelectedPeerCoin } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { getTotalRefreshCost } from "./refresh.js";
-import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
-
-const logger = new Logger("operations/peer-to-peer.ts");
-
-/**
- * Get information about the coin selected for signatures.
- */
-export async function queryCoinInfosForSelection(
- ws: InternalWalletState,
- csel: PeerPushPaymentCoinSelection,
-): Promise<SpendCoinDetails[]> {
- let infos: SpendCoinDetails[] = [];
- await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < csel.coinPubs.length; i++) {
- const coin = await tx.coins.get(csel.coinPubs[i]);
- if (!coin) {
- throw Error("coin not found anymore");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("denom for coin not found anymore");
- }
- infos.push({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- ageCommitmentProof: coin.ageCommitmentProof,
- contribution: csel.contributions[i],
- });
- }
- });
- return infos;
-}
-
-export async function getTotalPeerPaymentCost(
- ws: InternalWalletState,
- pcs: SelectedPeerCoin[],
-): Promise<AmountJson> {
- const currency = Amounts.currencyOf(pcs[0].contribution);
- return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.length; i++) {
- const coin = await tx.coins.get(pcs[i].coinPub);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denomInfo) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
- );
- }
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denomInfo.value,
- pcs[i].contribution,
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
- denomInfo,
- amountLeft,
- ws.config.testing.denomselAllowLate,
- );
- costs.push(Amounts.parseOrThrow(pcs[i].contribution));
- costs.push(refreshCost);
- }
- const zero = Amounts.zeroOfAmount(pcs[0].contribution);
- return Amounts.sum([zero, ...costs]).amount;
- });
-}
-
-interface ExchangePurseStatus {
- balance: AmountString;
- deposit_timestamp?: TalerProtocolTimestamp;
- merge_timestamp?: TalerProtocolTimestamp;
-}
-
-export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
- buildCodecForObject<ExchangePurseStatus>()
- .property("balance", codecForAmountString())
- .property("deposit_timestamp", codecOptional(codecForTimestamp))
- .property("merge_timestamp", codecOptional(codecForTimestamp))
- .build("ExchangePurseStatus");
-
-export async function getMergeReserveInfo(
- ws: InternalWalletState,
- req: {
- exchangeBaseUrl: string;
- },
-): Promise<ReserveRecord> {
- // We have to eagerly create the key pair outside of the transaction,
- // due to the async crypto API.
- const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
-
- const mergeReserveRecord: ReserveRecord = await ws.db.runReadWriteTx(
- ["exchanges", "reserves"],
- async (tx) => {
- const ex = await tx.exchanges.get(req.exchangeBaseUrl);
- checkDbInvariant(!!ex);
- if (ex.currentMergeReserveRowId != null) {
- const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
- checkDbInvariant(!!reserve);
- return reserve;
- }
- const reserve: ReserveRecord = {
- reservePriv: newReservePair.priv,
- reservePub: newReservePair.pub,
- };
- const insertResp = await tx.reserves.put(reserve);
- checkDbInvariant(typeof insertResp.key === "number");
- reserve.rowId = insertResp.key;
- ex.currentMergeReserveRowId = reserve.rowId;
- await tx.exchanges.put(ex);
- return reserve;
- },
- );
-
- return mergeReserveRecord;
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
deleted file mode 100644
index e97466084..000000000
--- a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
+++ /dev/null
@@ -1,1204 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- AbsoluteTime,
- Amounts,
- CancellationToken,
- CheckPeerPullCreditRequest,
- CheckPeerPullCreditResponse,
- ContractTermsUtil,
- ExchangeReservePurseRequest,
- HttpStatusCode,
- InitiatePeerPullCreditRequest,
- InitiatePeerPullCreditResponse,
- Logger,
- NotificationType,
- PeerContractTerms,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TalerUriAction,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- WalletAccountMergeFlags,
- WalletKycUuid,
- codecForAny,
- codecForWalletKycUuid,
- encodeCrock,
- getRandomBytes,
- j2s,
- makeErrorDetail,
- stringifyTalerUri,
- talerPaytoFromExchangeReserve,
-} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import {
- KycPendingInfo,
- KycUserType,
- PeerPullCreditRecord,
- PeerPullPaymentCreditStatus,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
- fetchFreshExchange,
- timestampOptionalPreciseFromDb,
- timestampPreciseFromDb,
- timestampPreciseToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import {
- codecForExchangePurseStatus,
- getMergeReserveInfo,
-} from "./pay-peer-common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-import {
- getExchangeWithdrawalInfo,
- internalCreateWithdrawalGroup,
-} from "./withdraw.js";
-
-const logger = new Logger("pay-peer-pull-credit.ts");
-
-export class PeerPullCreditTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly retryTag: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public pursePub: string,
- ) {
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, pursePub } = this;
- await ws.db.runReadWriteTx(
- ["withdrawalGroups", "peerPullCredit", "tombstones"],
- async (tx) => {
- const pullIni = await tx.peerPullCredit.get(pursePub);
- if (!pullIni) {
- return;
- }
- if (pullIni.withdrawalGroupId) {
- const withdrawalGroupId = pullIni.withdrawalGroupId;
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- }
- }
- await tx.peerPullCredit.delete(pursePub);
- await tx.tombstones.put({
- id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
- });
- },
- );
-
- return;
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, pursePub, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse;
- break;
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired;
- break;
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing;
- break;
- case PeerPullPaymentCreditStatus.PendingReady:
- newStatus = PeerPullPaymentCreditStatus.SuspendedReady;
- break;
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- newStatus =
- PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, pursePub, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- case PeerPullPaymentCreditStatus.PendingReady:
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- break;
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPullPaymentCreditStatus.Failed;
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.stopShepherdTask(retryTag);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, pursePub, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- case PeerPullPaymentCreditStatus.PendingReady:
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.Aborted:
- break;
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse;
- break;
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
- break;
- case PeerPullPaymentCreditStatus.SuspendedReady:
- newStatus = PeerPullPaymentCreditStatus.PendingReady;
- break;
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing;
- break;
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, pursePub, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- throw Error("can't abort anymore");
- case PeerPullPaymentCreditStatus.PendingReady:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-async function queryPurseForPeerPullCredit(
- ws: InternalWalletState,
- pullIni: PeerPullCreditRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const purseDepositUrl = new URL(
- `purses/${pullIni.pursePub}/deposit`,
- pullIni.exchangeBaseUrl,
- );
- purseDepositUrl.searchParams.set("timeout_ms", "30000");
- logger.info(`querying purse status via ${purseDepositUrl.href}`);
- const resp = await ws.http.fetch(purseDepositUrl.href, {
- timeout: { d_ms: 60000 },
- cancellationToken,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullIni.pursePub,
- });
-
- logger.info(`purse status code: HTTP ${resp.status}`);
-
- switch (resp.status) {
- case HttpStatusCode.Gone: {
- // Exchange says that purse doesn't exist anymore => expired!
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
- if (!finPi) {
- logger.warn("peerPullCredit not found anymore");
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(finPi);
- if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
- finPi.status = PeerPullPaymentCreditStatus.Expired;
- }
- await tx.peerPullCredit.put(finPi);
- const newTxState = computePeerPullCreditTransactionState(finPi);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
- }
- case HttpStatusCode.NotFound:
- return TaskRunResult.backoff();
- }
-
- const result = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangePurseStatus(),
- );
-
- logger.trace(`purse status: ${j2s(result)}`);
-
- const depositTimestamp = result.deposit_timestamp;
-
- if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) {
- logger.info("purse not ready yet (no deposit)");
- return TaskRunResult.backoff();
- }
-
- const reserve = await ws.db.runReadOnlyTx(["reserves"], async (tx) => {
- return await tx.reserves.get(pullIni.mergeReserveRowId);
- });
-
- if (!reserve) {
- throw Error("reserve for peer pull credit not found in wallet DB");
- }
-
- await internalCreateWithdrawalGroup(ws, {
- amount: Amounts.parseOrThrow(pullIni.amount),
- wgInfo: {
- withdrawalType: WithdrawalRecordType.PeerPullCredit,
- contractPriv: pullIni.contractPriv,
- },
- forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
- exchangeBaseUrl: pullIni.exchangeBaseUrl,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair: {
- priv: reserve.reservePriv,
- pub: reserve.reservePub,
- },
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
- if (!finPi) {
- logger.warn("peerPullCredit not found anymore");
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(finPi);
- if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
- finPi.status = PeerPullPaymentCreditStatus.PendingWithdrawing;
- }
- await tx.peerPullCredit.put(finPi);
- const newTxState = computePeerPullCreditTransactionState(finPi);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
-}
-
-async function longpollKycStatus(
- ws: InternalWalletState,
- pursePub: string,
- exchangeUrl: string,
- kycInfo: KycPendingInfo,
- userType: KycUserType,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
-
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const peerIni = await tx.peerPullCredit.get(pursePub);
- if (!peerIni) {
- return;
- }
- if (
- peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired
- ) {
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(peerIni);
- peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse;
- const newTxState = computePeerPullCreditTransactionState(peerIni);
- await tx.peerPullCredit.put(peerIni);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-async function processPeerPullCreditAbortingDeletePurse(
- ws: InternalWalletState,
- peerPullIni: PeerPullCreditRecord,
-): Promise<TaskRunResult> {
- const { pursePub, pursePriv } = peerPullIni;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
-
- const sigResp = await ws.cryptoApi.signDeletePurse({
- pursePriv,
- });
- const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl);
- const resp = await ws.http.fetch(purseUrl.href, {
- method: "DELETE",
- headers: {
- "taler-purse-signature": sigResp.sig,
- },
- });
- logger.info(`deleted purse with response status ${resp.status}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "peerPullCredit",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- ],
- async (tx) => {
- const ppiRec = await tx.peerPullCredit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) {
- return undefined;
- }
- const oldTxState = computePeerPullCreditTransactionState(ppiRec);
- ppiRec.status = PeerPullPaymentCreditStatus.Aborted;
- await tx.peerPullCredit.put(ppiRec);
- const newTxState = computePeerPullCreditTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-
- return TaskRunResult.backoff();
-}
-
-async function handlePeerPullCreditWithdrawing(
- ws: InternalWalletState,
- pullIni: PeerPullCreditRecord,
-): Promise<TaskRunResult> {
- if (!pullIni.withdrawalGroupId) {
- throw Error("invalid db state (withdrawing, but no withdrawal group ID");
- }
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullIni.pursePub,
- });
- const wgId = pullIni.withdrawalGroupId;
- let finished: boolean = false;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit", "withdrawalGroups"],
- async (tx) => {
- const ppi = await tx.peerPullCredit.get(pullIni.pursePub);
- if (!ppi) {
- finished = true;
- return;
- }
- if (ppi.status !== PeerPullPaymentCreditStatus.PendingWithdrawing) {
- finished = true;
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(ppi);
- const wg = await tx.withdrawalGroups.get(wgId);
- if (!wg) {
- // FIXME: Fail the operation instead?
- return undefined;
- }
- switch (wg.status) {
- case WithdrawalGroupStatus.Done:
- finished = true;
- ppi.status = PeerPullPaymentCreditStatus.Done;
- break;
- // FIXME: Also handle other final states!
- }
- await tx.peerPullCredit.put(ppi);
- const newTxState = computePeerPullCreditTransactionState(ppi);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- if (finished) {
- return TaskRunResult.finished();
- } else {
- // FIXME: Return indicator that we depend on the other operation!
- return TaskRunResult.backoff();
- }
-}
-
-async function handlePeerPullCreditCreatePurse(
- ws: InternalWalletState,
- pullIni: PeerPullCreditRecord,
-): Promise<TaskRunResult> {
- const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
- const pursePub = pullIni.pursePub;
- const mergeReserve = await ws.db.runReadOnlyTx(["reserves"], async (tx) => {
- return tx.reserves.get(pullIni.mergeReserveRowId);
- });
-
- if (!mergeReserve) {
- throw Error("merge reserve for peer pull payment not found in database");
- }
-
- const contractTermsRecord = await ws.db.runReadOnlyTx(
- ["contractTerms"],
- async (tx) => {
- return tx.contractTerms.get(pullIni.contractTermsHash);
- },
- );
-
- if (!contractTermsRecord) {
- throw Error("contract terms for peer pull payment not found in database");
- }
-
- const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw;
-
- const reservePayto = talerPaytoFromExchangeReserve(
- pullIni.exchangeBaseUrl,
- mergeReserve.reservePub,
- );
-
- const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
- contractPriv: pullIni.contractPriv,
- contractPub: pullIni.contractPub,
- contractTerms: contractTermsRecord.contractTermsRaw,
- pursePriv: pullIni.pursePriv,
- pursePub: pullIni.pursePub,
- nonce: pullIni.contractEncNonce,
- });
-
- const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp);
-
- const purseExpiration = contractTerms.purse_expiration;
- const sigRes = await ws.cryptoApi.signReservePurseCreate({
- contractTermsHash: pullIni.contractTermsHash,
- flags: WalletAccountMergeFlags.CreateWithPurseFee,
- mergePriv: pullIni.mergePriv,
- mergeTimestamp: TalerPreciseTimestamp.round(mergeTimestamp),
- purseAmount: pullIni.amount,
- purseExpiration: purseExpiration,
- purseFee: purseFee,
- pursePriv: pullIni.pursePriv,
- pursePub: pullIni.pursePub,
- reservePayto,
- reservePriv: mergeReserve.reservePriv,
- });
-
- const reservePurseReqBody: ExchangeReservePurseRequest = {
- merge_sig: sigRes.mergeSig,
- merge_timestamp: TalerPreciseTimestamp.round(mergeTimestamp),
- h_contract_terms: pullIni.contractTermsHash,
- merge_pub: pullIni.mergePub,
- min_age: 0,
- purse_expiration: purseExpiration,
- purse_fee: purseFee,
- purse_pub: pullIni.pursePub,
- purse_sig: sigRes.purseSig,
- purse_value: pullIni.amount,
- reserve_sig: sigRes.accountSig,
- econtract: econtractResp.econtract,
- };
-
- logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
-
- const reservePurseMergeUrl = new URL(
- `reserves/${mergeReserve.reservePub}/purse`,
- pullIni.exchangeBaseUrl,
- );
-
- const httpResp = await ws.http.fetch(reservePurseMergeUrl.href, {
- method: "POST",
- body: reservePurseReqBody,
- });
-
- if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
- const respJson = await httpResp.json();
- const kycPending = codecForWalletKycUuid().decode(respJson);
- logger.info(`kyc uuid response: ${j2s(kycPending)}`);
- return processPeerPullCreditKycRequired(ws, pullIni, kycPending);
- }
-
- const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
-
- logger.info(`reserve merge response: ${j2s(resp)}`);
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullIni.pursePub,
- });
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pi2 = await tx.peerPullCredit.get(pursePub);
- if (!pi2) {
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(pi2);
- pi2.status = PeerPullPaymentCreditStatus.PendingReady;
- await tx.peerPullCredit.put(pi2);
- const newTxState = computePeerPullCreditTransactionState(pi2);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
-}
-
-export async function processPeerPullCredit(
- ws: InternalWalletState,
- pursePub: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const pullIni = await ws.db.runReadOnlyTx(["peerPullCredit"], async (tx) => {
- return tx.peerPullCredit.get(pursePub);
- });
- if (!pullIni) {
- throw Error("peer pull payment initiation not found in database");
- }
-
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
-
- logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
-
- switch (pullIni.status) {
- case PeerPullPaymentCreditStatus.Done: {
- return TaskRunResult.finished();
- }
- case PeerPullPaymentCreditStatus.PendingReady:
- return queryPurseForPeerPullCredit(ws, pullIni, cancellationToken);
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired: {
- if (!pullIni.kycInfo) {
- throw Error("invalid state, kycInfo required");
- }
- return await longpollKycStatus(
- ws,
- pursePub,
- pullIni.exchangeBaseUrl,
- pullIni.kycInfo,
- "individual",
- cancellationToken,
- );
- }
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- return handlePeerPullCreditCreatePurse(ws, pullIni);
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- return await processPeerPullCreditAbortingDeletePurse(ws, pullIni);
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- return handlePeerPullCreditWithdrawing(ws, pullIni);
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- break;
- default:
- assertUnreachable(pullIni.status);
- }
-
- return TaskRunResult.finished();
-}
-
-async function processPeerPullCreditKycRequired(
- ws: InternalWalletState,
- peerIni: PeerPullCreditRecord,
- kycPending: WalletKycUuid,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: peerIni.pursePub,
- });
- const { pursePub } = peerIni;
-
- const userType = "individual";
- const url = new URL(
- `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
- peerIni.exchangeBaseUrl,
- );
-
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- });
-
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- logger.warn("kyc requested, but already fulfilled");
- return TaskRunResult.backoff();
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const { transitionInfo, result } = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const peerInc = await tx.peerPullCredit.get(pursePub);
- if (!peerInc) {
- return {
- transitionInfo: undefined,
- result: TaskRunResult.finished(),
- };
- }
- const oldTxState = computePeerPullCreditTransactionState(peerInc);
- peerInc.kycInfo = {
- paytoHash: kycPending.h_payto,
- requirementRow: kycPending.requirement_row,
- };
- peerInc.kycUrl = kycStatus.kyc_url;
- peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
- const newTxState = computePeerPullCreditTransactionState(peerInc);
- await tx.peerPullCredit.put(peerInc);
- // We'll remove this eventually! New clients should rely on the
- // kycUrl field of the transaction, not the error code.
- const res: TaskRunResult = {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
- {
- kycUrl: kycStatus.kyc_url,
- },
- ),
- };
- return {
- transitionInfo: { oldTxState, newTxState },
- result: res,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
-}
-
-/**
- * Check fees and available exchanges for a peer push payment initiation.
- */
-export async function checkPeerPullPaymentInitiation(
- ws: InternalWalletState,
- req: CheckPeerPullCreditRequest,
-): Promise<CheckPeerPullCreditResponse> {
- // FIXME: We don't support exchanges with purse fees yet.
- // Select an exchange where we have money in the specified currency
- // FIXME: How do we handle regional currency scopes here? Is it an additional input?
-
- logger.trace("checking peer-pull-credit fees");
-
- const currency = Amounts.currencyOf(req.amount);
- let exchangeUrl;
- if (req.exchangeBaseUrl) {
- exchangeUrl = req.exchangeBaseUrl;
- } else {
- exchangeUrl = await getPreferredExchangeForCurrency(ws, currency);
- }
-
- if (!exchangeUrl) {
- throw Error("no exchange found for initiating a peer pull payment");
- }
-
- logger.trace(`found ${exchangeUrl} as preferred exchange`);
-
- const wi = await getExchangeWithdrawalInfo(
- ws,
- exchangeUrl,
- Amounts.parseOrThrow(req.amount),
- undefined,
- );
-
- logger.trace(`got withdrawal info`);
-
- let numCoins = 0;
- for (let i = 0; i < wi.selectedDenoms.selectedDenoms.length; i++) {
- numCoins += wi.selectedDenoms.selectedDenoms[i].count;
- }
-
- return {
- exchangeBaseUrl: exchangeUrl,
- amountEffective: wi.withdrawalAmountEffective,
- amountRaw: req.amount,
- numCoins,
- };
-}
-
-/**
- * Find a preferred exchange based on when we withdrew last from this exchange.
- */
-async function getPreferredExchangeForCurrency(
- ws: InternalWalletState,
- currency: string,
-): Promise<string | undefined> {
- // Find an exchange with the matching currency.
- // Prefer exchanges with the most recent withdrawal.
- const url = await ws.db.runReadOnlyTx(["exchanges"], async (tx) => {
- const exchanges = await tx.exchanges.iter().toArray();
- let candidate = undefined;
- for (const e of exchanges) {
- if (e.detailsPointer?.currency !== currency) {
- continue;
- }
- if (!candidate) {
- candidate = e;
- continue;
- }
- if (candidate.lastWithdrawal && !e.lastWithdrawal) {
- continue;
- }
- const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
- e.lastWithdrawal,
- );
- const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
- candidate.lastWithdrawal,
- );
- if (exchangeLastWithdrawal && candidateLastWithdrawal) {
- if (
- AbsoluteTime.cmp(
- AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
- AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
- ) > 0
- ) {
- candidate = e;
- }
- }
- }
- if (candidate) {
- return candidate.baseUrl;
- }
- return undefined;
- });
- return url;
-}
-
-/**
- * Initiate a peer pull payment.
- */
-export async function initiatePeerPullPayment(
- ws: InternalWalletState,
- req: InitiatePeerPullCreditRequest,
-): Promise<InitiatePeerPullCreditResponse> {
- const currency = Amounts.currencyOf(req.partialContractTerms.amount);
- let maybeExchangeBaseUrl: string | undefined;
- if (req.exchangeBaseUrl) {
- maybeExchangeBaseUrl = req.exchangeBaseUrl;
- } else {
- maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency);
- }
-
- if (!maybeExchangeBaseUrl) {
- throw Error("no exchange found for initiating a peer pull payment");
- }
-
- const exchangeBaseUrl = maybeExchangeBaseUrl;
-
- await fetchFreshExchange(ws, exchangeBaseUrl);
-
- const mergeReserveInfo = await getMergeReserveInfo(ws, {
- exchangeBaseUrl: exchangeBaseUrl,
- });
-
- const pursePair = await ws.cryptoApi.createEddsaKeypair({});
- const mergePair = await ws.cryptoApi.createEddsaKeypair({});
-
- const contractTerms = req.partialContractTerms;
-
- const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
-
- const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
-
- const withdrawalGroupId = encodeCrock(getRandomBytes(32));
-
- const mergeReserveRowId = mergeReserveInfo.rowId;
- checkDbInvariant(!!mergeReserveRowId);
-
- const contractEncNonce = encodeCrock(getRandomBytes(24));
-
- const wi = await getExchangeWithdrawalInfo(
- ws,
- exchangeBaseUrl,
- Amounts.parseOrThrow(req.partialContractTerms.amount),
- undefined,
- );
-
- const mergeTimestamp = TalerPreciseTimestamp.now();
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit", "contractTerms"],
- async (tx) => {
- const ppi: PeerPullCreditRecord = {
- amount: req.partialContractTerms.amount,
- contractTermsHash: hContractTerms,
- exchangeBaseUrl: exchangeBaseUrl,
- pursePriv: pursePair.priv,
- pursePub: pursePair.pub,
- mergePriv: mergePair.priv,
- mergePub: mergePair.pub,
- status: PeerPullPaymentCreditStatus.PendingCreatePurse,
- mergeTimestamp: timestampPreciseToDb(mergeTimestamp),
- contractEncNonce,
- mergeReserveRowId: mergeReserveRowId,
- contractPriv: contractKeyPair.priv,
- contractPub: contractKeyPair.pub,
- withdrawalGroupId,
- estimatedAmountEffective: wi.withdrawalAmountEffective,
- };
- await tx.peerPullCredit.put(ppi);
- const oldTxState: TransactionState = {
- major: TransactionMajorState.None,
- };
- const newTxState = computePeerPullCreditTransactionState(ppi);
- await tx.contractTerms.put({
- contractTermsRaw: contractTerms,
- h: hContractTerms,
- });
- return { oldTxState, newTxState };
- },
- );
-
- const ctx = new PeerPullCreditTransactionContext(ws, pursePair.pub);
-
- // The pending-incoming balance has changed.
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: ctx.transactionId,
- });
-
- notifyTransition(ws, ctx.transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(ctx.retryTag);
-
- return {
- talerUri: stringifyTalerUri({
- type: TalerUriAction.PayPull,
- exchangeBaseUrl: exchangeBaseUrl,
- contractPriv: contractKeyPair.priv,
- }),
- transactionId: ctx.transactionId,
- };
-}
-
-export function computePeerPullCreditTransactionState(
- pullCreditRecord: PeerPullCreditRecord,
-): TransactionState {
- switch (pullCreditRecord.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.MergeKycRequired,
- };
- case PeerPullPaymentCreditStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Ready,
- };
- case PeerPullPaymentCreditStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Withdraw,
- };
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPullPaymentCreditStatus.SuspendedReady:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Ready,
- };
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Withdraw,
- };
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.MergeKycRequired,
- };
- case PeerPullPaymentCreditStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.DeletePurse,
- };
- case PeerPullPaymentCreditStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case PeerPullPaymentCreditStatus.Expired:
- return {
- major: TransactionMajorState.Expired,
- };
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.DeletePurse,
- };
- }
-}
-
-export function computePeerPullCreditTransactionActions(
- pullCreditRecord: PeerPullCreditRecord,
-): TransactionAction[] {
- switch (pullCreditRecord.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullPaymentCreditStatus.PendingReady:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullPaymentCreditStatus.Done:
- return [TransactionAction.Delete];
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPullPaymentCreditStatus.SuspendedReady:
- return [TransactionAction.Abort, TransactionAction.Resume];
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPullPaymentCreditStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPullPaymentCreditStatus.Failed:
- return [TransactionAction.Delete];
- case PeerPullPaymentCreditStatus.Expired:
- return [TransactionAction.Delete];
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- return [TransactionAction.Resume, TransactionAction.Fail];
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
deleted file mode 100644
index 1504f3d83..000000000
--- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
+++ /dev/null
@@ -1,883 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2024 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * @fileoverview
- * Implementation of the peer-pull-debit transaction, i.e.
- * paying for an invoice the wallet received from another wallet.
- */
-
-/**
- * Imports.
- */
-import {
- AcceptPeerPullPaymentResponse,
- Amounts,
- CoinRefreshRequest,
- ConfirmPeerPullDebitRequest,
- ContractTermsUtil,
- ExchangePurseDeposits,
- HttpStatusCode,
- Logger,
- NotificationType,
- PeerContractTerms,
- PreparePeerPullDebitRequest,
- PreparePeerPullDebitResponse,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolViolationError,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- codecForAny,
- codecForExchangeGetContractResponse,
- codecForPeerContractTerms,
- decodeCrock,
- eddsaGetPublic,
- encodeCrock,
- getRandomBytes,
- j2s,
- parsePayPullUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpResponse,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "@gnu-taler/taler-util/http";
-import {
- DbReadWriteTransaction,
- InternalWalletState,
- PeerPullDebitRecordStatus,
- PeerPullPaymentIncomingRecord,
- PendingTaskType,
- RefreshOperationStatus,
- StoreNames,
- TaskId,
- WalletStoresV1,
- createRefreshGroup,
- timestampPreciseToDb,
-} from "../index.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TransactionContext,
- TransitionResult,
- constructTaskIdentifier,
- spendCoins,
-} from "./common.js";
-import {
- codecForExchangePurseStatus,
- getTotalPeerPaymentCost,
- queryCoinInfosForSelection,
-} from "./pay-peer-common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
-} from "./transactions.js";
-
-const logger = new Logger("pay-peer-pull-debit.ts");
-
-/**
- * Common context for a peer-pull-debit transaction.
- */
-export class PeerPullDebitTransactionContext implements TransactionContext {
- ws: InternalWalletState;
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
- peerPullDebitId: string;
-
- constructor(ws: InternalWalletState, peerPullDebitId: string) {
- this.ws = ws;
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullDebit,
- peerPullDebitId,
- });
- this.peerPullDebitId = peerPullDebitId;
- }
-
- async deleteTransaction(): Promise<void> {
- const transactionId = this.transactionId;
- const ws = this.ws;
- const peerPullDebitId = this.peerPullDebitId;
- await ws.db.runReadWriteTx(["peerPullDebit", "tombstones"], async (tx) => {
- const debit = await tx.peerPullDebit.get(peerPullDebitId);
- if (debit) {
- await tx.peerPullDebit.delete(peerPullDebitId);
- await tx.tombstones.put({ id: transactionId });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const taskId = this.taskId;
- const transactionId = this.transactionId;
- const ws = this.ws;
- const peerPullDebitId = this.peerPullDebitId;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullDebit"],
- async (tx) => {
- const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pullDebitRec) {
- logger.warn(`peer pull debit ${peerPullDebitId} not found`);
- return;
- }
- let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
- switch (pullDebitRec.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- break;
- case PeerPullDebitRecordStatus.Done:
- break;
- case PeerPullDebitRecordStatus.PendingDeposit:
- newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
- break;
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- break;
- case PeerPullDebitRecordStatus.Aborted:
- break;
- case PeerPullDebitRecordStatus.AbortingRefresh:
- newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
- break;
- case PeerPullDebitRecordStatus.Failed:
- break;
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- break;
- default:
- assertUnreachable(pullDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
- pullDebitRec.status = newStatus;
- const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
- await tx.peerPullDebit.put(pullDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.stopShepherdTask(taskId);
- }
-
- async resumeTransaction(): Promise<void> {
- const ctx = this;
- await ctx.transition(async (pi) => {
- switch (pi.status) {
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- pi.status = PeerPullDebitRecordStatus.PendingDeposit;
- return TransitionResult.Transition;
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
- return TransitionResult.Transition;
- case PeerPullDebitRecordStatus.Aborted:
- case PeerPullDebitRecordStatus.AbortingRefresh:
- case PeerPullDebitRecordStatus.Failed:
- case PeerPullDebitRecordStatus.DialogProposed:
- case PeerPullDebitRecordStatus.Done:
- case PeerPullDebitRecordStatus.PendingDeposit:
- return TransitionResult.Stay;
- }
- });
- this.ws.taskScheduler.startShepherdTask(this.taskId);
- }
-
- async failTransaction(): Promise<void> {
- const ctx = this;
- await ctx.transition(async (pi) => {
- switch (pi.status) {
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- case PeerPullDebitRecordStatus.PendingDeposit:
- case PeerPullDebitRecordStatus.AbortingRefresh:
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- // FIXME: Should we also abort the corresponding refresh session?!
- pi.status = PeerPullDebitRecordStatus.Failed;
- return TransitionResult.Transition;
- default:
- return TransitionResult.Stay;
- }
- });
- this.ws.taskScheduler.stopShepherdTask(this.taskId);
- }
-
- async abortTransaction(): Promise<void> {
- const ctx = this;
- await ctx.transitionExtra(
- {
- extraStores: [
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "coins",
- "coinAvailability",
- ],
- },
- async (pi, tx) => {
- switch (pi.status) {
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- case PeerPullDebitRecordStatus.PendingDeposit:
- break;
- default:
- return TransitionResult.Stay;
- }
- const currency = Amounts.currencyOf(pi.totalCostEstimated);
- const coinPubs: CoinRefreshRequest[] = [];
-
- if (!pi.coinSel) {
- throw Error("invalid db state");
- }
-
- for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: pi.coinSel.contributions[i],
- coinPub: pi.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ctx.ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPullDebit,
- this.transactionId,
- );
-
- pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
- pi.abortRefreshGroupId = refresh.refreshGroupId;
- return TransitionResult.Transition;
- },
- );
- }
-
- async transition(
- f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResult>,
- ): Promise<void> {
- return this.transitionExtra(
- {
- extraStores: [],
- },
- f,
- );
- }
-
- async transitionExtra<
- StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
- >(
- opts: { extraStores: StoreNameArray },
- f: (
- rec: PeerPullPaymentIncomingRecord,
- tx: DbReadWriteTransaction<
- typeof WalletStoresV1,
- ["peerPullDebit", ...StoreNameArray]
- >,
- ) => Promise<TransitionResult>,
- ): Promise<void> {
- const ws = this.ws;
- const extraStores = opts.extraStores ?? [];
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullDebit", ...extraStores],
- async (tx) => {
- const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
- if (!pi) {
- throw Error("peer pull payment not found anymore");
- }
- const oldTxState = computePeerPullDebitTransactionState(pi);
- const res = await f(pi, tx);
- switch (res) {
- case TransitionResult.Transition: {
- await tx.peerPullDebit.put(pi);
- const newTxState = computePeerPullDebitTransactionState(pi);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- ws.taskScheduler.stopShepherdTask(this.taskId);
- notifyTransition(ws, this.transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-}
-
-async function handlePurseCreationConflict(
- ctx: PeerPullDebitTransactionContext,
- peerPullInc: PeerPullPaymentIncomingRecord,
- resp: HttpResponse,
-): Promise<TaskRunResult> {
- const ws = ctx.ws;
- const errResp = await readTalerErrorResponse(resp);
- if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await ctx.failTransaction();
- return TaskRunResult.finished();
- }
-
- // FIXME: Properly parse!
- const brokenCoinPub = (errResp as any).coin_pub;
- logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
-
- if (!brokenCoinPub) {
- // FIXME: Details!
- throw new TalerProtocolViolationError();
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
-
- const sel = peerPullInc.coinSel;
- if (!sel) {
- throw Error("invalid state (coin selection expected)");
- }
-
- const repair: PeerCoinRepair = {
- coinPubs: [],
- contribs: [],
- exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
- };
-
- for (let i = 0; i < sel.coinPubs.length; i++) {
- if (sel.coinPubs[i] != brokenCoinPub) {
- repair.coinPubs.push(sel.coinPubs[i]);
- repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
- }
- }
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
-
- if (coinSelRes.type == "failure") {
- // FIXME: Details!
- throw Error(
- "insufficient balance to re-select coins to repair double spending",
- );
- }
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- await ws.db.runReadWriteTx(["peerPullDebit"], async (tx) => {
- const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
- if (!myPpi) {
- return;
- }
- switch (myPpi.status) {
- case PeerPullDebitRecordStatus.PendingDeposit:
- case PeerPullDebitRecordStatus.SuspendedDeposit: {
- const sel = coinSelRes.result;
- myPpi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- totalCost: Amounts.stringify(totalAmount),
- };
- break;
- }
- default:
- return;
- }
- await tx.peerPullDebit.put(myPpi);
- });
- return TaskRunResult.backoff();
-}
-
-async function processPeerPullDebitPendingDeposit(
- ws: InternalWalletState,
- peerPullInc: PeerPullPaymentIncomingRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPullInc.pursePub;
-
- const coinSel = peerPullInc.coinSel;
- if (!coinSel) {
- throw Error("invalid state, no coins selected");
- }
-
- const coins = await queryCoinInfosForSelection(ws, coinSel);
-
- const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
- exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
- pursePub: peerPullInc.pursePub,
- coins,
- });
-
- const purseDepositUrl = new URL(
- `purses/${pursePub}/deposit`,
- peerPullInc.exchangeBaseUrl,
- );
-
- const depositPayload: ExchangePurseDeposits = {
- deposits: depositSigsResp.deposits,
- };
-
- if (logger.shouldLogTrace()) {
- logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
- }
-
- const httpResp = await ws.http.fetch(purseDepositUrl.href, {
- method: "POST",
- body: depositPayload,
- });
-
- const ctx = new PeerPullDebitTransactionContext(
- ws,
- peerPullInc.peerPullDebitId,
- );
-
- switch (httpResp.status) {
- case HttpStatusCode.Ok: {
- const resp = await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForAny(),
- );
- logger.trace(`purse deposit response: ${j2s(resp)}`);
-
- await ctx.transition(async (r) => {
- if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
- return TransitionResult.Stay;
- }
- r.status = PeerPullDebitRecordStatus.Done;
- return TransitionResult.Transition;
- });
- return TaskRunResult.finished();
- }
- case HttpStatusCode.Gone: {
- await ctx.abortTransaction();
- return TaskRunResult.backoff();
- }
- case HttpStatusCode.Conflict: {
- return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
- }
- default: {
- const errResp = await readTalerErrorResponse(httpResp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: errResp,
- };
- }
- }
-}
-
-async function processPeerPullDebitAbortingRefresh(
- ws: InternalWalletState,
- peerPullInc: PeerPullPaymentIncomingRecord,
-): Promise<TaskRunResult> {
- const peerPullDebitId = peerPullInc.peerPullDebitId;
- const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullDebit", "refreshGroups"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPullDebitRecordStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPullDebitRecordStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPullDebitRecordStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPullDebitRecordStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPullDebit.get(peerPullDebitId);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPullDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPullDebitTransactionState(newDg);
- await tx.peerPullDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.backoff();
-}
-
-export async function processPeerPullDebit(
- ws: InternalWalletState,
- peerPullDebitId: string,
-): Promise<TaskRunResult> {
- const peerPullInc = await ws.db.runReadOnlyTx(
- ["peerPullDebit"],
- async (tx) => {
- return tx.peerPullDebit.get(peerPullDebitId);
- },
- );
- if (!peerPullInc) {
- throw Error("peer pull debit not found");
- }
-
- switch (peerPullInc.status) {
- case PeerPullDebitRecordStatus.PendingDeposit:
- return await processPeerPullDebitPendingDeposit(ws, peerPullInc);
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return await processPeerPullDebitAbortingRefresh(ws, peerPullInc);
- }
- return TaskRunResult.finished();
-}
-
-export async function confirmPeerPullDebit(
- ws: InternalWalletState,
- req: ConfirmPeerPullDebitRequest,
-): Promise<AcceptPeerPullPaymentResponse> {
- let peerPullDebitId: string;
-
- if (req.transactionId) {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
- if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
- throw Error("invalid peer-pull-debit transaction identifier");
- }
- peerPullDebitId = parsedTx.peerPullDebitId;
- } else if (req.peerPullDebitId) {
- peerPullDebitId = req.peerPullDebitId;
- } else {
- throw Error("invalid request, transactionId or peerPullDebitId required");
- }
-
- const peerPullInc = await ws.db.runReadOnlyTx(
- ["peerPullDebit"],
- async (tx) => {
- return tx.peerPullDebit.get(peerPullDebitId);
- },
- );
-
- if (!peerPullInc) {
- throw Error(
- `can't accept unknown incoming p2p pull payment (${req.peerPullDebitId})`,
- );
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (logger.shouldLogTrace()) {
- logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
- }
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const sel = coinSelRes.result;
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- await ws.db.runReadWriteTx(
- [
- "exchanges",
- "coins",
- "denominations",
- "refreshGroups",
- "peerPullDebit",
- "coinAvailability",
- ],
- async (tx) => {
- await spendCoins(ws, tx, {
- // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- }),
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayPeerPull,
- });
-
- const pi = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pi) {
- throw Error();
- }
- if (pi.status === PeerPullDebitRecordStatus.DialogProposed) {
- pi.status = PeerPullDebitRecordStatus.PendingDeposit;
- pi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- totalCost: Amounts.stringify(totalAmount),
- };
- }
- await tx.peerPullDebit.put(pi);
- },
- );
-
- const ctx = new PeerPullDebitTransactionContext(ws, peerPullDebitId);
-
- const transactionId = ctx.transactionId;
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- transactionId,
- };
-}
-
-/**
- * Look up information about an incoming peer pull payment.
- * Store the results in the wallet DB.
- */
-export async function preparePeerPullDebit(
- ws: InternalWalletState,
- req: PreparePeerPullDebitRequest,
-): Promise<PreparePeerPullDebitResponse> {
- const uri = parsePayPullUri(req.talerUri);
-
- if (!uri) {
- throw Error("got invalid taler://pay-pull URI");
- }
-
- const existing = await ws.db.runReadOnlyTx(
- ["peerPullDebit", "contractTerms"],
- async (tx) => {
- const peerPullDebitRecord =
- await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
- uri.exchangeBaseUrl,
- uri.contractPriv,
- ]);
- if (!peerPullDebitRecord) {
- return;
- }
- const contractTerms = await tx.contractTerms.get(
- peerPullDebitRecord.contractTermsHash,
- );
- if (!contractTerms) {
- return;
- }
- return { peerPullDebitRecord, contractTerms };
- },
- );
-
- if (existing) {
- return {
- amount: existing.peerPullDebitRecord.amount,
- amountRaw: existing.peerPullDebitRecord.amount,
- amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
- contractTerms: existing.contractTerms.contractTermsRaw,
- peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
- }),
- };
- }
-
- const exchangeBaseUrl = uri.exchangeBaseUrl;
- const contractPriv = uri.contractPriv;
- const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
-
- const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
-
- const contractHttpResp = await ws.http.fetch(getContractUrl.href);
-
- const contractResp = await readSuccessResponseJsonOrThrow(
- contractHttpResp,
- codecForExchangeGetContractResponse(),
- );
-
- const pursePub = contractResp.purse_pub;
-
- const dec = await ws.cryptoApi.decryptContractForDeposit({
- ciphertext: contractResp.econtract,
- contractPriv: contractPriv,
- pursePub: pursePub,
- });
-
- const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
-
- const purseHttpResp = await ws.http.fetch(getPurseUrl.href);
-
- const purseStatus = await readSuccessResponseJsonOrThrow(
- purseHttpResp,
- codecForExchangePurseStatus(),
- );
-
- const peerPullDebitId = encodeCrock(getRandomBytes(32));
-
- let contractTerms: PeerContractTerms;
-
- if (dec.contractTerms) {
- contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
- // FIXME: Check that the purseStatus balance matches contract terms amount
- } else {
- // FIXME: In this case, where do we get the purse expiration from?!
- // https://bugs.gnunet.org/view.php?id=7706
- throw Error("pull payments without contract terms not supported yet");
- }
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
-
- // FIXME: Why don't we compute the totalCost here?!
-
- const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (logger.shouldLogTrace()) {
- logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
- }
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- await ws.db.runReadWriteTx(["peerPullDebit", "contractTerms"], async (tx) => {
- await tx.contractTerms.put({
- h: contractTermsHash,
- contractTermsRaw: contractTerms,
- }),
- await tx.peerPullDebit.add({
- peerPullDebitId,
- contractPriv: contractPriv,
- exchangeBaseUrl: exchangeBaseUrl,
- pursePub: pursePub,
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- contractTermsHash,
- amount: contractTerms.amount,
- status: PeerPullDebitRecordStatus.DialogProposed,
- totalCostEstimated: Amounts.stringify(totalAmount),
- });
- });
-
- return {
- amount: contractTerms.amount,
- amountEffective: Amounts.stringify(totalAmount),
- amountRaw: contractTerms.amount,
- contractTerms: contractTerms,
- peerPullDebitId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: peerPullDebitId,
- }),
- };
-}
-
-export function computePeerPullDebitTransactionState(
- pullDebitRecord: PeerPullPaymentIncomingRecord,
-): TransactionState {
- switch (pullDebitRecord.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case PeerPullDebitRecordStatus.PendingDeposit:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Deposit,
- };
- case PeerPullDebitRecordStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Deposit,
- };
- case PeerPullDebitRecordStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPullDebitRecordStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Refresh,
- };
- }
-}
-
-export function computePeerPullDebitTransactionActions(
- pullDebitRecord: PeerPullPaymentIncomingRecord,
-): TransactionAction[] {
- switch (pullDebitRecord.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- return [];
- case PeerPullDebitRecordStatus.PendingDeposit:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullDebitRecordStatus.Done:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPullDebitRecordStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return [TransactionAction.Fail, TransactionAction.Suspend];
- case PeerPullDebitRecordStatus.Failed:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- return [TransactionAction.Resume, TransactionAction.Fail];
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
deleted file mode 100644
index 412631356..000000000
--- a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
+++ /dev/null
@@ -1,1037 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- AcceptPeerPushPaymentResponse,
- Amounts,
- CancellationToken,
- ConfirmPeerPushCreditRequest,
- ContractTermsUtil,
- ExchangePurseMergeRequest,
- HttpStatusCode,
- Logger,
- NotificationType,
- PeerContractTerms,
- PreparePeerPushCreditRequest,
- PreparePeerPushCreditResponse,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TransactionAction,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- WalletAccountMergeFlags,
- WalletKycUuid,
- codecForAny,
- codecForExchangeGetContractResponse,
- codecForPeerContractTerms,
- codecForWalletKycUuid,
- decodeCrock,
- eddsaGetPublic,
- encodeCrock,
- getRandomBytes,
- j2s,
- makeErrorDetail,
- parsePayPushUri,
- talerPaytoFromExchangeReserve,
-} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import {
- InternalWalletState,
- KycPendingInfo,
- KycUserType,
- PeerPushCreditStatus,
- PeerPushPaymentIncomingRecord,
- PendingTaskType,
- TaskId,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
- timestampPreciseToDb,
-} from "../index.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import { fetchFreshExchange } from "./exchanges.js";
-import {
- codecForExchangePurseStatus,
- getMergeReserveInfo,
-} from "./pay-peer-common.js";
-import {
- TransitionInfo,
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
-} from "./transactions.js";
-import {
- PerformCreateWithdrawalGroupResult,
- getExchangeWithdrawalInfo,
- internalPerformCreateWithdrawalGroup,
- internalPrepareCreateWithdrawalGroup,
-} from "./withdraw.js";
-
-const logger = new Logger("pay-peer-push-credit.ts");
-
-export class PeerPushCreditTransactionContext implements TransactionContext {
- readonly transactionId: string;
- readonly retryTag: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public peerPushCreditId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushCredit,
- peerPushCreditId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, peerPushCreditId } = this;
- await ws.db.runReadWriteTx(
- ["withdrawalGroups", "peerPushCredit", "tombstones"],
- async (tx) => {
- const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushInc) {
- return;
- }
- if (pushInc.withdrawalGroupId) {
- const withdrawalGroupId = pushInc.withdrawalGroupId;
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- }
- }
- await tx.peerPushCredit.delete(peerPushCreditId);
- await tx.tombstones.put({
- id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
- });
- },
- );
- return;
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, peerPushCreditId, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- case PeerPushCreditStatus.Done:
- case PeerPushCreditStatus.SuspendedMerge:
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- case PeerPushCreditStatus.SuspendedWithdrawing:
- break;
- case PeerPushCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
- break;
- case PeerPushCreditStatus.PendingMerge:
- newStatus = PeerPushCreditStatus.SuspendedMerge;
- break;
- case PeerPushCreditStatus.PendingWithdrawing:
- // FIXME: Suspend internal withdrawal transaction!
- newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.stopShepherdTask(retryTag);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, peerPushCreditId, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.Done:
- break;
- case PeerPushCreditStatus.SuspendedMerge:
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- case PeerPushCreditStatus.SuspendedWithdrawing:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingMerge:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingWithdrawing:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, peerPushCreditId, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- case PeerPushCreditStatus.Done:
- case PeerPushCreditStatus.PendingMergeKycRequired:
- case PeerPushCreditStatus.PendingMerge:
- case PeerPushCreditStatus.PendingWithdrawing:
- case PeerPushCreditStatus.SuspendedMerge:
- newStatus = PeerPushCreditStatus.PendingMerge;
- break;
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
- break;
- case PeerPushCreditStatus.SuspendedWithdrawing:
- // FIXME: resume underlying "internal-withdrawal" transaction.
- newStatus = PeerPushCreditStatus.PendingWithdrawing;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, peerPushCreditId, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.Done:
- case PeerPushCreditStatus.Aborted:
- case PeerPushCreditStatus.Failed:
- // Already in a final state.
- return;
- case PeerPushCreditStatus.DialogProposed:
- case PeerPushCreditStatus.PendingMergeKycRequired:
- case PeerPushCreditStatus.PendingMerge:
- case PeerPushCreditStatus.PendingWithdrawing:
- case PeerPushCreditStatus.SuspendedMerge:
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- case PeerPushCreditStatus.SuspendedWithdrawing:
- newStatus = PeerPushCreditStatus.Failed;
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-export async function preparePeerPushCredit(
- ws: InternalWalletState,
- req: PreparePeerPushCreditRequest,
-): Promise<PreparePeerPushCreditResponse> {
- const uri = parsePayPushUri(req.talerUri);
-
- if (!uri) {
- throw Error("got invalid taler://pay-push URI");
- }
-
- const existing = await ws.db.runReadOnlyTx(
- ["contractTerms", "peerPushCredit"],
- async (tx) => {
- const existingPushInc =
- await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
- uri.exchangeBaseUrl,
- uri.contractPriv,
- ]);
- if (!existingPushInc) {
- return;
- }
- const existingContractTermsRec = await tx.contractTerms.get(
- existingPushInc.contractTermsHash,
- );
- if (!existingContractTermsRec) {
- throw Error(
- "contract terms for peer push payment credit not found in database",
- );
- }
- const existingContractTerms = codecForPeerContractTerms().decode(
- existingContractTermsRec.contractTermsRaw,
- );
- return { existingPushInc, existingContractTerms };
- },
- );
-
- if (existing) {
- return {
- amount: existing.existingContractTerms.amount,
- amountEffective: existing.existingPushInc.estimatedAmountEffective,
- amountRaw: existing.existingContractTerms.amount,
- contractTerms: existing.existingContractTerms,
- peerPushCreditId: existing.existingPushInc.peerPushCreditId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: existing.existingPushInc.peerPushCreditId,
- }),
- exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl,
- };
- }
-
- const exchangeBaseUrl = uri.exchangeBaseUrl;
-
- await fetchFreshExchange(ws, exchangeBaseUrl);
-
- const contractPriv = uri.contractPriv;
- const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
-
- const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
-
- const contractHttpResp = await ws.http.fetch(getContractUrl.href);
-
- const contractResp = await readSuccessResponseJsonOrThrow(
- contractHttpResp,
- codecForExchangeGetContractResponse(),
- );
-
- const pursePub = contractResp.purse_pub;
-
- const dec = await ws.cryptoApi.decryptContractForMerge({
- ciphertext: contractResp.econtract,
- contractPriv: contractPriv,
- pursePub: pursePub,
- });
-
- const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
-
- const purseHttpResp = await ws.http.fetch(getPurseUrl.href);
-
- const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
-
- const purseStatus = await readSuccessResponseJsonOrThrow(
- purseHttpResp,
- codecForExchangePurseStatus(),
- );
-
- logger.info(
- `peer push credit, purse balance ${purseStatus.balance}, contract amount ${contractTerms.amount}`,
- );
-
- const peerPushCreditId = encodeCrock(getRandomBytes(32));
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(
- dec.contractTerms,
- );
-
- const withdrawalGroupId = encodeCrock(getRandomBytes(32));
-
- const wi = await getExchangeWithdrawalInfo(
- ws,
- exchangeBaseUrl,
- Amounts.parseOrThrow(purseStatus.balance),
- undefined,
- );
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
- async (tx) => {
- const rec: PeerPushPaymentIncomingRecord = {
- peerPushCreditId,
- contractPriv: contractPriv,
- exchangeBaseUrl: exchangeBaseUrl,
- mergePriv: dec.mergePriv,
- pursePub: pursePub,
- timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- contractTermsHash,
- status: PeerPushCreditStatus.DialogProposed,
- withdrawalGroupId,
- currency: Amounts.currencyOf(purseStatus.balance),
- estimatedAmountEffective: Amounts.stringify(
- wi.withdrawalAmountEffective,
- ),
- };
- await tx.peerPushCredit.add(rec);
- await tx.contractTerms.put({
- h: contractTermsHash,
- contractTermsRaw: dec.contractTerms,
- });
-
- const newTxState = computePeerPushCreditTransactionState(rec);
-
- return {
- oldTxState: {
- major: TransactionMajorState.None,
- },
- newTxState,
- } satisfies TransitionInfo;
- },
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- return {
- amount: purseStatus.balance,
- amountEffective: wi.withdrawalAmountEffective,
- amountRaw: purseStatus.balance,
- contractTerms: dec.contractTerms,
- peerPushCreditId,
- transactionId,
- exchangeBaseUrl,
- };
-}
-
-async function longpollKycStatus(
- ws: InternalWalletState,
- peerPushCreditId: string,
- exchangeUrl: string,
- kycInfo: KycPendingInfo,
- userType: KycUserType,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return;
- }
- if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
- return;
- }
- const oldTxState = computePeerPushCreditTransactionState(peerInc);
- peerInc.status = PeerPushCreditStatus.PendingMerge;
- const newTxState = computePeerPushCreditTransactionState(peerInc);
- await tx.peerPushCredit.put(peerInc);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-async function processPeerPushCreditKycRequired(
- ws: InternalWalletState,
- peerInc: PeerPushPaymentIncomingRecord,
- kycPending: WalletKycUuid,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: peerInc.peerPushCreditId,
- });
- const { peerPushCreditId } = peerInc;
-
- const userType = "individual";
- const url = new URL(
- `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
- peerInc.exchangeBaseUrl,
- );
-
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- });
-
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- logger.warn("kyc requested, but already fulfilled");
- return TaskRunResult.finished();
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const { transitionInfo, result } = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return {
- transitionInfo: undefined,
- result: TaskRunResult.finished(),
- };
- }
- const oldTxState = computePeerPushCreditTransactionState(peerInc);
- peerInc.kycInfo = {
- paytoHash: kycPending.h_payto,
- requirementRow: kycPending.requirement_row,
- };
- peerInc.kycUrl = kycStatus.kyc_url;
- peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
- const newTxState = computePeerPushCreditTransactionState(peerInc);
- await tx.peerPushCredit.put(peerInc);
- // We'll remove this eventually! New clients should rely on the
- // kycUrl field of the transaction, not the error code.
- const res: TaskRunResult = {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
- {
- kycUrl: kycStatus.kyc_url,
- },
- ),
- };
- return {
- transitionInfo: { oldTxState, newTxState },
- result: res,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return result;
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
-}
-
-async function handlePendingMerge(
- ws: InternalWalletState,
- peerInc: PeerPushPaymentIncomingRecord,
- contractTerms: PeerContractTerms,
-): Promise<TaskRunResult> {
- const { peerPushCreditId } = peerInc;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
-
- const amount = Amounts.parseOrThrow(contractTerms.amount);
-
- const mergeReserveInfo = await getMergeReserveInfo(ws, {
- exchangeBaseUrl: peerInc.exchangeBaseUrl,
- });
-
- const mergeTimestamp = TalerProtocolTimestamp.now();
-
- const reservePayto = talerPaytoFromExchangeReserve(
- peerInc.exchangeBaseUrl,
- mergeReserveInfo.reservePub,
- );
-
- const sigRes = await ws.cryptoApi.signPurseMerge({
- contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
- flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
- mergePriv: peerInc.mergePriv,
- mergeTimestamp: mergeTimestamp,
- purseAmount: Amounts.stringify(amount),
- purseExpiration: contractTerms.purse_expiration,
- purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
- pursePub: peerInc.pursePub,
- reservePayto,
- reservePriv: mergeReserveInfo.reservePriv,
- });
-
- const mergePurseUrl = new URL(
- `purses/${peerInc.pursePub}/merge`,
- peerInc.exchangeBaseUrl,
- );
-
- const mergeReq: ExchangePurseMergeRequest = {
- payto_uri: reservePayto,
- merge_timestamp: mergeTimestamp,
- merge_sig: sigRes.mergeSig,
- reserve_sig: sigRes.accountSig,
- };
-
- const mergeHttpResp = await ws.http.fetch(mergePurseUrl.href, {
- method: "POST",
- body: mergeReq,
- });
-
- if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
- const respJson = await mergeHttpResp.json();
- const kycPending = codecForWalletKycUuid().decode(respJson);
- logger.info(`kyc uuid response: ${j2s(kycPending)}`);
- return processPeerPushCreditKycRequired(ws, peerInc, kycPending);
- }
-
- logger.trace(`merge request: ${j2s(mergeReq)}`);
- const res = await readSuccessResponseJsonOrThrow(
- mergeHttpResp,
- codecForAny(),
- );
- logger.trace(`merge response: ${j2s(res)}`);
-
- const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(ws, {
- amount,
- wgInfo: {
- withdrawalType: WithdrawalRecordType.PeerPushCredit,
- },
- forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
- exchangeBaseUrl: peerInc.exchangeBaseUrl,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair: {
- priv: mergeReserveInfo.reservePriv,
- pub: mergeReserveInfo.reservePub,
- },
- });
-
- const txRes = await ws.db.runReadWriteTx(
- [
- "contractTerms",
- "peerPushCredit",
- "withdrawalGroups",
- "reserves",
- "exchanges",
- "exchangeDetails",
- ],
- async (tx) => {
- const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return undefined;
- }
- const oldTxState = computePeerPushCreditTransactionState(peerInc);
- let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined =
- undefined;
- switch (peerInc.status) {
- case PeerPushCreditStatus.PendingMerge:
- case PeerPushCreditStatus.PendingMergeKycRequired: {
- peerInc.status = PeerPushCreditStatus.PendingWithdrawing;
- wgCreateRes = await internalPerformCreateWithdrawalGroup(
- ws,
- tx,
- withdrawalGroupPrep,
- );
- peerInc.withdrawalGroupId =
- wgCreateRes.withdrawalGroup.withdrawalGroupId;
- break;
- }
- }
- await tx.peerPushCredit.put(peerInc);
- const newTxState = computePeerPushCreditTransactionState(peerInc);
- return {
- peerPushCreditTransition: { oldTxState, newTxState },
- wgCreateRes,
- };
- },
- );
- // Transaction was committed, now we can emit notifications.
- if (txRes?.wgCreateRes?.exchangeNotif) {
- ws.notify(txRes.wgCreateRes.exchangeNotif);
- }
- notifyTransition(
- ws,
- withdrawalGroupPrep.transactionId,
- txRes?.wgCreateRes?.transitionInfo,
- );
- notifyTransition(ws, transactionId, txRes?.peerPushCreditTransition);
-
- return TaskRunResult.backoff();
-}
-
-async function handlePendingWithdrawing(
- ws: InternalWalletState,
- peerInc: PeerPushPaymentIncomingRecord,
-): Promise<TaskRunResult> {
- if (!peerInc.withdrawalGroupId) {
- throw Error("invalid db state (withdrawing, but no withdrawal group ID");
- }
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: peerInc.peerPushCreditId,
- });
- const wgId = peerInc.withdrawalGroupId;
- let finished: boolean = false;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit", "withdrawalGroups"],
- async (tx) => {
- const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
- if (!ppi) {
- finished = true;
- return;
- }
- if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) {
- finished = true;
- return;
- }
- const oldTxState = computePeerPushCreditTransactionState(ppi);
- const wg = await tx.withdrawalGroups.get(wgId);
- if (!wg) {
- // FIXME: Fail the operation instead?
- return undefined;
- }
- switch (wg.status) {
- case WithdrawalGroupStatus.Done:
- finished = true;
- ppi.status = PeerPushCreditStatus.Done;
- break;
- // FIXME: Also handle other final states!
- }
- await tx.peerPushCredit.put(ppi);
- const newTxState = computePeerPushCreditTransactionState(ppi);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- if (finished) {
- return TaskRunResult.finished();
- } else {
- // FIXME: Return indicator that we depend on the other operation!
- return TaskRunResult.backoff();
- }
-}
-
-export async function processPeerPushCredit(
- ws: InternalWalletState,
- peerPushCreditId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- let peerInc: PeerPushPaymentIncomingRecord | undefined;
- let contractTerms: PeerContractTerms | undefined;
- await ws.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
- async (tx) => {
- peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return;
- }
- const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
- if (ctRec) {
- contractTerms = ctRec.contractTermsRaw;
- }
- await tx.peerPushCredit.put(peerInc);
- },
- );
-
- if (!peerInc) {
- throw Error(
- `can't accept unknown incoming p2p push payment (${peerPushCreditId})`,
- );
- }
-
- logger.info(
- `processing peerPushCredit in state ${peerInc.status.toString(16)}`,
- );
-
- checkDbInvariant(!!contractTerms);
-
- switch (peerInc.status) {
- case PeerPushCreditStatus.PendingMergeKycRequired: {
- if (!peerInc.kycInfo) {
- throw Error("invalid state, kycInfo required");
- }
- return await longpollKycStatus(
- ws,
- peerPushCreditId,
- peerInc.exchangeBaseUrl,
- peerInc.kycInfo,
- "individual",
- cancellationToken,
- );
- }
-
- case PeerPushCreditStatus.PendingMerge:
- return handlePendingMerge(ws, peerInc, contractTerms);
-
- case PeerPushCreditStatus.PendingWithdrawing:
- return handlePendingWithdrawing(ws, peerInc);
-
- default:
- return TaskRunResult.finished();
- }
-}
-
-export async function confirmPeerPushCredit(
- ws: InternalWalletState,
- req: ConfirmPeerPushCreditRequest,
-): Promise<AcceptPeerPushPaymentResponse> {
- let peerInc: PeerPushPaymentIncomingRecord | undefined;
- let peerPushCreditId: string;
- if (req.peerPushCreditId) {
- peerPushCreditId = req.peerPushCreditId;
- } else if (req.transactionId) {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
- if (!parsedTx) {
- throw Error("invalid transaction ID");
- }
- if (parsedTx.tag !== TransactionType.PeerPushCredit) {
- throw Error("invalid transaction ID type");
- }
- peerPushCreditId = parsedTx.peerPushCreditId;
- } else {
- throw Error("no transaction ID (or deprecated peerPushCreditId) provided");
- }
-
- await ws.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
- async (tx) => {
- peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return;
- }
- if (peerInc.status === PeerPushCreditStatus.DialogProposed) {
- peerInc.status = PeerPushCreditStatus.PendingMerge;
- }
- await tx.peerPushCredit.put(peerInc);
- },
- );
-
- if (!peerInc) {
- throw Error(
- `can't accept unknown incoming p2p push payment (${req.peerPushCreditId})`,
- );
- }
-
- const ctx = new PeerPushCreditTransactionContext(ws, peerPushCreditId);
-
- ws.taskScheduler.startShepherdTask(ctx.retryTag);
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
-
- return {
- transactionId,
- };
-}
-
-export function computePeerPushCreditTransactionState(
- pushCreditRecord: PeerPushPaymentIncomingRecord,
-): TransactionState {
- switch (pushCreditRecord.status) {
- case PeerPushCreditStatus.DialogProposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case PeerPushCreditStatus.PendingMerge:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Merge,
- };
- case PeerPushCreditStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPushCreditStatus.PendingMergeKycRequired:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.KycRequired,
- };
- case PeerPushCreditStatus.PendingWithdrawing:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Withdraw,
- };
- case PeerPushCreditStatus.SuspendedMerge:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Merge,
- };
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.MergeKycRequired,
- };
- case PeerPushCreditStatus.SuspendedWithdrawing:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Withdraw,
- };
- case PeerPushCreditStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPushCreditStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- default:
- assertUnreachable(pushCreditRecord.status);
- }
-}
-
-export function computePeerPushCreditTransactionActions(
- pushCreditRecord: PeerPushPaymentIncomingRecord,
-): TransactionAction[] {
- switch (pushCreditRecord.status) {
- case PeerPushCreditStatus.DialogProposed:
- return [TransactionAction.Delete];
- case PeerPushCreditStatus.PendingMerge:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushCreditStatus.Done:
- return [TransactionAction.Delete];
- case PeerPushCreditStatus.PendingMergeKycRequired:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushCreditStatus.PendingWithdrawing:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushCreditStatus.SuspendedMerge:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushCreditStatus.SuspendedWithdrawing:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushCreditStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPushCreditStatus.Failed:
- return [TransactionAction.Delete];
- default:
- assertUnreachable(pushCreditRecord.status);
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
deleted file mode 100644
index 91c5430be..000000000
--- a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
+++ /dev/null
@@ -1,1150 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- Amounts,
- CancellationToken,
- CheckPeerPushDebitRequest,
- CheckPeerPushDebitResponse,
- CoinRefreshRequest,
- ContractTermsUtil,
- HttpStatusCode,
- InitiatePeerPushDebitRequest,
- InitiatePeerPushDebitResponse,
- Logger,
- NotificationType,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TalerProtocolViolationError,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- encodeCrock,
- getRandomBytes,
- j2s,
-} from "@gnu-taler/taler-util";
-import {
- HttpResponse,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "@gnu-taler/taler-util/http";
-import { EncryptContractRequest } from "../crypto/cryptoTypes.js";
-import {
- PeerPushDebitRecord,
- PeerPushDebitStatus,
- RefreshOperationStatus,
- createRefreshGroup,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TransactionContext,
- constructTaskIdentifier,
- spendCoins,
-} from "./common.js";
-import {
- codecForExchangePurseStatus,
- getTotalPeerPaymentCost,
- queryCoinInfosForSelection,
-} from "./pay-peer-common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-const logger = new Logger("pay-peer-push-debit.ts");
-
-export class PeerPushDebitTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly retryTag: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public pursePub: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, pursePub, transactionId } = this;
- await ws.db.runReadWriteTx(["peerPushDebit", "tombstones"], async (tx) => {
- const debit = await tx.peerPushDebit.get(pursePub);
- if (debit) {
- await tx.peerPushDebit.delete(pursePub);
- await tx.tombstones.put({ id: transactionId });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, pursePub, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- newStatus = PeerPushDebitStatus.SuspendedCreatePurse;
- break;
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshDeleted;
- break;
- case PeerPushDebitStatus.AbortingRefreshExpired:
- newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshExpired;
- break;
- case PeerPushDebitStatus.AbortingDeletePurse:
- newStatus = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
- break;
- case PeerPushDebitStatus.PendingReady:
- newStatus = PeerPushDebitStatus.SuspendedReady;
- break;
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.SuspendedReady:
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, pursePub, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.SuspendedReady:
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.PendingCreatePurse:
- // Network request might already be in-flight!
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Expired:
- case PeerPushDebitStatus.Failed:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, pursePub, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- newStatus = PeerPushDebitStatus.AbortingRefreshDeleted;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- newStatus = PeerPushDebitStatus.AbortingRefreshExpired;
- break;
- case PeerPushDebitStatus.SuspendedReady:
- newStatus = PeerPushDebitStatus.PendingReady;
- break;
- case PeerPushDebitStatus.SuspendedCreatePurse:
- newStatus = PeerPushDebitStatus.PendingCreatePurse;
- break;
- case PeerPushDebitStatus.PendingCreatePurse:
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.startShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, pursePub, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- // FIXME: What to do about the refresh group?
- newStatus = PeerPushDebitStatus.Failed;
- break;
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.SuspendedReady:
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.PendingCreatePurse:
- newStatus = PeerPushDebitStatus.Failed;
- break;
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-export async function checkPeerPushDebit(
- ws: InternalWalletState,
- req: CheckPeerPushDebitRequest,
-): Promise<CheckPeerPushDebitResponse> {
- const instructedAmount = Amounts.parseOrThrow(req.amount);
- logger.trace(
- `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
- );
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (coinSelRes.type === "failure") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
- logger.trace(`selected peer coins (len=${coinSelRes.result.coins.length})`);
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
- logger.trace("computed total peer payment cost");
- return {
- exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
- amountEffective: Amounts.stringify(totalAmount),
- amountRaw: req.amount,
- maxExpirationDate: coinSelRes.result.maxExpirationDate,
- };
-}
-
-async function handlePurseCreationConflict(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
- resp: HttpResponse,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const errResp = await readTalerErrorResponse(resp);
- const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
- if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await ctx.failTransaction();
- return TaskRunResult.finished();
- }
-
- // FIXME: Properly parse!
- const brokenCoinPub = (errResp as any).coin_pub;
- logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
-
- if (!brokenCoinPub) {
- // FIXME: Details!
- throw new TalerProtocolViolationError();
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
- const sel = peerPushInitiation.coinSel;
-
- const repair: PeerCoinRepair = {
- coinPubs: [],
- contribs: [],
- exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
- };
-
- for (let i = 0; i < sel.coinPubs.length; i++) {
- if (sel.coinPubs[i] != brokenCoinPub) {
- repair.coinPubs.push(sel.coinPubs[i]);
- repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
- }
- }
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
-
- if (coinSelRes.type == "failure") {
- // FIXME: Details!
- throw Error(
- "insufficient balance to re-select coins to repair double spending",
- );
- }
-
- await ws.db.runReadWriteTx(["peerPushDebit"], async (tx) => {
- const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub);
- if (!myPpi) {
- return;
- }
- switch (myPpi.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- case PeerPushDebitStatus.SuspendedCreatePurse: {
- const sel = coinSelRes.result;
- myPpi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- };
- break;
- }
- default:
- return;
- }
- await tx.peerPushDebit.put(myPpi);
- });
- return TaskRunResult.progress();
-}
-
-async function processPeerPushDebitCreateReserve(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const purseExpiration = peerPushInitiation.purseExpiration;
- const hContractTerms = peerPushInitiation.contractTermsHash;
- const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
- const transactionId = ctx.transactionId;
-
- logger.trace(`processing ${transactionId} pending(create-reserve)`);
-
- const contractTermsRecord = await ws.db.runReadOnlyTx(
- ["contractTerms"],
- async (tx) => {
- return tx.contractTerms.get(hContractTerms);
- },
- );
-
- if (!contractTermsRecord) {
- throw Error(
- `db invariant failed, contract terms for ${transactionId} missing`,
- );
- }
-
- const purseSigResp = await ws.cryptoApi.signPurseCreation({
- hContractTerms,
- mergePub: peerPushInitiation.mergePub,
- minAge: 0,
- purseAmount: peerPushInitiation.amount,
- purseExpiration: timestampProtocolFromDb(purseExpiration),
- pursePriv: peerPushInitiation.pursePriv,
- });
-
- const coins = await queryCoinInfosForSelection(
- ws,
- peerPushInitiation.coinSel,
- );
-
- const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
- exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
- pursePub: peerPushInitiation.pursePub,
- coins,
- });
-
- const encryptContractRequest: EncryptContractRequest = {
- contractTerms: contractTermsRecord.contractTermsRaw,
- mergePriv: peerPushInitiation.mergePriv,
- pursePriv: peerPushInitiation.pursePriv,
- pursePub: peerPushInitiation.pursePub,
- contractPriv: peerPushInitiation.contractPriv,
- contractPub: peerPushInitiation.contractPub,
- nonce: peerPushInitiation.contractEncNonce,
- };
-
- logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
-
- const econtractResp = await ws.cryptoApi.encryptContractForMerge(
- encryptContractRequest,
- );
-
- const createPurseUrl = new URL(
- `purses/${peerPushInitiation.pursePub}/create`,
- peerPushInitiation.exchangeBaseUrl,
- );
-
- const reqBody = {
- amount: peerPushInitiation.amount,
- merge_pub: peerPushInitiation.mergePub,
- purse_sig: purseSigResp.sig,
- h_contract_terms: hContractTerms,
- purse_expiration: timestampProtocolFromDb(purseExpiration),
- deposits: depositSigsResp.deposits,
- min_age: 0,
- econtract: econtractResp.econtract,
- };
-
- logger.trace(`request body: ${j2s(reqBody)}`);
-
- const httpResp = await ws.http.fetch(createPurseUrl.href, {
- method: "POST",
- body: reqBody,
- });
-
- {
- const resp = await httpResp.json();
- logger.info(`resp: ${j2s(resp)}`);
- }
-
- switch (httpResp.status) {
- case HttpStatusCode.Ok:
- break;
- case HttpStatusCode.Forbidden: {
- // FIXME: Store this error!
- await ctx.failTransaction();
- return TaskRunResult.finished();
- }
- case HttpStatusCode.Conflict: {
- // Handle double-spending
- return handlePurseCreationConflict(ws, peerPushInitiation, httpResp);
- }
- default: {
- const errResp = await readTalerErrorResponse(httpResp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: errResp,
- };
- }
- }
-
- if (httpResp.status !== HttpStatusCode.Ok) {
- // FIXME: do proper error reporting
- throw Error("got error response from exchange");
- }
-
- await transitionPeerPushDebitTransaction(ws, pursePub, {
- stFrom: PeerPushDebitStatus.PendingCreatePurse,
- stTo: PeerPushDebitStatus.PendingReady,
- });
-
- return TaskRunResult.backoff();
-}
-
-async function processPeerPushDebitAbortingDeletePurse(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const { pursePub, pursePriv } = peerPushInitiation;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
-
- const sigResp = await ws.cryptoApi.signDeletePurse({
- pursePriv,
- });
- const purseUrl = new URL(
- `purses/${pursePub}`,
- peerPushInitiation.exchangeBaseUrl,
- );
- const resp = await ws.http.fetch(purseUrl.href, {
- method: "DELETE",
- headers: {
- "taler-purse-signature": sigResp.sig,
- },
- });
- logger.info(`deleted purse with response status ${resp.status}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "peerPushDebit",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- ],
- async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
- return undefined;
- }
- const currency = Amounts.currencyOf(ppiRec.amount);
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- const coinPubs: CoinRefreshRequest[] = [];
-
- for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: ppiRec.coinSel.contributions[i],
- coinPub: ppiRec.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPushDebit,
- transactionId,
- );
- ppiRec.status = PeerPushDebitStatus.AbortingRefreshDeleted;
- ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-
- return TaskRunResult.backoff();
-}
-
-interface SimpleTransition {
- stFrom: PeerPushDebitStatus;
- stTo: PeerPushDebitStatus;
-}
-
-async function transitionPeerPushDebitTransaction(
- ws: InternalWalletState,
- pursePub: string,
- transitionSpec: SimpleTransition,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== transitionSpec.stFrom) {
- return undefined;
- }
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- ppiRec.status = transitionSpec.stTo;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processPeerPushDebitAbortingRefreshDeleted(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: peerPushInitiation.pursePub,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups", "peerPushDebit"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPushDebitStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPushDebitStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPushDebitStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPushDebitStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPushDebit.get(pursePub);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPushDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPushDebitTransactionState(newDg);
- await tx.peerPushDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.backoff();
-}
-
-async function processPeerPushDebitAbortingRefreshExpired(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: peerPushInitiation.pursePub,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit", "refreshGroups"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPushDebitStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPushDebitStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPushDebitStatus.Expired;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPushDebitStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPushDebit.get(pursePub);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPushDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPushDebitTransactionState(newDg);
- await tx.peerPushDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.backoff();
-}
-
-/**
- * Process the "pending(ready)" state of a peer-push-debit transaction.
- */
-async function processPeerPushDebitReady(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace("processing peer-push-debit pending(ready)");
- const pursePub = peerPushInitiation.pursePub;
- const transactionId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- const mergeUrl = new URL(
- `purses/${pursePub}/merge`,
- peerPushInitiation.exchangeBaseUrl,
- );
- mergeUrl.searchParams.set("timeout_ms", "30000");
- logger.info(`long-polling on purse status at ${mergeUrl.href}`);
- const resp = await ws.http.fetch(mergeUrl.href, {
- // timeout: getReserveRequestTimeout(withdrawalGroup),
- cancellationToken,
- });
- if (resp.status === HttpStatusCode.Ok) {
- const purseStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangePurseStatus(),
- );
- const mergeTimestamp = purseStatus.merge_timestamp;
- logger.info(`got purse status ${j2s(purseStatus)}`);
- if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) {
- return TaskRunResult.backoff();
- } else {
- await transitionPeerPushDebitTransaction(
- ws,
- peerPushInitiation.pursePub,
- {
- stFrom: PeerPushDebitStatus.PendingReady,
- stTo: PeerPushDebitStatus.Done,
- },
- );
- return TaskRunResult.finished();
- }
- } else if (resp.status === HttpStatusCode.Gone) {
- logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "peerPushDebit",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- ],
- async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== PeerPushDebitStatus.PendingReady) {
- return undefined;
- }
- const currency = Amounts.currencyOf(ppiRec.amount);
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- const coinPubs: CoinRefreshRequest[] = [];
-
- for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: ppiRec.coinSel.contributions[i],
- coinPub: ppiRec.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPushDebit,
- transactionId,
- );
- ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired;
- ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
- } else {
- logger.warn(`unexpected HTTP status for purse: ${resp.status}`);
- return TaskRunResult.backoff();
- }
-}
-
-export async function processPeerPushDebit(
- ws: InternalWalletState,
- pursePub: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const peerPushInitiation = await ws.db.runReadOnlyTx(
- ["peerPushDebit"],
- async (tx) => {
- return tx.peerPushDebit.get(pursePub);
- },
- );
- if (!peerPushInitiation) {
- throw Error("peer push payment not found");
- }
-
- switch (peerPushInitiation.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return processPeerPushDebitCreateReserve(ws, peerPushInitiation);
- case PeerPushDebitStatus.PendingReady:
- return processPeerPushDebitReady(
- ws,
- peerPushInitiation,
- cancellationToken,
- );
- case PeerPushDebitStatus.AbortingDeletePurse:
- return processPeerPushDebitAbortingDeletePurse(ws, peerPushInitiation);
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return processPeerPushDebitAbortingRefreshDeleted(ws, peerPushInitiation);
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return processPeerPushDebitAbortingRefreshExpired(ws, peerPushInitiation);
- default: {
- const txState = computePeerPushDebitTransactionState(peerPushInitiation);
- logger.warn(
- `not processing peer-push-debit transaction in state ${j2s(txState)}`,
- );
- }
- }
-
- return TaskRunResult.finished();
-}
-
-/**
- * Initiate sending a peer-to-peer push payment.
- */
-export async function initiatePeerPushDebit(
- ws: InternalWalletState,
- req: InitiatePeerPushDebitRequest,
-): Promise<InitiatePeerPushDebitResponse> {
- const instructedAmount = Amounts.parseOrThrow(
- req.partialContractTerms.amount,
- );
- const purseExpiration = req.partialContractTerms.purse_expiration;
- const contractTerms = req.partialContractTerms;
-
- const pursePair = await ws.cryptoApi.createEddsaKeypair({});
- const mergePair = await ws.cryptoApi.createEddsaKeypair({});
-
- const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
-
- const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const sel = coinSelRes.result;
-
- logger.info(`selected p2p coins (push):`);
- logger.trace(`${j2s(coinSelRes)}`);
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- logger.info(`computed total peer payment cost`);
-
- const pursePub = pursePair.pub;
-
- const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
-
- const transactionId = ctx.transactionId;
-
- const contractEncNonce = encodeCrock(getRandomBytes(24));
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "exchanges",
- "contractTerms",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "peerPushDebit",
- ],
- async (tx) => {
- // FIXME: Instead of directly doing a spendCoin here,
- // we might want to mark the coins as used and spend them
- // after we've been able to create the purse.
- await spendCoins(ws, tx, {
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pursePair.pub,
- }),
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayPeerPush,
- });
-
- const ppi: PeerPushDebitRecord = {
- amount: Amounts.stringify(instructedAmount),
- contractPriv: contractKeyPair.priv,
- contractPub: contractKeyPair.pub,
- contractTermsHash: hContractTerms,
- exchangeBaseUrl: sel.exchangeBaseUrl,
- mergePriv: mergePair.priv,
- mergePub: mergePair.pub,
- purseExpiration: timestampProtocolToDb(purseExpiration),
- pursePriv: pursePair.priv,
- pursePub: pursePair.pub,
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- status: PeerPushDebitStatus.PendingCreatePurse,
- contractEncNonce,
- coinSel: {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- },
- totalCost: Amounts.stringify(totalAmount),
- };
-
- await tx.peerPushDebit.add(ppi);
-
- await tx.contractTerms.put({
- h: hContractTerms,
- contractTermsRaw: contractTerms,
- });
-
- const newTxState = computePeerPushDebitTransactionState(ppi);
- return {
- oldTxState: { major: TransactionMajorState.None },
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- ws.taskScheduler.startShepherdTask(ctx.retryTag);
-
- return {
- contractPriv: contractKeyPair.priv,
- mergePriv: mergePair.priv,
- pursePub: pursePair.pub,
- exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pursePair.pub,
- }),
- };
-}
-
-export function computePeerPushDebitTransactionActions(
- ppiRecord: PeerPushDebitRecord,
-): TransactionAction[] {
- switch (ppiRecord.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushDebitStatus.PendingReady:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushDebitStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.AbortingDeletePurse:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedCreatePurse:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushDebitStatus.SuspendedReady:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushDebitStatus.Done:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.Expired:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.Failed:
- return [TransactionAction.Delete];
- }
-}
-
-export function computePeerPushDebitTransactionState(
- ppiRecord: PeerPushDebitRecord,
-): TransactionState {
- switch (ppiRecord.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPushDebitStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Ready,
- };
- case PeerPushDebitStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPushDebitStatus.AbortingDeletePurse:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.DeletePurse,
- };
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.RefreshExpired,
- };
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.DeletePurse,
- };
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.RefreshExpired,
- };
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPushDebitStatus.SuspendedCreatePurse:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPushDebitStatus.SuspendedReady:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Ready,
- };
- case PeerPushDebitStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPushDebitStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case PeerPushDebitStatus.Expired:
- return {
- major: TransactionMajorState.Expired,
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts
deleted file mode 100644
index b88115d8e..000000000
--- a/packages/taler-wallet-core/src/operations/recoup.ts
+++ /dev/null
@@ -1,535 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the recoup operation, which allows to recover the
- * value of coins held in a revoked denomination.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- Amounts,
- CoinStatus,
- Logger,
- RefreshReason,
- TalerPreciseTimestamp,
- TransactionType,
- URL,
- codecForRecoupConfirmation,
- codecForReserveStatus,
- encodeCrock,
- getRandomBytes,
- j2s,
-} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import {
- CoinRecord,
- CoinSourceType,
- RecoupGroupRecord,
- RecoupOperationStatus,
- RefreshCoinSource,
- WalletDbReadWriteTransaction,
- WithdrawCoinSource,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
- timestampPreciseToDb,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType } from "../pending-types.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import { createRefreshGroup } from "./refresh.js";
-import { constructTransactionIdentifier } from "./transactions.js";
-import { internalCreateWithdrawalGroup } from "./withdraw.js";
-
-const logger = new Logger("operations/recoup.ts");
-
-/**
- * Store a recoup group record in the database after marking
- * a coin in the group as finished.
- */
-async function putGroupAsFinished(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["recoupGroups", "denominations", "refreshGroups", "coins"]
- >,
- recoupGroup: RecoupGroupRecord,
- coinIdx: number,
-): Promise<void> {
- logger.trace(
- `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
- );
- if (recoupGroup.timestampFinished) {
- return;
- }
- recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
- await tx.recoupGroups.put(recoupGroup);
-}
-
-async function recoupRewardCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
-): Promise<void> {
- // We can't really recoup a coin we got via tipping.
- // Thus we just put the coin to sleep.
- // FIXME: somehow report this to the user
- await ws.db.runReadWriteTx(
- ["recoupGroups", "denominations", "refreshGroups", "coins"],
- async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- },
- );
-}
-
-async function recoupWithdrawCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: WithdrawCoinSource,
-): Promise<void> {
- const reservePub = cs.reservePub;
- const denomInfo = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- return denomInfo;
- });
- if (!denomInfo) {
- // FIXME: We should at least emit some pending operation / warning for this?
- return;
- }
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest({
- blindingKey: coin.blindingKey,
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- denomPub: denomInfo.denomPub,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- });
- const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
- logger.trace(`requesting recoup via ${reqUrl.href}`);
- const resp = await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: recoupRequest,
- });
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`);
-
- if (recoupConfirmation.reserve_pub !== reservePub) {
- throw Error(`Coin's reserve doesn't match reserve on recoup`);
- }
-
- // FIXME: verify that our expectations about the amount match
-
- await ws.db.runReadWriteTx(
- ["coins", "denominations", "recoupGroups", "refreshGroups"],
- async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const updatedCoin = await tx.coins.get(coin.coinPub);
- if (!updatedCoin) {
- return;
- }
- updatedCoin.status = CoinStatus.Dormant;
- await tx.coins.put(updatedCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- },
- );
-}
-
-async function recoupRefreshCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: RefreshCoinSource,
-): Promise<void> {
- const d = await ws.db.runReadOnlyTx(
- ["coins", "denominations"],
- async (tx) => {
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denomInfo) {
- return;
- }
- return { denomInfo };
- },
- );
- if (!d) {
- // FIXME: We should at least emit some pending operation / warning for this?
- return;
- }
-
- const recoupRequest = await ws.cryptoApi.createRecoupRefreshRequest({
- blindingKey: coin.blindingKey,
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- denomPub: d.denomInfo.denomPub,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- });
- const reqUrl = new URL(
- `/coins/${coin.coinPub}/recoup-refresh`,
- coin.exchangeBaseUrl,
- );
- logger.trace(`making recoup request for ${coin.coinPub}`);
-
- const resp = await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: recoupRequest,
- });
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
- throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
- }
-
- await ws.db.runReadWriteTx(
- ["coins", "denominations", "recoupGroups", "refreshGroups"],
- async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const oldCoin = await tx.coins.get(cs.oldCoinPub);
- const revokedCoin = await tx.coins.get(coin.coinPub);
- if (!revokedCoin) {
- logger.warn("revoked coin for recoup not found");
- return;
- }
- if (!oldCoin) {
- logger.warn("refresh old coin for recoup not found");
- return;
- }
- const oldCoinDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- );
- const revokedCoinDenom = await ws.getDenomInfo(
- ws,
- tx,
- revokedCoin.exchangeBaseUrl,
- revokedCoin.denomPubHash,
- );
- checkDbInvariant(!!oldCoinDenom);
- checkDbInvariant(!!revokedCoinDenom);
- revokedCoin.status = CoinStatus.Dormant;
- if (!revokedCoin.spendAllocation) {
- // We don't know what happened to this coin
- logger.error(
- `can't refresh-recoup coin ${revokedCoin.coinPub}, no spendAllocation known`,
- );
- } else {
- let residualAmount = Amounts.sub(
- revokedCoinDenom.value,
- revokedCoin.spendAllocation.amount,
- ).amount;
- recoupGroup.scheduleRefreshCoins.push({
- coinPub: oldCoin.coinPub,
- amount: Amounts.stringify(residualAmount),
- });
- }
- await tx.coins.put(revokedCoin);
- await tx.coins.put(oldCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- },
- );
-}
-
-export async function processRecoupGroup(
- ws: InternalWalletState,
- recoupGroupId: string,
-): Promise<TaskRunResult> {
- let recoupGroup = await ws.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
- return tx.recoupGroups.get(recoupGroupId);
- });
- if (!recoupGroup) {
- return TaskRunResult.finished();
- }
- if (recoupGroup.timestampFinished) {
- logger.trace("recoup group finished");
- return TaskRunResult.finished();
- }
- const ps = recoupGroup.coinPubs.map(async (x, i) => {
- try {
- await processRecoupForCoin(ws, recoupGroupId, i);
- } catch (e) {
- logger.warn(`processRecoup failed: ${e}`);
- throw e;
- }
- });
- await Promise.all(ps);
-
- recoupGroup = await ws.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
- return tx.recoupGroups.get(recoupGroupId);
- });
- if (!recoupGroup) {
- return TaskRunResult.finished();
- }
-
- for (const b of recoupGroup.recoupFinishedPerCoin) {
- if (!b) {
- return TaskRunResult.finished();
- }
- }
-
- logger.info("all recoups of recoup group are finished");
-
- const reserveSet = new Set<string>();
- const reservePrivMap: Record<string, string> = {};
- for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
- const coinPub = recoupGroup.coinPubs[i];
- await ws.db.runReadOnlyTx(["coins", "reserves"], async (tx) => {
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request recoup`);
- }
- if (coin.coinSource.type === CoinSourceType.Withdraw) {
- const reserve = await tx.reserves.indexes.byReservePub.get(
- coin.coinSource.reservePub,
- );
- if (!reserve) {
- return;
- }
- reserveSet.add(coin.coinSource.reservePub);
- reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
- }
- });
- }
-
- for (const reservePub of reserveSet) {
- const reserveUrl = new URL(
- `reserves/${reservePub}`,
- recoupGroup.exchangeBaseUrl,
- );
- logger.info(`querying reserve status for recoup via ${reserveUrl}`);
-
- const resp = await ws.http.fetch(reserveUrl.href);
-
- const result = await readSuccessResponseJsonOrThrow(
- resp,
- codecForReserveStatus(),
- );
- await internalCreateWithdrawalGroup(ws, {
- amount: Amounts.parseOrThrow(result.balance),
- exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair: {
- pub: reservePub,
- priv: reservePrivMap[reservePub],
- },
- wgInfo: {
- withdrawalType: WithdrawalRecordType.Recoup,
- },
- });
- }
-
- await ws.db.runReadWriteTx(
- [
- "recoupGroups",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "coins",
- ],
- async (tx) => {
- const rg2 = await tx.recoupGroups.get(recoupGroupId);
- if (!rg2) {
- return;
- }
- rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
- rg2.operationStatus = RecoupOperationStatus.Finished;
- if (rg2.scheduleRefreshCoins.length > 0) {
- await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
- rg2.scheduleRefreshCoins,
- RefreshReason.Recoup,
- constructTransactionIdentifier({
- tag: TransactionType.Recoup,
- recoupGroupId: rg2.recoupGroupId,
- }),
- );
- }
- await tx.recoupGroups.put(rg2);
- },
- );
- return TaskRunResult.finished();
-}
-
-export class RewardTransactionContext implements TransactionContext {
- abortTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- suspendTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- resumeTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- failTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- deleteTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- public transactionId: string;
- public retryTag: string;
-
- constructor(
- public ws: InternalWalletState,
- private recoupGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Recoup,
- recoupGroupId,
- });
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.Recoup,
- recoupGroupId,
- });
- }
-}
-
-export async function createRecoupGroup(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["recoupGroups", "denominations", "refreshGroups", "coins"]
- >,
- exchangeBaseUrl: string,
- coinPubs: string[],
-): Promise<string> {
- const recoupGroupId = encodeCrock(getRandomBytes(32));
-
- const recoupGroup: RecoupGroupRecord = {
- recoupGroupId,
- exchangeBaseUrl: exchangeBaseUrl,
- coinPubs: coinPubs,
- timestampFinished: undefined,
- timestampStarted: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- recoupFinishedPerCoin: coinPubs.map(() => false),
- scheduleRefreshCoins: [],
- operationStatus: RecoupOperationStatus.Pending,
- };
-
- for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
- const coinPub = coinPubs[coinIdx];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- continue;
- }
- await tx.coins.put(coin);
- }
-
- await tx.recoupGroups.put(recoupGroup);
-
- return recoupGroupId;
-}
-
-/**
- * Run the recoup protocol for a single coin in a recoup group.
- */
-async function processRecoupForCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
-): Promise<void> {
- const coin = await ws.db.runReadOnlyTx(
- ["coins", "recoupGroups"],
- async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.timestampFinished) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
-
- const coinPub = recoupGroup.coinPubs[coinIdx];
-
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request recoup`);
- }
- return coin;
- },
- );
-
- if (!coin) {
- return;
- }
-
- const cs = coin.coinSource;
-
- switch (cs.type) {
- case CoinSourceType.Reward:
- return recoupRewardCoin(ws, recoupGroupId, coinIdx, coin);
- case CoinSourceType.Refresh:
- return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
- case CoinSourceType.Withdraw:
- return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
- default:
- throw Error("unknown coin source type");
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts
deleted file mode 100644
index ad9fdedb4..000000000
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ /dev/null
@@ -1,1430 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- AgeCommitment,
- AgeRestriction,
- AmountJson,
- Amounts,
- amountToPretty,
- CancellationToken,
- codecForExchangeMeltResponse,
- codecForExchangeRevealResponse,
- CoinPublicKeyString,
- CoinRefreshRequest,
- CoinStatus,
- DenominationInfo,
- DenomKeyType,
- Duration,
- encodeCrock,
- ExchangeMeltRequest,
- ExchangeProtocolVersion,
- ExchangeRefreshRevealRequest,
- fnutil,
- ForceRefreshRequest,
- getErrorDetailFromException,
- getRandomBytes,
- HashCodeString,
- HttpStatusCode,
- j2s,
- Logger,
- makeErrorDetail,
- NotificationType,
- RefreshGroupId,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TransactionAction,
- TransactionMajorState,
- TransactionState,
- TransactionType,
- URL,
-} from "@gnu-taler/taler-util";
-import {
- readSuccessResponseJsonOrThrow,
- readUnexpectedResponseDetails,
-} from "@gnu-taler/taler-util/http";
-import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js";
-import {
- DerivedRefreshSession,
- RefreshNewDenomInfo,
-} from "../crypto/cryptoTypes.js";
-import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js";
-import {
- CoinRecord,
- CoinSourceType,
- DenominationRecord,
- RefreshCoinStatus,
- RefreshGroupRecord,
- RefreshOperationStatus,
-} from "../db.js";
-import {
- getCandidateWithdrawalDenomsTx,
- PendingTaskType,
- RefreshGroupPerExchangeInfo,
- RefreshSessionRecord,
- TaskId,
- timestampPreciseToDb,
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
-} from "../index.js";
-import {
- EXCHANGE_COINS_LOCK,
- InternalWalletState,
-} from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { selectWithdrawalDenominations } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- constructTaskIdentifier,
- makeCoinAvailable,
- makeCoinsVisible,
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
-} from "./common.js";
-import { fetchFreshExchange } from "./exchanges.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-const logger = new Logger("refresh.ts");
-
-export class RefreshTransactionContext implements TransactionContext {
- public transactionId: string;
- readonly taskId: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public refreshGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Refresh,
- refreshGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const refreshGroupId = this.refreshGroupId;
- const ws = this.ws;
- await ws.db.runReadWriteTx(["refreshGroups", "tombstones"], async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (rg) {
- await tx.refreshGroups.delete(refreshGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
- });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, refreshGroupId, transactionId } = this;
- let res = await ws.db.runReadWriteTx(["refreshGroups"], async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeRefreshTransactionState(dg);
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return undefined;
- case RefreshOperationStatus.Pending: {
- dg.operationStatus = RefreshOperationStatus.Suspended;
- await tx.refreshGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- }
- case RefreshOperationStatus.Suspended:
- return undefined;
- }
- return undefined;
- });
- if (res) {
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- transactionId,
- oldTxState: res.oldTxState,
- newTxState: res.newTxState,
- });
- }
- }
-
- async abortTransaction(): Promise<void> {
- // Refresh transactions only support fail, not abort.
- throw new Error("refresh transactions cannot be aborted");
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, refreshGroupId, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups"],
- async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return;
- }
- const oldState = computeRefreshTransactionState(dg);
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return;
- case RefreshOperationStatus.Pending: {
- return;
- }
- case RefreshOperationStatus.Suspended:
- dg.operationStatus = RefreshOperationStatus.Pending;
- await tx.refreshGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, refreshGroupId, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups"],
- async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return;
- }
- const oldState = computeRefreshTransactionState(dg);
- let newStatus: RefreshOperationStatus | undefined;
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- break;
- case RefreshOperationStatus.Pending:
- case RefreshOperationStatus.Suspended:
- newStatus = RefreshOperationStatus.Failed;
- break;
- case RefreshOperationStatus.Failed:
- break;
- default:
- assertUnreachable(dg.operationStatus);
- }
- if (newStatus) {
- dg.operationStatus = newStatus;
- await tx.refreshGroups.put(dg);
- }
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- },
- );
- ws.taskScheduler.stopShepherdTask(this.taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-}
-
-/**
- * Get the amount that we lose when refreshing a coin of the given denomination
- * with a certain amount left.
- *
- * If the amount left is zero, then the refresh cost
- * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
- * the right denominations), then the cost is the full amount left.
- *
- * Considers refresh fees, withdrawal fees after refresh and amounts too small
- * to refresh.
- */
-export function getTotalRefreshCost(
- denoms: DenominationRecord[],
- refreshedDenom: DenominationInfo,
- amountLeft: AmountJson,
- denomselAllowLate: boolean,
-): AmountJson {
- const withdrawAmount = Amounts.sub(
- amountLeft,
- refreshedDenom.feeRefresh,
- ).amount;
- const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
- const withdrawDenoms = selectWithdrawalDenominations(
- withdrawAmount,
- denoms,
- denomselAllowLate,
- );
- const resultingAmount = Amounts.add(
- Amounts.zeroOfCurrency(withdrawAmount.currency),
- ...withdrawDenoms.selectedDenoms.map(
- (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
- ),
- ).amount;
- const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
- logger.trace(
- `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
- totalCost,
- )}`,
- );
- return totalCost;
-}
-
-function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } {
- const allFinal = fnutil.all(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
- );
- const anyFailed = fnutil.any(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Failed,
- );
- if (allFinal) {
- if (anyFailed) {
- rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
- rg.operationStatus = RefreshOperationStatus.Failed;
- } else {
- rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
- rg.operationStatus = RefreshOperationStatus.Finished;
- }
- return { final: true };
- }
- return { final: false };
-}
-
-/**
- * Create a refresh session for one particular coin inside a refresh group.
- *
- * If the session already exists, return the existing one.
- *
- * If the session doesn't need to be created (refresh group gone or session already
- * finished), return undefined.
- */
-async function provideRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<RefreshSessionRecord | undefined> {
- logger.trace(
- `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
- );
-
- const d = await ws.db.runReadWriteTx(
- ["coins", "refreshGroups", "refreshSessions"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- if (
- refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished
- ) {
- return;
- }
- const existingRefreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
- const coin = await tx.coins.get(oldCoinPub);
- if (!coin) {
- throw Error("Can't refresh, coin not found");
- }
- return { refreshGroup, coin, existingRefreshSession };
- },
- );
-
- if (!d) {
- return undefined;
- }
-
- if (d.existingRefreshSession) {
- return d.existingRefreshSession;
- }
-
- const { refreshGroup, coin } = d;
-
- const exch = await fetchFreshExchange(ws, coin.exchangeBaseUrl);
-
- // FIXME: use helper functions from withdraw.ts
- // to update and filter withdrawable denoms.
-
- const { availableAmount, availableDenoms } = await ws.db.runReadOnlyTx(
- ["denominations"],
- async (tx) => {
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- exch.exchangeBaseUrl,
- coin.denomPubHash,
- );
-
- if (!oldDenom) {
- throw Error("db inconsistent: denomination for coin not found");
- }
-
- // FIXME: Use denom groups instead of querying all denominations!
- const availableDenoms: DenominationRecord[] =
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(exch.exchangeBaseUrl)
- .toArray();
-
- const availableAmount = Amounts.sub(
- refreshGroup.inputPerCoin[coinIndex],
- oldDenom.feeRefresh,
- ).amount;
- return { availableAmount, availableDenoms };
- },
- );
-
- const newCoinDenoms = selectWithdrawalDenominations(
- availableAmount,
- availableDenoms,
- ws.config.testing.denomselAllowLate,
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- if (newCoinDenoms.selectedDenoms.length === 0) {
- logger.trace(
- `not refreshing, available amount ${amountToPretty(
- availableAmount,
- )} too small`,
- );
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups", "coins", "coinAvailability"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- const updateRes = updateGroupStatus(rg);
- if (updateRes.final) {
- await makeCoinsVisible(ws, tx, transactionId);
- }
- await tx.refreshGroups.put(rg);
- const newTxState = computeRefreshTransactionState(rg);
- return { oldTxState, newTxState };
- },
- );
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return;
- }
-
- const sessionSecretSeed = encodeCrock(getRandomBytes(64));
-
- // Store refresh session for this coin in the database.
- const mySession = await ws.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- const existingSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (existingSession) {
- return existingSession;
- }
- const newSession: RefreshSessionRecord = {
- coinIndex,
- refreshGroupId,
- norevealIndex: undefined,
- sessionSecretSeed: sessionSecretSeed,
- newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
- count: x.count,
- denomPubHash: x.denomPubHash,
- })),
- amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue),
- };
- await tx.refreshSessions.put(newSession);
- return newSession;
- },
- );
- logger.trace(
- `found/created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
- );
- return mySession;
-}
-
-function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
- return Duration.fromSpec({
- seconds: 5,
- });
-}
-
-async function refreshMelt(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const d = await ws.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions", "coins", "denominations"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- return;
- }
- if (refreshSession.norevealIndex !== undefined) {
- return;
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- );
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- );
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- denomPubHash: newDenom.denomPubHash,
- feeWithdraw: newDenom.feeWithdraw,
- value: Amounts.stringify(newDenom.value),
- });
- }
- return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
- },
- );
-
- if (!d) {
- return;
- }
-
- const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
-
- let exchangeProtocolVersion: ExchangeProtocolVersion;
- switch (d.oldDenom.denomPub.cipher) {
- case DenomKeyType.Rsa: {
- exchangeProtocolVersion = ExchangeProtocolVersion.V12;
- break;
- }
- default:
- throw Error("unsupported key type");
- }
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- exchangeProtocolVersion,
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
- meltCoinMaxAge: oldCoin.maxAge,
- meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
- newCoinDenoms,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const reqUrl = new URL(
- `coins/${oldCoin.coinPub}/melt`,
- oldCoin.exchangeBaseUrl,
- );
-
- let maybeAch: HashCodeString | undefined;
- if (oldCoin.ageCommitmentProof) {
- maybeAch = AgeRestriction.hashCommitment(
- oldCoin.ageCommitmentProof.commitment,
- );
- }
-
- const meltReqBody: ExchangeMeltRequest = {
- coin_pub: oldCoin.coinPub,
- confirm_sig: derived.confirmSig,
- denom_pub_hash: oldCoin.denomPubHash,
- denom_sig: oldCoin.denomSig,
- rc: derived.hash,
- value_with_fee: Amounts.stringify(derived.meltValueWithFee),
- age_commitment_hash: maybeAch,
- };
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: meltReqBody,
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- if (resp.status === HttpStatusCode.NotFound) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions", "coins", "coinAvailability"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- throw Error(
- "db invariant failed: missing refresh session in database",
- );
- }
- refreshSession.lastError = errDetails;
- const updateRes = updateGroupStatus(rg);
- if (updateRes.final) {
- await makeCoinsVisible(ws, tx, transactionId);
- }
- await tx.refreshGroups.put(rg);
- await tx.refreshSessions.put(refreshSession);
- const newTxState = computeRefreshTransactionState(rg);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return;
- }
-
- if (resp.status === HttpStatusCode.Conflict) {
- // Just log for better diagnostics here, error status
- // will be handled later.
- logger.error(
- `melt request for ${Amounts.stringify(
- derived.meltValueWithFee,
- )} failed in refresh group ${refreshGroupId} due to conflict`,
- );
-
- const historySig = await ws.cryptoApi.signCoinHistoryRequest({
- coinPriv: oldCoin.coinPriv,
- coinPub: oldCoin.coinPub,
- startOffset: 0,
- });
-
- const historyUrl = new URL(
- `coins/${oldCoin.coinPub}/history`,
- oldCoin.exchangeBaseUrl,
- );
-
- const historyResp = await ws.http.fetch(historyUrl.href, {
- method: "GET",
- headers: {
- "Taler-Coin-History-Signature": historySig.sig,
- },
- });
-
- const historyJson = await historyResp.json();
- logger.info(`coin history: ${j2s(historyJson)}`);
-
- // FIXME: Before failing and re-trying, analyse response and adjust amount
- }
-
- const meltResponse = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeMeltResponse(),
- );
-
- const norevealIndex = meltResponse.noreveal_index;
-
- refreshSession.norevealIndex = norevealIndex;
-
- await ws.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- if (!rs) {
- return;
- }
- if (rs.norevealIndex !== undefined) {
- return;
- }
- rs.norevealIndex = norevealIndex;
- await tx.refreshSessions.put(rs);
- },
- );
-}
-
-export async function assembleRefreshRevealRequest(args: {
- cryptoApi: TalerCryptoInterface;
- derived: DerivedRefreshSession;
- norevealIndex: number;
- oldCoinPub: CoinPublicKeyString;
- oldCoinPriv: string;
- newDenoms: {
- denomPubHash: string;
- count: number;
- }[];
- oldAgeCommitment?: AgeCommitment;
-}): Promise<ExchangeRefreshRevealRequest> {
- const {
- derived,
- norevealIndex,
- cryptoApi,
- oldCoinPriv,
- oldCoinPub,
- newDenoms,
- } = args;
- const privs = Array.from(derived.transferPrivs);
- privs.splice(norevealIndex, 1);
-
- const planchets = derived.planchetsForGammas[norevealIndex];
- if (!planchets) {
- throw Error("refresh index error");
- }
-
- const newDenomsFlat: string[] = [];
- const linkSigs: string[] = [];
-
- for (let i = 0; i < newDenoms.length; i++) {
- const dsel = newDenoms[i];
- for (let j = 0; j < dsel.count; j++) {
- const newCoinIndex = linkSigs.length;
- const linkSig = await cryptoApi.signCoinLink({
- coinEv: planchets[newCoinIndex].coinEv,
- newDenomHash: dsel.denomPubHash,
- oldCoinPriv: oldCoinPriv,
- oldCoinPub: oldCoinPub,
- transferPub: derived.transferPubs[norevealIndex],
- });
- linkSigs.push(linkSig.sig);
- newDenomsFlat.push(dsel.denomPubHash);
- }
- }
-
- const req: ExchangeRefreshRevealRequest = {
- coin_evs: planchets.map((x) => x.coinEv),
- new_denoms_h: newDenomsFlat,
- transfer_privs: privs,
- transfer_pub: derived.transferPubs[norevealIndex],
- link_sigs: linkSigs,
- old_age_commitment: args.oldAgeCommitment?.publicKeys,
- };
- return req;
-}
-
-async function refreshReveal(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`,
- );
- const d = await ws.db.runReadOnlyTx(
- ["refreshGroups", "refreshSessions", "coins", "denominations"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- return;
- }
- const norevealIndex = refreshSession.norevealIndex;
- if (norevealIndex === undefined) {
- throw Error("can't reveal without melting first");
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- );
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- );
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- denomPubHash: newDenom.denomPubHash,
- feeWithdraw: newDenom.feeWithdraw,
- value: Amounts.stringify(newDenom.value),
- });
- }
- return {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- };
- },
- );
-
- if (!d) {
- return;
- }
-
- const {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- } = d;
-
- let exchangeProtocolVersion: ExchangeProtocolVersion;
- switch (d.oldDenom.denomPub.cipher) {
- case DenomKeyType.Rsa: {
- exchangeProtocolVersion = ExchangeProtocolVersion.V12;
- break;
- }
- default:
- throw Error("unsupported key type");
- }
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- exchangeProtocolVersion,
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
- newCoinDenoms,
- meltCoinMaxAge: oldCoin.maxAge,
- meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const reqUrl = new URL(
- `refreshes/${derived.hash}/reveal`,
- oldCoin.exchangeBaseUrl,
- );
-
- const req = await assembleRefreshRevealRequest({
- cryptoApi: ws.cryptoApi,
- derived,
- newDenoms: newCoinDenoms,
- norevealIndex: norevealIndex,
- oldCoinPriv: oldCoin.coinPriv,
- oldCoinPub: oldCoin.coinPub,
- oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment,
- });
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.fetch(reqUrl.href, {
- body: req,
- method: "POST",
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- const reveal = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeRevealResponse(),
- );
-
- const coins: CoinRecord[] = [];
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- for (let i = 0; i < refreshSession.newDenoms.length; i++) {
- const ncd = newCoinDenoms[i];
- for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
- const newCoinIndex = coins.length;
- const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
- if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
- throw Error("cipher unsupported");
- }
- const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
- const denomSig = await ws.cryptoApi.unblindDenominationSignature({
- planchet: {
- blindingKey: pc.blindingKey,
- denomPub: ncd.denomPub,
- },
- evSig,
- });
- const coin: CoinRecord = {
- blindingKey: pc.blindingKey,
- coinPriv: pc.coinPriv,
- coinPub: pc.coinPub,
- denomPubHash: ncd.denomPubHash,
- denomSig,
- exchangeBaseUrl: oldCoin.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Refresh,
- refreshGroupId,
- oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
- },
- sourceTransactionId: transactionId,
- coinEvHash: pc.coinEvHash,
- maxAge: pc.maxAge,
- ageCommitmentProof: pc.ageCommitmentProof,
- spendAllocation: undefined,
- };
-
- coins.push(coin);
- }
- }
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "coinAvailability",
- "refreshGroups",
- "refreshSessions",
- ],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- logger.warn("no refresh session found");
- return;
- }
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- if (!rs) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- updateGroupStatus(rg);
- for (const coin of coins) {
- await makeCoinAvailable(ws, tx, coin);
- }
- await makeCoinsVisible(ws, tx, transactionId);
- await tx.refreshGroups.put(rg);
- const newTxState = computeRefreshTransactionState(rg);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- logger.trace("refresh finished (end of reveal)");
-}
-
-export async function processRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace(`processing refresh group ${refreshGroupId}`);
-
- const refreshGroup = await ws.db.runReadOnlyTx(
- ["refreshGroups"],
- async (tx) => tx.refreshGroups.get(refreshGroupId),
- );
- if (!refreshGroup) {
- return TaskRunResult.finished();
- }
- if (refreshGroup.timestampFinished) {
- return TaskRunResult.finished();
- }
- // Process refresh sessions of the group in parallel.
- logger.trace(
- `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`,
- );
- let errors: TalerErrorDetail[] = [];
- let inShutdown = false;
- const ps = refreshGroup.oldCoinPubs.map((x, i) =>
- processRefreshSession(ws, refreshGroupId, i).catch((x) => {
- if (x instanceof CryptoApiStoppedError) {
- inShutdown = true;
- logger.info(
- "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
- );
- return;
- }
- if (x instanceof TalerError) {
- logger.warn("process refresh session got exception (TalerError)");
- logger.warn(`exc ${x}`);
- logger.warn(`exc stack ${x.stack}`);
- logger.warn(`error detail: ${j2s(x.errorDetail)}`);
- } else {
- logger.warn("process refresh session got exception");
- logger.warn(`exc ${x}`);
- logger.warn(`exc stack ${x.stack}`);
- }
- errors.push(getErrorDetailFromException(x));
- }),
- );
- try {
- logger.info("waiting for refreshes");
- await Promise.all(ps);
- logger.info("refresh group finished");
- } catch (e) {
- logger.warn("process refresh sessions got exception");
- logger.warn(`exception: ${e}`);
- }
- if (inShutdown) {
- return TaskRunResult.backoff();
- }
- if (errors.length > 0) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE,
- {
- numErrors: errors.length,
- errors: errors.slice(0, 5),
- },
- ),
- };
- }
-
- return TaskRunResult.backoff();
-}
-
-async function processRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
- );
- let { refreshGroup, refreshSession } = await ws.db.runReadOnlyTx(
- ["refreshGroups", "refreshSessions"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- return {
- refreshGroup: rg,
- refreshSession: rs,
- };
- },
- );
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
- return;
- }
- if (!refreshSession) {
- refreshSession = await provideRefreshSession(ws, refreshGroupId, coinIndex);
- }
- if (!refreshSession) {
- // We tried to create the refresh session, but didn't get a result back.
- // This means that either the session is finished, or that creating
- // one isn't necessary.
- return;
- }
- if (refreshSession.norevealIndex === undefined) {
- await refreshMelt(ws, refreshGroupId, coinIndex);
- }
- await refreshReveal(ws, refreshGroupId, coinIndex);
-}
-
-export interface RefreshOutputInfo {
- outputPerCoin: AmountJson[];
- perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>;
-}
-
-export async function calculateRefreshOutput(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<
- ["denominations", "coins", "refreshGroups", "coinAvailability"]
- >,
- currency: string,
- oldCoinPubs: CoinRefreshRequest[],
-): Promise<RefreshOutputInfo> {
- const estimatedOutputPerCoin: AmountJson[] = [];
-
- const denomsPerExchange: Record<string, DenominationRecord[]> = {};
-
- const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {};
-
- // FIXME: Use denom groups instead of querying all denominations!
- const getDenoms = async (
- exchangeBaseUrl: string,
- ): Promise<DenominationRecord[]> => {
- if (denomsPerExchange[exchangeBaseUrl]) {
- return denomsPerExchange[exchangeBaseUrl];
- }
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- exchangeBaseUrl,
- currency,
- );
- denomsPerExchange[exchangeBaseUrl] = allDenoms;
- return allDenoms;
- };
-
- for (const ocp of oldCoinPubs) {
- const coin = await tx.coins.get(ocp.coinPub);
- checkDbInvariant(!!coin, "coin must be in database");
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(
- !!denom,
- "denomination for existing coin must be in database",
- );
- const refreshAmount = ocp.amount;
- const denoms = await getDenoms(coin.exchangeBaseUrl);
- const cost = getTotalRefreshCost(
- denoms,
- denom,
- Amounts.parseOrThrow(refreshAmount),
- ws.config.testing.denomselAllowLate,
- );
- const output = Amounts.sub(refreshAmount, cost).amount;
- let exchInfo = infoPerExchange[coin.exchangeBaseUrl];
- if (!exchInfo) {
- infoPerExchange[coin.exchangeBaseUrl] = exchInfo = {
- outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)),
- };
- }
- exchInfo.outputEffective = Amounts.stringify(
- Amounts.add(exchInfo.outputEffective, output).amount,
- );
- estimatedOutputPerCoin.push(output);
- }
-
- return {
- outputPerCoin: estimatedOutputPerCoin,
- perExchangeInfo: infoPerExchange,
- };
-}
-
-async function applyRefresh(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["denominations", "coins", "refreshGroups", "coinAvailability"]
- >,
- oldCoinPubs: CoinRefreshRequest[],
- refreshGroupId: string,
-): Promise<void> {
- for (const ocp of oldCoinPubs) {
- const coin = await tx.coins.get(ocp.coinPub);
- checkDbInvariant(!!coin, "coin must be in database");
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(
- !!denom,
- "denomination for existing coin must be in database",
- );
- switch (coin.status) {
- case CoinStatus.Dormant:
- break;
- case CoinStatus.Fresh: {
- coin.status = CoinStatus.Dormant;
- const coinAv = await tx.coinAvailability.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- coin.maxAge,
- ]);
- checkDbInvariant(!!coinAv);
- checkDbInvariant(coinAv.freshCoinCount > 0);
- coinAv.freshCoinCount--;
- await tx.coinAvailability.put(coinAv);
- break;
- }
- case CoinStatus.FreshSuspended: {
- // For suspended coins, we don't have to adjust coin
- // availability, as they are not counted as available.
- coin.status = CoinStatus.Dormant;
- break;
- }
- default:
- assertUnreachable(coin.status);
- }
- if (!coin.spendAllocation) {
- coin.spendAllocation = {
- amount: Amounts.stringify(ocp.amount),
- // id: `txn:refresh:${refreshGroupId}`,
- id: constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- }),
- };
- }
- await tx.coins.put(coin);
- }
-}
-
-export interface CreateRefreshGroupResult {
- refreshGroupId: string;
-}
-
-/**
- * Create a refresh group for a list of coins.
- *
- * Refreshes the remaining amount on the coin, effectively capturing the remaining
- * value in the refresh group.
- *
- * The caller must also ensure that the coins that should be refreshed exist
- * in the current database transaction.
- */
-export async function createRefreshGroup(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["denominations", "coins", "refreshGroups", "coinAvailability"]
- >,
- currency: string,
- oldCoinPubs: CoinRefreshRequest[],
- refreshReason: RefreshReason,
- originatingTransactionId: string | undefined,
-): Promise<CreateRefreshGroupResult> {
- const refreshGroupId = encodeCrock(getRandomBytes(32));
-
- const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs);
-
- const estimatedOutputPerCoin = outInfo.outputPerCoin;
-
- await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId);
-
- const refreshGroup: RefreshGroupRecord = {
- operationStatus: RefreshOperationStatus.Pending,
- currency,
- timestampFinished: undefined,
- statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
- oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
- originatingTransactionId,
- reason: refreshReason,
- refreshGroupId,
- inputPerCoin: oldCoinPubs.map((x) => x.amount),
- expectedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
- Amounts.stringify(x),
- ),
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
-
- if (oldCoinPubs.length == 0) {
- logger.warn("created refresh group with zero coins");
- refreshGroup.timestampFinished = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- refreshGroup.operationStatus = RefreshOperationStatus.Finished;
- }
-
- await tx.refreshGroups.put(refreshGroup);
-
- logger.trace(`created refresh group ${refreshGroupId}`);
-
- const ctx = new RefreshTransactionContext(ws, refreshGroupId);
-
- // Shepherd the task.
- // If the current transaction fails to commit the refresh
- // group to the DB, the shepherd will give up.
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- refreshGroupId,
- };
-}
-
-export function computeRefreshTransactionState(
- rg: RefreshGroupRecord,
-): TransactionState {
- switch (rg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return {
- major: TransactionMajorState.Done,
- };
- case RefreshOperationStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case RefreshOperationStatus.Pending:
- return {
- major: TransactionMajorState.Pending,
- };
- case RefreshOperationStatus.Suspended:
- return {
- major: TransactionMajorState.Suspended,
- };
- }
-}
-
-export function computeRefreshTransactionActions(
- rg: RefreshGroupRecord,
-): TransactionAction[] {
- switch (rg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return [TransactionAction.Delete];
- case RefreshOperationStatus.Failed:
- return [TransactionAction.Delete];
- case RefreshOperationStatus.Pending:
- return [
- TransactionAction.Retry,
- TransactionAction.Suspend,
- TransactionAction.Fail,
- ];
- case RefreshOperationStatus.Suspended:
- return [TransactionAction.Resume, TransactionAction.Fail];
- }
-}
-
-export function getRefreshesForTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<string[]> {
- return ws.db.runReadOnlyTx(["refreshGroups"], async (tx) => {
- const groups =
- await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll(
- transactionId,
- );
- return groups.map((x) =>
- constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId: x.refreshGroupId,
- }),
- );
- });
-}
-
-export async function forceRefresh(
- ws: InternalWalletState,
- req: ForceRefreshRequest,
-): Promise<{ refreshGroupId: RefreshGroupId }> {
- if (req.coinPubList.length == 0) {
- throw Error("refusing to create empty refresh group");
- }
- const refreshGroupId = await ws.db.runReadWriteTx(
- ["refreshGroups", "coinAvailability", "denominations", "coins"],
- async (tx) => {
- let coinPubs: CoinRefreshRequest[] = [];
- for (const c of req.coinPubList) {
- const coin = await tx.coins.get(c);
- if (!coin) {
- throw Error(`coin (pubkey ${c}) not found`);
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(!!denom);
- coinPubs.push({
- coinPub: c,
- amount: denom?.value,
- });
- }
- return await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(coinPubs[0].amount),
- coinPubs,
- RefreshReason.Manual,
- undefined,
- );
- },
- );
-
- return {
- refreshGroupId,
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/reward.ts b/packages/taler-wallet-core/src/operations/reward.ts
deleted file mode 100644
index 7d826e630..000000000
--- a/packages/taler-wallet-core/src/operations/reward.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AcceptTipResponse,
- Logger,
- PrepareTipResult,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
-} from "@gnu-taler/taler-util";
-import { RewardRecord, RewardRecordStatus } from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import {
- TaskRunResult,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-const logger = new Logger("operations/tip.ts");
-
-export class RewardTransactionContext implements TransactionContext {
- public transactionId: string;
- public retryTag: string;
-
- constructor(
- public ws: InternalWalletState,
- public walletRewardId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId,
- });
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, walletRewardId } = this;
- await ws.db.runReadWriteTx(["rewards", "tombstones"], async (tx) => {
- const tipRecord = await tx.rewards.get(walletRewardId);
- if (tipRecord) {
- await tx.rewards.delete(walletRewardId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteReward + ":" + walletRewardId,
- });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, walletRewardId, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["rewards"],
- async (tx) => {
- const tipRec = await tx.rewards.get(walletRewardId);
- if (!tipRec) {
- logger.warn(`transaction tip ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (tipRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.SuspendedPickup:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.Failed:
- break;
- case RewardRecordStatus.PendingPickup:
- newStatus = RewardRecordStatus.SuspendedPickup;
- break;
-
- default:
- assertUnreachable(tipRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(tipRec);
- tipRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(tipRec);
- await tx.rewards.put(tipRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, walletRewardId, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["rewards"],
- async (tx) => {
- const tipRec = await tx.rewards.get(walletRewardId);
- if (!tipRec) {
- logger.warn(`transaction tip ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (tipRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.PendingPickup:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.Failed:
- break;
- case RewardRecordStatus.SuspendedPickup:
- newStatus = RewardRecordStatus.Aborted;
- break;
- default:
- assertUnreachable(tipRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(tipRec);
- tipRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(tipRec);
- await tx.rewards.put(tipRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, walletRewardId, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["rewards"],
- async (tx) => {
- const rewardRec = await tx.rewards.get(walletRewardId);
- if (!rewardRec) {
- logger.warn(`transaction reward ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (rewardRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.PendingPickup:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.Failed:
- break;
- case RewardRecordStatus.SuspendedPickup:
- newStatus = RewardRecordStatus.PendingPickup;
- break;
- default:
- assertUnreachable(rewardRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(rewardRec);
- rewardRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(rewardRec);
- await tx.rewards.put(rewardRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, walletRewardId, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["rewards"],
- async (tx) => {
- const tipRec = await tx.rewards.get(walletRewardId);
- if (!tipRec) {
- logger.warn(`transaction tip ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (tipRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.Failed:
- break;
- case RewardRecordStatus.PendingPickup:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.SuspendedPickup:
- newStatus = RewardRecordStatus.Failed;
- break;
- default:
- assertUnreachable(tipRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(tipRec);
- tipRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(tipRec);
- await tx.rewards.put(tipRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-}
-
-/**
- * Get the (DD37-style) transaction status based on the
- * database record of a reward.
- */
-export function computeRewardTransactionStatus(
- tipRecord: RewardRecord,
-): TransactionState {
- switch (tipRecord.status) {
- case RewardRecordStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case RewardRecordStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case RewardRecordStatus.PendingPickup:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pickup,
- };
- case RewardRecordStatus.DialogAccept:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case RewardRecordStatus.SuspendedPickup:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pickup,
- };
- case RewardRecordStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- default:
- assertUnreachable(tipRecord.status);
- }
-}
-
-export function computeTipTransactionActions(
- tipRecord: RewardRecord,
-): TransactionAction[] {
- switch (tipRecord.status) {
- case RewardRecordStatus.Done:
- return [TransactionAction.Delete];
- case RewardRecordStatus.Failed:
- return [TransactionAction.Delete];
- case RewardRecordStatus.Aborted:
- return [TransactionAction.Delete];
- case RewardRecordStatus.PendingPickup:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case RewardRecordStatus.SuspendedPickup:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case RewardRecordStatus.DialogAccept:
- return [TransactionAction.Abort];
- default:
- assertUnreachable(tipRecord.status);
- }
-}
-
-export async function prepareReward(
- ws: InternalWalletState,
- talerTipUri: string,
-): Promise<PrepareTipResult> {
- throw Error("the rewards feature is not supported anymore");
-}
-
-export async function processTip(
- ws: InternalWalletState,
- walletTipId: string,
-): Promise<TaskRunResult> {
- return TaskRunResult.finished();
-}
-
-export async function acceptTip(
- ws: InternalWalletState,
- transactionId: TransactionIdStr,
-): Promise<AcceptTipResponse> {
- throw Error("the rewards feature is not supported anymore");
-}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts
deleted file mode 100644
index 3c7845813..000000000
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ /dev/null
@@ -1,913 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * @file
- * Implementation of wallet-core operations that are used for testing,
- * but typically not in the production wallet.
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- addPaytoQueryParams,
- Amounts,
- AmountString,
- CheckPaymentResponse,
- codecForAny,
- codecForCheckPaymentResponse,
- ConfirmPayResultType,
- Duration,
- IntegrationTestArgs,
- IntegrationTestV2Args,
- j2s,
- Logger,
- NotificationType,
- OpenedPromise,
- openPromise,
- parsePaytoUri,
- PreparePayResultType,
- TalerCorebankApiClient,
- TestPayArgs,
- TestPayResult,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- WithdrawTestBalanceRequest,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
-} from "@gnu-taler/taler-util/http";
-import { getRefreshesForTransaction } from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import { getBalances } from "./balance.js";
-import { createDepositGroup } from "./deposits.js";
-import { fetchFreshExchange } from "./exchanges.js";
-import {
- confirmPay,
- preparePayForUri,
- startRefundQueryForUri,
-} from "./pay-merchant.js";
-import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
-import {
- confirmPeerPullDebit,
- preparePeerPullDebit,
-} from "./pay-peer-pull-debit.js";
-import {
- confirmPeerPushCredit,
- preparePeerPushCredit,
-} from "./pay-peer-push-credit.js";
-import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
-import { getTransactionById, getTransactions } from "./transactions.js";
-import { acceptWithdrawalFromUri } from "./withdraw.js";
-
-const logger = new Logger("operations/testing.ts");
-
-interface MerchantBackendInfo {
- baseUrl: string;
- authToken?: string;
-}
-
-export interface WithdrawTestBalanceResult {
- /**
- * Transaction ID of the newly created withdrawal transaction.
- */
- transactionId: string;
-
- /**
- * Account of the user registered for the withdrawal.
- */
- accountPaytoUri: string;
-}
-
-export async function withdrawTestBalance(
- ws: InternalWalletState,
- req: WithdrawTestBalanceRequest,
-): Promise<WithdrawTestBalanceResult> {
- const amount = req.amount;
- const exchangeBaseUrl = req.exchangeBaseUrl;
- const corebankApiBaseUrl = req.corebankApiBaseUrl;
-
- logger.trace(
- `Registering bank user, bank access base url ${corebankApiBaseUrl}`,
- );
-
- const corebankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
-
- const bankUser = await corebankClient.createRandomBankUser();
- logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
-
- corebankClient.setAuth(bankUser);
-
- const wresp = await corebankClient.createWithdrawalOperation(
- bankUser.username,
- amount,
- );
-
- const acceptResp = await acceptWithdrawalFromUri(ws, {
- talerWithdrawUri: wresp.taler_withdraw_uri,
- selectedExchange: exchangeBaseUrl,
- forcedDenomSel: req.forcedDenomSel,
- });
-
- await corebankClient.confirmWithdrawalOperation(bankUser.username, {
- withdrawalOperationId: wresp.withdrawal_id,
- });
-
- return {
- transactionId: acceptResp.transactionId,
- accountPaytoUri: bankUser.accountPaytoUri,
- };
-}
-
-/**
- * FIXME: User MerchantApiClient instead.
- */
-function getMerchantAuthHeader(m: MerchantBackendInfo): Record<string, string> {
- if (m.authToken) {
- return {
- Authorization: `Bearer ${m.authToken}`,
- };
- }
- return {};
-}
-
-/**
- * FIXME: User MerchantApiClient instead.
- */
-async function refund(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- orderId: string,
- reason: string,
- refundAmount: string,
-): Promise<string> {
- const reqUrl = new URL(
- `private/orders/${orderId}/refund`,
- merchantBackend.baseUrl,
- );
- const refundReq = {
- order_id: orderId,
- reason,
- refund: refundAmount,
- };
- const resp = await http.fetch(reqUrl.href, {
- method: "POST",
- body: refundReq,
- headers: getMerchantAuthHeader(merchantBackend),
- });
- const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- const refundUri = r.taler_refund_uri;
- if (!refundUri) {
- throw Error("no refund URI in response");
- }
- return refundUri;
-}
-
-/**
- * FIXME: User MerchantApiClient instead.
- */
-async function createOrder(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- amount: string,
- summary: string,
- fulfillmentUrl: string,
-): Promise<{ orderId: string }> {
- const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
- const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
- const orderReq = {
- order: {
- amount,
- summary,
- fulfillment_url: fulfillmentUrl,
- refund_deadline: { t_s: t },
- wire_transfer_deadline: { t_s: t },
- },
- };
- const resp = await http.fetch(reqUrl, {
- method: "POST",
- body: orderReq,
- headers: getMerchantAuthHeader(merchantBackend),
- });
- const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- const orderId = r.order_id;
- if (!orderId) {
- throw Error("no order id in response");
- }
- return { orderId };
-}
-
-/**
- * FIXME: User MerchantApiClient instead.
- */
-async function checkPayment(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- orderId: string,
-): Promise<CheckPaymentResponse> {
- const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
- reqUrl.searchParams.set("order_id", orderId);
- const resp = await http.fetch(reqUrl.href, {
- headers: getMerchantAuthHeader(merchantBackend),
- });
- return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
-}
-
-interface MakePaymentResult {
- orderId: string;
- paymentTransactionId: string;
-}
-
-async function makePayment(
- ws: InternalWalletState,
- merchant: MerchantBackendInfo,
- amount: string,
- summary: string,
-): Promise<MakePaymentResult> {
- const orderResp = await createOrder(
- ws.http,
- merchant,
- amount,
- summary,
- "taler://fulfillment-success/thx",
- );
-
- logger.trace("created order with orderId", orderResp.orderId);
-
- let paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
-
- logger.trace("payment status", paymentStatus);
-
- const talerPayUri = paymentStatus.taler_pay_uri;
- if (!talerPayUri) {
- throw Error("no taler://pay/ URI in payment response");
- }
-
- const preparePayResult = await preparePayForUri(ws, talerPayUri);
-
- logger.trace("prepare pay result", preparePayResult);
-
- if (preparePayResult.status != "payment-possible") {
- throw Error("payment not possible");
- }
-
- const confirmPayResult = await confirmPay(
- ws,
- preparePayResult.transactionId,
- undefined,
- );
-
- logger.trace("confirmPayResult", confirmPayResult);
-
- paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
-
- logger.trace("payment status after wallet payment:", paymentStatus);
-
- if (paymentStatus.order_status !== "paid") {
- throw Error("payment did not succeed");
- }
-
- return {
- orderId: orderResp.orderId,
- paymentTransactionId: preparePayResult.transactionId,
- };
-}
-
-export async function runIntegrationTest(
- ws: InternalWalletState,
- args: IntegrationTestArgs,
-): Promise<void> {
- logger.info("running test with arguments", args);
-
- const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
- const currency = parsedSpendAmount.currency;
-
- logger.info("withdrawing test balance");
- const withdrawRes1 = await withdrawTestBalance(ws, {
- amount: args.amountToWithdraw,
- corebankApiBaseUrl: args.corebankApiBaseUrl,
- exchangeBaseUrl: args.exchangeBaseUrl,
- });
- await waitUntilGivenTransactionsFinal(ws, [withdrawRes1.transactionId]);
- logger.info("done withdrawing test balance");
-
- const balance = await getBalances(ws);
-
- logger.trace(JSON.stringify(balance, null, 2));
-
- const myMerchant: MerchantBackendInfo = {
- baseUrl: args.merchantBaseUrl,
- authToken: args.merchantAuthToken,
- };
-
- const makePaymentRes = await makePayment(
- ws,
- myMerchant,
- args.amountToSpend,
- "hello world",
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- makePaymentRes.paymentTransactionId,
- );
-
- logger.trace("withdrawing test balance for refund");
- const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
- const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
- const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
- const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
-
- const withdrawRes2 = await withdrawTestBalance(ws, {
- amount: Amounts.stringify(withdrawAmountTwo),
- corebankApiBaseUrl: args.corebankApiBaseUrl,
- exchangeBaseUrl: args.exchangeBaseUrl,
- });
-
- await waitUntilGivenTransactionsFinal(ws, [withdrawRes2.transactionId]);
-
- const { orderId: refundOrderId } = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountTwo),
- "order that will be refunded",
- );
-
- const refundUri = await refund(
- ws.http,
- myMerchant,
- refundOrderId,
- "test refund",
- Amounts.stringify(refundAmount),
- );
-
- logger.trace("refund URI", refundUri);
-
- const refundResp = await startRefundQueryForUri(ws, refundUri);
-
- logger.trace("integration test: applied refund");
-
- // Wait until the refund is done
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- refundResp.transactionId,
- );
-
- logger.trace("integration test: making payment after refund");
-
- const paymentResp2 = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountThree),
- "payment after refund",
- );
-
- logger.trace("integration test: make payment done");
-
- await waitUntilGivenTransactionsFinal(ws, [
- paymentResp2.paymentTransactionId,
- ]);
- await waitUntilGivenTransactionsFinal(
- ws,
- await getRefreshesForTransaction(ws, paymentResp2.paymentTransactionId),
- );
-
- logger.trace("integration test: all done!");
-}
-
-/**
- * Wait until all transactions are in a final state.
- */
-export async function waitUntilAllTransactionsFinal(
- ws: InternalWalletState,
-): Promise<void> {
- logger.info("waiting until all transactions are in a final state");
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- switch (notif.newTxState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- break;
- default:
- p.resolve();
- }
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(ws, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
- }
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
- logger.info("done waiting until all transactions are in a final state");
-}
-
-/**
- * Wait until all chosen transactions are in a final state.
- */
-export async function waitUntilGivenTransactionsFinal(
- ws: InternalWalletState,
- transactionIds: string[],
-): Promise<void> {
- logger.info(
- `waiting until given ${transactionIds.length} transactions are in a final state`,
- );
- logger.info(`transaction IDs are: ${j2s(transactionIds)}`);
- if (transactionIds.length === 0) {
- return;
- }
- ws.ensureTaskLoopRunning();
- const txIdSet = new Set(transactionIds);
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- if (!txIdSet.has(notif.transactionId)) {
- return;
- }
- switch (notif.newTxState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- break;
- default:
- p.resolve();
- }
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(ws, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- if (!txIdSet.has(tx.transactionId)) {
- // Don't look at this transaction, we're not interested in it.
- continue;
- }
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
- }
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
- logger.info("done waiting until given transactions are in a final state");
-}
-
-export async function waitUntilRefreshesDone(
- ws: InternalWalletState,
-): Promise<void> {
- logger.info("waiting until all refresh transactions are in a final state");
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- switch (notif.newTxState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- break;
- default:
- p.resolve();
- }
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(ws, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- if (tx.type !== TransactionType.Refresh) {
- continue;
- }
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
- }
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
- logger.info("done waiting until all refreshes are in a final state");
-}
-
-async function waitUntilTransactionPendingReady(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- logger.info(`starting waiting for ${transactionId} to be in pending(ready)`);
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- p.resolve();
- }
- });
- while (1) {
- p = openPromise();
- const tx = await getTransactionById(ws, {
- transactionId,
- });
- if (
- tx.txState.major == TransactionMajorState.Pending &&
- tx.txState.minor === TransactionMinorState.Ready
- ) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- logger.info(`done waiting for ${transactionId} to be in pending(ready)`);
- cancelNotifs();
-}
-
-/**
- * Wait until a transaction is in a particular state.
- */
-export async function waitTransactionState(
- ws: InternalWalletState,
- transactionId: string,
- txState: TransactionState,
-): Promise<void> {
- logger.info(
- `starting waiting for ${transactionId} to be in ${JSON.stringify(
- txState,
- )})`,
- );
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- p.resolve();
- }
- });
- while (1) {
- p = openPromise();
- const tx = await getTransactionById(ws, {
- transactionId,
- });
- if (
- tx.txState.major === txState.major &&
- tx.txState.minor === txState.minor
- ) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- logger.info(
- `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
- );
- cancelNotifs();
-}
-
-export async function waitUntilTransactionWithAssociatedRefreshesFinal(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- await waitUntilGivenTransactionsFinal(ws, [transactionId]);
- await waitUntilGivenTransactionsFinal(
- ws,
- await getRefreshesForTransaction(ws, transactionId),
- );
-}
-
-export async function waitUntilTransactionFinal(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- await waitUntilGivenTransactionsFinal(ws, [transactionId]);
-}
-
-export async function runIntegrationTest2(
- ws: InternalWalletState,
- args: IntegrationTestV2Args,
-): Promise<void> {
- ws.ensureTaskLoopRunning();
- logger.info("running test with arguments", args);
-
- const exchangeInfo = await fetchFreshExchange(ws, args.exchangeBaseUrl);
-
- const currency = exchangeInfo.currency;
-
- const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`);
- const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);
-
- logger.info("withdrawing test balance");
- const withdrawalRes = await withdrawTestBalance(ws, {
- amount: Amounts.stringify(amountToWithdraw),
- corebankApiBaseUrl: args.corebankApiBaseUrl,
- exchangeBaseUrl: args.exchangeBaseUrl,
- });
- await waitUntilTransactionFinal(ws, withdrawalRes.transactionId);
- logger.info("done withdrawing test balance");
-
- const balance = await getBalances(ws);
-
- logger.trace(JSON.stringify(balance, null, 2));
-
- const myMerchant: MerchantBackendInfo = {
- baseUrl: args.merchantBaseUrl,
- authToken: args.merchantAuthToken,
- };
-
- const makePaymentRes = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(amountToSpend),
- "hello world",
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- makePaymentRes.paymentTransactionId,
- );
-
- logger.trace("withdrawing test balance for refund");
- const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
- const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
- const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
- const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
-
- const withdrawalRes2 = await withdrawTestBalance(ws, {
- amount: Amounts.stringify(withdrawAmountTwo),
- corebankApiBaseUrl: args.corebankApiBaseUrl,
- exchangeBaseUrl: args.exchangeBaseUrl,
- });
-
- // Wait until the withdraw is done
- await waitUntilTransactionFinal(ws, withdrawalRes2.transactionId);
-
- const { orderId: refundOrderId } = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountTwo),
- "order that will be refunded",
- );
-
- const refundUri = await refund(
- ws.http,
- myMerchant,
- refundOrderId,
- "test refund",
- Amounts.stringify(refundAmount),
- );
-
- logger.trace("refund URI", refundUri);
-
- const refundResp = await startRefundQueryForUri(ws, refundUri);
-
- logger.trace("integration test: applied refund");
-
- // Wait until the refund is done
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- refundResp.transactionId,
- );
-
- logger.trace("integration test: making payment after refund");
-
- const makePaymentRes2 = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountThree),
- "payment after refund",
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- makePaymentRes2.paymentTransactionId,
- );
-
- logger.trace("integration test: make payment done");
-
- const peerPushInit = await initiatePeerPushDebit(ws, {
- partialContractTerms: {
- amount: `${currency}:1` as AmountString,
- summary: "Payment Peer Push Test",
- purse_expiration: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- Duration.fromSpec({ hours: 1 }),
- ),
- ),
- },
- });
-
- await waitUntilTransactionPendingReady(ws, peerPushInit.transactionId);
- const txDetails = await getTransactionById(ws, {
- transactionId: peerPushInit.transactionId,
- });
-
- if (txDetails.type !== TransactionType.PeerPushDebit) {
- throw Error("internal invariant failed");
- }
-
- if (!txDetails.talerUri) {
- throw Error("internal invariant failed");
- }
-
- const peerPushCredit = await preparePeerPushCredit(ws, {
- talerUri: txDetails.talerUri,
- });
-
- await confirmPeerPushCredit(ws, {
- transactionId: peerPushCredit.transactionId,
- });
-
- const peerPullInit = await initiatePeerPullPayment(ws, {
- partialContractTerms: {
- amount: `${currency}:1` as AmountString,
- summary: "Payment Peer Pull Test",
- purse_expiration: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- Duration.fromSpec({ hours: 1 }),
- ),
- ),
- },
- });
-
- await waitUntilTransactionPendingReady(ws, peerPullInit.transactionId);
-
- const peerPullInc = await preparePeerPullDebit(ws, {
- talerUri: peerPullInit.talerUri,
- });
-
- await confirmPeerPullDebit(ws, {
- peerPullDebitId: peerPullInc.peerPullDebitId,
- });
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- peerPullInc.transactionId,
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- peerPullInit.transactionId,
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- peerPushCredit.transactionId,
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- peerPushInit.transactionId,
- );
-
- let depositPayto = withdrawalRes.accountPaytoUri;
-
- const parsedPayto = parsePaytoUri(depositPayto);
- if (!parsedPayto) {
- throw Error("invalid payto");
- }
-
- // Work around libeufin-bank bug where receiver-name is missing
- if (!parsedPayto.params["receiver-name"]) {
- depositPayto = addPaytoQueryParams(depositPayto, {
- "receiver-name": "Test",
- });
- }
-
- await createDepositGroup(ws, {
- amount: `${currency}:5` as AmountString,
- depositPaytoUri: depositPayto,
- });
-
- logger.trace("integration test: all done!");
-}
-
-export async function testPay(
- ws: InternalWalletState,
- args: TestPayArgs,
-): Promise<TestPayResult> {
- logger.trace("creating order");
- const merchant = {
- authToken: args.merchantAuthToken,
- baseUrl: args.merchantBaseUrl,
- };
- const orderResp = await createOrder(
- ws.http,
- merchant,
- args.amount,
- args.summary,
- "taler://fulfillment-success/thank+you",
- );
- logger.trace("created new order with order ID", orderResp.orderId);
- const checkPayResp = await checkPayment(ws.http, merchant, orderResp.orderId);
- const talerPayUri = checkPayResp.taler_pay_uri;
- if (!talerPayUri) {
- console.error("fatal: no taler pay URI received from backend");
- process.exit(1);
- }
- logger.trace("taler pay URI:", talerPayUri);
- const result = await preparePayForUri(ws, talerPayUri);
- if (result.status !== PreparePayResultType.PaymentPossible) {
- throw Error(`unexpected prepare pay status: ${result.status}`);
- }
- const r = await confirmPay(
- ws,
- result.transactionId,
- undefined,
- args.forcedCoinSel,
- );
- if (r.type != ConfirmPayResultType.Done) {
- throw Error("payment not done");
- }
- const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(result.proposalId);
- });
- checkLogicInvariant(!!purchase);
- return {
- payCoinSelection: purchase.payInfo?.payCoinSelection!,
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts
deleted file mode 100644
index 1d3ea3d5a..000000000
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ /dev/null
@@ -1,2007 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalIDB } from "@gnu-taler/idb-bridge";
-import {
- AbsoluteTime,
- Amounts,
- DepositTransactionTrackingState,
- j2s,
- Logger,
- NotificationType,
- OrderShortInfo,
- PeerContractTerms,
- RefundInfoShort,
- RefundPaymentInfo,
- ScopeType,
- stringifyPayPullUri,
- stringifyPayPushUri,
- TalerErrorCode,
- TalerPreciseTimestamp,
- Transaction,
- TransactionByIdRequest,
- TransactionIdStr,
- TransactionMajorState,
- TransactionRecordFilter,
- TransactionsRequest,
- TransactionsResponse,
- TransactionState,
- TransactionType,
- TransactionWithdrawal,
- WalletContractData,
- WithdrawalTransactionByURIRequest,
- WithdrawalType,
-} from "@gnu-taler/taler-util";
-import {
- DepositElementStatus,
- DepositGroupRecord,
- OperationRetryRecord,
- PeerPullCreditRecord,
- PeerPullDebitRecordStatus,
- PeerPullPaymentIncomingRecord,
- PeerPushCreditStatus,
- PeerPushDebitRecord,
- PeerPushPaymentIncomingRecord,
- PurchaseRecord,
- PurchaseStatus,
- RefreshGroupRecord,
- RefreshOperationStatus,
- RefundGroupRecord,
- RewardRecord,
- WithdrawalGroupRecord,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
-} from "../db.js";
-import {
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- PeerPushDebitStatus,
- timestampPreciseFromDb,
- timestampProtocolFromDb,
- WalletDbReadOnlyTransaction,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
- constructTaskIdentifier,
- TaskIdentifiers,
- TransactionContext,
-} from "./common.js";
-import {
- computeDepositTransactionActions,
- computeDepositTransactionStatus,
- DepositTransactionContext,
-} from "./deposits.js";
-import {
- ExchangeWireDetails,
- getExchangeWireDetailsInTx,
-} from "./exchanges.js";
-import {
- computePayMerchantTransactionActions,
- computePayMerchantTransactionState,
- computeRefundTransactionState,
- expectProposalDownload,
- extractContractData,
- PayMerchantTransactionContext,
- RefundTransactionContext,
-} from "./pay-merchant.js";
-import {
- computePeerPullCreditTransactionActions,
- computePeerPullCreditTransactionState,
- PeerPullCreditTransactionContext,
-} from "./pay-peer-pull-credit.js";
-import {
- computePeerPullDebitTransactionActions,
- computePeerPullDebitTransactionState,
- PeerPullDebitTransactionContext,
-} from "./pay-peer-pull-debit.js";
-import {
- computePeerPushCreditTransactionActions,
- computePeerPushCreditTransactionState,
- PeerPushCreditTransactionContext,
-} from "./pay-peer-push-credit.js";
-import {
- computePeerPushDebitTransactionActions,
- computePeerPushDebitTransactionState,
- PeerPushDebitTransactionContext,
-} from "./pay-peer-push-debit.js";
-import {
- computeRefreshTransactionActions,
- computeRefreshTransactionState,
- RefreshTransactionContext,
-} from "./refresh.js";
-import {
- computeRewardTransactionStatus,
- computeTipTransactionActions,
- RewardTransactionContext,
-} from "./reward.js";
-import {
- augmentPaytoUrisForWithdrawal,
- computeWithdrawalTransactionActions,
- computeWithdrawalTransactionStatus,
- WithdrawTransactionContext,
-} from "./withdraw.js";
-
-const logger = new Logger("taler-wallet-core:transactions.ts");
-
-function shouldSkipCurrency(
- transactionsRequest: TransactionsRequest | undefined,
- currency: string,
- exchangesInTransaction: string[],
-): boolean {
- if (transactionsRequest?.scopeInfo) {
- const sameCurrency = Amounts.isSameCurrency(
- currency,
- transactionsRequest.scopeInfo.currency,
- );
- switch (transactionsRequest.scopeInfo.type) {
- case ScopeType.Global: {
- return !sameCurrency;
- }
- case ScopeType.Exchange: {
- return (
- !sameCurrency ||
- (exchangesInTransaction.length > 0 &&
- !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url))
- );
- }
- case ScopeType.Auditor: {
- // same currency and same auditor
- throw Error("filering balance in auditor scope is not implemented");
- }
- default:
- assertUnreachable(transactionsRequest.scopeInfo);
- }
- }
- // FIXME: remove next release
- if (transactionsRequest?.currency) {
- return (
- transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
- );
- }
- return false;
-}
-
-function shouldSkipSearch(
- transactionsRequest: TransactionsRequest | undefined,
- fields: string[],
-): boolean {
- if (!transactionsRequest?.search) {
- return false;
- }
- const needle = transactionsRequest.search.trim();
- for (const f of fields) {
- if (f.indexOf(needle) >= 0) {
- return false;
- }
- }
- return true;
-}
-
-/**
- * Fallback order of transactions that have the same timestamp.
- */
-const txOrder: { [t in TransactionType]: number } = {
- [TransactionType.Withdrawal]: 1,
- [TransactionType.Reward]: 2,
- [TransactionType.Payment]: 3,
- [TransactionType.PeerPullCredit]: 4,
- [TransactionType.PeerPullDebit]: 5,
- [TransactionType.PeerPushCredit]: 6,
- [TransactionType.PeerPushDebit]: 7,
- [TransactionType.Refund]: 8,
- [TransactionType.Deposit]: 9,
- [TransactionType.Refresh]: 10,
- [TransactionType.Recoup]: 11,
- [TransactionType.InternalWithdrawal]: 12,
-};
-
-export async function getTransactionById(
- ws: InternalWalletState,
- req: TransactionByIdRequest,
-): Promise<Transaction> {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
-
- if (!parsedTx) {
- throw Error("invalid transaction ID");
- }
-
- switch (parsedTx.tag) {
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal: {
- const withdrawalGroupId = parsedTx.withdrawalGroupId;
- return await ws.db.runReadWriteTx(
- [
- "withdrawalGroups",
- "exchangeDetails",
- "exchanges",
- "operationRetries",
- ],
- async (tx) => {
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
-
- if (!withdrawalGroupRecord) throw Error("not found");
-
- const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
- const ort = await tx.operationRetries.get(opId);
-
- if (
- withdrawalGroupRecord.wgInfo.withdrawalType ===
- WithdrawalRecordType.BankIntegrated
- ) {
- return buildTransactionForBankIntegratedWithdraw(
- withdrawalGroupRecord,
- ort,
- );
- }
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- withdrawalGroupRecord.exchangeBaseUrl,
- );
- if (!exchangeDetails) throw Error("not exchange details");
-
- return buildTransactionForManualWithdraw(
- withdrawalGroupRecord,
- exchangeDetails,
- ort,
- );
- },
- );
- }
-
- case TransactionType.Recoup:
- throw new Error("not yet supported");
-
- case TransactionType.Payment: {
- const proposalId = parsedTx.proposalId;
- return await ws.db.runReadWriteTx(
- [
- "purchases",
- "tombstones",
- "operationRetries",
- "contractTerms",
- "refundGroups",
- ],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) throw Error("not found");
- const download = await expectProposalDownload(ws, purchase, tx);
- const contractData = download.contractData;
- const payOpId = TaskIdentifiers.forPay(purchase);
- const payRetryRecord = await tx.operationRetries.get(payOpId);
-
- const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
- purchase.proposalId,
- );
-
- return buildTransactionForPurchase(
- purchase,
- contractData,
- refunds,
- payRetryRecord,
- );
- },
- );
- }
-
- case TransactionType.Refresh: {
- // FIXME: We should return info about the refresh here!;
- const refreshGroupId = parsedTx.refreshGroupId;
- return await ws.db.runReadOnlyTx(
- ["refreshGroups", "operationRetries"],
- async (tx) => {
- const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroupRec) {
- throw Error("not found");
- }
- const retries = await tx.operationRetries.get(
- TaskIdentifiers.forRefresh(refreshGroupRec),
- );
- return buildTransactionForRefresh(refreshGroupRec, retries);
- },
- );
- }
-
- case TransactionType.Reward: {
- const tipId = parsedTx.walletRewardId;
- return await ws.db.runReadWriteTx(
- ["rewards", "operationRetries"],
- async (tx) => {
- const tipRecord = await tx.rewards.get(tipId);
- if (!tipRecord) throw Error("not found");
-
- const retries = await tx.operationRetries.get(
- TaskIdentifiers.forTipPickup(tipRecord),
- );
- return buildTransactionForTip(tipRecord, retries);
- },
- );
- }
-
- case TransactionType.Deposit: {
- const depositGroupId = parsedTx.depositGroupId;
- return await ws.db.runReadWriteTx(
- ["depositGroups", "operationRetries"],
- async (tx) => {
- const depositRecord = await tx.depositGroups.get(depositGroupId);
- if (!depositRecord) throw Error("not found");
-
- const retries = await tx.operationRetries.get(
- TaskIdentifiers.forDeposit(depositRecord),
- );
- return buildTransactionForDeposit(depositRecord, retries);
- },
- );
- }
-
- case TransactionType.Refund: {
- return await ws.db.runReadOnlyTx(
- ["refundGroups", "purchases", "operationRetries", "contractTerms"],
- async (tx) => {
- const refundRecord = await tx.refundGroups.get(
- parsedTx.refundGroupId,
- );
- if (!refundRecord) {
- throw Error("not found");
- }
- const contractData = await lookupMaybeContractData(
- tx,
- refundRecord?.proposalId,
- );
- return buildTransactionForRefund(refundRecord, contractData);
- },
- );
- }
- case TransactionType.PeerPullDebit: {
- return await ws.db.runReadWriteTx(
- ["peerPullDebit", "contractTerms"],
- async (tx) => {
- const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
- if (!debit) throw Error("not found");
- const contractTermsRec = await tx.contractTerms.get(
- debit.contractTermsHash,
- );
- if (!contractTermsRec)
- throw Error("contract terms for peer-pull-debit not found");
- return buildTransactionForPullPaymentDebit(
- debit,
- contractTermsRec.contractTermsRaw,
- );
- },
- );
- }
-
- case TransactionType.PeerPushDebit: {
- return await ws.db.runReadWriteTx(
- ["peerPushDebit", "contractTerms"],
- async (tx) => {
- const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
- if (!debit) throw Error("not found");
- const ct = await tx.contractTerms.get(debit.contractTermsHash);
- checkDbInvariant(!!ct);
- return buildTransactionForPushPaymentDebit(
- debit,
- ct.contractTermsRaw,
- );
- },
- );
- }
-
- case TransactionType.PeerPushCredit: {
- const peerPushCreditId = parsedTx.peerPushCreditId;
- return await ws.db.runReadWriteTx(
- [
- "peerPushCredit",
- "contractTerms",
- "withdrawalGroups",
- "operationRetries",
- ],
- async (tx) => {
- const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushInc) throw Error("not found");
- const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
- checkDbInvariant(!!ct);
-
- let wg: WithdrawalGroupRecord | undefined = undefined;
- let wgOrt: OperationRetryRecord | undefined = undefined;
- if (pushInc.withdrawalGroupId) {
- wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
- if (wg) {
- const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
- wgOrt = await tx.operationRetries.get(withdrawalOpId);
- }
- }
- const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
- let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
-
- return buildTransactionForPeerPushCredit(
- pushInc,
- pushIncOrt,
- ct.contractTermsRaw,
- wg,
- wgOrt,
- );
- },
- );
- }
-
- case TransactionType.PeerPullCredit: {
- const pursePub = parsedTx.pursePub;
- return await ws.db.runReadWriteTx(
- [
- "peerPullCredit",
- "contractTerms",
- "withdrawalGroups",
- "operationRetries",
- ],
- async (tx) => {
- const pushInc = await tx.peerPullCredit.get(pursePub);
- if (!pushInc) throw Error("not found");
- const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
- checkDbInvariant(!!ct);
-
- let wg: WithdrawalGroupRecord | undefined = undefined;
- let wgOrt: OperationRetryRecord | undefined = undefined;
- if (pushInc.withdrawalGroupId) {
- wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
- if (wg) {
- const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
- wgOrt = await tx.operationRetries.get(withdrawalOpId);
- }
- }
- const pushIncOpId =
- TaskIdentifiers.forPeerPullPaymentInitiation(pushInc);
- let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
-
- return buildTransactionForPeerPullCredit(
- pushInc,
- pushIncOrt,
- ct.contractTermsRaw,
- wg,
- wgOrt,
- );
- },
- );
- }
- }
-}
-
-function buildTransactionForPushPaymentDebit(
- pi: PeerPushDebitRecord,
- contractTerms: PeerContractTerms,
- ort?: OperationRetryRecord,
-): Transaction {
- let talerUri: string | undefined = undefined;
- switch (pi.status) {
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.SuspendedReady:
- talerUri = stringifyPayPushUri({
- exchangeBaseUrl: pi.exchangeBaseUrl,
- contractPriv: pi.contractPriv,
- });
- }
- const txState = computePeerPushDebitTransactionState(pi);
- return {
- type: TransactionType.PeerPushDebit,
- txState,
- txActions: computePeerPushDebitTransactionActions(pi),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost))
- : pi.totalCost,
- amountRaw: pi.amount,
- exchangeBaseUrl: pi.exchangeBaseUrl,
- info: {
- expiration: contractTerms.purse_expiration,
- summary: contractTerms.summary,
- },
- timestamp: timestampPreciseFromDb(pi.timestampCreated),
- talerUri,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pi.pursePub,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForPullPaymentDebit(
- pi: PeerPullPaymentIncomingRecord,
- contractTerms: PeerContractTerms,
- ort?: OperationRetryRecord,
-): Transaction {
- const txState = computePeerPullDebitTransactionState(pi);
- return {
- type: TransactionType.PeerPullDebit,
- txState,
- txActions: computePeerPullDebitTransactionActions(pi),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(pi.amount))
- : pi.coinSel?.totalCost
- ? pi.coinSel?.totalCost
- : Amounts.stringify(pi.amount),
- amountRaw: Amounts.stringify(pi.amount),
- exchangeBaseUrl: pi.exchangeBaseUrl,
- info: {
- expiration: contractTerms.purse_expiration,
- summary: contractTerms.summary,
- },
- timestamp: timestampPreciseFromDb(pi.timestampCreated),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: pi.peerPullDebitId,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForPeerPullCredit(
- pullCredit: PeerPullCreditRecord,
- pullCreditOrt: OperationRetryRecord | undefined,
- peerContractTerms: PeerContractTerms,
- wsr: WithdrawalGroupRecord | undefined,
- wsrOrt: OperationRetryRecord | undefined,
-): Transaction {
- if (wsr) {
- if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) {
- throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`);
- }
- /**
- * FIXME: this should be handled in the withdrawal process.
- * PeerPull withdrawal fails until reserve have funds but it is not
- * an error from the user perspective.
- */
- const silentWithdrawalErrorForInvoice =
- wsrOrt?.lastError &&
- wsrOrt.lastError.code ===
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
- Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => {
- return (
- e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
- e.httpStatusCode === 409
- );
- });
- const txState = computePeerPullCreditTransactionState(pullCredit);
- return {
- type: TransactionType.PeerPullCredit,
- txState,
- txActions: computePeerPullCreditTransactionActions(pullCredit),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
- : Amounts.stringify(wsr.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(wsr.instructedAmount),
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
- info: {
- expiration: peerContractTerms.purse_expiration,
- summary: peerContractTerms.summary,
- },
- talerUri: stringifyPayPullUri({
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- contractPriv: wsr.wgInfo.contractPriv,
- }),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullCredit.pursePub,
- }),
- kycUrl: pullCredit.kycUrl,
- ...(wsrOrt?.lastError
- ? {
- error: silentWithdrawalErrorForInvoice
- ? undefined
- : wsrOrt.lastError,
- }
- : {}),
- };
- }
-
- const txState = computePeerPullCreditTransactionState(pullCredit);
- return {
- type: TransactionType.PeerPullCredit,
- txState,
- txActions: computePeerPullCreditTransactionActions(pullCredit),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
- : Amounts.stringify(pullCredit.estimatedAmountEffective),
- amountRaw: Amounts.stringify(peerContractTerms.amount),
- exchangeBaseUrl: pullCredit.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
- info: {
- expiration: peerContractTerms.purse_expiration,
- summary: peerContractTerms.summary,
- },
- talerUri: stringifyPayPullUri({
- exchangeBaseUrl: pullCredit.exchangeBaseUrl,
- contractPriv: pullCredit.contractPriv,
- }),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullCredit.pursePub,
- }),
- kycUrl: pullCredit.kycUrl,
- ...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
- };
-}
-
-function buildTransactionForPeerPushCredit(
- pushInc: PeerPushPaymentIncomingRecord,
- pushOrt: OperationRetryRecord | undefined,
- peerContractTerms: PeerContractTerms,
- wsr: WithdrawalGroupRecord | undefined,
- wsrOrt: OperationRetryRecord | undefined,
-): Transaction {
- if (wsr) {
- if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
- throw Error("invalid withdrawal group type for push payment credit");
- }
-
- const txState = computePeerPushCreditTransactionState(pushInc);
- return {
- type: TransactionType.PeerPushCredit,
- txState,
- txActions: computePeerPushCreditTransactionActions(pushInc),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
- : Amounts.stringify(wsr.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(wsr.instructedAmount),
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- info: {
- expiration: peerContractTerms.purse_expiration,
- summary: peerContractTerms.summary,
- },
- timestamp: timestampPreciseFromDb(wsr.timestampStart),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: pushInc.peerPushCreditId,
- }),
- kycUrl: pushInc.kycUrl,
- ...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
- };
- }
-
- const txState = computePeerPushCreditTransactionState(pushInc);
- return {
- type: TransactionType.PeerPushCredit,
- txState,
- txActions: computePeerPushCreditTransactionActions(pushInc),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
- : // FIXME: This is wrong, needs to consider fees!
- Amounts.stringify(peerContractTerms.amount),
- amountRaw: Amounts.stringify(peerContractTerms.amount),
- exchangeBaseUrl: pushInc.exchangeBaseUrl,
- info: {
- expiration: peerContractTerms.purse_expiration,
- summary: peerContractTerms.summary,
- },
- kycUrl: pushInc.kycUrl,
- timestamp: timestampPreciseFromDb(pushInc.timestamp),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: pushInc.peerPushCreditId,
- }),
- ...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}),
- };
-}
-
-function buildTransactionForBankIntegratedWithdraw(
- wgRecord: WithdrawalGroupRecord,
- ort?: OperationRetryRecord,
-): TransactionWithdrawal {
- if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
- throw Error("");
-
- const txState = computeWithdrawalTransactionStatus(wgRecord);
- return {
- type: TransactionType.Withdrawal,
- txState,
- txActions: computeWithdrawalTransactionActions(wgRecord),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
- : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(wgRecord.instructedAmount),
- withdrawalDetails: {
- type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
- exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
- reservePub: wgRecord.reservePub,
- bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
- reserveIsReady:
- wgRecord.status === WithdrawalGroupStatus.Done ||
- wgRecord.status === WithdrawalGroupStatus.PendingReady,
- },
- kycUrl: wgRecord.kycUrl,
- exchangeBaseUrl: wgRecord.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: wgRecord.withdrawalGroupId,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function isUnsuccessfulTransaction(state: TransactionState): boolean {
- return (
- state.major === TransactionMajorState.Aborted ||
- state.major === TransactionMajorState.Expired ||
- state.major === TransactionMajorState.Aborting ||
- state.major === TransactionMajorState.Deleted ||
- state.major === TransactionMajorState.Failed
- );
-}
-
-function buildTransactionForManualWithdraw(
- withdrawalGroup: WithdrawalGroupRecord,
- exchangeDetails: ExchangeWireDetails,
- ort?: OperationRetryRecord,
-): TransactionWithdrawal {
- if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
- throw Error("");
-
- const plainPaytoUris =
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
-
- const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
- plainPaytoUris,
- withdrawalGroup.reservePub,
- withdrawalGroup.instructedAmount,
- );
-
- const txState = computeWithdrawalTransactionStatus(withdrawalGroup);
-
- return {
- type: TransactionType.Withdrawal,
- txState,
- txActions: computeWithdrawalTransactionActions(withdrawalGroup),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(
- Amounts.zeroOfAmount(withdrawalGroup.instructedAmount),
- )
- : Amounts.stringify(withdrawalGroup.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
- withdrawalDetails: {
- type: WithdrawalType.ManualTransfer,
- reservePub: withdrawalGroup.reservePub,
- exchangePaytoUris,
- exchangeCreditAccountDetails:
- withdrawalGroup.wgInfo.exchangeCreditAccounts,
- reserveIsReady:
- withdrawalGroup.status === WithdrawalGroupStatus.Done ||
- withdrawalGroup.status === WithdrawalGroupStatus.PendingReady,
- },
- kycUrl: withdrawalGroup.kycUrl,
- exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForRefund(
- refundRecord: RefundGroupRecord,
- maybeContractData: WalletContractData | undefined,
-): Transaction {
- let paymentInfo: RefundPaymentInfo | undefined = undefined;
-
- if (maybeContractData) {
- paymentInfo = {
- merchant: maybeContractData.merchant,
- summary: maybeContractData.summary,
- summary_i18n: maybeContractData.summaryI18n,
- };
- }
-
- const txState = computeRefundTransactionState(refundRecord);
- return {
- type: TransactionType.Refund,
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
- : refundRecord.amountEffective,
- amountRaw: refundRecord.amountRaw,
- refundedTransactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: refundRecord.proposalId,
- }),
- timestamp: timestampPreciseFromDb(refundRecord.timestampCreated),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Refund,
- refundGroupId: refundRecord.refundGroupId,
- }),
- txState,
- txActions: [],
- paymentInfo,
- };
-}
-
-function buildTransactionForRefresh(
- refreshGroupRecord: RefreshGroupRecord,
- ort?: OperationRetryRecord,
-): Transaction {
- const inputAmount = Amounts.sumOrZero(
- refreshGroupRecord.currency,
- refreshGroupRecord.inputPerCoin,
- ).amount;
- const outputAmount = Amounts.sumOrZero(
- refreshGroupRecord.currency,
- refreshGroupRecord.expectedOutputPerCoin,
- ).amount;
- const txState = computeRefreshTransactionState(refreshGroupRecord);
- return {
- type: TransactionType.Refresh,
- txState,
- txActions: computeRefreshTransactionActions(refreshGroupRecord),
- refreshReason: refreshGroupRecord.reason,
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(inputAmount))
- : Amounts.stringify(Amounts.sub(outputAmount, inputAmount).amount),
- amountRaw: Amounts.stringify(
- Amounts.zeroOfCurrency(refreshGroupRecord.currency),
- ),
- refreshInputAmount: Amounts.stringify(inputAmount),
- refreshOutputAmount: Amounts.stringify(outputAmount),
- originatingTransactionId: refreshGroupRecord.originatingTransactionId,
- timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId: refreshGroupRecord.refreshGroupId,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForDeposit(
- dg: DepositGroupRecord,
- ort?: OperationRetryRecord,
-): Transaction {
- let deposited = true;
- for (const d of dg.statusPerCoin) {
- if (d == DepositElementStatus.DepositPending) {
- deposited = false;
- }
- }
-
- const trackingState: DepositTransactionTrackingState[] = [];
-
- for (const ts of Object.values(dg.trackingState ?? {})) {
- trackingState.push({
- amountRaw: ts.amountRaw,
- timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
- wireFee: ts.wireFee,
- wireTransferId: ts.wireTransferId,
- });
- }
-
- const txState = computeDepositTransactionStatus(dg);
- return {
- type: TransactionType.Deposit,
- txState,
- txActions: computeDepositTransactionActions(dg),
- amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
- : Amounts.stringify(dg.totalPayCost),
- timestamp: timestampPreciseFromDb(dg.timestampCreated),
- targetPaytoUri: dg.wire.payto_uri,
- wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: dg.depositGroupId,
- }),
- wireTransferProgress:
- (100 *
- dg.statusPerCoin.reduce(
- (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
- 0,
- )) /
- dg.statusPerCoin.length,
- depositGroupId: dg.depositGroupId,
- trackingState,
- deposited,
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForTip(
- tipRecord: RewardRecord,
- ort?: OperationRetryRecord,
-): Transaction {
- checkLogicInvariant(!!tipRecord.acceptedTimestamp);
-
- const txState = computeRewardTransactionStatus(tipRecord);
- return {
- type: TransactionType.Reward,
- txState,
- txActions: computeTipTransactionActions(tipRecord),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(tipRecord.rewardAmountEffective))
- : Amounts.stringify(tipRecord.rewardAmountEffective),
- amountRaw: Amounts.stringify(tipRecord.rewardAmountRaw),
- timestamp: timestampPreciseFromDb(tipRecord.acceptedTimestamp),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: tipRecord.walletRewardId,
- }),
- merchantBaseUrl: tipRecord.merchantBaseUrl,
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-async function lookupMaybeContractData(
- tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>,
- proposalId: string,
-): Promise<WalletContractData | undefined> {
- let contractData: WalletContractData | undefined = undefined;
- const purchaseTx = await tx.purchases.get(proposalId);
- if (purchaseTx && purchaseTx.download) {
- const download = purchaseTx.download;
- const contractTermsRecord = await tx.contractTerms.get(
- download.contractTermsHash,
- );
- if (!contractTermsRecord) {
- return;
- }
- contractData = extractContractData(
- contractTermsRecord?.contractTermsRaw,
- download.contractTermsHash,
- download.contractTermsMerchantSig,
- );
- }
-
- return contractData;
-}
-
-async function buildTransactionForPurchase(
- purchaseRecord: PurchaseRecord,
- contractData: WalletContractData,
- refundsInfo: RefundGroupRecord[],
- ort?: OperationRetryRecord,
-): Promise<Transaction> {
- const zero = Amounts.zeroOfAmount(contractData.amount);
-
- const info: OrderShortInfo = {
- merchant: contractData.merchant,
- orderId: contractData.orderId,
- summary: contractData.summary,
- summary_i18n: contractData.summaryI18n,
- contractTermsHash: contractData.contractTermsHash,
- };
-
- if (contractData.fulfillmentUrl !== "") {
- info.fulfillmentUrl = contractData.fulfillmentUrl;
- }
-
- const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
- amountEffective: r.amountEffective,
- amountRaw: r.amountRaw,
- timestamp: TalerPreciseTimestamp.round(
- timestampPreciseFromDb(r.timestampCreated),
- ),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Refund,
- refundGroupId: r.refundGroupId,
- }),
- }));
-
- const timestamp = purchaseRecord.timestampAccept;
- checkDbInvariant(!!timestamp);
- checkDbInvariant(!!purchaseRecord.payInfo);
-
- const txState = computePayMerchantTransactionState(purchaseRecord);
- return {
- type: TransactionType.Payment,
- txState,
- txActions: computePayMerchantTransactionActions(purchaseRecord),
- amountRaw: Amounts.stringify(contractData.amount),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(zero)
- : Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
- totalRefundRaw: Amounts.stringify(zero), // FIXME!
- totalRefundEffective: Amounts.stringify(zero), // FIXME!
- refundPending:
- purchaseRecord.refundAmountAwaiting === undefined
- ? undefined
- : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
- refunds,
- posConfirmation: purchaseRecord.posConfirmation,
- timestamp: timestampPreciseFromDb(timestamp),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: purchaseRecord.proposalId,
- }),
- proposalId: purchaseRecord.proposalId,
- info,
- refundQueryActive:
- purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-export async function getWithdrawalTransactionByUri(
- ws: InternalWalletState,
- request: WithdrawalTransactionByURIRequest,
-): Promise<TransactionWithdrawal | undefined> {
- return await ws.db.runReadWriteTx(
- ["withdrawalGroups", "exchangeDetails", "exchanges", "operationRetries"],
- async (tx) => {
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
- request.talerWithdrawUri,
- );
-
- if (!withdrawalGroupRecord) {
- return undefined;
- }
-
- const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
- const ort = await tx.operationRetries.get(opId);
-
- if (
- withdrawalGroupRecord.wgInfo.withdrawalType ===
- WithdrawalRecordType.BankIntegrated
- ) {
- return buildTransactionForBankIntegratedWithdraw(
- withdrawalGroupRecord,
- ort,
- );
- }
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- withdrawalGroupRecord.exchangeBaseUrl,
- );
- if (!exchangeDetails) throw Error("not exchange details");
-
- return buildTransactionForManualWithdraw(
- withdrawalGroupRecord,
- exchangeDetails,
- ort,
- );
- },
- );
-}
-
-/**
- * Retrieve the full event history for this wallet.
- */
-export async function getTransactions(
- ws: InternalWalletState,
- transactionsRequest?: TransactionsRequest,
-): Promise<TransactionsResponse> {
- const transactions: Transaction[] = [];
-
- const filter: TransactionRecordFilter = {};
- if (transactionsRequest?.filterByState) {
- filter.onlyState = transactionsRequest.filterByState;
- }
-
- await ws.db.runReadOnlyTx(
- [
- "coins",
- "denominations",
- "depositGroups",
- "exchangeDetails",
- "exchanges",
- "operationRetries",
- "peerPullDebit",
- "peerPushDebit",
- "peerPushCredit",
- "peerPullCredit",
- "planchets",
- "purchases",
- "contractTerms",
- "recoupGroups",
- "rewards",
- "tombstones",
- "withdrawalGroups",
- "refreshGroups",
- "refundGroups",
- ],
- async (tx) => {
- await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
- const amount = Amounts.parseOrThrow(pi.amount);
- const exchangesInTx = [pi.exchangeBaseUrl];
- if (
- shouldSkipCurrency(
- transactionsRequest,
- amount.currency,
- exchangesInTx,
- )
- ) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- const ct = await tx.contractTerms.get(pi.contractTermsHash);
- checkDbInvariant(!!ct);
- transactions.push(
- buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw),
- );
- });
-
- await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
- const amount = Amounts.parseOrThrow(pi.amount);
- const exchangesInTx = [pi.exchangeBaseUrl];
- if (
- shouldSkipCurrency(
- transactionsRequest,
- amount.currency,
- exchangesInTx,
- )
- ) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- if (
- pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
- pi.status !== PeerPullDebitRecordStatus.Done
- ) {
- // FIXME: Why?!
- return;
- }
-
- const contractTermsRec = await tx.contractTerms.get(
- pi.contractTermsHash,
- );
- if (!contractTermsRec) {
- return;
- }
-
- transactions.push(
- buildTransactionForPullPaymentDebit(
- pi,
- contractTermsRec.contractTermsRaw,
- ),
- );
- });
-
- await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
- if (!pi.currency) {
- // Legacy transaction
- return;
- }
- const exchangesInTx = [pi.exchangeBaseUrl];
- if (
- shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx)
- ) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- if (pi.status === PeerPushCreditStatus.DialogProposed) {
- // We don't report proposed push credit transactions, user needs
- // to scan URI again and confirm to see it.
- return;
- }
- const ct = await tx.contractTerms.get(pi.contractTermsHash);
- let wg: WithdrawalGroupRecord | undefined = undefined;
- let wgOrt: OperationRetryRecord | undefined = undefined;
- if (pi.withdrawalGroupId) {
- wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
- if (wg) {
- const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
- wgOrt = await tx.operationRetries.get(withdrawalOpId);
- }
- }
- const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi);
- let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
-
- checkDbInvariant(!!ct);
- transactions.push(
- buildTransactionForPeerPushCredit(
- pi,
- pushIncOrt,
- ct.contractTermsRaw,
- wg,
- wgOrt,
- ),
- );
- });
-
- await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
- const currency = Amounts.currencyOf(pi.amount);
- const exchangesInTx = [pi.exchangeBaseUrl];
- if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- const ct = await tx.contractTerms.get(pi.contractTermsHash);
- let wg: WithdrawalGroupRecord | undefined = undefined;
- let wgOrt: OperationRetryRecord | undefined = undefined;
- if (pi.withdrawalGroupId) {
- wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
- if (wg) {
- const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
- wgOrt = await tx.operationRetries.get(withdrawalOpId);
- }
- }
- const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi);
- let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
-
- checkDbInvariant(!!ct);
- transactions.push(
- buildTransactionForPeerPullCredit(
- pi,
- pushIncOrt,
- ct.contractTermsRaw,
- wg,
- wgOrt,
- ),
- );
- });
-
- await iterRecordsForRefund(tx, filter, async (refundGroup) => {
- const currency = Amounts.currencyOf(refundGroup.amountRaw);
-
- const exchangesInTx: string[] = [];
- const p = await tx.purchases.get(refundGroup.proposalId);
- if (!p || !p.payInfo) return; //refund with no payment
-
- // FIXME: This is very slow, should become obsolete with materialized transactions.
- for (const cp of p.payInfo.payCoinSelection.coinPubs) {
- const c = await tx.coins.get(cp);
- if (c?.exchangeBaseUrl) {
- exchangesInTx.push(c.exchangeBaseUrl);
- }
- }
-
- if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
- return;
- }
- const contractData = await lookupMaybeContractData(
- tx,
- refundGroup.proposalId,
- );
- transactions.push(buildTransactionForRefund(refundGroup, contractData));
- });
-
- await iterRecordsForRefresh(tx, filter, async (rg) => {
- const exchangesInTx = rg.infoPerExchange
- ? Object.keys(rg.infoPerExchange)
- : [];
- if (
- shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx)
- ) {
- return;
- }
- let required = false;
- const opId = TaskIdentifiers.forRefresh(rg);
- if (transactionsRequest?.includeRefreshes) {
- required = true;
- } else if (rg.operationStatus !== RefreshOperationStatus.Finished) {
- const ort = await tx.operationRetries.get(opId);
- if (ort) {
- required = true;
- }
- }
- if (required) {
- const ort = await tx.operationRetries.get(opId);
- transactions.push(buildTransactionForRefresh(rg, ort));
- }
- });
-
- await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
- const exchangesInTx = [wsr.exchangeBaseUrl];
- if (
- shouldSkipCurrency(
- transactionsRequest,
- Amounts.currencyOf(wsr.rawWithdrawalAmount),
- exchangesInTx,
- )
- ) {
- return;
- }
-
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
-
- const opId = TaskIdentifiers.forWithdrawal(wsr);
- const ort = await tx.operationRetries.get(opId);
-
- switch (wsr.wgInfo.withdrawalType) {
- case WithdrawalRecordType.PeerPullCredit:
- // Will be reported by the corresponding p2p transaction.
- // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
- // FIXME: Still report if requested with verbose option?
- return;
- case WithdrawalRecordType.PeerPushCredit:
- // Will be reported by the corresponding p2p transaction.
- // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
- // FIXME: Still report if requested with verbose option?
- return;
- case WithdrawalRecordType.BankIntegrated:
- transactions.push(
- buildTransactionForBankIntegratedWithdraw(wsr, ort),
- );
- return;
- case WithdrawalRecordType.BankManual: {
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- wsr.exchangeBaseUrl,
- );
- if (!exchangeDetails) {
- // FIXME: report somehow
- return;
- }
-
- transactions.push(
- buildTransactionForManualWithdraw(wsr, exchangeDetails, ort),
- );
- return;
- }
- case WithdrawalRecordType.Recoup:
- // FIXME: Do we also report a transaction here?
- return;
- }
- });
-
- await iterRecordsForDeposit(tx, filter, async (dg) => {
- const amount = Amounts.parseOrThrow(dg.amount);
- const exchangesInTx = dg.infoPerExchange
- ? Object.keys(dg.infoPerExchange)
- : [];
- if (
- shouldSkipCurrency(
- transactionsRequest,
- amount.currency,
- exchangesInTx,
- )
- ) {
- return;
- }
- const opId = TaskIdentifiers.forDeposit(dg);
- const retryRecord = await tx.operationRetries.get(opId);
-
- transactions.push(buildTransactionForDeposit(dg, retryRecord));
- });
-
- await iterRecordsForPurchase(tx, filter, async (purchase) => {
- const download = purchase.download;
- if (!download) {
- return;
- }
- if (!purchase.payInfo) {
- return;
- }
-
- const exchangesInTx: string[] = [];
- for (const cp of purchase.payInfo.payCoinSelection.coinPubs) {
- const c = await tx.coins.get(cp);
- if (c?.exchangeBaseUrl) {
- exchangesInTx.push(c.exchangeBaseUrl);
- }
- }
-
- if (
- shouldSkipCurrency(
- transactionsRequest,
- download.currency,
- exchangesInTx,
- )
- ) {
- return;
- }
- const contractTermsRecord = await tx.contractTerms.get(
- download.contractTermsHash,
- );
- if (!contractTermsRecord) {
- return;
- }
- if (
- shouldSkipSearch(transactionsRequest, [
- contractTermsRecord?.contractTermsRaw?.summary || "",
- ])
- ) {
- return;
- }
-
- const contractData = extractContractData(
- contractTermsRecord?.contractTermsRaw,
- download.contractTermsHash,
- download.contractTermsMerchantSig,
- );
-
- const payOpId = TaskIdentifiers.forPay(purchase);
- const payRetryRecord = await tx.operationRetries.get(payOpId);
-
- const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
- purchase.proposalId,
- );
-
- transactions.push(
- await buildTransactionForPurchase(
- purchase,
- contractData,
- refunds,
- payRetryRecord,
- ),
- );
- });
-
- //FIXME: remove rewards
- await iterRecordsForReward(tx, filter, async (tipRecord) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency,
- [tipRecord.exchangeBaseUrl],
- )
- ) {
- return;
- }
- if (!tipRecord.acceptedTimestamp) {
- return;
- }
- const opId = TaskIdentifiers.forTipPickup(tipRecord);
- const retryRecord = await tx.operationRetries.get(opId);
- transactions.push(buildTransactionForTip(tipRecord, retryRecord));
- });
- //ends REMOVE REWARDS
- },
- );
-
- // One-off checks, because of a bug where the wallet previously
- // did not migrate the DB correctly and caused these amounts
- // to be missing sometimes.
- for (let tx of transactions) {
- if (!tx.amountEffective) {
- logger.warn(`missing amountEffective in ${j2s(tx)}`);
- }
- if (!tx.amountRaw) {
- logger.warn(`missing amountRaw in ${j2s(tx)}`);
- }
- if (!tx.timestamp) {
- logger.warn(`missing timestamp in ${j2s(tx)}`);
- }
- }
-
- const isPending = (x: Transaction) =>
- x.txState.major === TransactionMajorState.Pending ||
- x.txState.major === TransactionMajorState.Aborting ||
- x.txState.major === TransactionMajorState.Dialog;
-
- const txPending = transactions.filter((x) => isPending(x));
- const txNotPending = transactions.filter((x) => !isPending(x));
-
- let sortSign: number;
- if (transactionsRequest?.sort == "descending") {
- sortSign = -1;
- } else {
- sortSign = 1;
- }
-
- const txCmp = (h1: Transaction, h2: Transaction) => {
- // Order transactions by timestamp. Newest transactions come first.
- const tsCmp = AbsoluteTime.cmp(
- AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
- AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
- );
- // If the timestamp is exactly the same, order by transaction type.
- if (tsCmp === 0) {
- return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
- }
- return sortSign * tsCmp;
- };
-
- txPending.sort(txCmp);
- txNotPending.sort(txCmp);
-
- return { transactions: [...txNotPending, ...txPending] };
-}
-
-export type ParsedTransactionIdentifier =
- | { tag: TransactionType.Deposit; depositGroupId: string }
- | { tag: TransactionType.Payment; proposalId: string }
- | { tag: TransactionType.PeerPullDebit; peerPullDebitId: string }
- | { tag: TransactionType.PeerPullCredit; pursePub: string }
- | { tag: TransactionType.PeerPushCredit; peerPushCreditId: string }
- | { tag: TransactionType.PeerPushDebit; pursePub: string }
- | { tag: TransactionType.Refresh; refreshGroupId: string }
- | { tag: TransactionType.Refund; refundGroupId: string }
- | { tag: TransactionType.Reward; walletRewardId: string }
- | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
- | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string }
- | { tag: TransactionType.Recoup; recoupGroupId: string };
-
-export function constructTransactionIdentifier(
- pTxId: ParsedTransactionIdentifier,
-): TransactionIdStr {
- switch (pTxId.tag) {
- case TransactionType.Deposit:
- return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr;
- case TransactionType.Payment:
- return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr;
- case TransactionType.PeerPullCredit:
- return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
- case TransactionType.PeerPullDebit:
- return `txn:${pTxId.tag}:${pTxId.peerPullDebitId}` as TransactionIdStr;
- case TransactionType.PeerPushCredit:
- return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` as TransactionIdStr;
- case TransactionType.PeerPushDebit:
- return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
- case TransactionType.Refresh:
- return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
- case TransactionType.Refund:
- return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
- case TransactionType.Reward:
- return `txn:${pTxId.tag}:${pTxId.walletRewardId}` as TransactionIdStr;
- case TransactionType.Withdrawal:
- return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
- case TransactionType.InternalWithdrawal:
- return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
- case TransactionType.Recoup:
- return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr;
- default:
- assertUnreachable(pTxId);
- }
-}
-
-/**
- * Parse a transaction identifier string into a typed, structured representation.
- */
-export function parseTransactionIdentifier(
- transactionId: string,
-): ParsedTransactionIdentifier | undefined {
- const txnParts = transactionId.split(":");
-
- if (txnParts.length < 3) {
- throw Error("id should have al least 3 parts separated by ':'");
- }
-
- const [prefix, type, ...rest] = txnParts;
-
- if (prefix != "txn") {
- throw Error("invalid transaction identifier");
- }
-
- switch (type) {
- case TransactionType.Deposit:
- return { tag: TransactionType.Deposit, depositGroupId: rest[0] };
- case TransactionType.Payment:
- return { tag: TransactionType.Payment, proposalId: rest[0] };
- case TransactionType.PeerPullCredit:
- return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] };
- case TransactionType.PeerPullDebit:
- return {
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: rest[0],
- };
- case TransactionType.PeerPushCredit:
- return {
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: rest[0],
- };
- case TransactionType.PeerPushDebit:
- return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] };
- case TransactionType.Refresh:
- return { tag: TransactionType.Refresh, refreshGroupId: rest[0] };
- case TransactionType.Refund:
- return {
- tag: TransactionType.Refund,
- refundGroupId: rest[0],
- };
- case TransactionType.Reward:
- return {
- tag: TransactionType.Reward,
- walletRewardId: rest[0],
- };
- case TransactionType.Withdrawal:
- return {
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: rest[0],
- };
- default:
- return undefined;
- }
-}
-
-function maybeTaskFromTransaction(transactionId: string): TaskId | undefined {
- const parsedTx = parseTransactionIdentifier(transactionId);
-
- if (!parsedTx) {
- throw Error("invalid transaction identifier");
- }
-
- // FIXME: We currently don't cancel active long-polling tasks here.
-
- switch (parsedTx.tag) {
- case TransactionType.PeerPullCredit:
- return constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub: parsedTx.pursePub,
- });
- case TransactionType.Deposit:
- return constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId: parsedTx.depositGroupId,
- });
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal:
- return constructTaskIdentifier({
- tag: PendingTaskType.Withdraw,
- withdrawalGroupId: parsedTx.withdrawalGroupId,
- });
- case TransactionType.Payment:
- return constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId: parsedTx.proposalId,
- });
- case TransactionType.Reward:
- return constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId: parsedTx.walletRewardId,
- });
- case TransactionType.Refresh:
- return constructTaskIdentifier({
- tag: PendingTaskType.Refresh,
- refreshGroupId: parsedTx.refreshGroupId,
- });
- case TransactionType.PeerPullDebit:
- return constructTaskIdentifier({
- tag: PendingTaskType.PeerPullDebit,
- peerPullDebitId: parsedTx.peerPullDebitId,
- });
- case TransactionType.PeerPushCredit:
- return constructTaskIdentifier({
- tag: PendingTaskType.PeerPushCredit,
- peerPushCreditId: parsedTx.peerPushCreditId,
- });
- case TransactionType.PeerPushDebit:
- return constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub: parsedTx.pursePub,
- });
- case TransactionType.Refund:
- // Nothing to do for a refund transaction.
- return undefined;
- case TransactionType.Recoup:
- return constructTaskIdentifier({
- tag: PendingTaskType.Recoup,
- recoupGroupId: parsedTx.recoupGroupId,
- });
- default:
- assertUnreachable(parsedTx);
- }
-}
-
-/**
- * Immediately retry the underlying operation
- * of a transaction.
- */
-export async function retryTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- logger.info(`resetting retry timeout for ${transactionId}`);
- const taskId = maybeTaskFromTransaction(transactionId);
- if (taskId) {
- ws.taskScheduler.resetTaskRetries(taskId);
- }
-}
-
-async function getContextForTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<TransactionContext> {
- const tx = parseTransactionIdentifier(transactionId);
- if (!tx) {
- throw Error("invalid transaction ID");
- }
- switch (tx.tag) {
- case TransactionType.Deposit:
- return new DepositTransactionContext(ws, tx.depositGroupId);
- case TransactionType.Refresh:
- return new RefreshTransactionContext(ws, tx.refreshGroupId);
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal:
- return new WithdrawTransactionContext(ws, tx.withdrawalGroupId);
- case TransactionType.Payment:
- return new PayMerchantTransactionContext(ws, tx.proposalId);
- case TransactionType.PeerPullCredit:
- return new PeerPullCreditTransactionContext(ws, tx.pursePub);
- case TransactionType.PeerPushDebit:
- return new PeerPushDebitTransactionContext(ws, tx.pursePub);
- case TransactionType.PeerPullDebit:
- return new PeerPullDebitTransactionContext(ws, tx.peerPullDebitId);
- case TransactionType.PeerPushCredit:
- return new PeerPushCreditTransactionContext(ws, tx.peerPushCreditId);
- case TransactionType.Refund:
- return new RefundTransactionContext(ws, tx.refundGroupId);
- case TransactionType.Reward:
- return new RewardTransactionContext(ws, tx.walletRewardId);
- case TransactionType.Recoup:
- throw new Error("not yet supported");
- //return new RecoupTransactionContext(ws, tx.recoupGroupId);
- default:
- assertUnreachable(tx);
- }
-}
-
-/**
- * Suspends a pending transaction, stopping any associated network activities,
- * but with a chance of trying again at a later time. This could be useful if
- * a user needs to save battery power or bandwidth and an operation is expected
- * to take longer (such as a backup, recovery or very large withdrawal operation).
- */
-export async function suspendTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.suspendTransaction();
-}
-
-export async function failTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.failTransaction();
-}
-
-/**
- * Resume a suspended transaction.
- */
-export async function resumeTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.resumeTransaction();
-}
-
-/**
- * Permanently delete a transaction based on the transaction ID.
- */
-export async function deleteTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.deleteTransaction();
-}
-
-export async function abortTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.abortTransaction();
-}
-
-export interface TransitionInfo {
- oldTxState: TransactionState;
- newTxState: TransactionState;
-}
-
-/**
- * Notify of a state transition if necessary.
- */
-export function notifyTransition(
- ws: InternalWalletState,
- transactionId: string,
- transitionInfo: TransitionInfo | undefined,
- experimentalUserData: any = undefined,
-): void {
- if (
- transitionInfo &&
- !(
- transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
- transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
- )
- ) {
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- oldTxState: transitionInfo.oldTxState,
- newTxState: transitionInfo.newTxState,
- transactionId,
- experimentalUserData,
- });
- }
-}
-
-/**
- * Iterate refresh records based on a filter.
- */
-async function iterRecordsForRefresh(
- tx: WalletDbReadOnlyTransaction<["refreshGroups"]>,
- filter: TransactionRecordFilter,
- f: (r: RefreshGroupRecord) => Promise<void>,
-): Promise<void> {
- let refreshGroups: RefreshGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- RefreshOperationStatus.Pending,
- RefreshOperationStatus.Suspended,
- );
- refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
- } else {
- refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
- }
-
- for (const r of refreshGroups) {
- await f(r);
- }
-}
-
-async function iterRecordsForWithdrawal(
- tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>,
- filter: TransactionRecordFilter,
- f: (r: WithdrawalGroupRecord) => Promise<void>,
-): Promise<void> {
- let withdrawalGroupRecords: WithdrawalGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- withdrawalGroupRecords =
- await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange);
- } else {
- withdrawalGroupRecords =
- await tx.withdrawalGroups.indexes.byStatus.getAll();
- }
- for (const wgr of withdrawalGroupRecords) {
- await f(wgr);
- }
-}
-
-async function iterRecordsForDeposit(
- tx: WalletDbReadOnlyTransaction<["depositGroups"]>,
- filter: TransactionRecordFilter,
- f: (r: DepositGroupRecord) => Promise<void>,
-): Promise<void> {
- let dgs: DepositGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange);
- } else {
- dgs = await tx.depositGroups.indexes.byStatus.getAll();
- }
-
- for (const dg of dgs) {
- await f(dg);
- }
-}
-
-async function iterRecordsForReward(
- tx: WalletDbReadOnlyTransaction<["rewards"]>,
- filter: TransactionRecordFilter,
- f: (r: RewardRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.rewards.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.rewards.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForRefund(
- tx: WalletDbReadOnlyTransaction<["refundGroups"]>,
- filter: TransactionRecordFilter,
- f: (r: RefundGroupRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.refundGroups.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPurchase(
- tx: WalletDbReadOnlyTransaction<["purchases"]>,
- filter: TransactionRecordFilter,
- f: (r: PurchaseRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPeerPullCredit(
- tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>,
- filter: TransactionRecordFilter,
- f: (r: PeerPullCreditRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPeerPullDebit(
- tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>,
- filter: TransactionRecordFilter,
- f: (r: PeerPullPaymentIncomingRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPeerPushDebit(
- tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>,
- filter: TransactionRecordFilter,
- f: (r: PeerPushDebitRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPeerPushCredit(
- tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>,
- filter: TransactionRecordFilter,
- f: (r: PeerPushPaymentIncomingRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/withdraw.test.ts b/packages/taler-wallet-core/src/operations/withdraw.test.ts
deleted file mode 100644
index 97a80ec26..000000000
--- a/packages/taler-wallet-core/src/operations/withdraw.test.ts
+++ /dev/null
@@ -1,370 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { AmountString, Amounts, DenomKeyType } from "@gnu-taler/taler-util";
-import test from "ava";
-import {
- DenominationRecord,
- DenominationVerificationStatus,
- timestampProtocolToDb,
-} from "../db.js";
-import { selectWithdrawalDenominations } from "../util/coinSelection.js";
-
-test("withdrawal selection bug repro", (t) => {
- const amount = {
- currency: "KUDOS",
- fraction: 43000000,
- value: 23,
- };
-
- const denoms: DenominationRecord[] = [
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
- age_mask: 0,
- },
- denomPubHash:
- "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- currency: "KUDOS",
- value: "KUDOS:1000" as AmountString,
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
- age_mask: 0,
- },
-
- denomPubHash:
- "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: "KUDOS:10" as AmountString,
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
- age_mask: 0,
- },
- denomPubHash:
- "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: "KUDOS:5" as AmountString,
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
- age_mask: 0,
- },
-
- denomPubHash:
- "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: "KUDOS:1" as AmountString,
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
- age_mask: 0,
- },
- denomPubHash:
- "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: Amounts.stringify({
- currency: "KUDOS",
- fraction: 10000000,
- value: 0,
- }),
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
- age_mask: 0,
- },
- denomPubHash:
- "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: "KUDOS:2" as AmountString,
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- ];
-
- const res = selectWithdrawalDenominations(amount, denoms);
-
- t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
- t.pass();
-});
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
deleted file mode 100644
index 542868de0..000000000
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ /dev/null
@@ -1,2754 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2024 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AcceptManualWithdrawalResult,
- AcceptWithdrawalResponse,
- AgeRestriction,
- AmountJson,
- AmountLike,
- AmountString,
- Amounts,
- BankWithdrawDetails,
- CancellationToken,
- CoinStatus,
- CurrencySpecification,
- DenomKeyType,
- DenomSelectionState,
- Duration,
- ExchangeBatchWithdrawRequest,
- ExchangeUpdateStatus,
- ExchangeWireAccount,
- ExchangeWithdrawBatchResponse,
- ExchangeWithdrawRequest,
- ExchangeWithdrawResponse,
- ExchangeWithdrawalDetails,
- ForcedDenomSel,
- HttpStatusCode,
- LibtoolVersion,
- Logger,
- NotificationType,
- TalerBankIntegrationHttpClient,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- UnblindedSignature,
- WalletNotification,
- WithdrawUriInfoResponse,
- WithdrawalExchangeAccountDetails,
- addPaytoQueryParams,
- canonicalizeBaseUrl,
- codecForAny,
- codecForCashinConversionResponse,
- codecForConversionBankConfig,
- codecForExchangeWithdrawBatchResponse,
- codecForReserveStatus,
- codecForWalletKycUuid,
- codecForWithdrawOperationStatusResponse,
- encodeCrock,
- getErrorDetailFromException,
- getRandomBytes,
- j2s,
- makeErrorDetail,
- parseWithdrawUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- HttpResponse,
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
-import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
-import {
- CoinRecord,
- CoinSourceType,
- DenominationRecord,
- DenominationVerificationStatus,
- KycPendingInfo,
- PlanchetRecord,
- PlanchetStatus,
- WalletStoresV1,
- WgInfo,
- WithdrawalGroupRecord,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
-} from "../db.js";
-import {
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
- isWithdrawableDenom,
- timestampPreciseToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
- makeCoinAvailable,
- makeCoinsVisible,
-} from "../operations/common.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import {
- selectForcedWithdrawalDenominations,
- selectWithdrawalDenominations,
-} from "../util/coinSelection.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import { DbAccess } from "../util/query.js";
-import {
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "../versions.js";
-import {
- ReadyExchangeSummary,
- fetchFreshExchange,
- getExchangePaytoUri,
- getExchangeWireDetailsInTx,
- listExchanges,
- markExchangeUsed,
-} from "./exchanges.js";
-import {
- TransitionInfo,
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-/**
- * Logger for this file.
- */
-const logger = new Logger("operations/withdraw.ts");
-
-export class WithdrawTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public withdrawalGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Withdraw,
- withdrawalGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, withdrawalGroupId } = this;
- await ws.db.runReadWriteTx(
- ["withdrawalGroups", "tombstones"],
- async (tx) => {
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- return;
- }
- },
- );
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.PendingReady:
- newStatus = WithdrawalGroupStatus.SuspendedReady;
- break;
- case WithdrawalGroupStatus.AbortingBank:
- newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
- break;
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
- break;
- case WithdrawalGroupStatus.PendingRegisteringBank:
- newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
- break;
- case WithdrawalGroupStatus.PendingQueryingStatus:
- newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
- break;
- case WithdrawalGroupStatus.PendingKyc:
- newStatus = WithdrawalGroupStatus.SuspendedKyc;
- break;
- case WithdrawalGroupStatus.PendingAml:
- newStatus = WithdrawalGroupStatus.SuspendedAml;
- break;
- default:
- logger.warn(
- `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
- );
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- newStatus = WithdrawalGroupStatus.AbortingBank;
- break;
- case WithdrawalGroupStatus.SuspendedAml:
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.PendingAml:
- case WithdrawalGroupStatus.PendingKyc:
- case WithdrawalGroupStatus.PendingQueryingStatus:
- newStatus = WithdrawalGroupStatus.AbortedExchange;
- break;
- case WithdrawalGroupStatus.PendingReady:
- newStatus = WithdrawalGroupStatus.SuspendedReady;
- break;
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.AbortingBank:
- // No transition needed, but not an error
- break;
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedBankAborted:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.FailedAbortingBank:
- // Not allowed
- throw Error("abort not allowed in current state");
- default:
- assertUnreachable(wg.status);
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(taskId);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedReady:
- newStatus = WithdrawalGroupStatus.PendingReady;
- break;
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- newStatus = WithdrawalGroupStatus.AbortingBank;
- break;
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
- break;
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
- break;
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
- break;
- case WithdrawalGroupStatus.SuspendedAml:
- newStatus = WithdrawalGroupStatus.PendingAml;
- break;
- case WithdrawalGroupStatus.SuspendedKyc:
- newStatus = WithdrawalGroupStatus.PendingKyc;
- break;
- default:
- logger.warn(
- `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
- );
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
- const stateUpdate = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.AbortingBank:
- newStatus = WithdrawalGroupStatus.FailedAbortingBank;
- break;
- default:
- break;
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, stateUpdate);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-/**
- * Compute the DD37 transaction state of a withdrawal transaction
- * from the database's withdrawal group record.
- */
-export function computeWithdrawalTransactionStatus(
- wgRecord: WithdrawalGroupRecord,
-): TransactionState {
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.FailedBankAborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case WithdrawalGroupStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case WithdrawalGroupStatus.PendingRegisteringBank:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.BankRegisterReserve,
- };
- case WithdrawalGroupStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.WithdrawCoins,
- };
- case WithdrawalGroupStatus.PendingQueryingStatus:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.ExchangeWaitReserve,
- };
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.BankConfirmTransfer,
- };
- case WithdrawalGroupStatus.AbortingBank:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Bank,
- };
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Bank,
- };
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.ExchangeWaitReserve,
- };
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.BankRegisterReserve,
- };
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.BankConfirmTransfer,
- };
- case WithdrawalGroupStatus.SuspendedReady: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.WithdrawCoins,
- };
- }
- case WithdrawalGroupStatus.PendingAml: {
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AmlRequired,
- };
- }
- case WithdrawalGroupStatus.PendingKyc: {
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.KycRequired,
- };
- }
- case WithdrawalGroupStatus.SuspendedAml: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.AmlRequired,
- };
- }
- case WithdrawalGroupStatus.SuspendedKyc: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.KycRequired,
- };
- }
- case WithdrawalGroupStatus.FailedAbortingBank:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.AbortingBank,
- };
- case WithdrawalGroupStatus.AbortedExchange:
- return {
- major: TransactionMajorState.Aborted,
- minor: TransactionMinorState.Exchange,
- };
-
- case WithdrawalGroupStatus.AbortedBank:
- return {
- major: TransactionMajorState.Aborted,
- minor: TransactionMinorState.Bank,
- };
- }
-}
-
-/**
- * Compute DD37 transaction actions for a withdrawal transaction
- * based on the database's withdrawal group record.
- */
-export function computeWithdrawalTransactionActions(
- wgRecord: WithdrawalGroupRecord,
-): TransactionAction[] {
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.FailedBankAborted:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.Done:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.PendingRegisteringBank:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingReady:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingQueryingStatus:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.AbortingBank:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedReady:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingAml:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingKyc:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedAml:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedKyc:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.FailedAbortingBank:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.AbortedExchange:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.AbortedBank:
- return [TransactionAction.Delete];
- }
-}
-
-/**
- * Get information about a withdrawal from
- * a taler://withdraw URI by asking the bank.
- *
- * FIXME: Move into bank client.
- */
-export async function getBankWithdrawalInfo(
- http: HttpRequestLibrary,
- talerWithdrawUri: string,
-): Promise<BankWithdrawDetails> {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse URL ${talerWithdrawUri}`);
- }
-
- const bankApi = new TalerBankIntegrationHttpClient(
- uriResult.bankIntegrationApiBaseUrl,
- http,
- );
-
- const { body: config } = await bankApi.getConfig();
-
- if (!bankApi.isCompatible(config.version)) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- bankProtocolVersion: config.version,
- walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- },
- "bank integration protocol version not compatible with wallet",
- );
- }
-
- const resp = await bankApi.getWithdrawalOperationById(
- uriResult.withdrawalOperationId,
- );
-
- if (resp.type === "fail") {
- throw TalerError.fromUncheckedDetail(resp.detail);
- }
- const { body: status } = resp;
-
- logger.info(`bank withdrawal operation status: ${j2s(status)}`);
-
- return {
- operationId: uriResult.withdrawalOperationId,
- apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
- amount: Amounts.parseOrThrow(status.amount),
- confirmTransferUrl: status.confirm_transfer_url,
- senderWire: status.sender_wire,
- suggestedExchange: status.suggested_exchange,
- wireTypes: status.wire_types,
- status: status.status,
- };
-}
-
-/**
- * Return denominations that can potentially used for a withdrawal.
- */
-async function getCandidateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<DenominationRecord[]> {
- return await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency);
- });
-}
-
-export async function getCandidateWithdrawalDenomsTx(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<["denominations"]>,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<DenominationRecord[]> {
- // FIXME: Use denom groups instead of querying all denominations!
- const allDenoms =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
- return allDenoms
- .filter((d) => d.currency === currency)
- .filter((d) => isWithdrawableDenom(d, ws.config.testing.denomselAllowLate));
-}
-
-/**
- * Generate a planchet for a coin index in a withdrawal group.
- * Does not actually withdraw the coin yet.
- *
- * Split up so that we can parallelize the crypto, but serialize
- * the exchange requests per reserve.
- */
-async function processPlanchetGenerate(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- coinIdx: number,
-): Promise<void> {
- let planchet = await ws.db.runReadOnlyTx(["planchets"], async (tx) => {
- return tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- });
- if (planchet) {
- return;
- }
- let ci = 0;
- let maybeDenomPubHash: string | undefined;
- for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
- const d = withdrawalGroup.denomsSel.selectedDenoms[di];
- if (coinIdx >= ci && coinIdx < ci + d.count) {
- maybeDenomPubHash = d.denomPubHash;
- break;
- }
- ci += d.count;
- }
- if (!maybeDenomPubHash) {
- throw Error("invariant violated");
- }
- const denomPubHash = maybeDenomPubHash;
-
- const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- denomPubHash,
- );
- });
- checkDbInvariant(!!denom);
- const r = await ws.cryptoApi.createPlanchet({
- denomPub: denom.denomPub,
- feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
- reservePriv: withdrawalGroup.reservePriv,
- reservePub: withdrawalGroup.reservePub,
- value: Amounts.parseOrThrow(denom.value),
- coinIndex: coinIdx,
- secretSeed: withdrawalGroup.secretSeed,
- restrictAge: withdrawalGroup.restrictAge,
- });
- const newPlanchet: PlanchetRecord = {
- blindingKey: r.blindingKey,
- coinEv: r.coinEv,
- coinEvHash: r.coinEvHash,
- coinIdx,
- coinPriv: r.coinPriv,
- coinPub: r.coinPub,
- denomPubHash: r.denomPubHash,
- planchetStatus: PlanchetStatus.Pending,
- withdrawSig: r.withdrawSig,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- ageCommitmentProof: r.ageCommitmentProof,
- lastError: undefined,
- };
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- const p = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (p) {
- planchet = p;
- return;
- }
- await tx.planchets.put(newPlanchet);
- planchet = newPlanchet;
- });
-}
-
-interface WithdrawalRequestBatchArgs {
- coinStartIndex: number;
-
- batchSize: number;
-}
-
-interface WithdrawalBatchResult {
- coinIdxs: number[];
- batchResp: ExchangeWithdrawBatchResponse;
-}
-
-enum AmlStatus {
- normal = 0,
- pending = 1,
- fronzen = 2,
-}
-
-/**
- * Transition a withdrawal transaction with a (new) KYC URL.
- *
- * Emit a notification for the (self-)transition.
- */
-async function transitionKycUrlUpdate(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- kycUrl: string,
-): Promise<void> {
- let notificationKycUrl: string | undefined = undefined;
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
- const transactionId = ctx.transactionId;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingReady: {
- wg2.kycUrl = kycUrl;
- notificationKycUrl = kycUrl;
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- if (transitionInfo) {
- // Always notify, even on self-transition, as the KYC URL might have changed.
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- oldTxState: transitionInfo.oldTxState,
- newTxState: transitionInfo.newTxState,
- transactionId,
- experimentalUserData: notificationKycUrl,
- });
- }
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-}
-
-async function handleKycRequired(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- resp: HttpResponse,
- startIdx: number,
- requestCoinIdxs: number[],
-): Promise<void> {
- logger.info("withdrawal requires KYC");
- const respJson = await resp.json();
- const uuidResp = codecForWalletKycUuid().decode(respJson);
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
- const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const userType = "individual";
- const kycInfo: KycPendingInfo = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- });
- let kycUrl: string;
- let amlStatus: AmlStatus | undefined;
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- logger.warn("kyc requested, but already fulfilled");
- return;
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- kycUrl = kycStatus.kyc_url;
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- amlStatus = kycStatus.aml_status;
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
-
- let notificationKycUrl: string | undefined = undefined;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["planchets", "withdrawalGroups"],
- async (tx) => {
- for (let i = startIdx; i < requestCoinIdxs.length; i++) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- requestCoinIdxs[i],
- ]);
- if (!planchet) {
- continue;
- }
- planchet.planchetStatus = PlanchetStatus.KycRequired;
- await tx.planchets.put(planchet);
- }
- const wg2 = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingReady: {
- wg2.kycPending = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- wg2.kycUrl = kycUrl;
- wg2.status =
- amlStatus === AmlStatus.normal || amlStatus === undefined
- ? WithdrawalGroupStatus.PendingKyc
- : amlStatus === AmlStatus.pending
- ? WithdrawalGroupStatus.PendingAml
- : amlStatus === AmlStatus.fronzen
- ? WithdrawalGroupStatus.SuspendedAml
- : assertUnreachable(amlStatus);
-
- notificationKycUrl = kycUrl;
-
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- notifyTransition(ws, transactionId, transitionInfo, notificationKycUrl);
-}
-
-/**
- * Send the withdrawal request for a generated planchet to the exchange.
- *
- * The verification of the response is done asynchronously to enable parallelism.
- */
-async function processPlanchetExchangeBatchRequest(
- ws: InternalWalletState,
- wgContext: WithdrawalGroupContext,
- args: WithdrawalRequestBatchArgs,
-): Promise<WithdrawalBatchResult> {
- const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
- logger.info(
- `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
- );
-
- const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
- // Indices of coins that are included in the batch request
- const requestCoinIdxs: number[] = [];
-
- await ws.db.runReadOnlyTx(["planchets", "denominations"], async (tx) => {
- for (
- let coinIdx = args.coinStartIndex;
- coinIdx < args.coinStartIndex + args.batchSize &&
- coinIdx < wgContext.numPlanchets;
- coinIdx++
- ) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- continue;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- continue;
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
-
- if (!denom) {
- logger.error("db inconsistent: denom for planchet not found");
- continue;
- }
-
- const planchetReq: ExchangeWithdrawRequest = {
- denom_pub_hash: planchet.denomPubHash,
- reserve_sig: planchet.withdrawSig,
- coin_ev: planchet.coinEv,
- };
- batchReq.planchets.push(planchetReq);
- requestCoinIdxs.push(coinIdx);
- }
- });
-
- if (batchReq.planchets.length == 0) {
- logger.warn("empty withdrawal batch");
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
-
- async function storeCoinError(e: any, coinIdx: number): Promise<void> {
- const errDetail = getErrorDetailFromException(e);
- logger.trace("withdrawal request failed", e);
- logger.trace(String(e));
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = errDetail;
- await tx.planchets.put(planchet);
- });
- }
-
- // FIXME: handle individual error codes better!
-
- const reqUrl = new URL(
- `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
- withdrawalGroup.exchangeBaseUrl,
- ).href;
-
- try {
- const resp = await ws.http.fetch(reqUrl, {
- method: "POST",
- body: batchReq,
- });
- if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
- await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
- const r = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeWithdrawBatchResponse(),
- );
- return {
- coinIdxs: requestCoinIdxs,
- batchResp: r,
- };
- } catch (e) {
- await storeCoinError(e, requestCoinIdxs[0]);
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
-}
-
-async function processPlanchetVerifyAndStoreCoin(
- ws: InternalWalletState,
- wgContext: WithdrawalGroupContext,
- coinIdx: number,
- resp: ExchangeWithdrawResponse,
-): Promise<void> {
- const withdrawalGroup = wgContext.wgRecord;
- logger.trace(`checking and storing planchet idx=${coinIdx}`);
- const d = await ws.db.runReadOnlyTx(
- ["planchets", "denominations"],
- async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
- if (!denomInfo) {
- return;
- }
- return {
- planchet,
- denomInfo,
- exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
- };
- },
- );
-
- if (!d) {
- return;
- }
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
- });
-
- const { planchet, denomInfo } = d;
-
- const planchetDenomPub = denomInfo.denomPub;
- if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
- throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
- }
-
- let evSig = resp.ev_sig;
- if (!(evSig.cipher === DenomKeyType.Rsa)) {
- throw Error("unsupported cipher");
- }
-
- const denomSigRsa = await ws.cryptoApi.rsaUnblind({
- bk: planchet.blindingKey,
- blindedSig: evSig.blinded_rsa_signature,
- pk: planchetDenomPub.rsa_public_key,
- });
-
- const isValid = await ws.cryptoApi.rsaVerify({
- hm: planchet.coinPub,
- pk: planchetDenomPub.rsa_public_key,
- sig: denomSigRsa.sig,
- });
-
- if (!isValid) {
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = makeErrorDetail(
- TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
- {},
- "invalid signature from the exchange after unblinding",
- );
- await tx.planchets.put(planchet);
- });
- return;
- }
-
- let denomSig: UnblindedSignature;
- if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
- denomSig = {
- cipher: planchetDenomPub.cipher,
- rsa_signature: denomSigRsa.sig,
- };
- } else {
- throw Error("unsupported cipher");
- }
-
- const coin: CoinRecord = {
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- denomPubHash: planchet.denomPubHash,
- denomSig,
- coinEvHash: planchet.coinEvHash,
- exchangeBaseUrl: d.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Withdraw,
- coinIndex: coinIdx,
- reservePub: withdrawalGroup.reservePub,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- },
- sourceTransactionId: transactionId,
- maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
- ageCommitmentProof: planchet.ageCommitmentProof,
- spendAllocation: undefined,
- };
-
- const planchetCoinPub = planchet.coinPub;
-
- wgContext.planchetsFinished.add(planchet.coinPub);
-
- await ws.db.runReadWriteTx(
- ["planchets", "coins", "coinAvailability", "denominations"],
- async (tx) => {
- const p = await tx.planchets.get(planchetCoinPub);
- if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
- return;
- }
- p.planchetStatus = PlanchetStatus.WithdrawalDone;
- p.lastError = undefined;
- await tx.planchets.put(p);
- await makeCoinAvailable(ws, tx, coin);
- },
- );
-}
-
-/**
- * Make sure that denominations that currently can be used for withdrawal
- * are validated, and the result of validation is stored in the database.
- */
-async function updateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- logger.trace(
- `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
- );
- const exchangeDetails = await ws.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails"],
- async (tx) => {
- return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
- },
- );
- if (!exchangeDetails) {
- logger.error("exchange details not available");
- throw Error(`exchange ${exchangeBaseUrl} details not available`);
- }
- // First do a pass where the validity of candidate denominations
- // is checked and the result is stored in the database.
- logger.trace("getting candidate denominations");
- const denominations = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- exchangeDetails.currency,
- );
- logger.trace(`got ${denominations.length} candidate denominations`);
- const batchSize = 500;
- let current = 0;
-
- while (current < denominations.length) {
- const updatedDenominations: DenominationRecord[] = [];
- // Do a batch of batchSize
- for (
- let batchIdx = 0;
- batchIdx < batchSize && current < denominations.length;
- batchIdx++, current++
- ) {
- const denom = denominations[current];
- if (
- denom.verificationStatus === DenominationVerificationStatus.Unverified
- ) {
- logger.trace(
- `Validating denomination (${current + 1}/${
- denominations.length
- }) signature of ${denom.denomPubHash}`,
- );
- let valid = false;
- if (ws.config.testing.insecureTrustExchange) {
- valid = true;
- } else {
- const res = await ws.cryptoApi.isValidDenom({
- denom,
- masterPub: exchangeDetails.masterPublicKey,
- });
- valid = res.valid;
- }
- logger.trace(`Done validating ${denom.denomPubHash}`);
- if (!valid) {
- logger.warn(
- `Signature check for denomination h=${denom.denomPubHash} failed`,
- );
- denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
- } else {
- denom.verificationStatus =
- DenominationVerificationStatus.VerifiedGood;
- }
- updatedDenominations.push(denom);
- }
- }
- if (updatedDenominations.length > 0) {
- logger.trace("writing denomination batch to db");
- await ws.db.runReadWriteTx(["denominations"], async (tx) => {
- for (let i = 0; i < updatedDenominations.length; i++) {
- const denom = updatedDenominations[i];
- await tx.denominations.put(denom);
- }
- });
- logger.trace("done with DB write");
- }
- }
-}
-
-/**
- * Update the information about a reserve that is stored in the wallet
- * by querying the reserve's exchange.
- *
- * If the reserve have funds that are not allocated in a withdrawal group yet
- * and are big enough to withdraw with available denominations,
- * create a new withdrawal group for the remaining amount.
- */
-async function queryReserve(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- checkDbInvariant(!!withdrawalGroup);
- if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
- return TaskRunResult.backoff();
- }
- const reservePub = withdrawalGroup.reservePub;
-
- const reserveUrl = new URL(
- `reserves/${reservePub}`,
- withdrawalGroup.exchangeBaseUrl,
- );
- reserveUrl.searchParams.set("timeout_ms", "30000");
-
- logger.trace(`querying reserve status via ${reserveUrl.href}`);
-
- const resp = await ws.http.fetch(reserveUrl.href, {
- timeout: getReserveRequestTimeout(withdrawalGroup),
- cancellationToken,
- });
-
- logger.trace(`reserve status code: HTTP ${resp.status}`);
-
- const result = await readSuccessResponseJsonOrErrorCode(
- resp,
- codecForReserveStatus(),
- );
-
- if (result.isError) {
- logger.trace(
- `got reserve status error, EC=${result.talerErrorResponse.code}`,
- );
- if (resp.status === HttpStatusCode.NotFound) {
- return TaskRunResult.backoff();
- } else {
- throwUnexpectedRequestError(resp, result.talerErrorResponse);
- }
- }
-
- logger.trace(`got reserve status ${j2s(result.response)}`);
-
- const transitionResult = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return undefined;
- }
- const txStateOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.PendingReady;
- const txStateNew = computeWithdrawalTransactionStatus(wg);
- wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStateOld,
- newTxState: txStateNew,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionResult);
-
- return TaskRunResult.backoff();
-}
-
-/**
- * Withdrawal context that is kept in-memory.
- *
- * Used to store some cached info during a withdrawal operation.
- */
-export interface WithdrawalGroupContext {
- numPlanchets: number;
- planchetsFinished: Set<string>;
-
- /**
- * Cached withdrawal group record from the database.
- */
- wgRecord: WithdrawalGroupRecord;
-}
-
-async function processWithdrawalGroupAbortingBank(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const { withdrawalGroupId } = withdrawalGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- const wgInfo = withdrawalGroup.wgInfo;
- if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
- throw Error("invalid state (aborting(bank) without bank info");
- }
- const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
- logger.info(`aborting withdrawal at ${abortUrl}`);
- const abortResp = await ws.http.fetch(abortUrl, {
- method: "POST",
- body: {},
- });
- logger.info(`abort response status: ${abortResp.status}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return undefined;
- }
- const txStatusOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.AbortedBank;
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- const txStatusNew = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStatusOld,
- newTxState: txStatusNew,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
-}
-
-/**
- * Store in the database that the KYC for a withdrawal is now
- * satisfied.
- */
-async function transitionKycSatisfied(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg2 = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingKyc: {
- delete wg2.kycPending;
- delete wg2.kycUrl;
- wg2.status = WithdrawalGroupStatus.PendingReady;
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processWithdrawalGroupPendingKyc(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const userType = "individual";
- const kycInfo = withdrawalGroup.kycPending;
- if (!kycInfo) {
- throw Error("no kyc info available in pending(kyc)");
- }
- const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- url.searchParams.set("timeout_ms", "30000");
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
-
- logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`);
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- await transitionKycSatisfied(ws, withdrawalGroup);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const kycUrl = kycStatus.kyc_url;
- if (typeof kycUrl === "string") {
- await transitionKycUrlUpdate(ws, withdrawalGroupId, kycUrl);
- }
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-async function processWithdrawalGroupPendingReady(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const { withdrawalGroupId } = withdrawalGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- await fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl);
-
- if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
- logger.warn("Finishing empty withdrawal group (no denoms)");
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return undefined;
- }
- const txStatusOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.Done;
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- const txStatusNew = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStatusOld,
- newTxState: txStatusNew,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
- .map((x) => x.count)
- .reduce((a, b) => a + b);
-
- const wgContext: WithdrawalGroupContext = {
- numPlanchets: numTotalCoins,
- planchetsFinished: new Set<string>(),
- wgRecord: withdrawalGroup,
- };
-
- await ws.db.runReadOnlyTx(["planchets"], async (tx) => {
- const planchets =
- await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
- for (const p of planchets) {
- if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
- wgContext.planchetsFinished.add(p.coinPub);
- }
- }
- });
-
- // We sequentially generate planchets, so that
- // large withdrawal groups don't make the wallet unresponsive.
- for (let i = 0; i < numTotalCoins; i++) {
- await processPlanchetGenerate(ws, withdrawalGroup, i);
- }
-
- const maxBatchSize = 100;
-
- for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
- const resp = await processPlanchetExchangeBatchRequest(ws, wgContext, {
- batchSize: maxBatchSize,
- coinStartIndex: i,
- });
- let work: Promise<void>[] = [];
- work = [];
- for (let j = 0; j < resp.coinIdxs.length; j++) {
- if (!resp.batchResp.ev_sigs[j]) {
- // response may not be available when there is kyc needed
- continue;
- }
- work.push(
- processPlanchetVerifyAndStoreCoin(
- ws,
- wgContext,
- resp.coinIdxs[j],
- resp.batchResp.ev_sigs[j],
- ),
- );
- }
- await Promise.all(work);
- }
-
- let numFinished = 0;
- const errorsPerCoin: Record<number, TalerErrorDetail> = {};
- let numPlanchetErrors = 0;
- const maxReportedErrors = 5;
-
- const res = await ws.db.runReadWriteTx(
- ["coins", "coinAvailability", "withdrawalGroups", "planchets"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return;
- }
-
- await tx.planchets.indexes.byGroup
- .iter(withdrawalGroupId)
- .forEach((x) => {
- if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
- numFinished++;
- }
- if (x.lastError) {
- numPlanchetErrors++;
- if (numPlanchetErrors < maxReportedErrors) {
- errorsPerCoin[x.coinIdx] = x.lastError;
- }
- }
- });
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
- if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- wg.status = WithdrawalGroupStatus.Done;
- await makeCoinsVisible(ws, tx, transactionId);
- }
-
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
-
- return {
- kycInfo: wg.kycPending,
- transitionInfo: {
- oldTxState,
- newTxState,
- },
- };
- },
- );
-
- if (!res) {
- throw Error("withdrawal group does not exist anymore");
- }
-
- notifyTransition(ws, transactionId, res.transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- if (numPlanchetErrors > 0) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
- {
- errorsPerCoin,
- numErrors: numPlanchetErrors,
- },
- ),
- };
- }
-
- return TaskRunResult.backoff();
-}
-
-export async function processWithdrawalGroup(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace("processing withdrawal group", withdrawalGroupId);
- const withdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return tx.withdrawalGroups.get(withdrawalGroupId);
- },
- );
-
- if (!withdrawalGroup) {
- throw Error(`withdrawal group ${withdrawalGroupId} not found`);
- }
-
- switch (withdrawalGroup.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- await processReserveBankStatus(ws, withdrawalGroupId);
- // FIXME: This will get called by the main task loop, why call it here?!
- return await processWithdrawalGroup(
- ws,
- withdrawalGroupId,
- cancellationToken,
- );
- case WithdrawalGroupStatus.PendingQueryingStatus: {
- return queryReserve(ws, withdrawalGroupId, cancellationToken);
- }
- case WithdrawalGroupStatus.PendingWaitConfirmBank: {
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
- case WithdrawalGroupStatus.PendingAml:
- // FIXME: Handle this case, withdrawal doesn't support AML yet.
- return TaskRunResult.backoff();
- case WithdrawalGroupStatus.PendingKyc:
- return processWithdrawalGroupPendingKyc(
- ws,
- withdrawalGroup,
- cancellationToken,
- );
- case WithdrawalGroupStatus.PendingReady:
- // Continue with the actual withdrawal!
- return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
- case WithdrawalGroupStatus.AbortingBank:
- return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup);
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.FailedAbortingBank:
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.SuspendedAml:
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedBankAborted:
- // Nothing to do.
- return TaskRunResult.finished();
- default:
- assertUnreachable(withdrawalGroup.status);
- }
-}
-
-const AGE_MASK_GROUPS = "8:10:12:14:16:18"
- .split(":")
- .map((n) => parseInt(n, 10));
-
-export async function getExchangeWithdrawalInfo(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- instructedAmount: AmountJson,
- ageRestricted: number | undefined,
-): Promise<ExchangeWithdrawalDetails> {
- logger.trace("updating exchange");
- const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- if (exchange.currency != instructedAmount.currency) {
- // Specifying the amount in the conversion input currency is not yet supported.
- // We might add support for it later.
- throw new Error(
- `withdrawal only supported when specifying target currency ${exchange.currency}`,
- );
- }
-
- const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount,
- });
-
- logger.trace("updating withdrawal denoms");
- await updateWithdrawalDenoms(ws, exchangeBaseUrl);
-
- logger.trace("getting candidate denoms");
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- instructedAmount.currency,
- );
- logger.trace("selecting withdrawal denoms");
- const selectedDenoms = selectWithdrawalDenominations(
- instructedAmount,
- denoms,
- ws.config.testing.denomselAllowLate,
- );
-
- logger.trace("selection done");
-
- if (selectedDenoms.selectedDenoms.length === 0) {
- throw Error(
- `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
- instructedAmount,
- )}`,
- );
- }
-
- const exchangeWireAccounts: string[] = [];
-
- for (const account of exchange.wireInfo.accounts) {
- exchangeWireAccounts.push(account.payto_uri);
- }
-
- let hasDenomWithAgeRestriction = false;
-
- logger.trace("computing earliest deposit expiration");
-
- let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
- for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
- const ds = selectedDenoms.selectedDenoms[i];
- // FIXME: Do in one transaction!
- const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
- });
- checkDbInvariant(!!denom);
- hasDenomWithAgeRestriction =
- hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
- const expireDeposit = denom.stampExpireDeposit;
- if (!earliestDepositExpiration) {
- earliestDepositExpiration = expireDeposit;
- continue;
- }
- if (
- AbsoluteTime.cmp(
- AbsoluteTime.fromProtocolTimestamp(expireDeposit),
- AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration),
- ) < 0
- ) {
- earliestDepositExpiration = expireDeposit;
- }
- }
-
- checkLogicInvariant(!!earliestDepositExpiration);
-
- const possibleDenoms = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- instructedAmount.currency,
- );
-
- let versionMatch;
- if (exchange.protocolVersionRange) {
- versionMatch = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- exchange.protocolVersionRange,
- );
-
- if (
- versionMatch &&
- !versionMatch.compatible &&
- versionMatch.currentCmp === -1
- ) {
- logger.warn(
- `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
- );
- }
- }
-
- let tosAccepted = false;
- if (exchange.tosAcceptedTimestamp) {
- if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
- tosAccepted = true;
- }
- }
-
- const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
- if (!paytoUris) {
- throw Error("exchange is in invalid state");
- }
-
- const ret: ExchangeWithdrawalDetails = {
- earliestDepositExpiration,
- exchangePaytoUris: paytoUris,
- exchangeWireAccounts,
- exchangeCreditAccountDetails: withdrawalAccountsList,
- exchangeVersion: exchange.protocolVersionRange || "unknown",
- numOfferedDenoms: possibleDenoms.length,
- selectedDenoms,
- // FIXME: delete this field / replace by something we can display to the user
- trustedAuditorPubs: [],
- versionMatch,
- walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- termsOfServiceAccepted: tosAccepted,
- withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
- withdrawalAmountRaw: Amounts.stringify(instructedAmount),
- // TODO: remove hardcoding, this should be calculated from the denominations info
- // force enabled for testing
- ageRestrictionOptions: hasDenomWithAgeRestriction
- ? AGE_MASK_GROUPS
- : undefined,
- scopeInfo: exchange.scopeInfo,
- };
- return ret;
-}
-
-export interface GetWithdrawalDetailsForUriOpts {
- restrictAge?: number;
- notifyChangeFromPendingTimeoutMs?: number;
-}
-
-type WithdrawalOperationMemoryMap = {
- [uri: string]: boolean | undefined;
-};
-const ongoingChecks: WithdrawalOperationMemoryMap = {};
-/**
- * Get more information about a taler://withdraw URI.
- *
- * As side effects, the bank (via the bank integration API) is queried
- * and the exchange suggested by the bank is ephemerally added
- * to the wallet's list of known exchanges.
- */
-export async function getWithdrawalDetailsForUri(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- opts: GetWithdrawalDetailsForUriOpts = {},
-): Promise<WithdrawUriInfoResponse> {
- logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
- const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
- logger.trace(`got bank info`);
- if (info.suggestedExchange) {
- try {
- // If the exchange entry doesn't exist yet,
- // it'll be created as an ephemeral entry.
- await fetchFreshExchange(ws, info.suggestedExchange);
- } catch (e) {
- // We still continued if it failed, as other exchanges might be available.
- // We don't want to fail if the bank-suggested exchange is broken/offline.
- logger.trace(
- `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
- );
- }
- }
-
- const currency = Amounts.currencyOf(info.amount);
-
- const listExchangesResp = await listExchanges(ws);
- const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
- return (
- x.currency === currency &&
- (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
- x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
- );
- });
-
- // FIXME: this should be removed after the extended version of
- // withdrawal state machine. issue #8099
- if (
- info.status === "pending" &&
- opts.notifyChangeFromPendingTimeoutMs !== undefined &&
- !ongoingChecks[talerWithdrawUri]
- ) {
- ongoingChecks[talerWithdrawUri] = true;
- const bankApi = new TalerBankIntegrationHttpClient(
- info.apiBaseUrl,
- ws.http,
- );
- console.log(
- `waiting operation (${info.operationId}) to change from pending`,
- );
- bankApi
- .getWithdrawalOperationById(info.operationId, {
- old_state: "pending",
- timeoutMs: opts.notifyChangeFromPendingTimeoutMs,
- })
- .then((resp) => {
- console.log(
- `operation (${info.operationId}) to change to ${JSON.stringify(
- resp,
- undefined,
- 2,
- )}`,
- );
- ws.notify({
- type: NotificationType.WithdrawalOperationTransition,
- operationId: info.operationId,
- state: resp.type === "fail" ? info.status : resp.body.status,
- });
- ongoingChecks[talerWithdrawUri] = false;
- });
- }
-
- return {
- operationId: info.operationId,
- confirmTransferUrl: info.confirmTransferUrl,
- status: info.status,
- amount: Amounts.stringify(info.amount),
- defaultExchangeBaseUrl: info.suggestedExchange,
- possibleExchanges,
- };
-}
-
-export function augmentPaytoUrisForWithdrawal(
- plainPaytoUris: string[],
- reservePub: string,
- instructedAmount: AmountLike,
-): string[] {
- return plainPaytoUris.map((x) =>
- addPaytoQueryParams(x, {
- amount: Amounts.stringify(instructedAmount),
- message: `Taler Withdrawal ${reservePub}`,
- }),
- );
-}
-
-/**
- * Get payto URIs that can be used to fund a withdrawal operation.
- */
-export async function getFundingPaytoUris(
- tx: WalletDbReadOnlyTransaction<
- ["withdrawalGroups", "exchanges", "exchangeDetails"]
- >,
- withdrawalGroupId: string,
-): Promise<string[]> {
- const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
- checkDbInvariant(!!withdrawalGroup);
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- withdrawalGroup.exchangeBaseUrl,
- );
- if (!exchangeDetails) {
- logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
- return [];
- }
- const plainPaytoUris =
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
- if (!plainPaytoUris) {
- logger.error(
- `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
- );
- return [];
- }
- return augmentPaytoUrisForWithdrawal(
- plainPaytoUris,
- withdrawalGroup.reservePub,
- withdrawalGroup.instructedAmount,
- );
-}
-
-async function getWithdrawalGroupRecordTx(
- db: DbAccess<typeof WalletStoresV1>,
- req: {
- withdrawalGroupId: string;
- },
-): Promise<WithdrawalGroupRecord | undefined> {
- return await db.runReadOnlyTx(["withdrawalGroups"], async (tx) => {
- return tx.withdrawalGroups.get(req.withdrawalGroupId);
- });
-}
-
-export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
- return { d_ms: 60000 };
-}
-
-export function getBankStatusUrl(talerWithdrawUri: string): string {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
- }
- const url = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- return url.href;
-}
-
-export function getBankAbortUrl(talerWithdrawUri: string): string {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
- }
- const url = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- return url.href;
-}
-
-async function registerReserveWithBank(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<void> {
- const withdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return await tx.withdrawalGroups.get(withdrawalGroupId);
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- switch (withdrawalGroup?.status) {
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- break;
- default:
- return;
- }
- if (
- withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
- ) {
- throw Error("expecting withdrarwal type = bank integrated");
- }
- const bankInfo = withdrawalGroup.wgInfo.bankInfo;
- if (!bankInfo) {
- return;
- }
- const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
- const reqBody = {
- reserve_pub: withdrawalGroup.reservePub,
- selected_exchange: bankInfo.exchangePaytoUri,
- };
- logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
- const httpResp = await ws.http.fetch(bankStatusUrl, {
- method: "POST",
- body: reqBody,
- timeout: getReserveRequestTimeout(withdrawalGroup),
- });
- // FIXME: libeufin-bank currently doesn't return a response in the right format, so we don't validate at all.
- await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return undefined;
- }
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
- );
- const oldTxState = computeWithdrawalTransactionStatus(r);
- r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processReserveBankStatus(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<TaskRunResult> {
- const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- switch (withdrawalGroup?.status) {
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- break;
- default:
- return TaskRunResult.backoff();
- }
-
- if (
- withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
- ) {
- throw Error("wrong withdrawal record type");
- }
- const bankInfo = withdrawalGroup.wgInfo.bankInfo;
- if (!bankInfo) {
- return TaskRunResult.backoff();
- }
-
- const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
-
- const statusResp = await ws.http.fetch(bankStatusUrl, {
- timeout: getReserveRequestTimeout(withdrawalGroup),
- });
- const status = await readSuccessResponseJsonOrThrow(
- statusResp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- if (status.aborted) {
- logger.info("bank aborted the withdrawal");
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return;
- }
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const oldTxState = computeWithdrawalTransactionStatus(r);
- r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
- r.status = WithdrawalGroupStatus.FailedBankAborted;
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- // Bank still needs to know our reserve info
- if (!status.selection_done) {
- await registerReserveWithBank(ws, withdrawalGroupId);
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
-
- // FIXME: Why do we do this?!
- if (withdrawalGroup.status === WithdrawalGroupStatus.PendingRegisteringBank) {
- await registerReserveWithBank(ws, withdrawalGroupId);
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return undefined;
- }
- // Re-check reserve status within transaction
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return undefined;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- const oldTxState = computeWithdrawalTransactionStatus(r);
- if (status.transfer_done) {
- logger.info("withdrawal: transfer confirmed by bank.");
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
- r.status = WithdrawalGroupStatus.PendingQueryingStatus;
- } else {
- logger.trace("withdrawal: transfer not yet confirmed by bank");
- r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
- r.senderWire = status.sender_wire;
- }
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- if (transitionInfo) {
- return TaskRunResult.progress();
- } else {
- return TaskRunResult.backoff();
- }
-}
-
-export interface PrepareCreateWithdrawalGroupResult {
- withdrawalGroup: WithdrawalGroupRecord;
- transactionId: string;
- creationInfo?: {
- amount: AmountJson;
- canonExchange: string;
- };
-}
-
-export async function internalPrepareCreateWithdrawalGroup(
- ws: InternalWalletState,
- args: {
- reserveStatus: WithdrawalGroupStatus;
- amount: AmountJson;
- exchangeBaseUrl: string;
- forcedWithdrawalGroupId?: string;
- forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
- restrictAge?: number;
- wgInfo: WgInfo;
- },
-): Promise<PrepareCreateWithdrawalGroupResult> {
- const reserveKeyPair =
- args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const secretSeed = encodeCrock(getRandomBytes(32));
- const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
- const amount = args.amount;
- const currency = Amounts.currencyOf(amount);
-
- let withdrawalGroupId;
-
- if (args.forcedWithdrawalGroupId) {
- withdrawalGroupId = args.forcedWithdrawalGroupId;
- const wgId = withdrawalGroupId;
- const existingWg = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return tx.withdrawalGroups.get(wgId);
- },
- );
-
- if (existingWg) {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: existingWg.withdrawalGroupId,
- });
- return { withdrawalGroup: existingWg, transactionId };
- }
- } else {
- withdrawalGroupId = encodeCrock(getRandomBytes(32));
- }
-
- await updateWithdrawalDenoms(ws, canonExchange);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- canonExchange,
- currency,
- );
-
- let initialDenomSel: DenomSelectionState;
- const denomSelUid = encodeCrock(getRandomBytes(16));
- if (args.forcedDenomSel) {
- logger.warn("using forced denom selection");
- initialDenomSel = selectForcedWithdrawalDenominations(
- amount,
- denoms,
- args.forcedDenomSel,
- ws.config.testing.denomselAllowLate,
- );
- } else {
- initialDenomSel = selectWithdrawalDenominations(
- amount,
- denoms,
- ws.config.testing.denomselAllowLate,
- );
- }
-
- const withdrawalGroup: WithdrawalGroupRecord = {
- denomSelUid,
- denomsSel: initialDenomSel,
- exchangeBaseUrl: canonExchange,
- instructedAmount: Amounts.stringify(amount),
- timestampStart: timestampPreciseToDb(now),
- rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
- effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
- secretSeed,
- reservePriv: reserveKeyPair.priv,
- reservePub: reserveKeyPair.pub,
- status: args.reserveStatus,
- withdrawalGroupId,
- restrictAge: args.restrictAge,
- senderWire: undefined,
- timestampFinish: undefined,
- wgInfo: args.wgInfo,
- };
-
- await fetchFreshExchange(ws, canonExchange);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- });
-
- return {
- withdrawalGroup,
- transactionId,
- creationInfo: {
- canonExchange,
- amount,
- },
- };
-}
-
-export interface PerformCreateWithdrawalGroupResult {
- withdrawalGroup: WithdrawalGroupRecord;
- transitionInfo: TransitionInfo | undefined;
-
- /**
- * Notification for the exchange state transition.
- *
- * Should be emitted after the transaction has succeeded.
- */
- exchangeNotif: WalletNotification | undefined;
-}
-
-export async function internalPerformCreateWithdrawalGroup(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["withdrawalGroups", "reserves", "exchanges"]
- >,
- prep: PrepareCreateWithdrawalGroupResult,
-): Promise<PerformCreateWithdrawalGroupResult> {
- const { withdrawalGroup } = prep;
- if (!prep.creationInfo) {
- return {
- withdrawalGroup,
- transitionInfo: undefined,
- exchangeNotif: undefined,
- };
- }
- const existingWg = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (existingWg) {
- return {
- withdrawalGroup: existingWg,
- exchangeNotif: undefined,
- transitionInfo: undefined,
- };
- }
- await tx.withdrawalGroups.add(withdrawalGroup);
- await tx.reserves.put({
- reservePub: withdrawalGroup.reservePub,
- reservePriv: withdrawalGroup.reservePriv,
- });
-
- const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
- if (exchange) {
- exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
- await tx.exchanges.put(exchange);
- }
-
- const oldTxState = {
- major: TransactionMajorState.None,
- minor: undefined,
- };
- const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
- const transitionInfo = {
- oldTxState,
- newTxState,
- };
-
- const exchangeUsedRes = await markExchangeUsed(
- ws,
- tx,
- prep.withdrawalGroup.exchangeBaseUrl,
- );
-
- const ctx = new WithdrawTransactionContext(
- ws,
- withdrawalGroup.withdrawalGroupId,
- );
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- withdrawalGroup,
- transitionInfo,
- exchangeNotif: exchangeUsedRes.notif,
- };
-}
-
-/**
- * Create a withdrawal group.
- *
- * If a forcedWithdrawalGroupId is given and a
- * withdrawal group with this ID already exists,
- * the existing one is returned. No conflict checking
- * of the other arguments is done in that case.
- */
-export async function internalCreateWithdrawalGroup(
- ws: InternalWalletState,
- args: {
- reserveStatus: WithdrawalGroupStatus;
- amount: AmountJson;
- exchangeBaseUrl: string;
- forcedWithdrawalGroupId?: string;
- forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
- restrictAge?: number;
- wgInfo: WgInfo;
- },
-): Promise<WithdrawalGroupRecord> {
- const prep = await internalPrepareCreateWithdrawalGroup(ws, args);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
- });
- const res = await ws.db.runReadWriteTx(
- ["withdrawalGroups", "reserves", "exchanges", "exchangeDetails"],
- async (tx) => {
- return await internalPerformCreateWithdrawalGroup(ws, tx, prep);
- },
- );
- if (res.exchangeNotif) {
- ws.notify(res.exchangeNotif);
- }
- notifyTransition(ws, transactionId, res.transitionInfo);
- return res.withdrawalGroup;
-}
-
-export async function acceptWithdrawalFromUri(
- ws: InternalWalletState,
- req: {
- talerWithdrawUri: string;
- selectedExchange: string;
- forcedDenomSel?: ForcedDenomSel;
- restrictAge?: number;
- },
-): Promise<AcceptWithdrawalResponse> {
- const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
- logger.info(
- `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
- );
- const existingWithdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
- req.talerWithdrawUri,
- );
- },
- );
-
- if (existingWithdrawalGroup) {
- let url: string | undefined;
- if (
- existingWithdrawalGroup.wgInfo.withdrawalType ===
- WithdrawalRecordType.BankIntegrated
- ) {
- url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
- }
- return {
- reservePub: existingWithdrawalGroup.reservePub,
- confirmTransferUrl: url,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
- }),
- };
- }
-
- await fetchFreshExchange(ws, selectedExchange);
- const withdrawInfo = await getBankWithdrawalInfo(
- ws.http,
- req.talerWithdrawUri,
- );
- const exchangePaytoUri = await getExchangePaytoUri(
- ws,
- selectedExchange,
- withdrawInfo.wireTypes,
- );
-
- const exchange = await fetchFreshExchange(ws, selectedExchange);
-
- const withdrawalAccountList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount: withdrawInfo.amount,
- });
-
- const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
- amount: withdrawInfo.amount,
- exchangeBaseUrl: req.selectedExchange,
- wgInfo: {
- withdrawalType: WithdrawalRecordType.BankIntegrated,
- exchangeCreditAccounts: withdrawalAccountList,
- bankInfo: {
- exchangePaytoUri,
- talerWithdrawUri: req.talerWithdrawUri,
- confirmUrl: withdrawInfo.confirmTransferUrl,
- timestampBankConfirmed: undefined,
- timestampReserveInfoPosted: undefined,
- },
- },
- restrictAge: req.restrictAge,
- forcedDenomSel: req.forcedDenomSel,
- reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
- });
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
-
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
-
- const transactionId = ctx.transactionId;
-
- // We do this here, as the reserve should be registered before we return,
- // so that we can redirect the user to the bank's status page.
- await processReserveBankStatus(ws, withdrawalGroupId);
- const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- if (
- processedWithdrawalGroup?.status === WithdrawalGroupStatus.FailedBankAborted
- ) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
- {},
- );
- }
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- reservePub: withdrawalGroup.reservePub,
- confirmTransferUrl: withdrawInfo.confirmTransferUrl,
- transactionId,
- };
-}
-
-async function fetchAccount(
- ws: InternalWalletState,
- instructedAmount: AmountJson,
- acct: ExchangeWireAccount,
- reservePub?: string,
-): Promise<WithdrawalExchangeAccountDetails> {
- let paytoUri: string;
- let transferAmount: AmountString | undefined = undefined;
- let currencySpecification: CurrencySpecification | undefined = undefined;
- if (acct.conversion_url != null) {
- const reqUrl = new URL("cashin-rate", acct.conversion_url);
- reqUrl.searchParams.set(
- "amount_credit",
- Amounts.stringify(instructedAmount),
- );
- const httpResp = await ws.http.fetch(reqUrl.href);
- const respOrErr = await readSuccessResponseJsonOrErrorCode(
- httpResp,
- codecForCashinConversionResponse(),
- );
- if (respOrErr.isError) {
- return {
- status: "error",
- paytoUri: acct.payto_uri,
- conversionError: respOrErr.talerErrorResponse,
- };
- }
- const resp = respOrErr.response;
- paytoUri = acct.payto_uri;
- transferAmount = resp.amount_debit;
- const configUrl = new URL("config", acct.conversion_url);
- const configResp = await ws.http.fetch(configUrl.href);
- const configRespOrError = await readSuccessResponseJsonOrErrorCode(
- configResp,
- codecForConversionBankConfig(),
- );
- if (configRespOrError.isError) {
- return {
- status: "error",
- paytoUri: acct.payto_uri,
- conversionError: configRespOrError.talerErrorResponse,
- };
- }
- const configParsed = configRespOrError.response;
- currencySpecification = configParsed.fiat_currency_specification;
- } else {
- paytoUri = acct.payto_uri;
- transferAmount = Amounts.stringify(instructedAmount);
- }
- paytoUri = addPaytoQueryParams(paytoUri, {
- amount: Amounts.stringify(transferAmount),
- });
- if (reservePub != null) {
- paytoUri = addPaytoQueryParams(paytoUri, {
- message: `Taler Withdrawal ${reservePub}`,
- });
- }
- const acctInfo: WithdrawalExchangeAccountDetails = {
- status: "ok",
- paytoUri,
- transferAmount,
- currencySpecification,
- creditRestrictions: acct.credit_restrictions,
- };
- if (transferAmount != null) {
- acctInfo.transferAmount = transferAmount;
- }
- return acctInfo;
-}
-
-/**
- * Gather information about bank accounts that can be used for
- * withdrawals. This includes accounts that are in a different
- * currency and require conversion.
- */
-async function fetchWithdrawalAccountInfo(
- ws: InternalWalletState,
- req: {
- exchange: ReadyExchangeSummary;
- instructedAmount: AmountJson;
- reservePub?: string;
- },
-): Promise<WithdrawalExchangeAccountDetails[]> {
- const { exchange, instructedAmount } = req;
- const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
- for (let acct of exchange.wireInfo.accounts) {
- const acctInfo = await fetchAccount(
- ws,
- req.instructedAmount,
- acct,
- req.reservePub,
- );
- withdrawalAccounts.push(acctInfo);
- }
- return withdrawalAccounts;
-}
-
-/**
- * Create a manual withdrawal operation.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- *
- * Asynchronously starts the withdrawal.
- */
-export async function createManualWithdrawal(
- ws: InternalWalletState,
- req: {
- exchangeBaseUrl: string;
- amount: AmountLike;
- restrictAge?: number;
- forcedDenomSel?: ForcedDenomSel;
- },
-): Promise<AcceptManualWithdrawalResult> {
- const { exchangeBaseUrl } = req;
- const amount = Amounts.parseOrThrow(req.amount);
- const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- if (exchange.currency != amount.currency) {
- throw Error(
- "manual withdrawal with conversion from foreign currency is not yet supported",
- );
- }
- const reserveKeyPair: EddsaKeypair = await ws.cryptoApi.createEddsaKeypair(
- {},
- );
-
- const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount: amount,
- reservePub: reserveKeyPair.pub,
- });
-
- const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
- amount: Amounts.jsonifyAmount(req.amount),
- wgInfo: {
- withdrawalType: WithdrawalRecordType.BankManual,
- exchangeCreditAccounts: withdrawalAccountsList,
- },
- exchangeBaseUrl: req.exchangeBaseUrl,
- forcedDenomSel: req.forcedDenomSel,
- restrictAge: req.restrictAge,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair,
- });
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
-
- const transactionId = ctx.transactionId;
-
- const exchangePaytoUris = await ws.db.runReadOnlyTx(
- ["withdrawalGroups", "exchanges", "exchangeDetails"],
- async (tx) => {
- return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
- },
- );
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- reservePub: withdrawalGroup.reservePub,
- exchangePaytoUris: exchangePaytoUris,
- withdrawalAccountsList: withdrawalAccountsList,
- transactionId,
- };
-}