commit e951075d2ef52fa8e9e7489c62031777c3a7e66b
parent e975740ac4e9ba4bc531226784d640a018c00833
Author: Florian Dold <florian@dold.me>
Date: Mon, 19 Feb 2024 18:05:48 +0100
wallet-core: flatten directory structure
Diffstat:
52 files changed, 22344 insertions(+), 22344 deletions(-)
diff --git a/packages/taler-wallet-core/src/attention.ts b/packages/taler-wallet-core/src/attention.ts
@@ -0,0 +1,133 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AttentionInfo,
+ Logger,
+ TalerPreciseTimestamp,
+ UserAttentionByIdRequest,
+ UserAttentionPriority,
+ UserAttentionUnreadList,
+ UserAttentionsCountResponse,
+ UserAttentionsRequest,
+ UserAttentionsResponse,
+} from "@gnu-taler/taler-util";
+import { timestampPreciseFromDb, timestampPreciseToDb } from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+
+const logger = new Logger("operations/attention.ts");
+
+export async function getUserAttentionsUnreadCount(
+ ws: InternalWalletState,
+ req: UserAttentionsRequest,
+): Promise<UserAttentionsCountResponse> {
+ const total = await ws.db.runReadOnlyTx(["userAttention"], async (tx) => {
+ let count = 0;
+ await tx.userAttention.iter().forEach((x) => {
+ if (
+ req.priority !== undefined &&
+ UserAttentionPriority[x.info.type] !== req.priority
+ )
+ return;
+ if (x.read !== undefined) return;
+ count++;
+ });
+
+ return count;
+ });
+
+ return { total };
+}
+
+export async function getUserAttentions(
+ ws: InternalWalletState,
+ req: UserAttentionsRequest,
+): Promise<UserAttentionsResponse> {
+ return await ws.db.runReadOnlyTx(["userAttention"], async (tx) => {
+ const pending: UserAttentionUnreadList = [];
+ await tx.userAttention.iter().forEach((x) => {
+ if (
+ req.priority !== undefined &&
+ UserAttentionPriority[x.info.type] !== req.priority
+ )
+ return;
+ pending.push({
+ info: x.info,
+ when: timestampPreciseFromDb(x.created),
+ read: x.read !== undefined,
+ });
+ });
+
+ return { pending };
+ });
+}
+
+export async function markAttentionRequestAsRead(
+ ws: InternalWalletState,
+ req: UserAttentionByIdRequest,
+): Promise<void> {
+ await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
+ const ua = await tx.userAttention.get([req.entityId, req.type]);
+ if (!ua) throw Error("attention request not found");
+ tx.userAttention.put({
+ ...ua,
+ read: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ });
+}
+
+/**
+ * the wallet need the user attention to complete a task
+ * internal API
+ *
+ * @param ws
+ * @param info
+ */
+export async function addAttentionRequest(
+ ws: InternalWalletState,
+ info: AttentionInfo,
+ entityId: string,
+): Promise<void> {
+ await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
+ await tx.userAttention.put({
+ info,
+ entityId,
+ created: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ read: undefined,
+ });
+ });
+}
+
+/**
+ * user completed the task, attention request is not needed
+ * internal API
+ *
+ * @param ws
+ * @param created
+ */
+export async function removeAttentionRequest(
+ ws: InternalWalletState,
+ req: UserAttentionByIdRequest,
+): Promise<void> {
+ await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
+ const ua = await tx.userAttention.get([req.entityId, req.type]);
+ if (!ua) throw Error("attention request not found");
+ await tx.userAttention.delete([req.entityId, req.type]);
+ });
+}
diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts
@@ -0,0 +1,1059 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of wallet backups (export/import/upload) and sync
+ * server management.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ AttentionType,
+ BackupRecovery,
+ Codec,
+ EddsaKeyPair,
+ HttpStatusCode,
+ Logger,
+ PreparePayResult,
+ RecoveryLoadRequest,
+ RecoveryMergeStrategy,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ URL,
+ buildCodecForObject,
+ buildCodecForUnion,
+ bytesToString,
+ canonicalJson,
+ canonicalizeBaseUrl,
+ codecForAmountString,
+ codecForBoolean,
+ codecForConstString,
+ codecForList,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ decodeCrock,
+ durationFromSpec,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ hash,
+ j2s,
+ kdf,
+ notEmpty,
+ secretbox,
+ secretbox_open,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import {
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { gunzipSync, gzipSync } from "fflate";
+import {
+ BackupProviderRecord,
+ BackupProviderState,
+ BackupProviderStateTag,
+ BackupProviderTerms,
+ ConfigRecord,
+ ConfigRecordKey,
+ WalletBackupConfState,
+ WalletDbReadOnlyTransaction,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseToDb,
+} from "../../db.js";
+import { InternalWalletState } from "../../internal-wallet-state.js";
+import {
+ checkDbInvariant,
+ checkLogicInvariant,
+} from "../../util/invariants.js";
+import { addAttentionRequest, removeAttentionRequest } from "../../attention.js";
+import {
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+} from "../../common.js";
+import { preparePayForUri } from "../../pay-merchant.js";
+
+const logger = new Logger("operations/backup.ts");
+
+function concatArrays(xs: Uint8Array[]): Uint8Array {
+ let len = 0;
+ for (const x of xs) {
+ len += x.byteLength;
+ }
+ const out = new Uint8Array(len);
+ let offset = 0;
+ for (const x of xs) {
+ out.set(x, offset);
+ offset += x.length;
+ }
+ return out;
+}
+
+const magic = "TLRWBK01";
+
+/**
+ * Encrypt the backup.
+ *
+ * Blob format:
+ * Magic "TLRWBK01" (8 bytes)
+ * Nonce (24 bytes)
+ * Compressed JSON blob (rest)
+ */
+export async function encryptBackup(
+ config: WalletBackupConfState,
+ blob: any,
+): Promise<Uint8Array> {
+ const chunks: Uint8Array[] = [];
+ chunks.push(stringToBytes(magic));
+ const nonceStr = config.lastBackupNonce;
+ checkLogicInvariant(!!nonceStr);
+ const nonce = decodeCrock(nonceStr).slice(0, 24);
+ chunks.push(nonce);
+ const backupJsonContent = canonicalJson(blob);
+ logger.trace("backup JSON size", backupJsonContent.length);
+ const compressedContent = gzipSync(stringToBytes(backupJsonContent), {
+ mtime: 0,
+ });
+ const secret = deriveBlobSecret(config);
+ const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
+ chunks.push(encrypted);
+ return concatArrays(chunks);
+}
+
+function deriveAccountKeyPair(
+ bc: WalletBackupConfState,
+ providerUrl: string,
+): EddsaKeyPair {
+ const privateKey = kdf(
+ 32,
+ decodeCrock(bc.walletRootPriv),
+ stringToBytes("taler-sync-account-key-salt"),
+ stringToBytes(providerUrl),
+ );
+ return {
+ eddsaPriv: privateKey,
+ eddsaPub: eddsaGetPublic(privateKey),
+ };
+}
+
+function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
+ return kdf(
+ 32,
+ decodeCrock(bc.walletRootPriv),
+ stringToBytes("taler-sync-blob-secret-salt"),
+ stringToBytes("taler-sync-blob-secret-info"),
+ );
+}
+
+interface BackupForProviderArgs {
+ backupProviderBaseUrl: string;
+}
+
+function getNextBackupTimestamp(): TalerPreciseTimestamp {
+ // FIXME: Randomize!
+ return AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ durationFromSpec({ minutes: 5 }),
+ ),
+ );
+}
+
+async function runBackupCycleForProvider(
+ ws: InternalWalletState,
+ args: BackupForProviderArgs,
+): Promise<TaskRunResult> {
+ const provider = await ws.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ return tx.backupProviders.get(args.backupProviderBaseUrl);
+ },
+ );
+
+ if (!provider) {
+ logger.warn("provider disappeared");
+ return TaskRunResult.finished();
+ }
+
+ //const backupJson = await exportBackup(ws);
+ // FIXME: re-implement backup
+ const backupJson = {};
+ const backupConfig = await provideBackupState(ws);
+ const encBackup = await encryptBackup(backupConfig, backupJson);
+ const currentBackupHash = hash(encBackup);
+
+ const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
+
+ const newHash = encodeCrock(currentBackupHash);
+ const oldHash = provider.lastBackupHash;
+
+ logger.trace(`trying to upload backup to ${provider.baseUrl}`);
+ logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
+
+ const syncSigResp = await ws.cryptoApi.makeSyncSignature({
+ newHash: encodeCrock(currentBackupHash),
+ oldHash: provider.lastBackupHash,
+ accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
+ });
+
+ logger.trace(`sync signature is ${syncSigResp}`);
+
+ const accountBackupUrl = new URL(
+ `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
+ provider.baseUrl,
+ );
+
+ if (provider.shouldRetryFreshProposal) {
+ accountBackupUrl.searchParams.set("fresh", "yes");
+ }
+
+ const resp = await ws.http.fetch(accountBackupUrl.href, {
+ method: "POST",
+ body: encBackup,
+ headers: {
+ "content-type": "application/octet-stream",
+ "sync-signature": syncSigResp.sig,
+ "if-none-match": newHash,
+ ...(provider.lastBackupHash
+ ? {
+ "if-match": provider.lastBackupHash,
+ }
+ : {}),
+ },
+ });
+
+ logger.trace(`sync response status: ${resp.status}`);
+
+ if (resp.status === HttpStatusCode.NotModified) {
+ await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ });
+
+ removeAttentionRequest(ws, {
+ entityId: provider.baseUrl,
+ type: AttentionType.BackupUnpaid,
+ });
+
+ return TaskRunResult.finished();
+ }
+
+ if (resp.status === HttpStatusCode.PaymentRequired) {
+ logger.trace("payment required for backup");
+ logger.trace(`headers: ${j2s(resp.headers)}`);
+ const talerUri = resp.headers.get("taler");
+ if (!talerUri) {
+ throw Error("no taler URI available to pay provider");
+ }
+
+ //We can't delay downloading the proposal since we need the id
+ //FIXME: check download errors
+ let res: PreparePayResult | undefined = undefined;
+ try {
+ res = await preparePayForUri(ws, talerUri);
+ } catch (e) {
+ const error = TalerError.fromException(e);
+ if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) {
+ throw error;
+ }
+ }
+
+ if (res === undefined) {
+ //claimed
+
+ await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.shouldRetryFreshProposal = true;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ });
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+ const result = res;
+
+ await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ // const opId = TaskIdentifiers.forBackup(prov);
+ // await scheduleRetryInTx(ws, tx, opId);
+ prov.currentPaymentProposalId = result.proposalId;
+ prov.shouldRetryFreshProposal = false;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ });
+
+ addAttentionRequest(
+ ws,
+ {
+ type: AttentionType.BackupUnpaid,
+ provider_base_url: provider.baseUrl,
+ talerUri,
+ },
+ provider.baseUrl,
+ );
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+
+ if (resp.status === HttpStatusCode.NoContent) {
+ await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(currentBackupHash);
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ });
+
+ removeAttentionRequest(ws, {
+ entityId: provider.baseUrl,
+ type: AttentionType.BackupUnpaid,
+ });
+
+ return {
+ type: TaskRunResultType.Finished,
+ };
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ logger.info("conflicting backup found");
+ const backupEnc = new Uint8Array(await resp.bytes());
+ const backupConfig = await provideBackupState(ws);
+ // const blob = await decryptBackup(backupConfig, backupEnc);
+ // FIXME: Re-implement backup import with merging
+ // await importBackup(ws, blob, cryptoData);
+ await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(hash(backupEnc));
+ // FIXME: Allocate error code for this situation?
+ // FIXME: Add operation retry record!
+ const opId = TaskIdentifiers.forBackup(prov);
+ //await scheduleRetryInTx(ws, tx, opId);
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ });
+ logger.info("processed existing backup");
+ // Now upload our own, merged backup.
+ return await runBackupCycleForProvider(ws, args);
+ }
+
+ // Some other response that we did not expect!
+
+ logger.error("parsing error response");
+
+ const err = await readTalerErrorResponse(resp);
+ logger.error(`got error response from backup provider: ${j2s(err)}`);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: err,
+ };
+}
+
+export async function processBackupForProvider(
+ ws: InternalWalletState,
+ backupProviderBaseUrl: string,
+): Promise<TaskRunResult> {
+ const provider = await ws.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ return await tx.backupProviders.get(backupProviderBaseUrl);
+ },
+ );
+ if (!provider) {
+ throw Error("unknown backup provider");
+ }
+
+ logger.info(`running backup for provider ${backupProviderBaseUrl}`);
+
+ return await runBackupCycleForProvider(ws, {
+ backupProviderBaseUrl: provider.baseUrl,
+ });
+}
+
+export interface RemoveBackupProviderRequest {
+ provider: string;
+}
+
+export const codecForRemoveBackupProvider =
+ (): Codec<RemoveBackupProviderRequest> =>
+ buildCodecForObject<RemoveBackupProviderRequest>()
+ .property("provider", codecForString())
+ .build("RemoveBackupProviderRequest");
+
+export async function removeBackupProvider(
+ ws: InternalWalletState,
+ req: RemoveBackupProviderRequest,
+): Promise<void> {
+ await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ await tx.backupProviders.delete(req.provider);
+ });
+}
+
+export interface RunBackupCycleRequest {
+ /**
+ * List of providers to backup or empty for all known providers.
+ */
+ providers?: Array<string>;
+}
+
+export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
+ buildCodecForObject<RunBackupCycleRequest>()
+ .property("providers", codecOptional(codecForList(codecForString())))
+ .build("RunBackupCycleRequest");
+
+/**
+ * Do one backup cycle that consists of:
+ * 1. Exporting a backup and try to upload it.
+ * Stop if this step succeeds.
+ * 2. Download, verify and import backups from connected sync accounts.
+ * 3. Upload the updated backup blob.
+ */
+export async function runBackupCycle(
+ ws: InternalWalletState,
+ req: RunBackupCycleRequest,
+): Promise<void> {
+ const providers = await ws.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ if (req.providers) {
+ const rs = await Promise.all(
+ req.providers.map((id) => tx.backupProviders.get(id)),
+ );
+ return rs.filter(notEmpty);
+ }
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+
+ for (const provider of providers) {
+ await runBackupCycleForProvider(ws, {
+ backupProviderBaseUrl: provider.baseUrl,
+ });
+ }
+}
+
+export interface SyncTermsOfServiceResponse {
+ // maximum backup size supported
+ storage_limit_in_megabytes: number;
+
+ // Fee for an account, per year.
+ annual_fee: AmountString;
+
+ // protocol version supported by the server,
+ // for now always "0.0".
+ version: string;
+}
+
+export const codecForSyncTermsOfServiceResponse =
+ (): Codec<SyncTermsOfServiceResponse> =>
+ buildCodecForObject<SyncTermsOfServiceResponse>()
+ .property("storage_limit_in_megabytes", codecForNumber())
+ .property("annual_fee", codecForAmountString())
+ .property("version", codecForString())
+ .build("SyncTermsOfServiceResponse");
+
+export interface AddBackupProviderRequest {
+ backupProviderBaseUrl: string;
+
+ name: string;
+ /**
+ * Activate the provider. Should only be done after
+ * the user has reviewed the provider.
+ */
+ activate?: boolean;
+}
+
+export const codecForAddBackupProviderRequest =
+ (): Codec<AddBackupProviderRequest> =>
+ buildCodecForObject<AddBackupProviderRequest>()
+ .property("backupProviderBaseUrl", codecForString())
+ .property("name", codecForString())
+ .property("activate", codecOptional(codecForBoolean()))
+ .build("AddBackupProviderRequest");
+
+export type AddBackupProviderResponse =
+ | AddBackupProviderOk
+ | AddBackupProviderPaymentRequired;
+
+interface AddBackupProviderOk {
+ status: "ok";
+}
+interface AddBackupProviderPaymentRequired {
+ status: "payment-required";
+ talerUri?: string;
+}
+
+export const codecForAddBackupProviderOk = (): Codec<AddBackupProviderOk> =>
+ buildCodecForObject<AddBackupProviderOk>()
+ .property("status", codecForConstString("ok"))
+ .build("AddBackupProviderOk");
+
+export const codecForAddBackupProviderPaymenrRequired =
+ (): Codec<AddBackupProviderPaymentRequired> =>
+ buildCodecForObject<AddBackupProviderPaymentRequired>()
+ .property("status", codecForConstString("payment-required"))
+ .property("talerUri", codecOptional(codecForString()))
+ .build("AddBackupProviderPaymentRequired");
+
+export const codecForAddBackupProviderResponse =
+ (): Codec<AddBackupProviderResponse> =>
+ buildCodecForUnion<AddBackupProviderResponse>()
+ .discriminateOn("status")
+ .alternative("ok", codecForAddBackupProviderOk())
+ .alternative(
+ "payment-required",
+ codecForAddBackupProviderPaymenrRequired(),
+ )
+ .build("AddBackupProviderResponse");
+
+export async function addBackupProvider(
+ ws: InternalWalletState,
+ req: AddBackupProviderRequest,
+): Promise<AddBackupProviderResponse> {
+ logger.info(`adding backup provider ${j2s(req)}`);
+ await provideBackupState(ws);
+ const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
+ await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const oldProv = await tx.backupProviders.get(canonUrl);
+ if (oldProv) {
+ logger.info("old backup provider found");
+ if (req.activate) {
+ oldProv.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ };
+ logger.info("setting existing backup provider to active");
+ await tx.backupProviders.put(oldProv);
+ }
+ return;
+ }
+ });
+ const termsUrl = new URL("config", canonUrl);
+ const resp = await ws.http.fetch(termsUrl.href);
+ const terms = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForSyncTermsOfServiceResponse(),
+ );
+ await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ let state: BackupProviderState;
+ //FIXME: what is the difference provisional and ready?
+ if (req.activate) {
+ state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ } else {
+ state = {
+ tag: BackupProviderStateTag.Provisional,
+ };
+ }
+ await tx.backupProviders.put({
+ state,
+ name: req.name,
+ terms: {
+ annualFee: terms.annual_fee,
+ storageLimitInMegabytes: terms.storage_limit_in_megabytes,
+ supportedProtocolVersion: terms.version,
+ },
+ shouldRetryFreshProposal: false,
+ paymentProposalIds: [],
+ baseUrl: canonUrl,
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ });
+
+ return await runFirstBackupCycleForProvider(ws, {
+ backupProviderBaseUrl: canonUrl,
+ });
+}
+
+async function runFirstBackupCycleForProvider(
+ ws: InternalWalletState,
+ args: BackupForProviderArgs,
+): Promise<AddBackupProviderResponse> {
+ throw Error("not implemented");
+ // const resp = await runBackupCycleForProvider(ws, args);
+ // switch (resp.type) {
+ // case TaskRunResultType.Error:
+ // throw TalerError.fromDetail(
+ // TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ // resp.errorDetail as any, //FIXME create an error for backup problems
+ // );
+ // case TaskRunResultType.Finished:
+ // return {
+ // status: "ok",
+ // };
+ // case TaskRunResultType.Pending:
+ // return {
+ // status: "payment-required",
+ // talerUri: "FIXME",
+ // //talerUri: resp.result.talerUri,
+ // };
+ // default:
+ // assertUnreachable(resp);
+ // }
+}
+
+export async function restoreFromRecoverySecret(): Promise<void> {
+ return;
+}
+
+/**
+ * Information about one provider.
+ *
+ * We don't store the account key here,
+ * as that's derived from the wallet root key.
+ */
+export interface ProviderInfo {
+ active: boolean;
+ syncProviderBaseUrl: string;
+ name: string;
+ terms?: BackupProviderTerms;
+ /**
+ * Last communication issue with the provider.
+ */
+ lastError?: TalerErrorDetail;
+ lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp;
+ lastAttemptedBackupTimestamp?: TalerPreciseTimestamp;
+ paymentProposalIds: string[];
+ backupProblem?: BackupProblem;
+ paymentStatus: ProviderPaymentStatus;
+}
+
+export type BackupProblem =
+ | BackupUnreadableProblem
+ | BackupConflictingDeviceProblem;
+
+export interface BackupUnreadableProblem {
+ type: "backup-unreadable";
+}
+
+export interface BackupUnreadableProblem {
+ type: "backup-unreadable";
+}
+
+export interface BackupConflictingDeviceProblem {
+ type: "backup-conflicting-device";
+ otherDeviceId: string;
+ myDeviceId: string;
+ backupTimestamp: AbsoluteTime;
+}
+
+export type ProviderPaymentStatus =
+ | ProviderPaymentTermsChanged
+ | ProviderPaymentPaid
+ | ProviderPaymentInsufficientBalance
+ | ProviderPaymentUnpaid
+ | ProviderPaymentPending;
+
+export interface BackupInfo {
+ walletRootPub: string;
+ deviceId: string;
+ providers: ProviderInfo[];
+}
+
+export enum ProviderPaymentType {
+ Unpaid = "unpaid",
+ Pending = "pending",
+ InsufficientBalance = "insufficient-balance",
+ Paid = "paid",
+ TermsChanged = "terms-changed",
+}
+
+export interface ProviderPaymentUnpaid {
+ type: ProviderPaymentType.Unpaid;
+}
+
+export interface ProviderPaymentInsufficientBalance {
+ type: ProviderPaymentType.InsufficientBalance;
+ amount: AmountString;
+}
+
+export interface ProviderPaymentPending {
+ type: ProviderPaymentType.Pending;
+ talerUri?: string;
+}
+
+export interface ProviderPaymentPaid {
+ type: ProviderPaymentType.Paid;
+ paidUntil: AbsoluteTime;
+}
+
+export interface ProviderPaymentTermsChanged {
+ type: ProviderPaymentType.TermsChanged;
+ paidUntil: AbsoluteTime;
+ oldTerms: BackupProviderTerms;
+ newTerms: BackupProviderTerms;
+}
+
+async function getProviderPaymentInfo(
+ ws: InternalWalletState,
+ provider: BackupProviderRecord,
+): Promise<ProviderPaymentStatus> {
+ throw Error("not implemented");
+ // if (!provider.currentPaymentProposalId) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+ // const status = await checkPaymentByProposalId(
+ // ws,
+ // provider.currentPaymentProposalId,
+ // ).catch(() => undefined);
+
+ // if (!status) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+
+ // switch (status.status) {
+ // case PreparePayResultType.InsufficientBalance:
+ // return {
+ // type: ProviderPaymentType.InsufficientBalance,
+ // amount: status.amountRaw,
+ // };
+ // case PreparePayResultType.PaymentPossible:
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // case PreparePayResultType.AlreadyConfirmed:
+ // if (status.paid) {
+ // return {
+ // type: ProviderPaymentType.Paid,
+ // paidUntil: AbsoluteTime.addDuration(
+ // AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp),
+ // durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
+ // ),
+ // };
+ // } else {
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // }
+ // default:
+ // assertUnreachable(status);
+ // }
+}
+
+/**
+ * Get information about the current state of wallet backups.
+ */
+export async function getBackupInfo(
+ ws: InternalWalletState,
+): Promise<BackupInfo> {
+ const backupConfig = await provideBackupState(ws);
+ const providerRecords = await ws.db.runReadOnlyTx(
+ ["backupProviders", "operationRetries"],
+ async (tx) => {
+ return await tx.backupProviders.iter().mapAsync(async (bp) => {
+ const opId = TaskIdentifiers.forBackup(bp);
+ const retryRecord = await tx.operationRetries.get(opId);
+ return {
+ provider: bp,
+ retryRecord,
+ };
+ });
+ },
+ );
+ const providers: ProviderInfo[] = [];
+ for (const x of providerRecords) {
+ providers.push({
+ active: x.provider.state.tag !== BackupProviderStateTag.Provisional,
+ syncProviderBaseUrl: x.provider.baseUrl,
+ lastSuccessfulBackupTimestamp: timestampOptionalPreciseFromDb(
+ x.provider.lastBackupCycleTimestamp,
+ ),
+ paymentProposalIds: x.provider.paymentProposalIds,
+ lastError:
+ x.provider.state.tag === BackupProviderStateTag.Retrying
+ ? x.retryRecord?.lastError
+ : undefined,
+ paymentStatus: await getProviderPaymentInfo(ws, x.provider),
+ terms: x.provider.terms,
+ name: x.provider.name,
+ });
+ }
+ return {
+ deviceId: backupConfig.deviceId,
+ walletRootPub: backupConfig.walletRootPub,
+ providers,
+ };
+}
+
+/**
+ * Get backup recovery information, including the wallet's
+ * private key.
+ */
+export async function getBackupRecovery(
+ ws: InternalWalletState,
+): Promise<BackupRecovery> {
+ const bs = await provideBackupState(ws);
+ const providers = await ws.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+ return {
+ providers: providers
+ .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
+ .map((x) => {
+ return {
+ name: x.name,
+ url: x.baseUrl,
+ };
+ }),
+ walletRootPriv: bs.walletRootPriv,
+ };
+}
+
+async function backupRecoveryTheirs(
+ ws: InternalWalletState,
+ br: BackupRecovery,
+) {
+ await ws.db.runReadWriteTx(["backupProviders", "config"], async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ checkDbInvariant(!!backupStateEntry);
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ backupStateEntry.value.lastBackupNonce = undefined;
+ backupStateEntry.value.lastBackupTimestamp = undefined;
+ backupStateEntry.value.lastBackupCheckTimestamp = undefined;
+ backupStateEntry.value.lastBackupPlainHash = undefined;
+ backupStateEntry.value.walletRootPriv = br.walletRootPriv;
+ backupStateEntry.value.walletRootPub = encodeCrock(
+ eddsaGetPublic(decodeCrock(br.walletRootPriv)),
+ );
+ await tx.config.put(backupStateEntry);
+ for (const prov of br.providers) {
+ const existingProv = await tx.backupProviders.get(prov.url);
+ if (!existingProv) {
+ await tx.backupProviders.put({
+ baseUrl: prov.url,
+ name: prov.name,
+ paymentProposalIds: [],
+ shouldRetryFreshProposal: false,
+ state: {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ },
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ }
+ }
+ const providers = await tx.backupProviders.iter().toArray();
+ for (const prov of providers) {
+ prov.lastBackupCycleTimestamp = undefined;
+ prov.lastBackupHash = undefined;
+ await tx.backupProviders.put(prov);
+ }
+ });
+}
+
+async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) {
+ throw Error("not implemented");
+}
+
+export async function loadBackupRecovery(
+ ws: InternalWalletState,
+ br: RecoveryLoadRequest,
+): Promise<void> {
+ const bs = await provideBackupState(ws);
+ const providers = await ws.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+ let strategy = br.strategy;
+ if (
+ br.recovery.walletRootPriv != bs.walletRootPriv &&
+ providers.length > 0 &&
+ !strategy
+ ) {
+ throw Error(
+ "recovery load strategy must be specified for wallet with existing providers",
+ );
+ } else if (!strategy) {
+ // Default to using the new key if we don't have providers yet.
+ strategy = RecoveryMergeStrategy.Theirs;
+ }
+ if (strategy === RecoveryMergeStrategy.Theirs) {
+ return backupRecoveryTheirs(ws, br.recovery);
+ } else {
+ return backupRecoveryOurs(ws, br.recovery);
+ }
+}
+
+export async function decryptBackup(
+ backupConfig: WalletBackupConfState,
+ data: Uint8Array,
+): Promise<any> {
+ const rMagic = bytesToString(data.slice(0, 8));
+ if (rMagic != magic) {
+ throw Error("invalid backup file (magic tag mismatch)");
+ }
+
+ const nonce = data.slice(8, 8 + 24);
+ const box = data.slice(8 + 24);
+ const secret = deriveBlobSecret(backupConfig);
+ const dataCompressed = secretbox_open(box, nonce, secret);
+ if (!dataCompressed) {
+ throw Error("decryption failed");
+ }
+ return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
+}
+
+export async function provideBackupState(
+ ws: InternalWalletState,
+): Promise<WalletBackupConfState> {
+ const bs: ConfigRecord | undefined = await ws.db.runReadOnlyTx(
+ ["config"],
+ async (tx) => {
+ return await tx.config.get(ConfigRecordKey.WalletBackupState);
+ },
+ );
+ if (bs) {
+ checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
+ return bs.value;
+ }
+ // We need to generate the key outside of the transaction
+ // due to how IndexedDB works.
+ const k = await ws.cryptoApi.createEddsaKeypair({});
+ const d = getRandomBytes(5);
+ // FIXME: device ID should be configured when wallet is initialized
+ // and be based on hostname
+ const deviceId = `wallet-core-${encodeCrock(d)}`;
+ return await ws.db.runReadWriteTx(["config"], async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (!backupStateEntry) {
+ backupStateEntry = {
+ key: ConfigRecordKey.WalletBackupState,
+ value: {
+ deviceId,
+ walletRootPub: k.pub,
+ walletRootPriv: k.priv,
+ lastBackupPlainHash: undefined,
+ },
+ };
+ await tx.config.put(backupStateEntry);
+ }
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ return backupStateEntry.value;
+ });
+}
+
+export async function getWalletBackupState(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<["config"]>,
+): Promise<WalletBackupConfState> {
+ const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
+ checkDbInvariant(!!bs, "wallet backup state should be in DB");
+ checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
+ return bs.value;
+}
+
+export async function setWalletDeviceId(
+ ws: InternalWalletState,
+ deviceId: string,
+): Promise<void> {
+ await provideBackupState(ws);
+ await ws.db.runReadWriteTx(["config"], async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (
+ !backupStateEntry ||
+ backupStateEntry.key !== ConfigRecordKey.WalletBackupState
+ ) {
+ return;
+ }
+ backupStateEntry.value.deviceId = deviceId;
+ await tx.config.put(backupStateEntry);
+ });
+}
+
+export async function getWalletDeviceId(
+ ws: InternalWalletState,
+): Promise<string> {
+ const bs = await provideBackupState(ws);
+ return bs.deviceId;
+}
diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts b/packages/taler-wallet-core/src/backup/state.ts
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -0,0 +1,730 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Functions to compute the wallet's balance.
+ *
+ * There are multiple definition of the wallet's balance.
+ * We use the following terminology:
+ *
+ * - "available": Balance that is available
+ * for spending from transactions in their final state and
+ * expected to be available from pending refreshes.
+ *
+ * - "pending-incoming": Expected (positive!) delta
+ * to the available balance that we expect to have
+ * after pending operations reach the "done" state.
+ *
+ * - "pending-outgoing": Amount that is currently allocated
+ * to be spent, but the spend operation could still be aborted
+ * and part of the pending-outgoing amount could be recovered.
+ *
+ * - "material": Balance that the wallet believes it could spend *right now*,
+ * without waiting for any operations to complete.
+ * This balance type is important when showing "insufficient balance" error messages.
+ *
+ * - "age-acceptable": Subset of the material balance that can be spent
+ * with age restrictions applied.
+ *
+ * - "merchant-acceptable": Subset of the material balance that can be spent with a particular
+ * merchant (restricted via min age, exchange, auditor, wire_method).
+ *
+ * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
+ * can accept via their supported wire methods.
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AllowedAuditorInfo,
+ AllowedExchangeInfo,
+ AmountJson,
+ AmountLike,
+ Amounts,
+ BalanceFlag,
+ BalancesResponse,
+ canonicalizeBaseUrl,
+ GetBalanceDetailRequest,
+ Logger,
+ parsePaytoUri,
+ ScopeInfo,
+ ScopeType,
+} from "@gnu-taler/taler-util";
+import {
+ DepositOperationStatus,
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ WalletDbReadOnlyTransaction,
+ WithdrawalGroupStatus,
+} from "./db.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { checkLogicInvariant } from "./util/invariants.js";
+import {
+ getExchangeScopeInfo,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("operations/balance.ts");
+
+interface WalletBalance {
+ scopeInfo: ScopeInfo;
+ available: AmountJson;
+ pendingIncoming: AmountJson;
+ pendingOutgoing: AmountJson;
+ flagIncomingKyc: boolean;
+ flagIncomingAml: boolean;
+ flagIncomingConfirmation: boolean;
+ flagOutgoingKyc: boolean;
+}
+
+/**
+ * Compute the available amount that the wallet expects to get
+ * out of a refresh group.
+ */
+function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
+ // Don't count finished refreshes, since the refresh already resulted
+ // in coins being added to the wallet.
+ let available = Amounts.zeroOfCurrency(r.currency);
+ if (r.timestampFinished) {
+ return available;
+ }
+ for (let i = 0; i < r.oldCoinPubs.length; i++) {
+ available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount;
+ }
+ return available;
+}
+
+function getBalanceKey(scopeInfo: ScopeInfo): string {
+ switch (scopeInfo.type) {
+ case ScopeType.Auditor:
+ return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
+ case ScopeType.Exchange:
+ return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
+ case ScopeType.Global:
+ return `${scopeInfo.type};${scopeInfo.currency}`;
+ }
+}
+
+class BalancesStore {
+ private exchangeScopeCache: Record<string, ScopeInfo> = {};
+ private balanceStore: Record<string, WalletBalance> = {};
+
+ constructor(
+ private ws: InternalWalletState,
+ private tx: WalletDbReadOnlyTransaction<
+ [
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+ ) {}
+
+ /**
+ * Add amount to a balance field, both for
+ * the slicing by exchange and currency.
+ */
+ private async initBalance(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<WalletBalance> {
+ let scopeInfo: ScopeInfo | undefined =
+ this.exchangeScopeCache[exchangeBaseUrl];
+ if (!scopeInfo) {
+ scopeInfo = await getExchangeScopeInfo(
+ this.tx,
+ exchangeBaseUrl,
+ currency,
+ );
+ this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo;
+ }
+ const balanceKey = getBalanceKey(scopeInfo);
+ const b = this.balanceStore[balanceKey];
+ if (!b) {
+ const zero = Amounts.zeroOfCurrency(currency);
+ this.balanceStore[balanceKey] = {
+ scopeInfo,
+ available: zero,
+ pendingIncoming: zero,
+ pendingOutgoing: zero,
+ flagIncomingAml: false,
+ flagIncomingConfirmation: false,
+ flagIncomingKyc: false,
+ flagOutgoingKyc: false,
+ };
+ }
+ return this.balanceStore[balanceKey];
+ }
+
+ async addAvailable(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.available = Amounts.add(b.available, amount).amount;
+ }
+
+ async addPendingIncoming(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.pendingIncoming = Amounts.add(b.available, amount).amount;
+ }
+
+ async setFlagIncomingAml(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingAml = true;
+ }
+
+ async setFlagIncomingKyc(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingKyc = true;
+ }
+
+ async setFlagIncomingConfirmation(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingConfirmation = true;
+ }
+
+ async setFlagOutgoingKyc(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagOutgoingKyc = true;
+ }
+
+ toBalancesResponse(): BalancesResponse {
+ const balancesResponse: BalancesResponse = {
+ balances: [],
+ };
+
+ const balanceStore = this.balanceStore;
+
+ Object.keys(balanceStore)
+ .sort()
+ .forEach((c) => {
+ const v = balanceStore[c];
+ const flags: BalanceFlag[] = [];
+ if (v.flagIncomingAml) {
+ flags.push(BalanceFlag.IncomingAml);
+ }
+ if (v.flagIncomingKyc) {
+ flags.push(BalanceFlag.IncomingKyc);
+ }
+ if (v.flagIncomingConfirmation) {
+ flags.push(BalanceFlag.IncomingConfirmation);
+ }
+ if (v.flagOutgoingKyc) {
+ flags.push(BalanceFlag.OutgoingKyc);
+ }
+ balancesResponse.balances.push({
+ scopeInfo: v.scopeInfo,
+ available: Amounts.stringify(v.available),
+ pendingIncoming: Amounts.stringify(v.pendingIncoming),
+ pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
+ // FIXME: This field is basically not implemented, do we even need it?
+ hasPendingTransactions: false,
+ // FIXME: This field is basically not implemented, do we even need it?
+ requiresUserInput: false,
+ flags,
+ });
+ });
+ return balancesResponse;
+ }
+}
+
+/**
+ * Get balance information.
+ */
+export async function getBalancesInsideTransaction(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "coinAvailability",
+ "refreshGroups",
+ "depositGroups",
+ "withdrawalGroups",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ]
+ >,
+): Promise<BalancesResponse> {
+ const balanceStore: BalancesStore = new BalancesStore(ws, tx);
+
+ const keyRangeActive = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+
+ await tx.coinAvailability.iter().forEachAsync(async (ca) => {
+ const count = ca.visibleCoinCount ?? 0;
+ for (let i = 0; i < count; i++) {
+ await balanceStore.addAvailable(
+ ca.currency,
+ ca.exchangeBaseUrl,
+ ca.value,
+ );
+ }
+ });
+
+ await tx.refreshGroups.iter().forEachAsync(async (r) => {
+ switch (r.operationStatus) {
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended:
+ break;
+ default:
+ return;
+ }
+ const perExchange = r.infoPerExchange;
+ if (!perExchange) {
+ return;
+ }
+ for (const [e, x] of Object.entries(perExchange)) {
+ await balanceStore.addAvailable(r.currency, e, x.outputEffective);
+ }
+ });
+
+ await tx.withdrawalGroups.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (wgRecord) => {
+ const currency = Amounts.currencyOf(wgRecord.denomsSel.totalCoinValue);
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.Done:
+ // Does not count as pendingIncoming
+ return;
+ case WithdrawalGroupStatus.PendingReady:
+ case WithdrawalGroupStatus.AbortingBank:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ // Pending, but no special flag.
+ break;
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.PendingKyc:
+ await balanceStore.setFlagIncomingKyc(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.SuspendedAml:
+ await balanceStore.setFlagIncomingAml(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ await balanceStore.setFlagIncomingConfirmation(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ default:
+ assertUnreachable(wgRecord.status);
+ }
+ await balanceStore.addPendingIncoming(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ wgRecord.denomsSel.totalCoinValue,
+ );
+ });
+
+ await tx.depositGroups.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (dgRecord) => {
+ const perExchange = dgRecord.infoPerExchange;
+ if (!perExchange) {
+ return;
+ }
+ for (const [e, x] of Object.entries(perExchange)) {
+ const currency = Amounts.currencyOf(dgRecord.amount);
+ switch (dgRecord.operationStatus) {
+ case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.PendingKyc:
+ await balanceStore.setFlagOutgoingKyc(currency, e);
+ }
+ }
+ });
+
+ return balanceStore.toBalancesResponse();
+}
+
+/**
+ * Get detailed balance information, sliced by exchange and by currency.
+ */
+export async function getBalances(
+ ws: InternalWalletState,
+): Promise<BalancesResponse> {
+ logger.trace("starting to compute balance");
+
+ const wbal = await ws.db.runReadWriteTx(
+ [
+ "coinAvailability",
+ "coins",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "purchases",
+ "refreshGroups",
+ "withdrawalGroups",
+ ],
+ async (tx) => {
+ return getBalancesInsideTransaction(ws, tx);
+ },
+ );
+
+ logger.trace("finished computing wallet balance");
+
+ return wbal;
+}
+
+/**
+ * Information about the balance for a particular payment to a particular
+ * merchant.
+ */
+export interface MerchantPaymentBalanceDetails {
+ balanceAvailable: AmountJson;
+}
+
+export interface MerchantPaymentRestrictionsForBalance {
+ currency: string;
+ minAge: number;
+ acceptedExchanges: AllowedExchangeInfo[];
+ acceptedAuditors: AllowedAuditorInfo[];
+ acceptedWireMethods: string[];
+}
+
+export interface AcceptableExchanges {
+ /**
+ * Exchanges accepted by the merchant, but wire method might not match.
+ */
+ acceptableExchanges: string[];
+
+ /**
+ * Exchanges accepted by the merchant, including a matching
+ * wire method, i.e. the merchant can deposit coins there.
+ */
+ depositableExchanges: string[];
+}
+
+/**
+ * Get all exchanges that are acceptable for a particular payment.
+ */
+export async function getAcceptableExchangeBaseUrls(
+ ws: InternalWalletState,
+ req: MerchantPaymentRestrictionsForBalance,
+): Promise<AcceptableExchanges> {
+ const acceptableExchangeUrls = new Set<string>();
+ const depositableExchangeUrls = new Set<string>();
+ await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
+ // FIXME: We should have a DB index to look up all exchanges
+ // for a particular auditor ...
+
+ const canonExchanges = new Set<string>();
+ const canonAuditors = new Set<string>();
+
+ for (const exchangeHandle of req.acceptedExchanges) {
+ const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl);
+ canonExchanges.add(normUrl);
+ }
+
+ for (const auditorHandle of req.acceptedAuditors) {
+ const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl);
+ canonAuditors.add(normUrl);
+ }
+
+ await tx.exchanges.iter().forEachAsync(async (exchange) => {
+ const dp = exchange.detailsPointer;
+ if (!dp) {
+ return;
+ }
+ const { currency, masterPublicKey } = dp;
+ const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([
+ exchange.baseUrl,
+ currency,
+ masterPublicKey,
+ ]);
+ if (!exchangeDetails) {
+ return;
+ }
+
+ let acceptable = false;
+
+ if (canonExchanges.has(exchange.baseUrl)) {
+ acceptableExchangeUrls.add(exchange.baseUrl);
+ acceptable = true;
+ }
+ for (const exchangeAuditor of exchangeDetails.auditors) {
+ if (canonAuditors.has(exchangeAuditor.auditor_url)) {
+ acceptableExchangeUrls.add(exchange.baseUrl);
+ acceptable = true;
+ break;
+ }
+ }
+
+ if (!acceptable) {
+ return;
+ }
+ // FIXME: Also consider exchange and auditor public key
+ // instead of just base URLs?
+
+ let wireMethodSupported = false;
+ for (const acc of exchangeDetails.wireInfo.accounts) {
+ const pp = parsePaytoUri(acc.payto_uri);
+ checkLogicInvariant(!!pp);
+ for (const wm of req.acceptedWireMethods) {
+ if (pp.targetType === wm) {
+ wireMethodSupported = true;
+ break;
+ }
+ if (wireMethodSupported) {
+ break;
+ }
+ }
+ }
+
+ acceptableExchangeUrls.add(exchange.baseUrl);
+ if (wireMethodSupported) {
+ depositableExchangeUrls.add(exchange.baseUrl);
+ }
+ });
+ });
+ return {
+ acceptableExchanges: [...acceptableExchangeUrls],
+ depositableExchanges: [...depositableExchangeUrls],
+ };
+}
+
+export interface MerchantPaymentBalanceDetails {
+ /**
+ * Balance of type "available" (see balance.ts for definition).
+ */
+ balanceAvailable: AmountJson;
+
+ /**
+ * Balance of type "material" (see balance.ts for definition).
+ */
+ balanceMaterial: AmountJson;
+
+ /**
+ * Balance of type "age-acceptable" (see balance.ts for definition).
+ */
+ balanceAgeAcceptable: AmountJson;
+
+ /**
+ * Balance of type "merchant-acceptable" (see balance.ts for definition).
+ */
+ balanceMerchantAcceptable: AmountJson;
+
+ /**
+ * Balance of type "merchant-depositable" (see balance.ts for definition).
+ */
+ balanceMerchantDepositable: AmountJson;
+}
+
+export async function getMerchantPaymentBalanceDetails(
+ ws: InternalWalletState,
+ req: MerchantPaymentRestrictionsForBalance,
+): Promise<MerchantPaymentBalanceDetails> {
+ const acceptability = await getAcceptableExchangeBaseUrls(ws, req);
+
+ const d: MerchantPaymentBalanceDetails = {
+ balanceAvailable: Amounts.zeroOfCurrency(req.currency),
+ balanceMaterial: Amounts.zeroOfCurrency(req.currency),
+ balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
+ balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency),
+ balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency),
+ };
+
+ await ws.db.runReadOnlyTx(
+ ["coinAvailability", "refreshGroups"],
+ async (tx) => {
+ await tx.coinAvailability.iter().forEach((ca) => {
+ if (ca.currency != req.currency) {
+ return;
+ }
+ const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
+ const coinAmount: AmountJson = Amounts.mult(
+ singleCoinAmount,
+ ca.freshCoinCount,
+ ).amount;
+ d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
+ d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
+ if (ca.maxAge === 0 || ca.maxAge > req.minAge) {
+ d.balanceAgeAcceptable = Amounts.add(
+ d.balanceAgeAcceptable,
+ coinAmount,
+ ).amount;
+ if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) {
+ d.balanceMerchantAcceptable = Amounts.add(
+ d.balanceMerchantAcceptable,
+ coinAmount,
+ ).amount;
+ if (
+ acceptability.depositableExchanges.includes(ca.exchangeBaseUrl)
+ ) {
+ d.balanceMerchantDepositable = Amounts.add(
+ d.balanceMerchantDepositable,
+ coinAmount,
+ ).amount;
+ }
+ }
+ }
+ });
+
+ await tx.refreshGroups.iter().forEach((r) => {
+ if (r.currency != req.currency) {
+ return;
+ }
+ d.balanceAvailable = Amounts.add(
+ d.balanceAvailable,
+ computeRefreshGroupAvailableAmount(r),
+ ).amount;
+ });
+ },
+ );
+
+ return d;
+}
+
+export async function getBalanceDetail(
+ ws: InternalWalletState,
+ req: GetBalanceDetailRequest,
+): Promise<MerchantPaymentBalanceDetails> {
+ const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
+ const wires = new Array<string>();
+ await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || req.currency !== details.currency) {
+ continue;
+ }
+ details.wireInfo.accounts.forEach((a) => {
+ const payto = parsePaytoUri(a.payto_uri);
+ if (payto && !wires.includes(payto.targetType)) {
+ wires.push(payto.targetType);
+ }
+ });
+ exchanges.push({
+ exchangePub: details.masterPublicKey,
+ exchangeBaseUrl: e.baseUrl,
+ });
+ }
+ });
+
+ return await getMerchantPaymentBalanceDetails(ws, {
+ currency: req.currency,
+ acceptedAuditors: [],
+ acceptedExchanges: exchanges,
+ acceptedWireMethods: wires,
+ minAge: 0,
+ });
+}
+
+export interface PeerPaymentRestrictionsForBalance {
+ currency: string;
+ restrictExchangeTo?: string;
+}
+
+export interface PeerPaymentBalanceDetails {
+ /**
+ * Balance of type "available" (see balance.ts for definition).
+ */
+ balanceAvailable: AmountJson;
+
+ /**
+ * Balance of type "material" (see balance.ts for definition).
+ */
+ balanceMaterial: AmountJson;
+}
+
+export async function getPeerPaymentBalanceDetailsInTx(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<["coinAvailability", "refreshGroups"]>,
+ req: PeerPaymentRestrictionsForBalance,
+): Promise<PeerPaymentBalanceDetails> {
+ let balanceAvailable = Amounts.zeroOfCurrency(req.currency);
+ let balanceMaterial = Amounts.zeroOfCurrency(req.currency);
+
+ await tx.coinAvailability.iter().forEach((ca) => {
+ if (ca.currency != req.currency) {
+ return;
+ }
+ if (
+ req.restrictExchangeTo &&
+ req.restrictExchangeTo !== ca.exchangeBaseUrl
+ ) {
+ return;
+ }
+ const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
+ const coinAmount: AmountJson = Amounts.mult(
+ singleCoinAmount,
+ ca.freshCoinCount,
+ ).amount;
+ balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount;
+ balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount;
+ });
+
+ await tx.refreshGroups.iter().forEach((r) => {
+ if (r.currency != req.currency) {
+ return;
+ }
+ balanceAvailable = Amounts.add(
+ balanceAvailable,
+ computeRefreshGroupAvailableAmount(r),
+ ).amount;
+ });
+
+ return {
+ balanceAvailable,
+ balanceMaterial,
+ };
+}
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -0,0 +1,693 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ CoinRefreshRequest,
+ CoinStatus,
+ Duration,
+ ExchangeEntryState,
+ ExchangeEntryStatus,
+ ExchangeTosStatus,
+ ExchangeUpdateStatus,
+ Logger,
+ RefreshReason,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TombstoneIdStr,
+ TransactionIdStr,
+ durationMul,
+} from "@gnu-taler/taler-util";
+import {
+ BackupProviderRecord,
+ CoinRecord,
+ DbPreciseTimestamp,
+ DepositGroupRecord,
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ ExchangeEntryRecord,
+ PeerPullCreditRecord,
+ PeerPullPaymentIncomingRecord,
+ PeerPushDebitRecord,
+ PeerPushPaymentIncomingRecord,
+ PurchaseRecord,
+ RecoupGroupRecord,
+ RefreshGroupRecord,
+ RewardRecord,
+ WalletDbReadWriteTransaction,
+ WithdrawalGroupRecord,
+ timestampPreciseToDb,
+} from "./db.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { PendingTaskType, TaskId } from "./pending-types.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js";
+import { createRefreshGroup } from "./refresh.js";
+
+const logger = new Logger("operations/common.ts");
+
+export interface CoinsSpendInfo {
+ coinPubs: string[];
+ contributions: AmountJson[];
+ refreshReason: RefreshReason;
+ /**
+ * Identifier for what the coin has been spent for.
+ */
+ allocationId: TransactionIdStr;
+}
+
+export async function makeCoinsVisible(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>,
+ transactionId: string,
+): Promise<void> {
+ const coins =
+ await tx.coins.indexes.bySourceTransactionId.getAll(transactionId);
+ for (const coinRecord of coins) {
+ if (!coinRecord.visible) {
+ coinRecord.visible = 1;
+ await tx.coins.put(coinRecord);
+ const ageRestriction = coinRecord.maxAge;
+ const car = await tx.coinAvailability.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ logger.error("missing coin availability record");
+ continue;
+ }
+ const visCount = car.visibleCoinCount ?? 0;
+ car.visibleCoinCount = visCount + 1;
+ await tx.coinAvailability.put(car);
+ }
+ }
+}
+
+export async function makeCoinAvailable(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["coins", "coinAvailability", "denominations"]
+ >,
+ coinRecord: CoinRecord,
+): Promise<void> {
+ checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
+ const existingCoin = await tx.coins.get(coinRecord.coinPub);
+ if (existingCoin) {
+ return;
+ }
+ const denom = await tx.denominations.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ const ageRestriction = coinRecord.maxAge;
+ let car = await tx.coinAvailability.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ car = {
+ maxAge: ageRestriction,
+ value: denom.value,
+ currency: denom.currency,
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ freshCoinCount: 0,
+ visibleCoinCount: 0,
+ };
+ }
+ car.freshCoinCount++;
+ await tx.coins.put(coinRecord);
+ await tx.coinAvailability.put(car);
+}
+
+export async function spendCoins(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["coins", "coinAvailability", "refreshGroups", "denominations"]
+ >,
+ csi: CoinsSpendInfo,
+): Promise<void> {
+ if (csi.coinPubs.length != csi.contributions.length) {
+ throw Error("assertion failed");
+ }
+ if (csi.coinPubs.length === 0) {
+ return;
+ }
+ let refreshCoinPubs: CoinRefreshRequest[] = [];
+ for (let i = 0; i < csi.coinPubs.length; i++) {
+ const coin = await tx.coins.get(csi.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin allocated for payment doesn't exist anymore");
+ }
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ const coinAvailability = await tx.coinAvailability.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ coin.maxAge,
+ ]);
+ checkDbInvariant(!!coinAvailability);
+ const contrib = csi.contributions[i];
+ if (coin.status !== CoinStatus.Fresh) {
+ const alloc = coin.spendAllocation;
+ if (!alloc) {
+ continue;
+ }
+ if (alloc.id !== csi.allocationId) {
+ // FIXME: assign error code
+ logger.info("conflicting coin allocation ID");
+ logger.info(`old ID: ${alloc.id}, new ID: ${csi.allocationId}`);
+ throw Error("conflicting coin allocation (id)");
+ }
+ if (0 !== Amounts.cmp(alloc.amount, contrib)) {
+ // FIXME: assign error code
+ throw Error("conflicting coin allocation (contrib)");
+ }
+ continue;
+ }
+ coin.status = CoinStatus.Dormant;
+ coin.spendAllocation = {
+ id: csi.allocationId,
+ amount: Amounts.stringify(contrib),
+ };
+ const remaining = Amounts.sub(denom.value, contrib);
+ if (remaining.saturated) {
+ throw Error("not enough remaining balance on coin for payment");
+ }
+ refreshCoinPubs.push({
+ amount: Amounts.stringify(remaining.amount),
+ coinPub: coin.coinPub,
+ });
+ checkDbInvariant(!!coinAvailability);
+ if (coinAvailability.freshCoinCount === 0) {
+ throw Error(
+ `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+ );
+ }
+ coinAvailability.freshCoinCount--;
+ if (coin.visible) {
+ if (!coinAvailability.visibleCoinCount) {
+ logger.error("coin availability inconsistent");
+ } else {
+ coinAvailability.visibleCoinCount--;
+ }
+ }
+ await tx.coins.put(coin);
+ await tx.coinAvailability.put(coinAvailability);
+ }
+
+ await createRefreshGroup(
+ ws,
+ tx,
+ Amounts.currencyOf(csi.contributions[0]),
+ refreshCoinPubs,
+ csi.refreshReason,
+ csi.allocationId,
+ );
+}
+
+export enum TombstoneTag {
+ DeleteWithdrawalGroup = "delete-withdrawal-group",
+ DeleteReserve = "delete-reserve",
+ DeletePayment = "delete-payment",
+ DeleteReward = "delete-reward",
+ DeleteRefreshGroup = "delete-refresh-group",
+ DeleteDepositGroup = "delete-deposit-group",
+ DeleteRefund = "delete-refund",
+ DeletePeerPullDebit = "delete-peer-pull-debit",
+ DeletePeerPushDebit = "delete-peer-push-debit",
+ DeletePeerPullCredit = "delete-peer-pull-credit",
+ DeletePeerPushCredit = "delete-peer-push-credit",
+}
+
+export function getExchangeTosStatusFromRecord(
+ exchange: ExchangeEntryRecord,
+): ExchangeTosStatus {
+ if (!exchange.tosAcceptedEtag) {
+ return ExchangeTosStatus.Proposed;
+ }
+ if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) {
+ return ExchangeTosStatus.Accepted;
+ }
+ return ExchangeTosStatus.Proposed;
+}
+
+export function getExchangeUpdateStatusFromRecord(
+ r: ExchangeEntryRecord,
+): ExchangeUpdateStatus {
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ return ExchangeUpdateStatus.UnavailableUpdate;
+ case ExchangeEntryDbUpdateStatus.Initial:
+ return ExchangeUpdateStatus.Initial;
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ return ExchangeUpdateStatus.InitialUpdate;
+ case ExchangeEntryDbUpdateStatus.Ready:
+ return ExchangeUpdateStatus.Ready;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ return ExchangeUpdateStatus.ReadyUpdate;
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ return ExchangeUpdateStatus.Suspended;
+ }
+}
+
+export function getExchangeEntryStatusFromRecord(
+ r: ExchangeEntryRecord,
+): ExchangeEntryStatus {
+ switch (r.entryStatus) {
+ case ExchangeEntryDbRecordStatus.Ephemeral:
+ return ExchangeEntryStatus.Ephemeral;
+ case ExchangeEntryDbRecordStatus.Preset:
+ return ExchangeEntryStatus.Preset;
+ case ExchangeEntryDbRecordStatus.Used:
+ return ExchangeEntryStatus.Used;
+ }
+}
+
+/**
+ * Compute the state of an exchange entry from the DB
+ * record.
+ */
+export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState {
+ return {
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+ tosStatus: getExchangeTosStatusFromRecord(r),
+ };
+}
+
+export type ParsedTombstone =
+ | {
+ tag: TombstoneTag.DeleteWithdrawalGroup;
+ withdrawalGroupId: string;
+ }
+ | { tag: TombstoneTag.DeleteRefund; refundGroupId: string }
+ | { tag: TombstoneTag.DeleteReserve; reservePub: string }
+ | { tag: TombstoneTag.DeleteRefreshGroup; refreshGroupId: string }
+ | { tag: TombstoneTag.DeleteReward; walletTipId: string }
+ | { tag: TombstoneTag.DeletePayment; proposalId: string };
+
+export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
+ switch (p.tag) {
+ case TombstoneTag.DeleteWithdrawalGroup:
+ return `tmb:${p.tag}:${p.withdrawalGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteRefund:
+ return `tmb:${p.tag}:${p.refundGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteReserve:
+ return `tmb:${p.tag}:${p.reservePub}` as TombstoneIdStr;
+ case TombstoneTag.DeletePayment:
+ return `tmb:${p.tag}:${p.proposalId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteRefreshGroup:
+ return `tmb:${p.tag}:${p.refreshGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteReward:
+ return `tmb:${p.tag}:${p.walletTipId}` as TombstoneIdStr;
+ default:
+ assertUnreachable(p);
+ }
+}
+
+/**
+ * Uniform interface for a particular wallet transaction.
+ */
+export interface TransactionManager {
+ get taskId(): TaskId;
+ get transactionId(): TransactionIdStr;
+ fail(): Promise<void>;
+ abort(): Promise<void>;
+ suspend(): Promise<void>;
+ resume(): Promise<void>;
+ process(): Promise<TaskRunResult>;
+}
+
+export enum TaskRunResultType {
+ Finished = "finished",
+ Backoff = "backoff",
+ Progress = "progress",
+ Error = "error",
+ ScheduleLater = "schedule-later",
+}
+
+export type TaskRunResult =
+ | TaskRunFinishedResult
+ | TaskRunErrorResult
+ | TaskRunBackoffResult
+ | TaskRunProgressResult
+ | TaskRunScheduleLaterResult;
+
+export namespace TaskRunResult {
+ /**
+ * Task is finished and does not need to be processed again.
+ */
+ export function finished(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Finished,
+ };
+ }
+ /**
+ * Task is waiting for something, should be invoked
+ * again with exponentiall back-off until some other
+ * result is returned.
+ */
+ export function backoff(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Backoff,
+ };
+ }
+ /**
+ * Task made progress and should be processed again.
+ */
+ export function progress(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Progress,
+ };
+ }
+ /**
+ * Run the task again at a fixed time in the future.
+ */
+ export function runAgainAt(runAt: AbsoluteTime): TaskRunResult {
+ return {
+ type: TaskRunResultType.ScheduleLater,
+ runAt,
+ };
+ }
+}
+
+export interface TaskRunFinishedResult {
+ type: TaskRunResultType.Finished;
+}
+
+export interface TaskRunBackoffResult {
+ type: TaskRunResultType.Backoff;
+}
+
+export interface TaskRunProgressResult {
+ type: TaskRunResultType.Progress;
+}
+
+export interface TaskRunScheduleLaterResult {
+ type: TaskRunResultType.ScheduleLater;
+ runAt: AbsoluteTime;
+}
+
+export interface TaskRunErrorResult {
+ type: TaskRunResultType.Error;
+ errorDetail: TalerErrorDetail;
+}
+
+export interface DbRetryInfo {
+ firstTry: DbPreciseTimestamp;
+ nextRetry: DbPreciseTimestamp;
+ retryCounter: number;
+}
+
+export interface RetryPolicy {
+ readonly backoffDelta: Duration;
+ readonly backoffBase: number;
+ readonly maxTimeout: Duration;
+}
+
+const defaultRetryPolicy: RetryPolicy = {
+ backoffBase: 1.5,
+ backoffDelta: Duration.fromSpec({ seconds: 1 }),
+ maxTimeout: Duration.fromSpec({ minutes: 2 }),
+};
+
+function updateTimeout(
+ r: DbRetryInfo,
+ p: RetryPolicy = defaultRetryPolicy,
+): void {
+ const now = AbsoluteTime.now();
+ if (now.t_ms === "never") {
+ throw Error("assertion failed");
+ }
+ if (p.backoffDelta.d_ms === "forever") {
+ r.nextRetry = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ );
+ return;
+ }
+
+ const nextIncrement =
+ p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
+
+ const t =
+ now.t_ms +
+ (p.maxTimeout.d_ms === "forever"
+ ? nextIncrement
+ : Math.min(p.maxTimeout.d_ms, nextIncrement));
+ r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
+}
+
+export namespace DbRetryInfo {
+ export function getDuration(
+ r: DbRetryInfo | undefined,
+ p: RetryPolicy = defaultRetryPolicy,
+ ): Duration {
+ if (!r) {
+ // If we don't have any retry info, run immediately.
+ return { d_ms: 0 };
+ }
+ if (p.backoffDelta.d_ms === "forever") {
+ return { d_ms: "forever" };
+ }
+ const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
+ return {
+ d_ms:
+ p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t),
+ };
+ }
+
+ export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo {
+ const now = TalerPreciseTimestamp.now();
+ const info: DbRetryInfo = {
+ firstTry: timestampPreciseToDb(now),
+ nextRetry: timestampPreciseToDb(now),
+ retryCounter: 0,
+ };
+ updateTimeout(info, p);
+ return info;
+ }
+
+ export function increment(
+ r: DbRetryInfo | undefined,
+ p: RetryPolicy = defaultRetryPolicy,
+ ): DbRetryInfo {
+ if (!r) {
+ return reset(p);
+ }
+ const r2 = { ...r };
+ r2.retryCounter++;
+ updateTimeout(r2, p);
+ return r2;
+ }
+}
+
+/**
+ * Timestamp after which the wallet would do an auto-refresh.
+ */
+export function getAutoRefreshExecuteThreshold(d: {
+ stampExpireWithdraw: TalerProtocolTimestamp;
+ stampExpireDeposit: TalerProtocolTimestamp;
+}): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ d.stampExpireWithdraw,
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ d.stampExpireDeposit,
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.5);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
+ * Parsed representation of task identifiers.
+ */
+export type ParsedTaskIdentifier =
+ | {
+ tag: PendingTaskType.Withdraw;
+ withdrawalGroupId: string;
+ }
+ | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
+ | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
+ | { tag: PendingTaskType.Deposit; depositGroupId: string }
+ | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string }
+ | { tag: PendingTaskType.PeerPullCredit; pursePub: string }
+ | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string }
+ | { tag: PendingTaskType.PeerPushDebit; pursePub: string }
+ | { tag: PendingTaskType.Purchase; proposalId: string }
+ | { tag: PendingTaskType.Recoup; recoupGroupId: string }
+ | { tag: PendingTaskType.RewardPickup; walletRewardId: string }
+ | { tag: PendingTaskType.Refresh; refreshGroupId: string };
+
+export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
+ const task = x.split(":");
+
+ if (task.length < 2) {
+ throw Error("task id should have al least 2 parts separated by ':'");
+ }
+
+ const [type, ...rest] = task;
+ switch (type) {
+ case PendingTaskType.Backup:
+ return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) };
+ case PendingTaskType.Deposit:
+ return { tag: type, depositGroupId: rest[0] };
+ case PendingTaskType.ExchangeUpdate:
+ return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
+ case PendingTaskType.PeerPullCredit:
+ return { tag: type, pursePub: rest[0] };
+ case PendingTaskType.PeerPullDebit:
+ return { tag: type, peerPullDebitId: rest[0] };
+ case PendingTaskType.PeerPushCredit:
+ return { tag: type, peerPushCreditId: rest[0] };
+ case PendingTaskType.PeerPushDebit:
+ return { tag: type, pursePub: rest[0] };
+ case PendingTaskType.Purchase:
+ return { tag: type, proposalId: rest[0] };
+ case PendingTaskType.Recoup:
+ return { tag: type, recoupGroupId: rest[0] };
+ case PendingTaskType.Refresh:
+ return { tag: type, refreshGroupId: rest[0] };
+ case PendingTaskType.RewardPickup:
+ return { tag: type, walletRewardId: rest[0] };
+ case PendingTaskType.Withdraw:
+ return { tag: type, withdrawalGroupId: rest[0] };
+ default:
+ throw Error("invalid task identifier");
+ }
+}
+
+export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskId {
+ switch (p.tag) {
+ case PendingTaskType.Backup:
+ return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId;
+ case PendingTaskType.Deposit:
+ return `${p.tag}:${p.depositGroupId}` as TaskId;
+ case PendingTaskType.ExchangeUpdate:
+ return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskId;
+ case PendingTaskType.PeerPullDebit:
+ return `${p.tag}:${p.peerPullDebitId}` as TaskId;
+ case PendingTaskType.PeerPushCredit:
+ return `${p.tag}:${p.peerPushCreditId}` as TaskId;
+ case PendingTaskType.PeerPullCredit:
+ return `${p.tag}:${p.pursePub}` as TaskId;
+ case PendingTaskType.PeerPushDebit:
+ return `${p.tag}:${p.pursePub}` as TaskId;
+ case PendingTaskType.Purchase:
+ return `${p.tag}:${p.proposalId}` as TaskId;
+ case PendingTaskType.Recoup:
+ return `${p.tag}:${p.recoupGroupId}` as TaskId;
+ case PendingTaskType.Refresh:
+ return `${p.tag}:${p.refreshGroupId}` as TaskId;
+ case PendingTaskType.RewardPickup:
+ return `${p.tag}:${p.walletRewardId}` as TaskId;
+ case PendingTaskType.Withdraw:
+ return `${p.tag}:${p.withdrawalGroupId}` as TaskId;
+ default:
+ assertUnreachable(p);
+ }
+}
+
+export namespace TaskIdentifiers {
+ export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId {
+ return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId;
+ }
+ export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId {
+ return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
+ exch.baseUrl,
+ )}` as TaskId;
+ }
+ export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId {
+ return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
+ exchBaseUrl,
+ )}` as TaskId;
+ }
+ export function forTipPickup(tipRecord: RewardRecord): TaskId {
+ return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId;
+ }
+ export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId {
+ return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId;
+ }
+ export function forPay(purchaseRecord: PurchaseRecord): TaskId {
+ return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId;
+ }
+ export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId {
+ return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId;
+ }
+ export function forDeposit(depositRecord: DepositGroupRecord): TaskId {
+ return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId;
+ }
+ export function forBackup(backupRecord: BackupProviderRecord): TaskId {
+ return `${PendingTaskType.Backup}:${encodeURIComponent(
+ backupRecord.baseUrl,
+ )}` as TaskId;
+ }
+ export function forPeerPushPaymentInitiation(
+ ppi: PeerPushDebitRecord,
+ ): TaskId {
+ return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId;
+ }
+ export function forPeerPullPaymentInitiation(
+ ppi: PeerPullCreditRecord,
+ ): TaskId {
+ return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId;
+ }
+ export function forPeerPullPaymentDebit(
+ ppi: PeerPullPaymentIncomingRecord,
+ ): TaskId {
+ return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskId;
+ }
+ export function forPeerPushCredit(
+ ppi: PeerPushPaymentIncomingRecord,
+ ): TaskId {
+ return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId;
+ }
+}
+
+/**
+ * Result of a transaction transition.
+ */
+export enum TransitionResult {
+ Transition = 1,
+ Stay = 2,
+}
+
+/**
+ * Transaction context.
+ * Uniform interface to all transactions.
+ */
+export interface TransactionContext {
+ abortTransaction(): Promise<void>;
+ suspendTransaction(): Promise<void>;
+ resumeTransaction(): Promise<void>;
+ failTransaction(): Promise<void>;
+ deleteTransaction(): Promise<void>;
+}
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -60,7 +60,7 @@ import {
WithdrawalExchangeAccountDetails,
codecForAny,
} from "@gnu-taler/taler-util";
-import { DbRetryInfo, TaskIdentifiers } from "./operations/common.js";
+import { DbRetryInfo, TaskIdentifiers } from "./common.js";
import {
DbAccess,
DbReadOnlyTransaction,
@@ -74,7 +74,7 @@ import {
describeStore,
describeStoreV2,
openDatabase,
-} from "./util/query.js";
+} from "./query.js";
/**
* This file contains the database schema of the Taler wallet together
diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts
@@ -64,11 +64,11 @@ import {
ExchangeKeysDownloadResult,
isWithdrawableDenom,
} from "./index.js";
-import { assembleRefreshRevealRequest } from "./operations/refresh.js";
+import { assembleRefreshRevealRequest } from "./refresh.js";
import {
getBankStatusUrl,
getBankWithdrawalInfo,
-} from "./operations/withdraw.js";
+} from "./withdraw.js";
const logger = new Logger("dbless.ts");
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -0,0 +1,1598 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the deposit transaction.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ BatchDepositRequestCoin,
+ CancellationToken,
+ CoinRefreshRequest,
+ CreateDepositGroupRequest,
+ CreateDepositGroupResponse,
+ DepositGroupFees,
+ Duration,
+ ExchangeBatchDepositRequest,
+ ExchangeRefundRequest,
+ HttpStatusCode,
+ Logger,
+ MerchantContractTerms,
+ NotificationType,
+ PayCoinSelection,
+ PrepareDepositRequest,
+ PrepareDepositResponse,
+ RefreshReason,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TrackTransaction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WireFee,
+ canonicalJson,
+ codecForBatchDepositSuccess,
+ codecForTackTransactionAccepted,
+ codecForTackTransactionWired,
+ durationFromSpec,
+ encodeCrock,
+ getRandomBytes,
+ hashTruncate32,
+ hashWire,
+ j2s,
+ parsePaytoUri,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import { DepositElementStatus, DepositGroupRecord } from "./db.js";
+import {
+ DepositOperationStatus,
+ DepositTrackingInfo,
+ KycPendingInfo,
+ PendingTaskType,
+ RefreshOperationStatus,
+ TaskId,
+ createRefreshGroup,
+ getCandidateWithdrawalDenomsTx,
+ getTotalRefreshCost,
+ timestampPreciseToDb,
+ timestampProtocolToDb,
+} from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { selectPayCoinsNew } from "./util/coinSelection.js";
+import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js";
+import {
+ TaskRunResult,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import { getExchangeWireDetailsInTx } from "./exchanges.js";
+import {
+ extractContractData,
+ generateDepositPermissions,
+ getTotalPaymentCost,
+} from "./pay-merchant.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("deposits.ts");
+
+export class DepositTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskId;
+ constructor(
+ public ws: InternalWalletState,
+ public depositGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const depositGroupId = this.depositGroupId;
+ const ws = this.ws;
+ // FIXME: We should check first if we are in a final state
+ // where deletion is allowed.
+ await ws.db.runReadWriteTx(["depositGroups", "tombstones"], async (tx) => {
+ const tipRecord = await tx.depositGroups.get(depositGroupId);
+ if (tipRecord) {
+ await tx.depositGroups.delete(depositGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
+ });
+ }
+ });
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ let newOpStatus: DepositOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.PendingDeposit:
+ newOpStatus = DepositOperationStatus.SuspendedDeposit;
+ break;
+ case DepositOperationStatus.PendingKyc:
+ newOpStatus = DepositOperationStatus.SuspendedKyc;
+ break;
+ case DepositOperationStatus.PendingTrack:
+ newOpStatus = DepositOperationStatus.SuspendedTrack;
+ break;
+ case DepositOperationStatus.Aborting:
+ newOpStatus = DepositOperationStatus.SuspendedAborting;
+ break;
+ }
+ if (!newOpStatus) {
+ return undefined;
+ }
+ dg.operationStatus = newOpStatus;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return undefined;
+ case DepositOperationStatus.PendingDeposit: {
+ dg.operationStatus = DepositOperationStatus.Aborting;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ }
+ case DepositOperationStatus.SuspendedDeposit:
+ // FIXME: Can we abort a suspended transaction?!
+ return undefined;
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ let newOpStatus: DepositOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.SuspendedDeposit:
+ newOpStatus = DepositOperationStatus.PendingDeposit;
+ break;
+ case DepositOperationStatus.SuspendedAborting:
+ newOpStatus = DepositOperationStatus.Aborting;
+ break;
+ case DepositOperationStatus.SuspendedKyc:
+ newOpStatus = DepositOperationStatus.PendingKyc;
+ break;
+ case DepositOperationStatus.SuspendedTrack:
+ newOpStatus = DepositOperationStatus.PendingTrack;
+ break;
+ }
+ if (!newOpStatus) {
+ return undefined;
+ }
+ dg.operationStatus = newOpStatus;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.Aborting: {
+ dg.operationStatus = DepositOperationStatus.Failed;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ }
+ }
+ return undefined;
+ },
+ );
+ // FIXME: Also cancel ongoing work (via cancellation token, once implemented)
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+}
+
+/**
+ * Get the (DD37-style) transaction status based on the
+ * database record of a deposit group.
+ */
+export function computeDepositTransactionStatus(
+ dg: DepositGroupRecord,
+): TransactionState {
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case DepositOperationStatus.PendingDeposit:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Deposit,
+ };
+ case DepositOperationStatus.PendingKyc:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case DepositOperationStatus.PendingTrack:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Track,
+ };
+ case DepositOperationStatus.SuspendedKyc:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case DepositOperationStatus.SuspendedTrack:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Track,
+ };
+ case DepositOperationStatus.SuspendedDeposit:
+ return {
+ major: TransactionMajorState.Suspended,
+ };
+ case DepositOperationStatus.Aborting:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ case DepositOperationStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case DepositOperationStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case DepositOperationStatus.SuspendedAborting:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ };
+ default:
+ assertUnreachable(dg.operationStatus);
+ }
+}
+
+/**
+ * Compute the possible actions possible on a deposit transaction
+ * based on the current transaction state.
+ */
+export function computeDepositTransactionActions(
+ dg: DepositGroupRecord,
+): TransactionAction[] {
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.PendingDeposit:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case DepositOperationStatus.SuspendedDeposit:
+ return [TransactionAction.Resume];
+ case DepositOperationStatus.Aborting:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case DepositOperationStatus.Aborted:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.Failed:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.SuspendedAborting:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case DepositOperationStatus.PendingKyc:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case DepositOperationStatus.PendingTrack:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case DepositOperationStatus.SuspendedKyc:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case DepositOperationStatus.SuspendedTrack:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ default:
+ assertUnreachable(dg.operationStatus);
+ }
+}
+
+/**
+ * Check whether the refresh associated with the
+ * aborting deposit group is done.
+ *
+ * If done, mark the deposit transaction as aborted.
+ *
+ * Otherwise continue waiting.
+ *
+ * FIXME: Wait for the refresh group notifications instead of periodically
+ * checking the refresh group status.
+ * FIXME: This is just one transaction, can't we do this in the initial
+ * transaction of processDepositGroup?
+ */
+async function waitForRefreshOnDepositGroup(
+ ws: InternalWalletState,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: depositGroup.depositGroupId,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups", "refreshGroups"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: DepositOperationStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into aborted.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = DepositOperationStatus.Aborted;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = DepositOperationStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = DepositOperationStatus.Aborted;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ newDg.operationStatus = newOpState;
+ const newTxState = computeDepositTransactionStatus(newDg);
+ await tx.depositGroups.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+}
+
+async function refundDepositGroup(
+ ws: InternalWalletState,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const newTxPerCoin = [...depositGroup.statusPerCoin];
+ logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`);
+ for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
+ const st = depositGroup.statusPerCoin[i];
+ switch (st) {
+ case DepositElementStatus.RefundFailed:
+ case DepositElementStatus.RefundSuccess:
+ break;
+ default: {
+ const coinPub = depositGroup.payCoinSelection.coinPubs[i];
+ const coinExchange = await ws.db.runReadOnlyTx(
+ ["coins"],
+ async (tx) => {
+ const coinRecord = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coinRecord);
+ return coinRecord.exchangeBaseUrl;
+ },
+ );
+ const refundAmount = depositGroup.payCoinSelection.coinContributions[i];
+ // We use a constant refund transaction ID, since there can
+ // only be one refund.
+ const rtid = 1;
+ const sig = await ws.cryptoApi.signRefund({
+ coinPub,
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ merchantPub: depositGroup.merchantPub,
+ refundAmount: refundAmount,
+ rtransactionId: rtid,
+ });
+ const refundReq: ExchangeRefundRequest = {
+ h_contract_terms: depositGroup.contractTermsHash,
+ merchant_pub: depositGroup.merchantPub,
+ merchant_sig: sig.sig,
+ refund_amount: refundAmount,
+ rtransaction_id: rtid,
+ };
+ const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
+ const httpResp = await ws.http.fetch(refundUrl.href, {
+ method: "POST",
+ body: refundReq,
+ });
+ logger.info(
+ `coin ${i} refund HTTP status for coin: ${httpResp.status}`,
+ );
+ let newStatus: DepositElementStatus;
+ if (httpResp.status === 200) {
+ // FIXME: validate response
+ newStatus = DepositElementStatus.RefundSuccess;
+ } else {
+ // FIXME: Store problem somewhere!
+ newStatus = DepositElementStatus.RefundFailed;
+ }
+ // FIXME: Handle case where refund request needs to be tried again
+ newTxPerCoin[i] = newStatus;
+ break;
+ }
+ }
+ }
+ let isDone = true;
+ for (let i = 0; i < newTxPerCoin.length; i++) {
+ if (
+ newTxPerCoin[i] != DepositElementStatus.RefundFailed &&
+ newTxPerCoin[i] != DepositElementStatus.RefundSuccess
+ ) {
+ isDone = false;
+ }
+ }
+
+ const currency = Amounts.currencyOf(depositGroup.totalPayCost);
+
+ await ws.db.runReadWriteTx(
+ [
+ "depositGroups",
+ "refreshGroups",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ newDg.statusPerCoin = newTxPerCoin;
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < newTxPerCoin.length; i++) {
+ refreshCoins.push({
+ amount: depositGroup.payCoinSelection.coinContributions[i],
+ coinPub: depositGroup.payCoinSelection.coinPubs[i],
+ });
+ }
+ if (isDone) {
+ const rgid = await createRefreshGroup(
+ ws,
+ tx,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortDeposit,
+ constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: newDg.depositGroupId,
+ }),
+ );
+ newDg.abortRefreshGroupId = rgid.refreshGroupId;
+ }
+ await tx.depositGroups.put(newDg);
+ },
+ );
+
+ return TaskRunResult.backoff();
+}
+
+async function processDepositGroupAborting(
+ ws: InternalWalletState,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ logger.info("processing deposit tx in 'aborting'");
+ const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
+ if (!abortRefreshGroupId) {
+ logger.info("refunding deposit group");
+ return refundDepositGroup(ws, depositGroup);
+ }
+ logger.info("waiting for refresh");
+ return waitForRefreshOnDepositGroup(ws, depositGroup);
+}
+
+async function processDepositGroupPendingKyc(
+ ws: InternalWalletState,
+ depositGroup: DepositGroupRecord,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+ const retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId,
+ });
+
+ const kycInfo = depositGroup.kycInfo;
+ const userType = "individual";
+
+ if (!kycInfo) {
+ throw Error("invalid DB state, in pending(kyc), but no kycInfo present");
+ }
+
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ kycInfo.exchangeBaseUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await ws.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const newDg = await tx.depositGroups.get(depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ newDg.operationStatus = DepositOperationStatus.PendingTrack;
+ const newTxState = computeDepositTransactionStatus(newDg);
+ await tx.depositGroups.put(newDg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Tracking information from the exchange indicated that
+ * KYC is required. We need to check the KYC info
+ * and transition the transaction to the KYC required state.
+ */
+async function transitionToKycRequired(
+ ws: InternalWalletState,
+ depositGroup: DepositGroupRecord,
+ kycInfo: KycPendingInfo,
+ exchangeUrl: string,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+ const userType = "individual";
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusReq = await ws.http.fetch(url.href, {
+ method: "GET",
+ });
+ if (kycStatusReq.status === HttpStatusCode.Ok) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.backoff();
+ } else if (kycStatusReq.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusReq.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ if (dg.operationStatus !== DepositOperationStatus.PendingTrack) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ dg.kycInfo = {
+ exchangeBaseUrl: exchangeUrl,
+ kycUrl: kycStatus.kyc_url,
+ paytoHash: kycInfo.paytoHash,
+ requirementRow: kycInfo.requirementRow,
+ };
+ await tx.depositGroups.put(dg);
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
+ }
+}
+
+async function processDepositGroupPendingTrack(
+ ws: InternalWalletState,
+ depositGroup: DepositGroupRecord,
+ cancellationToken?: CancellationToken,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+ for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
+ const coinPub = depositGroup.payCoinSelection.coinPubs[i];
+ // FIXME: Make the URL part of the coin selection?
+ const exchangeBaseUrl = await ws.db.runReadWriteTx(
+ ["coins"],
+ async (tx) => {
+ const coinRecord = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coinRecord);
+ return coinRecord.exchangeBaseUrl;
+ },
+ );
+
+ let updatedTxStatus: DepositElementStatus | undefined = undefined;
+ let newWiredCoin:
+ | {
+ id: string;
+ value: DepositTrackingInfo;
+ }
+ | undefined;
+
+ if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) {
+ const track = await trackDeposit(
+ ws,
+ depositGroup,
+ coinPub,
+ exchangeBaseUrl,
+ );
+
+ if (track.type === "accepted") {
+ if (!track.kyc_ok && track.requirement_row !== undefined) {
+ const paytoHash = encodeCrock(
+ hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")),
+ );
+ const { requirement_row: requirementRow } = track;
+ const kycInfo: KycPendingInfo = {
+ paytoHash,
+ requirementRow,
+ };
+ return transitionToKycRequired(
+ ws,
+ depositGroup,
+ kycInfo,
+ exchangeBaseUrl,
+ );
+ } else {
+ updatedTxStatus = DepositElementStatus.Tracking;
+ }
+ } else if (track.type === "wired") {
+ updatedTxStatus = DepositElementStatus.Wired;
+
+ const payto = parsePaytoUri(depositGroup.wire.payto_uri);
+ if (!payto) {
+ throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`);
+ }
+
+ const fee = await getExchangeWireFee(
+ ws,
+ payto.targetType,
+ exchangeBaseUrl,
+ track.execution_time,
+ );
+ const raw = Amounts.parseOrThrow(track.coin_contribution);
+ const wireFee = Amounts.parseOrThrow(fee.wireFee);
+
+ newWiredCoin = {
+ value: {
+ amountRaw: Amounts.stringify(raw),
+ wireFee: Amounts.stringify(wireFee),
+ exchangePub: track.exchange_pub,
+ timestampExecuted: timestampProtocolToDb(track.execution_time),
+ wireTransferId: track.wtid,
+ },
+ id: track.exchange_sig,
+ };
+ } else {
+ updatedTxStatus = DepositElementStatus.DepositPending;
+ }
+ }
+
+ if (updatedTxStatus !== undefined) {
+ await ws.db.runReadWriteTx(["depositGroups"], async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return;
+ }
+ if (updatedTxStatus !== undefined) {
+ dg.statusPerCoin[i] = updatedTxStatus;
+ }
+ if (newWiredCoin) {
+ /**
+ * FIXME: if there is a new wire information from the exchange
+ * it should add up to the previous tracking states.
+ *
+ * This may loose information by overriding prev state.
+ *
+ * And: add checks to integration tests
+ */
+ if (!dg.trackingState) {
+ dg.trackingState = {};
+ }
+
+ dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
+ }
+ await tx.depositGroups.put(dg);
+ });
+ }
+ }
+
+ let allWired = true;
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
+ if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) {
+ allWired = false;
+ break;
+ }
+ }
+ if (allWired) {
+ dg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ dg.operationStatus = DepositOperationStatus.Finished;
+ await tx.depositGroups.put(dg);
+ }
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ if (allWired) {
+ return TaskRunResult.finished();
+ } else {
+ // FIXME: Use long-polling.
+ return TaskRunResult.backoff();
+ }
+}
+
+async function processDepositGroupPendingDeposit(
+ ws: InternalWalletState,
+ depositGroup: DepositGroupRecord,
+ cancellationToken?: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.info("processing deposit group in pending(deposit)");
+ const depositGroupId = depositGroup.depositGroupId;
+ const contractTermsRec = await ws.db.runReadOnlyTx(
+ ["contractTerms"],
+ async (tx) => {
+ return tx.contractTerms.get(depositGroup.contractTermsHash);
+ },
+ );
+ if (!contractTermsRec) {
+ throw Error("contract terms for deposit not found in database");
+ }
+ const contractTerms: MerchantContractTerms =
+ contractTermsRec.contractTermsRaw;
+ const contractData = extractContractData(
+ contractTermsRec.contractTermsRaw,
+ depositGroup.contractTermsHash,
+ "",
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+
+ // Check for cancellation before expensive operations.
+ cancellationToken?.throwIfCancelled();
+
+ // FIXME: Cache these!
+ const depositPermissions = await generateDepositPermissions(
+ ws,
+ depositGroup.payCoinSelection,
+ contractData,
+ );
+
+ // Exchanges involved in the deposit
+ const exchanges: Set<string> = new Set();
+
+ for (const dp of depositPermissions) {
+ exchanges.add(dp.exchange_url);
+ }
+
+ // We need to do one batch per exchange.
+ for (const exchangeUrl of exchanges.values()) {
+ const coins: BatchDepositRequestCoin[] = [];
+ const batchIndexes: number[] = [];
+
+ const batchReq: ExchangeBatchDepositRequest = {
+ coins,
+ h_contract_terms: depositGroup.contractTermsHash,
+ merchant_payto_uri: depositGroup.wire.payto_uri,
+ merchant_pub: contractTerms.merchant_pub,
+ timestamp: contractTerms.timestamp,
+ wire_salt: depositGroup.wire.salt,
+ wire_transfer_deadline: contractTerms.wire_transfer_deadline,
+ refund_deadline: contractTerms.refund_deadline,
+ };
+
+ for (let i = 0; i < depositPermissions.length; i++) {
+ const perm = depositPermissions[i];
+ if (perm.exchange_url != exchangeUrl) {
+ continue;
+ }
+ coins.push({
+ coin_pub: perm.coin_pub,
+ coin_sig: perm.coin_sig,
+ contribution: Amounts.stringify(perm.contribution),
+ denom_pub_hash: perm.h_denom,
+ ub_sig: perm.ub_sig,
+ h_age_commitment: perm.h_age_commitment,
+ });
+ batchIndexes.push(i);
+ }
+
+ // Check for cancellation before making network request.
+ cancellationToken?.throwIfCancelled();
+ const url = new URL(`batch-deposit`, exchangeUrl);
+ logger.info(`depositing to ${url.href}`);
+ logger.trace(`deposit request: ${j2s(batchReq)}`);
+ const httpResp = await ws.http.fetch(url.href, {
+ method: "POST",
+ body: batchReq,
+ cancellationToken: cancellationToken,
+ });
+ await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForBatchDepositSuccess(),
+ );
+
+ await ws.db.runReadWriteTx(["depositGroups"], async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return;
+ }
+ for (const batchIndex of batchIndexes) {
+ const coinStatus = dg.statusPerCoin[batchIndex];
+ switch (coinStatus) {
+ case DepositElementStatus.DepositPending:
+ dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
+ await tx.depositGroups.put(dg);
+ }
+ }
+ });
+ }
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ dg.operationStatus = DepositOperationStatus.PendingTrack;
+ await tx.depositGroups.put(dg);
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+}
+
+/**
+ * Process a deposit group that is not in its final state yet.
+ */
+export async function processDepositGroup(
+ ws: InternalWalletState,
+ depositGroupId: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const depositGroup = await ws.db.runReadOnlyTx(
+ ["depositGroups"],
+ async (tx) => {
+ return tx.depositGroups.get(depositGroupId);
+ },
+ );
+ if (!depositGroup) {
+ logger.warn(`deposit group ${depositGroupId} not found`);
+ return TaskRunResult.finished();
+ }
+
+ switch (depositGroup.operationStatus) {
+ case DepositOperationStatus.PendingTrack:
+ return processDepositGroupPendingTrack(
+ ws,
+ depositGroup,
+ cancellationToken,
+ );
+ case DepositOperationStatus.PendingKyc:
+ return processDepositGroupPendingKyc(ws, depositGroup, cancellationToken);
+ case DepositOperationStatus.PendingDeposit:
+ return processDepositGroupPendingDeposit(
+ ws,
+ depositGroup,
+ cancellationToken,
+ );
+ case DepositOperationStatus.Aborting:
+ return processDepositGroupAborting(ws, depositGroup);
+ }
+
+ return TaskRunResult.finished();
+}
+
+/**
+ * FIXME: Consider moving this to exchanges.ts.
+ */
+async function getExchangeWireFee(
+ ws: InternalWalletState,
+ wireType: string,
+ baseUrl: string,
+ time: TalerProtocolTimestamp,
+): Promise<WireFee> {
+ const exchangeDetails = await ws.db.runReadOnlyTx(
+ ["exchangeDetails", "exchanges"],
+ async (tx) => {
+ const ex = await tx.exchanges.get(baseUrl);
+ if (!ex || !ex.detailsPointer) return undefined;
+ return await tx.exchangeDetails.indexes.byPointer.get([
+ baseUrl,
+ ex.detailsPointer.currency,
+ ex.detailsPointer.masterPublicKey,
+ ]);
+ },
+ );
+
+ if (!exchangeDetails) {
+ throw Error(`exchange missing: ${baseUrl}`);
+ }
+
+ const fees = exchangeDetails.wireInfo.feesForType[wireType];
+ if (!fees || fees.length === 0) {
+ throw Error(
+ `exchange ${baseUrl} doesn't have fees for wire type ${wireType}`,
+ );
+ }
+ const fee = fees.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.fromProtocolTimestamp(time),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ });
+ if (!fee) {
+ throw Error(
+ `exchange ${exchangeDetails.exchangeBaseUrl} doesn't have fees for wire type ${wireType} at ${time.t_s}`,
+ );
+ }
+
+ return fee;
+}
+
+async function trackDeposit(
+ ws: InternalWalletState,
+ depositGroup: DepositGroupRecord,
+ coinPub: string,
+ exchangeUrl: string,
+): Promise<TrackTransaction> {
+ const wireHash = hashWire(
+ depositGroup.wire.payto_uri,
+ depositGroup.wire.salt,
+ );
+
+ const url = new URL(
+ `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
+ exchangeUrl,
+ );
+ const sigResp = await ws.cryptoApi.signTrackTransaction({
+ coinPub,
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ merchantPub: depositGroup.merchantPub,
+ wireHash,
+ });
+ url.searchParams.set("merchant_sig", sigResp.sig);
+ const httpResp = await ws.http.fetch(url.href, { method: "GET" });
+ logger.trace(`deposits response status: ${httpResp.status}`);
+ switch (httpResp.status) {
+ case HttpStatusCode.Accepted: {
+ const accepted = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForTackTransactionAccepted(),
+ );
+ return { type: "accepted", ...accepted };
+ }
+ case HttpStatusCode.Ok: {
+ const wired = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForTackTransactionWired(),
+ );
+ return { type: "wired", ...wired };
+ }
+ default: {
+ throw Error(
+ `unexpected response from track-transaction (${httpResp.status})`,
+ );
+ }
+ }
+}
+
+/**
+ * Check if creating a deposit group is possible and calculate
+ * the associated fees.
+ *
+ * FIXME: This should be renamed to checkDepositGroup,
+ * as it doesn't prepare anything
+ */
+export async function prepareDepositGroup(
+ ws: InternalWalletState,
+ req: PrepareDepositRequest,
+): Promise<PrepareDepositResponse> {
+ const p = parsePaytoUri(req.depositPaytoUri);
+ if (!p) {
+ throw Error("invalid payto URI");
+ }
+ const amount = Amounts.parseOrThrow(req.amount);
+
+ const exchangeInfos: { url: string; master_pub: string }[] = [];
+
+ await ws.db.runReadOnlyTx(["exchangeDetails", "exchanges"], async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || amount.currency !== details.currency) {
+ continue;
+ }
+ exchangeInfos.push({
+ master_pub: details.masterPublicKey,
+ url: e.baseUrl,
+ });
+ }
+ });
+
+ const now = AbsoluteTime.now();
+ const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
+ const contractTerms: MerchantContractTerms = {
+ exchanges: exchangeInfos,
+ amount: req.amount,
+ max_fee: Amounts.stringify(amount),
+ max_wire_fee: Amounts.stringify(amount),
+ wire_method: p.targetType,
+ timestamp: nowRounded,
+ merchant_base_url: "",
+ summary: "",
+ nonce: "",
+ wire_transfer_deadline: nowRounded,
+ order_id: "",
+ h_wire: "",
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
+ ),
+ merchant: {
+ name: "(wallet)",
+ },
+ merchant_pub: "",
+ refund_deadline: TalerProtocolTimestamp.zero(),
+ };
+
+ const { h: contractTermsHash } = await ws.cryptoApi.hashString({
+ str: canonicalJson(contractTerms),
+ });
+
+ const contractData = extractContractData(
+ contractTerms,
+ contractTermsHash,
+ "",
+ );
+
+ const payCoinSel = await selectPayCoinsNew(ws, {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ wireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
+ wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
+ prevPayCoins: [],
+ });
+
+ if (payCoinSel.type !== "success") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ }
+
+ const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
+
+ const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
+ ws,
+ p.targetType,
+ payCoinSel.coinSel,
+ );
+
+ const fees = await getTotalFeesForDepositAmount(
+ ws,
+ p.targetType,
+ amount,
+ payCoinSel.coinSel,
+ );
+
+ return {
+ totalDepositCost: Amounts.stringify(totalDepositCost),
+ effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
+ fees,
+ };
+}
+
+export function generateDepositGroupTxId(): string {
+ const depositGroupId = encodeCrock(getRandomBytes(32));
+ return constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: depositGroupId,
+ });
+}
+
+export async function createDepositGroup(
+ ws: InternalWalletState,
+ req: CreateDepositGroupRequest,
+): Promise<CreateDepositGroupResponse> {
+ const p = parsePaytoUri(req.depositPaytoUri);
+ if (!p) {
+ throw Error("invalid payto URI");
+ }
+
+ const amount = Amounts.parseOrThrow(req.amount);
+
+ const exchangeInfos: { url: string; master_pub: string }[] = [];
+
+ await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || amount.currency !== details.currency) {
+ continue;
+ }
+ exchangeInfos.push({
+ master_pub: details.masterPublicKey,
+ url: e.baseUrl,
+ });
+ }
+ });
+
+ const now = AbsoluteTime.now();
+ const wireDeadline = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
+ );
+ const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
+ const noncePair = await ws.cryptoApi.createEddsaKeypair({});
+ const merchantPair = await ws.cryptoApi.createEddsaKeypair({});
+ const wireSalt = encodeCrock(getRandomBytes(16));
+ const wireHash = hashWire(req.depositPaytoUri, wireSalt);
+ const contractTerms: MerchantContractTerms = {
+ exchanges: exchangeInfos,
+ amount: req.amount,
+ max_fee: Amounts.stringify(amount),
+ max_wire_fee: Amounts.stringify(amount),
+ wire_method: p.targetType,
+ timestamp: nowRounded,
+ merchant_base_url: "",
+ summary: "",
+ nonce: noncePair.pub,
+ wire_transfer_deadline: wireDeadline,
+ order_id: "",
+ h_wire: wireHash,
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
+ ),
+ merchant: {
+ name: "(wallet)",
+ },
+ merchant_pub: merchantPair.pub,
+ refund_deadline: TalerProtocolTimestamp.zero(),
+ };
+
+ const { h: contractTermsHash } = await ws.cryptoApi.hashString({
+ str: canonicalJson(contractTerms),
+ });
+
+ const contractData = extractContractData(
+ contractTerms,
+ contractTermsHash,
+ "",
+ );
+
+ const payCoinSel = await selectPayCoinsNew(ws, {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ wireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
+ wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
+ prevPayCoins: [],
+ });
+
+ if (payCoinSel.type !== "success") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ }
+
+ const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
+
+ let depositGroupId: string;
+ if (req.transactionId) {
+ const txId = parseTransactionIdentifier(req.transactionId);
+ if (!txId || txId.tag !== TransactionType.Deposit) {
+ throw Error("invalid transaction ID");
+ }
+ depositGroupId = txId.depositGroupId;
+ } else {
+ depositGroupId = encodeCrock(getRandomBytes(32));
+ }
+
+ const counterpartyEffectiveDepositAmount =
+ await getCounterpartyEffectiveDepositAmount(
+ ws,
+ p.targetType,
+ payCoinSel.coinSel,
+ );
+
+ const depositGroup: DepositGroupRecord = {
+ contractTermsHash,
+ depositGroupId,
+ currency: Amounts.currencyOf(totalDepositCost),
+ amount: contractData.amount,
+ noncePriv: noncePair.priv,
+ noncePub: noncePair.pub,
+ timestampCreated: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(now),
+ ),
+ timestampFinished: undefined,
+ statusPerCoin: payCoinSel.coinSel.coinPubs.map(
+ () => DepositElementStatus.DepositPending,
+ ),
+ payCoinSelection: payCoinSel.coinSel,
+ payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
+ merchantPriv: merchantPair.priv,
+ merchantPub: merchantPair.pub,
+ totalPayCost: Amounts.stringify(totalDepositCost),
+ counterpartyEffectiveDepositAmount: Amounts.stringify(
+ counterpartyEffectiveDepositAmount,
+ ),
+ wireTransferDeadline: timestampProtocolToDb(
+ contractTerms.wire_transfer_deadline,
+ ),
+ wire: {
+ payto_uri: req.depositPaytoUri,
+ salt: wireSalt,
+ },
+ operationStatus: DepositOperationStatus.PendingDeposit,
+ };
+
+ const ctx = new DepositTransactionContext(ws, depositGroupId);
+ const transactionId = ctx.transactionId;
+
+ const newTxState = await ws.db.runReadWriteTx(
+ [
+ "depositGroups",
+ "coins",
+ "recoupGroups",
+ "denominations",
+ "refreshGroups",
+ "coinAvailability",
+ "contractTerms",
+ ],
+ async (tx) => {
+ await spendCoins(ws, tx, {
+ allocationId: transactionId,
+ coinPubs: payCoinSel.coinSel.coinPubs,
+ contributions: payCoinSel.coinSel.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayDeposit,
+ });
+ await tx.depositGroups.put(depositGroup);
+ await tx.contractTerms.put({
+ contractTermsRaw: contractTerms,
+ h: contractTermsHash,
+ });
+ return computeDepositTransactionStatus(depositGroup);
+ },
+ );
+
+ ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ });
+
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ depositGroupId,
+ transactionId,
+ };
+}
+
+/**
+ * Get the amount that will be deposited on the users bank
+ * account after depositing, not considering aggregation.
+ */
+export async function getCounterpartyEffectiveDepositAmount(
+ ws: InternalWalletState,
+ wireType: string,
+ pcs: PayCoinSelection,
+): Promise<AmountJson> {
+ const amt: AmountJson[] = [];
+ const fees: AmountJson[] = [];
+ const exchangeSet: Set<string> = new Set();
+
+ await ws.db.runReadOnlyTx(
+ ["coins", "denominations", "exchangeDetails", "exchanges"],
+ async (tx) => {
+ for (let i = 0; i < pcs.coinPubs.length; i++) {
+ const coin = await tx.coins.get(pcs.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't calculate deposit amount, coin not found");
+ }
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("can't find denomination to calculate deposit amount");
+ }
+ amt.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
+ fees.push(Amounts.parseOrThrow(denom.feeDeposit));
+ exchangeSet.add(coin.exchangeBaseUrl);
+ }
+
+ for (const exchangeUrl of exchangeSet.values()) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeUrl,
+ );
+ if (!exchangeDetails) {
+ continue;
+ }
+
+ // FIXME/NOTE: the line below _likely_ throws exception
+ // about "find method not found on undefined" when the wireType
+ // is not supported by the Exchange.
+ const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+ if (fee) {
+ fees.push(Amounts.parseOrThrow(fee));
+ }
+ }
+ },
+ );
+ return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
+}
+
+/**
+ * Get the fee amount that will be charged when trying to deposit the
+ * specified amount using the selected coins and the wire method.
+ */
+async function getTotalFeesForDepositAmount(
+ ws: InternalWalletState,
+ wireType: string,
+ total: AmountJson,
+ pcs: PayCoinSelection,
+): Promise<DepositGroupFees> {
+ const wireFee: AmountJson[] = [];
+ const coinFee: AmountJson[] = [];
+ const refreshFee: AmountJson[] = [];
+ const exchangeSet: Set<string> = new Set();
+ const currency = Amounts.currencyOf(total);
+
+ await ws.db.runReadOnlyTx(
+ ["coins", "denominations", "exchanges", "exchangeDetails"],
+ async (tx) => {
+ for (let i = 0; i < pcs.coinPubs.length; i++) {
+ const coin = await tx.coins.get(pcs.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't calculate deposit amount, coin not found");
+ }
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("can't find denomination to calculate deposit amount");
+ }
+ coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
+ exchangeSet.add(coin.exchangeBaseUrl);
+
+ const allDenoms = await getCandidateWithdrawalDenomsTx(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ currency,
+ );
+ const amountLeft = Amounts.sub(
+ denom.value,
+ pcs.coinContributions[i],
+ ).amount;
+ const refreshCost = getTotalRefreshCost(
+ allDenoms,
+ denom,
+ amountLeft,
+ ws.config.testing.denomselAllowLate,
+ );
+ refreshFee.push(refreshCost);
+ }
+
+ for (const exchangeUrl of exchangeSet.values()) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeUrl,
+ );
+ if (!exchangeDetails) {
+ continue;
+ }
+ const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
+ (x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ },
+ )?.wireFee;
+ if (fee) {
+ wireFee.push(Amounts.parseOrThrow(fee));
+ }
+ }
+ },
+ );
+
+ return {
+ coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
+ wire: Amounts.stringify(Amounts.sumOrZero(total.currency, wireFee).amount),
+ refresh: Amounts.stringify(
+ Amounts.sumOrZero(total.currency, refreshFee).amount,
+ ),
+ };
+}
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -0,0 +1,2007 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of exchange entry management in wallet-core.
+ * The details of exchange entry management are specified in DD48.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ Amounts,
+ AsyncFlag,
+ CancellationToken,
+ CoinRefreshRequest,
+ CoinStatus,
+ DeleteExchangeRequest,
+ DenomKeyType,
+ DenomOperationMap,
+ DenominationInfo,
+ DenominationPubKey,
+ Duration,
+ EddsaPublicKeyString,
+ ExchangeAuditor,
+ ExchangeDetailedResponse,
+ ExchangeGlobalFees,
+ ExchangeListItem,
+ ExchangeSignKeyJson,
+ ExchangeTosStatus,
+ ExchangeWireAccount,
+ ExchangesListResponse,
+ FeeDescription,
+ GetExchangeEntryByUrlRequest,
+ GetExchangeResourcesResponse,
+ GetExchangeTosResult,
+ GlobalFees,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ OperationErrorInfo,
+ Recoup,
+ RefreshReason,
+ ScopeInfo,
+ ScopeType,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ URL,
+ WalletNotification,
+ WireFee,
+ WireFeeMap,
+ WireFeesJson,
+ WireInfo,
+ assertUnreachable,
+ canonicalizeBaseUrl,
+ codecForExchangeKeysJson,
+ durationFromSpec,
+ durationMul,
+ encodeCrock,
+ hashDenomPub,
+ j2s,
+ makeErrorDetail,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ getExpiry,
+ readSuccessResponseJsonOrThrow,
+ readSuccessResponseTextOrThrow,
+} from "@gnu-taler/taler-util/http";
+import {
+ DenominationRecord,
+ DenominationVerificationStatus,
+ ExchangeDetailsRecord,
+ ExchangeEntryRecord,
+ WalletStoresV1,
+} from "./db.js";
+import {
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ PendingTaskType,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ createRefreshGroup,
+ createTimeline,
+ isWithdrawableDenom,
+ selectBestForOverlappingDenominations,
+ selectMinimumFee,
+ timestampAbsoluteFromDb,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { checkDbInvariant } from "./util/invariants.js";
+import { DbReadOnlyTransaction } from "./query.js";
+import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
+import {
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+ constructTaskIdentifier,
+ getAutoRefreshExecuteThreshold,
+ getExchangeEntryStatusFromRecord,
+ getExchangeState,
+ getExchangeTosStatusFromRecord,
+ getExchangeUpdateStatusFromRecord,
+} from "./common.js";
+
+const logger = new Logger("exchanges.ts");
+
+function getExchangeRequestTimeout(): Duration {
+ return Duration.fromSpec({
+ seconds: 15,
+ });
+}
+
+interface ExchangeTosDownloadResult {
+ tosText: string;
+ tosEtag: string;
+ tosContentType: string;
+ tosContentLanguage: string | undefined;
+ tosAvailableLanguages: string[];
+}
+
+async function downloadExchangeWithTermsOfService(
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ acceptFormat: string,
+ acceptLanguage: string | undefined,
+): Promise<ExchangeTosDownloadResult> {
+ logger.trace(`downloading exchange tos (type ${acceptFormat})`);
+ const reqUrl = new URL("terms", exchangeBaseUrl);
+ const headers: {
+ Accept: string;
+ "Accept-Language"?: string;
+ } = {
+ Accept: acceptFormat,
+ };
+
+ if (acceptLanguage) {
+ headers["Accept-Language"] = acceptLanguage;
+ }
+
+ const resp = await http.fetch(reqUrl.href, {
+ headers,
+ timeout,
+ });
+ const tosText = await readSuccessResponseTextOrThrow(resp);
+ const tosEtag = resp.headers.get("etag") || "unknown";
+ const tosContentLanguage = resp.headers.get("content-language") || undefined;
+ const tosContentType = resp.headers.get("content-type") || "text/plain";
+ const availLangStr = resp.headers.get("avail-languages") || "";
+ // Work around exchange bug that reports the same language multiple times.
+ const availLangSet = new Set<string>(
+ availLangStr.split(",").map((x) => x.trim()),
+ );
+ const tosAvailableLanguages = [...availLangSet];
+
+ return {
+ tosText,
+ tosEtag,
+ tosContentType,
+ tosContentLanguage,
+ tosAvailableLanguages,
+ };
+}
+
+/**
+ * Get exchange details from the database.
+ */
+async function getExchangeRecordsInternal(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeDetailsRecord | undefined> {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ return;
+ }
+ const dp = r.detailsPointer;
+ if (!dp) {
+ return;
+ }
+ const { currency, masterPublicKey } = dp;
+ return await tx.exchangeDetails.indexes.byPointer.get([
+ r.baseUrl,
+ currency,
+ masterPublicKey,
+ ]);
+}
+
+export async function getExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
+ ]
+ >,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<ScopeInfo> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return {
+ type: ScopeType.Exchange,
+ currency: currency,
+ url: exchangeBaseUrl,
+ };
+ }
+ return internalGetExchangeScopeInfo(tx, det);
+}
+
+async function internalGetExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ exchangeDetails: ExchangeDetailsRecord,
+): Promise<ScopeInfo> {
+ const globalExchangeRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ exchangeDetails.exchangeBaseUrl,
+ exchangeDetails.masterPublicKey,
+ ]);
+ if (globalExchangeRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Global,
+ };
+ } else {
+ for (const aud of exchangeDetails.auditors) {
+ const globalAuditorRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ aud.auditor_url,
+ aud.auditor_pub,
+ ]);
+ if (globalAuditorRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Auditor,
+ url: aud.auditor_url,
+ };
+ }
+ }
+ }
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Exchange,
+ url: exchangeDetails.exchangeBaseUrl,
+ };
+}
+
+async function makeExchangeListItem(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ r: ExchangeEntryRecord,
+ exchangeDetails: ExchangeDetailsRecord | undefined,
+ lastError: TalerErrorDetail | undefined,
+): Promise<ExchangeListItem> {
+ const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
+ ? {
+ error: lastError,
+ }
+ : undefined;
+
+ let scopeInfo: ScopeInfo | undefined = undefined;
+
+ if (exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+
+ return {
+ exchangeBaseUrl: r.baseUrl,
+ currency: exchangeDetails?.currency ?? r.presetCurrencyHint,
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+ tosStatus: getExchangeTosStatusFromRecord(r),
+ ageRestrictionOptions: exchangeDetails?.ageMask
+ ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
+ : [],
+ paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
+ lastUpdateErrorInfo,
+ scopeInfo,
+ };
+}
+
+export interface ExchangeWireDetails {
+ currency: string;
+ masterPublicKey: EddsaPublicKeyString;
+ wireInfo: WireInfo;
+ exchangeBaseUrl: string;
+ auditors: ExchangeAuditor[];
+ globalFees: ExchangeGlobalFees[];
+}
+
+export async function getExchangeWireDetailsInTx(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeWireDetails | undefined> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return undefined;
+ }
+ return {
+ currency: det.currency,
+ masterPublicKey: det.masterPublicKey,
+ wireInfo: det.wireInfo,
+ exchangeBaseUrl: det.exchangeBaseUrl,
+ auditors: det.auditors,
+ globalFees: det.globalFees,
+ };
+}
+
+export async function lookupExchangeByUri(
+ ws: InternalWalletState,
+ req: GetExchangeEntryByUrlRequest,
+): Promise<ExchangeListItem> {
+ return await ws.db.runReadOnlyTx(
+ [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
+ if (!exchangeRec) {
+ throw Error("exchange not found");
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ exchangeRec.baseUrl,
+ );
+ const opRetryRecord = await tx.operationRetries.get(
+ TaskIdentifiers.forExchangeUpdate(exchangeRec),
+ );
+ return await makeExchangeListItem(
+ tx,
+ exchangeRec,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ );
+ },
+ );
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function acceptExchangeTermsOfService(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await ws.db.runReadWriteTx(
+ ["exchangeDetails", "exchanges"],
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch && exch.tosCurrentEtag) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = exch.tosCurrentEtag;
+ exch.tosAcceptedTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ ws.notify(notif);
+ }
+}
+
+/**
+ * Validate wire fees and wire accounts.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateWireInfo(
+ ws: InternalWalletState,
+ versionCurrent: number,
+ wireInfo: ExchangeKeysDownloadResult,
+ masterPublicKey: string,
+): Promise<WireInfo> {
+ for (const a of wireInfo.accounts) {
+ logger.trace("validating exchange acct");
+ let isValid = false;
+ if (ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await ws.cryptoApi.isValidWireAccount({
+ masterPub: masterPublicKey,
+ paytoUri: a.payto_uri,
+ sig: a.master_sig,
+ versionCurrent,
+ conversionUrl: a.conversion_url,
+ creditRestrictions: a.credit_restrictions,
+ debitRestrictions: a.debit_restrictions,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange acct signature invalid");
+ }
+ }
+ logger.trace("account validation done");
+ const feesForType: WireFeeMap = {};
+ for (const wireMethod of Object.keys(wireInfo.wireFees)) {
+ const feeList: WireFee[] = [];
+ for (const x of wireInfo.wireFees[wireMethod]) {
+ const startStamp = x.start_date;
+ const endStamp = x.end_date;
+ const fee: WireFee = {
+ closingFee: Amounts.stringify(x.closing_fee),
+ endStamp,
+ sig: x.sig,
+ startStamp,
+ wireFee: Amounts.stringify(x.wire_fee),
+ };
+ let isValid = false;
+ if (ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await ws.cryptoApi.isValidWireFee({
+ masterPub: masterPublicKey,
+ type: wireMethod,
+ wf: fee,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange wire fee signature invalid");
+ }
+ feeList.push(fee);
+ }
+ feesForType[wireMethod] = feeList;
+ }
+
+ return {
+ accounts: wireInfo.accounts,
+ feesForType,
+ };
+}
+
+/**
+ * Validate global fees.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateGlobalFees(
+ ws: InternalWalletState,
+ fees: GlobalFees[],
+ masterPub: string,
+): Promise<ExchangeGlobalFees[]> {
+ const egf: ExchangeGlobalFees[] = [];
+ for (const gf of fees) {
+ logger.trace("validating exchange global fees");
+ let isValid = false;
+ if (ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await ws.cryptoApi.isValidGlobalFees({
+ masterPub,
+ gf,
+ });
+ isValid = v;
+ }
+
+ if (!isValid) {
+ throw Error("exchange global fees signature invalid: " + gf.master_sig);
+ }
+ egf.push({
+ accountFee: Amounts.stringify(gf.account_fee),
+ historyFee: Amounts.stringify(gf.history_fee),
+ purseFee: Amounts.stringify(gf.purse_fee),
+ startDate: gf.start_date,
+ endDate: gf.end_date,
+ signature: gf.master_sig,
+ historyTimeout: gf.history_expiration,
+ purseLimit: gf.purse_account_limit,
+ purseTimeout: gf.purse_timeout,
+ });
+ }
+
+ return egf;
+}
+
+/**
+ * Add an exchange entry to the wallet database in the
+ * entry state "preset".
+ *
+ * Returns the notification to the caller that should be emitted
+ * if the DB transaction succeeds.
+ */
+export async function addPresetExchangeEntry(
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+ currencyHint?: string,
+): Promise<{ notification?: WalletNotification }> {
+ let exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Preset,
+ updateStatus: ExchangeEntryDbUpdateStatus.Initial,
+ baseUrl: exchangeBaseUrl,
+ presetCurrencyHint: currencyHint,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ lastKeysEtag: undefined,
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ return {
+ notification: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: exchangeBaseUrl,
+ // Exchange did not exist yet
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ },
+ };
+ }
+ return {};
+}
+
+async function provideExchangeRecordInTx(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
+ baseUrl: string,
+): Promise<{
+ exchange: ExchangeEntryRecord;
+ exchangeDetails: ExchangeDetailsRecord | undefined;
+ notification?: WalletNotification;
+}> {
+ let notification: WalletNotification | undefined = undefined;
+ let exchange = await tx.exchanges.get(baseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
+ updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
+ baseUrl: baseUrl,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ lastKeysEtag: undefined,
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ exchange = r;
+ notification = {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: r.baseUrl,
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
+ return { exchange, exchangeDetails, notification };
+}
+
+export interface ExchangeKeysDownloadResult {
+ baseUrl: string;
+ masterPublicKey: string;
+ currency: string;
+ auditors: ExchangeAuditor[];
+ currentDenominations: DenominationRecord[];
+ protocolVersion: string;
+ signingKeys: ExchangeSignKeyJson[];
+ reserveClosingDelay: TalerProtocolDuration;
+ expiry: TalerProtocolTimestamp;
+ recoup: Recoup[];
+ listIssueDate: TalerProtocolTimestamp;
+ globalFees: GlobalFees[];
+ accounts: ExchangeWireAccount[];
+ wireFees: { [methodName: string]: WireFeesJson[] };
+}
+
+/**
+ * Download and validate an exchange's /keys data.
+ */
+async function downloadExchangeKeysInfo(
+ baseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ cancellationToken: CancellationToken,
+): Promise<ExchangeKeysDownloadResult> {
+ const keysUrl = new URL("keys", baseUrl);
+
+ const resp = await http.fetch(keysUrl.href, {
+ timeout,
+ cancellationToken,
+ });
+
+ logger.info("got response to /keys request");
+
+ // We must make sure to parse out the protocol version
+ // before we validate the body.
+ // Otherwise the parser might complain with a hard to understand
+ // message about some other field, when it is just a version
+ // incompatibility.
+
+ const keysJson = await resp.json();
+
+ const protocolVersion = keysJson.version;
+ if (typeof protocolVersion !== "string") {
+ throw Error("bad exchange, does not even specify protocol version");
+ }
+
+ const versionRes = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ protocolVersion,
+ );
+ if (!versionRes) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: resp.requestUrl,
+ httpStatusCode: resp.status,
+ requestMethod: resp.requestMethod,
+ },
+ "exchange protocol version malformed",
+ );
+ }
+ if (!versionRes.compatible) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ exchangeProtocolVersion: protocolVersion,
+ walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ },
+ "exchange protocol version not compatible with wallet",
+ );
+ }
+
+ const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeKeysJson(),
+ );
+
+ if (exchangeKeysJsonUnchecked.denominations.length === 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+ {
+ exchangeBaseUrl: baseUrl,
+ },
+ "exchange doesn't offer any denominations",
+ );
+ }
+
+ const currency = exchangeKeysJsonUnchecked.currency;
+
+ const currentDenominations: DenominationRecord[] = [];
+
+ for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
+ switch (denomGroup.cipher) {
+ case "RSA":
+ case "RSA+age_restricted": {
+ let ageMask = 0;
+ if (denomGroup.cipher === "RSA+age_restricted") {
+ ageMask = denomGroup.age_mask;
+ }
+ for (const denomIn of denomGroup.denoms) {
+ const denomPub: DenominationPubKey = {
+ age_mask: ageMask,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: denomIn.rsa_pub,
+ };
+ const denomPubHash = encodeCrock(hashDenomPub(denomPub));
+ const value = Amounts.parseOrThrow(denomGroup.value);
+ const rec: DenominationRecord = {
+ denomPub,
+ denomPubHash,
+ exchangeBaseUrl: baseUrl,
+ exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
+ isOffered: true,
+ isRevoked: false,
+ value: Amounts.stringify(value),
+ currency: value.currency,
+ stampExpireDeposit: timestampProtocolToDb(
+ denomIn.stamp_expire_deposit,
+ ),
+ stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
+ stampExpireWithdraw: timestampProtocolToDb(
+ denomIn.stamp_expire_withdraw,
+ ),
+ stampStart: timestampProtocolToDb(denomIn.stamp_start),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ masterSig: denomIn.master_sig,
+ listIssueDate: timestampProtocolToDb(
+ exchangeKeysJsonUnchecked.list_issue_date,
+ ),
+ fees: {
+ feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
+ feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
+ feeRefund: Amounts.stringify(denomGroup.fee_refund),
+ feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
+ },
+ };
+ currentDenominations.push(rec);
+ }
+ break;
+ }
+ case "CS+age_restricted":
+ case "CS":
+ logger.warn("Clause-Schnorr denominations not supported");
+ continue;
+ default:
+ logger.warn(
+ `denomination type ${(denomGroup as any).cipher} not supported`,
+ );
+ continue;
+ }
+ }
+
+ return {
+ masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
+ currency,
+ baseUrl: exchangeKeysJsonUnchecked.base_url,
+ auditors: exchangeKeysJsonUnchecked.auditors,
+ currentDenominations,
+ protocolVersion: exchangeKeysJsonUnchecked.version,
+ signingKeys: exchangeKeysJsonUnchecked.signkeys,
+ reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
+ expiry: AbsoluteTime.toProtocolTimestamp(
+ getExpiry(resp, {
+ minDuration: Duration.fromSpec({ hours: 1 }),
+ }),
+ ),
+ recoup: exchangeKeysJsonUnchecked.recoup ?? [],
+ listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
+ globalFees: exchangeKeysJsonUnchecked.global_fees,
+ accounts: exchangeKeysJsonUnchecked.accounts,
+ wireFees: exchangeKeysJsonUnchecked.wire_fees,
+ };
+}
+
+async function downloadTosFromAcceptedFormat(
+ ws: InternalWalletState,
+ baseUrl: string,
+ timeout: Duration,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<ExchangeTosDownloadResult> {
+ let tosFound: ExchangeTosDownloadResult | undefined;
+ // Remove this when exchange supports multiple content-type in accept header
+ if (acceptedFormat)
+ for (const format of acceptedFormat) {
+ const resp = await downloadExchangeWithTermsOfService(
+ baseUrl,
+ ws.http,
+ timeout,
+ format,
+ acceptLanguage,
+ );
+ if (resp.tosContentType === format) {
+ tosFound = resp;
+ break;
+ }
+ }
+ if (tosFound !== undefined) {
+ return tosFound;
+ }
+ // If none of the specified format was found try text/plain
+ return await downloadExchangeWithTermsOfService(
+ baseUrl,
+ ws.http,
+ timeout,
+ "text/plain",
+ acceptLanguage,
+ );
+}
+
+/**
+ * Transition an exchange into an updating state.
+ *
+ * If the update is forced, the exchange is put into an updating state
+ * even if the old information should still be up to date.
+ *
+ * If the exchange entry doesn't exist,
+ * a new ephemeral entry is created.
+ */
+async function startUpdateExchangeEntry(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ options: { forceUpdate?: boolean } = {},
+): Promise<void> {
+ const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+
+ logger.info(
+ `starting update of exchange entry ${canonBaseUrl}, forced=${
+ options.forceUpdate ?? false
+ }`,
+ );
+
+ const { notification } = await ws.db.runReadWriteTx(
+ ["exchanges", "exchangeDetails"],
+ async (tx) => {
+ return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl);
+ },
+ );
+
+ if (notification) {
+ ws.notify(notification);
+ }
+
+ const { oldExchangeState, newExchangeState, taskId } =
+ await ws.db.runReadWriteTx(
+ ["exchanges", "operationRetries"],
+ async (tx) => {
+ const r = await tx.exchanges.get(canonBaseUrl);
+ if (!r) {
+ throw Error("exchange not found");
+ }
+ const oldExchangeState = getExchangeState(r);
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ break;
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready: {
+ const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
+ timestampPreciseFromDb(r.nextUpdateStamp),
+ );
+ // Only update if entry is outdated or update is forced.
+ if (
+ options.forceUpdate ||
+ AbsoluteTime.isExpired(nextUpdateTimestamp)
+ ) {
+ r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
+ }
+ break;
+ }
+ case ExchangeEntryDbUpdateStatus.Initial:
+ r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
+ break;
+ }
+ await tx.exchanges.put(r);
+ const newExchangeState = getExchangeState(r);
+ // Reset retries for updating the exchange entry.
+ const taskId = TaskIdentifiers.forExchangeUpdate(r);
+ await tx.operationRetries.delete(taskId);
+ return { oldExchangeState, newExchangeState, taskId };
+ },
+ );
+ ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: canonBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ });
+ await ws.taskScheduler.resetTaskRetries(taskId);
+}
+
+/**
+ * Basic information about an exchange in a ready state.
+ */
+export interface ReadyExchangeSummary {
+ exchangeBaseUrl: string;
+ currency: string;
+ masterPub: string;
+ tosStatus: ExchangeTosStatus;
+ tosAcceptedEtag: string | undefined;
+ tosCurrentEtag: string | undefined;
+ wireInfo: WireInfo;
+ protocolVersionRange: string;
+ tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
+ scopeInfo: ScopeInfo;
+}
+
+async function internalWaitReadyExchange(
+ ws: InternalWalletState,
+ canonUrl: string,
+ exchangeNotifFlag: AsyncFlag,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ const operationId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: canonUrl,
+ });
+ while (true) {
+ logger.info(`waiting for ready exchange ${canonUrl}`);
+ const { exchange, exchangeDetails, retryInfo, scopeInfo } =
+ await ws.db.runReadOnlyTx(
+ [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ async (tx) => {
+ const exchange = await tx.exchanges.get(canonUrl);
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ canonUrl,
+ );
+ const retryInfo = await tx.operationRetries.get(operationId);
+ let scopeInfo: ScopeInfo | undefined = undefined;
+ if (exchange && exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+ return { exchange, exchangeDetails, retryInfo, scopeInfo };
+ },
+ );
+
+ if (!exchange) {
+ throw Error("exchange entry does not exist anymore");
+ }
+
+ let ready = false;
+
+ switch (exchange.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Ready:
+ ready = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ // If the update is forced,
+ // we wait until we're in a full "ready" state,
+ // as we're not happy with the stale information.
+ if (!options.forceUpdate) {
+ ready = true;
+ }
+ break;
+ default: {
+ if (retryInfo) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ }
+ }
+ }
+
+ if (!ready) {
+ logger.info("waiting for exchange update notification");
+ await exchangeNotifFlag.wait();
+ logger.info("done waiting for exchange update notification");
+ exchangeNotifFlag.reset();
+ continue;
+ }
+
+ if (!exchangeDetails) {
+ throw Error("invariant failed");
+ }
+
+ if (!scopeInfo) {
+ throw Error("invariant failed");
+ }
+
+ const res: ReadyExchangeSummary = {
+ currency: exchangeDetails.currency,
+ exchangeBaseUrl: canonUrl,
+ masterPub: exchangeDetails.masterPublicKey,
+ tosStatus: getExchangeTosStatusFromRecord(exchange),
+ tosAcceptedEtag: exchange.tosAcceptedEtag,
+ wireInfo: exchangeDetails.wireInfo,
+ protocolVersionRange: exchangeDetails.protocolVersionRange,
+ tosCurrentEtag: exchange.tosCurrentEtag,
+ tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
+ exchange.tosAcceptedTimestamp,
+ ),
+ scopeInfo,
+ };
+
+ if (options.expectedMasterPub) {
+ if (res.masterPub !== options.expectedMasterPub) {
+ throw Error(
+ "public key of the exchange does not match expected public key",
+ );
+ }
+ }
+ return res;
+ }
+}
+
+/**
+ * Ensure that a fresh exchange entry exists for the given
+ * exchange base URL.
+ *
+ * The cancellation token can be used to abort waiting for the
+ * updated exchange entry.
+ *
+ * If an exchange entry for the database doesn't exist in the
+ * DB, it will be added ephemerally.
+ *
+ * If the expectedMasterPub is given and does not match the actual
+ * master pub, an exception will be thrown. However, the exchange
+ * will still have been added as an ephemeral exchange entry.
+ */
+export async function fetchFreshExchange(
+ ws: InternalWalletState,
+ baseUrl: string,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ const canonUrl = canonicalizeBaseUrl(baseUrl);
+
+ ws.ensureTaskLoopRunning();
+
+ await startUpdateExchangeEntry(ws, canonUrl, {
+ forceUpdate: options.forceUpdate,
+ });
+
+ return waitReadyExchange(ws, canonUrl, options);
+}
+
+async function waitReadyExchange(
+ ws: InternalWalletState,
+ canonUrl: string,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const exchangeNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.ExchangeStateTransition &&
+ notif.exchangeBaseUrl === canonUrl
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ exchangeNotifFlag.raise();
+ }
+ });
+
+ try {
+ const res = await internalWaitReadyExchange(
+ ws,
+ canonUrl,
+ exchangeNotifFlag,
+ options,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+/**
+ * Update an exchange entry in the wallet's database
+ * by fetching the /keys and /wire information.
+ * Optionally link the reserve entry to the new or existing
+ * exchange entry in then DB.
+ */
+export async function updateExchangeFromUrlHandler(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
+ exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+
+ const oldExchangeRec = await ws.db.runReadOnlyTx(
+ ["exchanges"],
+ async (tx) => {
+ return tx.exchanges.get(exchangeBaseUrl);
+ },
+ );
+
+ if (!oldExchangeRec) {
+ logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
+ return TaskRunResult.finished();
+ }
+
+ let updateRequestedExplicitly = false;
+
+ switch (oldExchangeRec.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ logger.info(`not updating exchange in status "suspended"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.Initial:
+ logger.info(`not updating exchange in status "initial"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ updateRequestedExplicitly = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready:
+ break;
+ default:
+ assertUnreachable(oldExchangeRec.updateStatus);
+ }
+
+ let refreshCheckNecessary = true;
+
+ if (!updateRequestedExplicitly) {
+ // If the update wasn't requested explicitly,
+ // check if we really need to update.
+
+ let nextUpdateStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextUpdateStamp,
+ );
+
+ let nextRefreshCheckStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextRefreshCheckStamp,
+ );
+
+ let updateNecessary = true;
+
+ if (
+ !AbsoluteTime.isNever(nextUpdateStamp) &&
+ !AbsoluteTime.isExpired(nextUpdateStamp)
+ ) {
+ logger.info(
+ `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextUpdateStamp,
+ )}`,
+ );
+ updateNecessary = false;
+ }
+
+ if (
+ !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
+ !AbsoluteTime.isExpired(nextRefreshCheckStamp)
+ ) {
+ logger.info(
+ `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextRefreshCheckStamp,
+ )}`,
+ );
+ refreshCheckNecessary = false;
+ }
+
+ if (!(updateNecessary || refreshCheckNecessary)) {
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
+ );
+ }
+ }
+
+ // When doing the auto-refresh check, we always update
+ // the key info before that.
+
+ logger.trace("updating exchange /keys info");
+
+ const timeout = getExchangeRequestTimeout();
+
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ ws.http,
+ timeout,
+ cancellationToken,
+ );
+
+ logger.trace("validating exchange wire info");
+
+ const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
+ if (!version) {
+ // Should have been validated earlier.
+ throw Error("unexpected invalid version");
+ }
+
+ const wireInfo = await validateWireInfo(
+ ws,
+ version.current,
+ keysInfo,
+ keysInfo.masterPublicKey,
+ );
+
+ const globalFees = await validateGlobalFees(
+ ws,
+ keysInfo.globalFees,
+ keysInfo.masterPublicKey,
+ );
+ if (keysInfo.baseUrl != exchangeBaseUrl) {
+ logger.warn("exchange base URL mismatch");
+ const errorDetail: TalerErrorDetail = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
+ {
+ urlWallet: exchangeBaseUrl,
+ urlExchange: keysInfo.baseUrl,
+ },
+ );
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail,
+ };
+ }
+
+ logger.trace("finished validating exchange /wire info");
+
+ // We download the text/plain version here,
+ // because that one needs to exist, and we
+ // will get the current etag from the response.
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ ws,
+ exchangeBaseUrl,
+ timeout,
+ ["text/plain"],
+ );
+
+ let recoupGroupId: string | undefined;
+
+ logger.trace("updating exchange info in database");
+
+ let detailsPointerChanged = false;
+
+ let ageMask = 0;
+ for (const x of keysInfo.currentDenominations) {
+ if (
+ isWithdrawableDenom(x, ws.config.testing.denomselAllowLate) &&
+ x.denomPub.age_mask != 0
+ ) {
+ ageMask = x.denomPub.age_mask;
+ break;
+ }
+ }
+
+ const updated = await ws.db.runReadWriteTx(
+ [
+ "exchanges",
+ "exchangeDetails",
+ "exchangeSignKeys",
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "recoupGroups",
+ ],
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
+ return;
+ }
+ const oldExchangeState = getExchangeState(r);
+ const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ if (!existingDetails) {
+ detailsPointerChanged = true;
+ }
+ if (existingDetails) {
+ if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
+ detailsPointerChanged = true;
+ }
+ if (existingDetails.currency !== keysInfo.currency) {
+ detailsPointerChanged = true;
+ }
+ // FIXME: We need to do some consistency checks!
+ }
+ const newDetails: ExchangeDetailsRecord = {
+ auditors: keysInfo.auditors,
+ currency: keysInfo.currency,
+ masterPublicKey: keysInfo.masterPublicKey,
+ protocolVersionRange: keysInfo.protocolVersion,
+ reserveClosingDelay: keysInfo.reserveClosingDelay,
+ globalFees,
+ exchangeBaseUrl: r.baseUrl,
+ wireInfo,
+ ageMask,
+ };
+ r.tosCurrentEtag = tosDownload.tosEtag;
+ if (existingDetails?.rowId) {
+ newDetails.rowId = existingDetails.rowId;
+ }
+ r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ r.nextUpdateStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
+ ),
+ );
+ // New denominations might be available.
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ if (detailsPointerChanged) {
+ r.detailsPointer = {
+ currency: newDetails.currency,
+ masterPublicKey: newDetails.masterPublicKey,
+ updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ }
+ r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
+ await tx.exchanges.put(r);
+ const drRowId = await tx.exchangeDetails.put(newDetails);
+ checkDbInvariant(typeof drRowId.key === "number");
+
+ for (const sk of keysInfo.signingKeys) {
+ // FIXME: validate signing keys before inserting them
+ await tx.exchangeSignKeys.put({
+ exchangeDetailsRowId: drRowId.key,
+ masterSig: sk.master_sig,
+ signkeyPub: sk.key,
+ stampEnd: timestampProtocolToDb(sk.stamp_end),
+ stampExpire: timestampProtocolToDb(sk.stamp_expire),
+ stampStart: timestampProtocolToDb(sk.stamp_start),
+ });
+ }
+
+ logger.trace("updating denominations in database");
+ const currentDenomSet = new Set<string>(
+ keysInfo.currentDenominations.map((x) => x.denomPubHash),
+ );
+ for (const currentDenom of keysInfo.currentDenominations) {
+ const oldDenom = await tx.denominations.get([
+ exchangeBaseUrl,
+ currentDenom.denomPubHash,
+ ]);
+ if (oldDenom) {
+ // FIXME: Do consistency check, report to auditor if necessary.
+ } else {
+ await tx.denominations.put(currentDenom);
+ }
+ }
+
+ // Update list issue date for all denominations,
+ // and mark non-offered denominations as such.
+ await tx.denominations.indexes.byExchangeBaseUrl
+ .iter(r.baseUrl)
+ .forEachAsync(async (x) => {
+ if (!currentDenomSet.has(x.denomPubHash)) {
+ // FIXME: Here, an auditor report should be created, unless
+ // the denomination is really legally expired.
+ if (x.isOffered) {
+ x.isOffered = false;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=false`,
+ );
+ }
+ } else {
+ x.listIssueDate = timestampProtocolToDb(keysInfo.listIssueDate);
+ if (!x.isOffered) {
+ x.isOffered = true;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=true`,
+ );
+ }
+ }
+ await tx.denominations.put(x);
+ });
+
+ logger.trace("done updating denominations in database");
+
+ // Handle recoup
+ const recoupDenomList = keysInfo.recoup;
+ const newlyRevokedCoinPubs: string[] = [];
+ logger.trace("recoup list from exchange", recoupDenomList);
+ for (const recoupInfo of recoupDenomList) {
+ const oldDenom = await tx.denominations.get([
+ r.baseUrl,
+ recoupInfo.h_denom_pub,
+ ]);
+ if (!oldDenom) {
+ // We never even knew about the revoked denomination, all good.
+ continue;
+ }
+ if (oldDenom.isRevoked) {
+ // We already marked the denomination as revoked,
+ // this implies we revoked all coins
+ logger.trace("denom already revoked");
+ continue;
+ }
+ logger.info("revoking denom", recoupInfo.h_denom_pub);
+ oldDenom.isRevoked = true;
+ await tx.denominations.put(oldDenom);
+ const affectedCoins = await tx.coins.indexes.byDenomPubHash
+ .iter(recoupInfo.h_denom_pub)
+ .toArray();
+ for (const ac of affectedCoins) {
+ newlyRevokedCoinPubs.push(ac.coinPub);
+ }
+ }
+ if (newlyRevokedCoinPubs.length != 0) {
+ logger.info("recouping coins", newlyRevokedCoinPubs);
+ recoupGroupId = await ws.recoupOps.createRecoupGroup(
+ ws,
+ tx,
+ exchangeBaseUrl,
+ newlyRevokedCoinPubs,
+ );
+ }
+
+ const newExchangeState = getExchangeState(r);
+
+ return {
+ exchange: r,
+ exchangeDetails: newDetails,
+ oldExchangeState,
+ newExchangeState,
+ };
+ },
+ );
+
+ if (recoupGroupId) {
+ const recoupTaskId = constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId,
+ });
+ // Asynchronously start recoup. This doesn't need to finish
+ // for the exchange update to be considered finished.
+ ws.taskScheduler.startShepherdTask(recoupTaskId);
+ }
+
+ if (!updated) {
+ throw Error("something went wrong with updating the exchange");
+ }
+
+ logger.trace("done updating exchange info in database");
+
+ logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
+
+ let minCheckThreshold = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ durationFromSpec({ days: 1 }),
+ );
+
+ if (refreshCheckNecessary) {
+ // Do auto-refresh.
+ await ws.db.runReadWriteTx(
+ [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "exchanges",
+ ],
+ async (tx) => {
+ const exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange || !exchange.detailsPointer) {
+ return;
+ }
+ const coins = await tx.coins.indexes.byBaseUrl
+ .iter(exchangeBaseUrl)
+ .toArray();
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const coin of coins) {
+ if (coin.status !== CoinStatus.Fresh) {
+ continue;
+ }
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("denomination not in database");
+ continue;
+ }
+ const executeThreshold =
+ getAutoRefreshExecuteThresholdForDenom(denom);
+ if (AbsoluteTime.isExpired(executeThreshold)) {
+ refreshCoins.push({
+ coinPub: coin.coinPub,
+ amount: denom.value,
+ });
+ } else {
+ const checkThreshold = getAutoRefreshCheckThreshold(denom);
+ minCheckThreshold = AbsoluteTime.min(
+ minCheckThreshold,
+ checkThreshold,
+ );
+ }
+ }
+ if (refreshCoins.length > 0) {
+ const res = await createRefreshGroup(
+ ws,
+ tx,
+ exchange.detailsPointer?.currency,
+ refreshCoins,
+ RefreshReason.Scheduled,
+ undefined,
+ );
+ logger.trace(
+ `created refresh group for auto-refresh (${res.refreshGroupId})`,
+ );
+ }
+ logger.trace(
+ `next refresh check at ${AbsoluteTime.toIsoString(
+ minCheckThreshold,
+ )}`,
+ );
+ exchange.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
+ );
+ await tx.exchanges.put(exchange);
+ },
+ );
+ }
+
+ ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: updated.newExchangeState,
+ oldExchangeState: updated.oldExchangeState,
+ });
+
+ // Next invocation will cause the task to be run again
+ // at the necessary time.
+ return TaskRunResult.progress();
+}
+
+function getAutoRefreshExecuteThresholdForDenom(
+ d: DenominationRecord,
+): AbsoluteTime {
+ return getAutoRefreshExecuteThreshold({
+ stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
+ stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
+ });
+}
+
+/**
+ * Timestamp after which the wallet would do the next check for an auto-refresh.
+ */
+function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireWithdraw),
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireDeposit),
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.75);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
+ * Find a payto:// URI of the exchange that is of one
+ * of the given target types.
+ *
+ * Throws if no matching account was found.
+ */
+export async function getExchangePaytoUri(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ supportedTargetTypes: string[],
+): Promise<string> {
+ // We do the update here, since the exchange might not even exist
+ // yet in our database.
+ const details = await ws.db.runReadOnlyTx(
+ ["exchanges", "exchangeDetails"],
+ async (tx) => {
+ return getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ },
+ );
+ const accounts = details?.wireInfo.accounts ?? [];
+ for (const account of accounts) {
+ const res = parsePaytoUri(account.payto_uri);
+ if (!res) {
+ continue;
+ }
+ if (supportedTargetTypes.includes(res.targetType)) {
+ return account.payto_uri;
+ }
+ }
+ throw Error(
+ `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
+ supportedTargetTypes,
+ )}`,
+ );
+}
+
+/**
+ * Get the exchange ToS in the requested format.
+ * Try to download in the accepted format not cached.
+ */
+export async function getExchangeTos(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<GetExchangeTosResult> {
+ const exch = await fetchFreshExchange(ws, exchangeBaseUrl);
+
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ ws,
+ exchangeBaseUrl,
+ getExchangeRequestTimeout(),
+ acceptedFormat,
+ acceptLanguage,
+ );
+
+ await ws.db.runReadWriteTx(["exchanges"], async (tx) => {
+ const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
+ if (updateExchangeEntry) {
+ updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
+ await tx.exchanges.put(updateExchangeEntry);
+ }
+ });
+
+ return {
+ acceptedEtag: exch.tosAcceptedEtag,
+ currentEtag: tosDownload.tosEtag,
+ content: tosDownload.tosText,
+ contentType: tosDownload.tosContentType,
+ contentLanguage: tosDownload.tosContentLanguage,
+ tosStatus: exch.tosStatus,
+ tosAvailableLanguages: tosDownload.tosAvailableLanguages,
+ };
+}
+
+/**
+ * Parsed information about an exchange,
+ * obtained by requesting /keys.
+ */
+export interface ExchangeInfo {
+ keys: ExchangeKeysDownloadResult;
+}
+
+/**
+ * Helper function to download the exchange /keys info.
+ *
+ * Only used for testing / dbless wallet.
+ */
+export async function downloadExchangeInfo(
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+): Promise<ExchangeInfo> {
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ http,
+ Duration.getForever(),
+ CancellationToken.CONTINUE,
+ );
+ return {
+ keys: keysInfo,
+ };
+}
+
+/**
+ * List all exchange entries known to the wallet.
+ */
+export async function listExchanges(
+ ws: InternalWalletState,
+): Promise<ExchangesListResponse> {
+ const exchanges: ExchangeListItem[] = [];
+ await ws.db.runReadOnlyTx(
+ [
+ "exchanges",
+ "operationRetries",
+ "exchangeDetails",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ async (tx) => {
+ const exchangeRecords = await tx.exchanges.iter().toArray();
+ for (const r of exchangeRecords) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: r.baseUrl,
+ });
+ const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ const opRetryRecord = await tx.operationRetries.get(taskId);
+ exchanges.push(
+ await makeExchangeListItem(
+ tx,
+ r,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ ),
+ );
+ }
+ },
+ );
+ return { exchanges };
+}
+
+/**
+ * Transition an exchange to the "used" entry state if necessary.
+ *
+ * Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
+ *
+ * The caller should emit the returned notification iff the current transaction
+ * succeeded.
+ */
+export async function markExchangeUsed(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+): Promise<{ notif: WalletNotification | undefined }> {
+ exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+ logger.info(`marking exchange ${exchangeBaseUrl} as used`);
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exch) {
+ return {
+ notif: undefined,
+ };
+ }
+ const oldExchangeState = getExchangeState(exch);
+ switch (exch.entryStatus) {
+ case ExchangeEntryDbRecordStatus.Ephemeral:
+ case ExchangeEntryDbRecordStatus.Preset: {
+ exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ return {
+ notif: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification,
+ };
+ }
+ default:
+ return {
+ notif: undefined,
+ };
+ }
+}
+
+/**
+ * Get detailed information about the exchange including a timeline
+ * for the fees charged by the exchange.
+ */
+export async function getExchangeDetailedInfo(
+ ws: InternalWalletState,
+ exchangeBaseurl: string,
+): Promise<ExchangeDetailedResponse> {
+ const exchange = await ws.db.runReadOnlyTx(
+ ["exchanges", "exchangeDetails", "denominations"],
+ async (tx) => {
+ const ex = await tx.exchanges.get(exchangeBaseurl);
+ const dp = ex?.detailsPointer;
+ if (!dp) {
+ return;
+ }
+ const { currency } = dp;
+ const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
+ if (!exchangeDetails) {
+ return;
+ }
+ const denominationRecords =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
+
+ if (!denominationRecords) {
+ return;
+ }
+
+ const denominations: DenominationInfo[] = denominationRecords.map((x) =>
+ DenominationRecord.toDenomInfo(x),
+ );
+
+ return {
+ info: {
+ exchangeBaseUrl: ex.baseUrl,
+ currency,
+ paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+ auditors: exchangeDetails.auditors,
+ wireInfo: exchangeDetails.wireInfo,
+ globalFees: exchangeDetails.globalFees,
+ },
+ denominations,
+ };
+ },
+ );
+
+ if (!exchange) {
+ throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
+ }
+
+ const denoms = exchange.denominations.map((d) => ({
+ ...d,
+ group: Amounts.stringifyValue(d.value),
+ }));
+ const denomFees: DenomOperationMap<FeeDescription[]> = {
+ deposit: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refresh: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefresh",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refund: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefund",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ withdraw: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeWithdraw",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ };
+
+ const transferFees = Object.entries(
+ exchange.info.wireInfo.feesForType,
+ ).reduce(
+ (prev, [wireType, infoForType]) => {
+ const feesByGroup = [
+ ...infoForType.map((w) => ({
+ ...w,
+ fee: Amounts.stringify(w.closingFee),
+ group: "closing",
+ })),
+ ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
+ ];
+ prev[wireType] = createTimeline(
+ feesByGroup,
+ "sig",
+ "startStamp",
+ "endStamp",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+ return prev;
+ },
+ {} as Record<string, FeeDescription[]>,
+ );
+
+ const globalFeesByGroup = [
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.accountFee,
+ group: "account",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.historyFee,
+ group: "history",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.purseFee,
+ group: "purse",
+ })),
+ ];
+
+ const globalFees = createTimeline(
+ globalFeesByGroup,
+ "signature",
+ "startDate",
+ "endDate",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+
+ return {
+ exchange: {
+ ...exchange.info,
+ denomFees,
+ transferFees,
+ globalFees,
+ },
+ };
+}
+
+async function internalGetExchangeResources(
+ ws: InternalWalletState,
+ tx: DbReadOnlyTransaction<
+ typeof WalletStoresV1,
+ ["exchanges", "coins", "withdrawalGroups"]
+ >,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ let numWithdrawals = 0;
+ let numCoins = 0;
+ numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
+ numWithdrawals =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
+ const total = numWithdrawals + numCoins;
+ return {
+ hasResources: total != 0,
+ };
+}
+
+export async function deleteExchange(
+ ws: InternalWalletState,
+ req: DeleteExchangeRequest,
+): Promise<void> {
+ let inUse: boolean = false;
+ const exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl);
+ await ws.db.runReadWriteTx(
+ ["exchanges", "coins", "withdrawalGroups", "exchangeDetails"],
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRec) {
+ // Nothing to delete!
+ logger.info("no exchange found to delete");
+ return;
+ }
+ const res = await internalGetExchangeResources(ws, tx, exchangeBaseUrl);
+ if (res.hasResources) {
+ if (req.purge) {
+ const detRecs =
+ await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
+ for (const r of detRecs) {
+ if (r.rowId == null) {
+ // Should never happen, as rowId is the primary key.
+ continue;
+ }
+ await tx.exchangeDetails.delete(r.rowId);
+ }
+ // FIXME: Also remove records related to transactions?
+ } else {
+ inUse = true;
+ return;
+ }
+ }
+ await tx.exchanges.delete(exchangeBaseUrl);
+ },
+ );
+
+ if (inUse) {
+ throw TalerError.fromUncheckedDetail({
+ code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
+ hint: "Exchange in use.",
+ });
+ }
+}
+
+export async function getExchangeResources(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ // Withdrawals include internal withdrawals from peer transactions
+ const res = await ws.db.runReadOnlyTx(
+ ["exchanges", "withdrawalGroups", "coins"],
+ async (tx) => {
+ const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRecord) {
+ return undefined;
+ }
+ return internalGetExchangeResources(ws, tx, exchangeBaseUrl);
+ },
+ );
+ if (!res) {
+ throw Error("exchange not found");
+ }
+ return res;
+}
diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts
@@ -19,7 +19,7 @@
*/
// Util functionality
-export * from "./util/query.js";
+export * from "./query.js";
export * from "./versions.js";
@@ -27,11 +27,11 @@ export * from "./db.js";
// Crypto and crypto workers
// export * from "./crypto/workers/nodeThreadWorker.js";
-export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js";
export {
- CryptoWorkerFactory,
CryptoDispatcher,
+ CryptoWorkerFactory,
} from "./crypto/workers/crypto-dispatcher.js";
+export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js";
export * from "./pending-types.js";
@@ -39,20 +39,20 @@ export { InternalWalletState } from "./internal-wallet-state.js";
export * from "./wallet-api-types.js";
export * from "./wallet.js";
-export * from "./operations/backup/index.js";
+export * from "./backup/index.js";
-export * from "./operations/exchanges.js";
+export * from "./exchanges.js";
-export * from "./operations/withdraw.js";
-export * from "./operations/refresh.js";
+export * from "./refresh.js";
+export * from "./withdraw.js";
export * from "./dbless.js";
-export * from "./crypto/cryptoTypes.js";
export * from "./crypto/cryptoImplementation.js";
+export * from "./crypto/cryptoTypes.js";
-export * from "./util/timer.js";
export * from "./util/denominations.js";
+export * from "./util/timer.js";
export { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
export * from "./host-common.js";
diff --git a/packages/taler-wallet-core/src/internal-wallet-state.ts b/packages/taler-wallet-core/src/internal-wallet-state.ts
@@ -44,7 +44,7 @@ import {
WalletStoresV1,
} from "./db.js";
import { TaskScheduler } from "./shepherd.js";
-import { DbAccess } from "./util/query.js";
+import { DbAccess } from "./query.js";
import { TimerGroup } from "./util/timer.js";
import { WalletConfig } from "./wallet-api-types.js";
diff --git a/packages/taler-wallet-core/src/merchants.ts b/packages/taler-wallet-core/src/merchants.ts
@@ -0,0 +1,66 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A..
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ canonicalizeBaseUrl,
+ Logger,
+ URL,
+ codecForMerchantConfigResponse,
+ LibtoolVersion,
+} from "@gnu-taler/taler-util";
+import { InternalWalletState, MerchantInfo } from "./internal-wallet-state.js";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+
+const logger = new Logger("taler-wallet-core:merchants.ts");
+
+export async function getMerchantInfo(
+ ws: InternalWalletState,
+ merchantBaseUrl: string,
+): Promise<MerchantInfo> {
+ const canonBaseUrl = canonicalizeBaseUrl(merchantBaseUrl);
+
+ const existingInfo = ws.merchantInfoCache[canonBaseUrl];
+ if (existingInfo) {
+ return existingInfo;
+ }
+
+ const configUrl = new URL("config", canonBaseUrl);
+ const resp = await ws.http.fetch(configUrl.href);
+
+ const configResp = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantConfigResponse(),
+ );
+
+ logger.info(
+ `merchant "${canonBaseUrl}" reports protocol ${configResp.version}"`,
+ );
+
+ const parsedVersion = LibtoolVersion.parseVersion(configResp.version);
+ if (!parsedVersion) {
+ throw Error("invalid merchant version");
+ }
+
+ const merchantInfo: MerchantInfo = {
+ protocolVersionCurrent: parsedVersion.current,
+ };
+
+ ws.merchantInfoCache[canonBaseUrl] = merchantInfo;
+ return merchantInfo;
+}
diff --git a/packages/taler-wallet-core/src/operations/attention.ts b/packages/taler-wallet-core/src/operations/attention.ts
@@ -1,133 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AttentionInfo,
- Logger,
- TalerPreciseTimestamp,
- UserAttentionByIdRequest,
- UserAttentionPriority,
- UserAttentionUnreadList,
- UserAttentionsCountResponse,
- UserAttentionsRequest,
- UserAttentionsResponse,
-} from "@gnu-taler/taler-util";
-import { timestampPreciseFromDb, timestampPreciseToDb } from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-
-const logger = new Logger("operations/attention.ts");
-
-export async function getUserAttentionsUnreadCount(
- ws: InternalWalletState,
- req: UserAttentionsRequest,
-): Promise<UserAttentionsCountResponse> {
- const total = await ws.db.runReadOnlyTx(["userAttention"], async (tx) => {
- let count = 0;
- await tx.userAttention.iter().forEach((x) => {
- if (
- req.priority !== undefined &&
- UserAttentionPriority[x.info.type] !== req.priority
- )
- return;
- if (x.read !== undefined) return;
- count++;
- });
-
- return count;
- });
-
- return { total };
-}
-
-export async function getUserAttentions(
- ws: InternalWalletState,
- req: UserAttentionsRequest,
-): Promise<UserAttentionsResponse> {
- return await ws.db.runReadOnlyTx(["userAttention"], async (tx) => {
- const pending: UserAttentionUnreadList = [];
- await tx.userAttention.iter().forEach((x) => {
- if (
- req.priority !== undefined &&
- UserAttentionPriority[x.info.type] !== req.priority
- )
- return;
- pending.push({
- info: x.info,
- when: timestampPreciseFromDb(x.created),
- read: x.read !== undefined,
- });
- });
-
- return { pending };
- });
-}
-
-export async function markAttentionRequestAsRead(
- ws: InternalWalletState,
- req: UserAttentionByIdRequest,
-): Promise<void> {
- await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
- const ua = await tx.userAttention.get([req.entityId, req.type]);
- if (!ua) throw Error("attention request not found");
- tx.userAttention.put({
- ...ua,
- read: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- });
- });
-}
-
-/**
- * the wallet need the user attention to complete a task
- * internal API
- *
- * @param ws
- * @param info
- */
-export async function addAttentionRequest(
- ws: InternalWalletState,
- info: AttentionInfo,
- entityId: string,
-): Promise<void> {
- await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
- await tx.userAttention.put({
- info,
- entityId,
- created: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- read: undefined,
- });
- });
-}
-
-/**
- * user completed the task, attention request is not needed
- * internal API
- *
- * @param ws
- * @param created
- */
-export async function removeAttentionRequest(
- ws: InternalWalletState,
- req: UserAttentionByIdRequest,
-): Promise<void> {
- await ws.db.runReadWriteTx(["userAttention"], async (tx) => {
- const ua = await tx.userAttention.get([req.entityId, req.type]);
- if (!ua) throw Error("attention request not found");
- await tx.userAttention.delete([req.entityId, req.type]);
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts
@@ -1,1059 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of wallet backups (export/import/upload) and sync
- * server management.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AmountString,
- AttentionType,
- BackupRecovery,
- Codec,
- EddsaKeyPair,
- HttpStatusCode,
- Logger,
- PreparePayResult,
- RecoveryLoadRequest,
- RecoveryMergeStrategy,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- URL,
- buildCodecForObject,
- buildCodecForUnion,
- bytesToString,
- canonicalJson,
- canonicalizeBaseUrl,
- codecForAmountString,
- codecForBoolean,
- codecForConstString,
- codecForList,
- codecForNumber,
- codecForString,
- codecOptional,
- decodeCrock,
- durationFromSpec,
- eddsaGetPublic,
- encodeCrock,
- getRandomBytes,
- hash,
- j2s,
- kdf,
- notEmpty,
- secretbox,
- secretbox_open,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import {
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "@gnu-taler/taler-util/http";
-import { gunzipSync, gzipSync } from "fflate";
-import {
- BackupProviderRecord,
- BackupProviderState,
- BackupProviderStateTag,
- BackupProviderTerms,
- ConfigRecord,
- ConfigRecordKey,
- WalletBackupConfState,
- WalletDbReadOnlyTransaction,
- timestampOptionalPreciseFromDb,
- timestampPreciseToDb,
-} from "../../db.js";
-import { InternalWalletState } from "../../internal-wallet-state.js";
-import {
- checkDbInvariant,
- checkLogicInvariant,
-} from "../../util/invariants.js";
-import { addAttentionRequest, removeAttentionRequest } from "../attention.js";
-import {
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
-} from "../common.js";
-import { preparePayForUri } from "../pay-merchant.js";
-
-const logger = new Logger("operations/backup.ts");
-
-function concatArrays(xs: Uint8Array[]): Uint8Array {
- let len = 0;
- for (const x of xs) {
- len += x.byteLength;
- }
- const out = new Uint8Array(len);
- let offset = 0;
- for (const x of xs) {
- out.set(x, offset);
- offset += x.length;
- }
- return out;
-}
-
-const magic = "TLRWBK01";
-
-/**
- * Encrypt the backup.
- *
- * Blob format:
- * Magic "TLRWBK01" (8 bytes)
- * Nonce (24 bytes)
- * Compressed JSON blob (rest)
- */
-export async function encryptBackup(
- config: WalletBackupConfState,
- blob: any,
-): Promise<Uint8Array> {
- const chunks: Uint8Array[] = [];
- chunks.push(stringToBytes(magic));
- const nonceStr = config.lastBackupNonce;
- checkLogicInvariant(!!nonceStr);
- const nonce = decodeCrock(nonceStr).slice(0, 24);
- chunks.push(nonce);
- const backupJsonContent = canonicalJson(blob);
- logger.trace("backup JSON size", backupJsonContent.length);
- const compressedContent = gzipSync(stringToBytes(backupJsonContent), {
- mtime: 0,
- });
- const secret = deriveBlobSecret(config);
- const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
- chunks.push(encrypted);
- return concatArrays(chunks);
-}
-
-function deriveAccountKeyPair(
- bc: WalletBackupConfState,
- providerUrl: string,
-): EddsaKeyPair {
- const privateKey = kdf(
- 32,
- decodeCrock(bc.walletRootPriv),
- stringToBytes("taler-sync-account-key-salt"),
- stringToBytes(providerUrl),
- );
- return {
- eddsaPriv: privateKey,
- eddsaPub: eddsaGetPublic(privateKey),
- };
-}
-
-function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
- return kdf(
- 32,
- decodeCrock(bc.walletRootPriv),
- stringToBytes("taler-sync-blob-secret-salt"),
- stringToBytes("taler-sync-blob-secret-info"),
- );
-}
-
-interface BackupForProviderArgs {
- backupProviderBaseUrl: string;
-}
-
-function getNextBackupTimestamp(): TalerPreciseTimestamp {
- // FIXME: Randomize!
- return AbsoluteTime.toPreciseTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- durationFromSpec({ minutes: 5 }),
- ),
- );
-}
-
-async function runBackupCycleForProvider(
- ws: InternalWalletState,
- args: BackupForProviderArgs,
-): Promise<TaskRunResult> {
- const provider = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- return tx.backupProviders.get(args.backupProviderBaseUrl);
- },
- );
-
- if (!provider) {
- logger.warn("provider disappeared");
- return TaskRunResult.finished();
- }
-
- //const backupJson = await exportBackup(ws);
- // FIXME: re-implement backup
- const backupJson = {};
- const backupConfig = await provideBackupState(ws);
- const encBackup = await encryptBackup(backupConfig, backupJson);
- const currentBackupHash = hash(encBackup);
-
- const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
-
- const newHash = encodeCrock(currentBackupHash);
- const oldHash = provider.lastBackupHash;
-
- logger.trace(`trying to upload backup to ${provider.baseUrl}`);
- logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
-
- const syncSigResp = await ws.cryptoApi.makeSyncSignature({
- newHash: encodeCrock(currentBackupHash),
- oldHash: provider.lastBackupHash,
- accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
- });
-
- logger.trace(`sync signature is ${syncSigResp}`);
-
- const accountBackupUrl = new URL(
- `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
- provider.baseUrl,
- );
-
- if (provider.shouldRetryFreshProposal) {
- accountBackupUrl.searchParams.set("fresh", "yes");
- }
-
- const resp = await ws.http.fetch(accountBackupUrl.href, {
- method: "POST",
- body: encBackup,
- headers: {
- "content-type": "application/octet-stream",
- "sync-signature": syncSigResp.sig,
- "if-none-match": newHash,
- ...(provider.lastBackupHash
- ? {
- "if-match": provider.lastBackupHash,
- }
- : {}),
- },
- });
-
- logger.trace(`sync response status: ${resp.status}`);
-
- if (resp.status === HttpStatusCode.NotModified) {
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupCycleTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
- };
- await tx.backupProviders.put(prov);
- });
-
- removeAttentionRequest(ws, {
- entityId: provider.baseUrl,
- type: AttentionType.BackupUnpaid,
- });
-
- return TaskRunResult.finished();
- }
-
- if (resp.status === HttpStatusCode.PaymentRequired) {
- logger.trace("payment required for backup");
- logger.trace(`headers: ${j2s(resp.headers)}`);
- const talerUri = resp.headers.get("taler");
- if (!talerUri) {
- throw Error("no taler URI available to pay provider");
- }
-
- //We can't delay downloading the proposal since we need the id
- //FIXME: check download errors
- let res: PreparePayResult | undefined = undefined;
- try {
- res = await preparePayForUri(ws, talerUri);
- } catch (e) {
- const error = TalerError.fromException(e);
- if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) {
- throw error;
- }
- }
-
- if (res === undefined) {
- //claimed
-
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- prov.shouldRetryFreshProposal = true;
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- };
- await tx.backupProviders.put(prov);
- });
-
- throw Error("not implemented");
- // return {
- // type: TaskRunResultType.Pending,
- // };
- }
- const result = res;
-
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- // const opId = TaskIdentifiers.forBackup(prov);
- // await scheduleRetryInTx(ws, tx, opId);
- prov.currentPaymentProposalId = result.proposalId;
- prov.shouldRetryFreshProposal = false;
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- };
- await tx.backupProviders.put(prov);
- });
-
- addAttentionRequest(
- ws,
- {
- type: AttentionType.BackupUnpaid,
- provider_base_url: provider.baseUrl,
- talerUri,
- },
- provider.baseUrl,
- );
-
- throw Error("not implemented");
- // return {
- // type: TaskRunResultType.Pending,
- // };
- }
-
- if (resp.status === HttpStatusCode.NoContent) {
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupHash = encodeCrock(currentBackupHash);
- prov.lastBackupCycleTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
- };
- await tx.backupProviders.put(prov);
- });
-
- removeAttentionRequest(ws, {
- entityId: provider.baseUrl,
- type: AttentionType.BackupUnpaid,
- });
-
- return {
- type: TaskRunResultType.Finished,
- };
- }
-
- if (resp.status === HttpStatusCode.Conflict) {
- logger.info("conflicting backup found");
- const backupEnc = new Uint8Array(await resp.bytes());
- const backupConfig = await provideBackupState(ws);
- // const blob = await decryptBackup(backupConfig, backupEnc);
- // FIXME: Re-implement backup import with merging
- // await importBackup(ws, blob, cryptoData);
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- prov.lastBackupHash = encodeCrock(hash(backupEnc));
- // FIXME: Allocate error code for this situation?
- // FIXME: Add operation retry record!
- const opId = TaskIdentifiers.forBackup(prov);
- //await scheduleRetryInTx(ws, tx, opId);
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- };
- await tx.backupProviders.put(prov);
- });
- logger.info("processed existing backup");
- // Now upload our own, merged backup.
- return await runBackupCycleForProvider(ws, args);
- }
-
- // Some other response that we did not expect!
-
- logger.error("parsing error response");
-
- const err = await readTalerErrorResponse(resp);
- logger.error(`got error response from backup provider: ${j2s(err)}`);
- return {
- type: TaskRunResultType.Error,
- errorDetail: err,
- };
-}
-
-export async function processBackupForProvider(
- ws: InternalWalletState,
- backupProviderBaseUrl: string,
-): Promise<TaskRunResult> {
- const provider = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- return await tx.backupProviders.get(backupProviderBaseUrl);
- },
- );
- if (!provider) {
- throw Error("unknown backup provider");
- }
-
- logger.info(`running backup for provider ${backupProviderBaseUrl}`);
-
- return await runBackupCycleForProvider(ws, {
- backupProviderBaseUrl: provider.baseUrl,
- });
-}
-
-export interface RemoveBackupProviderRequest {
- provider: string;
-}
-
-export const codecForRemoveBackupProvider =
- (): Codec<RemoveBackupProviderRequest> =>
- buildCodecForObject<RemoveBackupProviderRequest>()
- .property("provider", codecForString())
- .build("RemoveBackupProviderRequest");
-
-export async function removeBackupProvider(
- ws: InternalWalletState,
- req: RemoveBackupProviderRequest,
-): Promise<void> {
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- await tx.backupProviders.delete(req.provider);
- });
-}
-
-export interface RunBackupCycleRequest {
- /**
- * List of providers to backup or empty for all known providers.
- */
- providers?: Array<string>;
-}
-
-export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
- buildCodecForObject<RunBackupCycleRequest>()
- .property("providers", codecOptional(codecForList(codecForString())))
- .build("RunBackupCycleRequest");
-
-/**
- * Do one backup cycle that consists of:
- * 1. Exporting a backup and try to upload it.
- * Stop if this step succeeds.
- * 2. Download, verify and import backups from connected sync accounts.
- * 3. Upload the updated backup blob.
- */
-export async function runBackupCycle(
- ws: InternalWalletState,
- req: RunBackupCycleRequest,
-): Promise<void> {
- const providers = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- if (req.providers) {
- const rs = await Promise.all(
- req.providers.map((id) => tx.backupProviders.get(id)),
- );
- return rs.filter(notEmpty);
- }
- return await tx.backupProviders.iter().toArray();
- },
- );
-
- for (const provider of providers) {
- await runBackupCycleForProvider(ws, {
- backupProviderBaseUrl: provider.baseUrl,
- });
- }
-}
-
-export interface SyncTermsOfServiceResponse {
- // maximum backup size supported
- storage_limit_in_megabytes: number;
-
- // Fee for an account, per year.
- annual_fee: AmountString;
-
- // protocol version supported by the server,
- // for now always "0.0".
- version: string;
-}
-
-export const codecForSyncTermsOfServiceResponse =
- (): Codec<SyncTermsOfServiceResponse> =>
- buildCodecForObject<SyncTermsOfServiceResponse>()
- .property("storage_limit_in_megabytes", codecForNumber())
- .property("annual_fee", codecForAmountString())
- .property("version", codecForString())
- .build("SyncTermsOfServiceResponse");
-
-export interface AddBackupProviderRequest {
- backupProviderBaseUrl: string;
-
- name: string;
- /**
- * Activate the provider. Should only be done after
- * the user has reviewed the provider.
- */
- activate?: boolean;
-}
-
-export const codecForAddBackupProviderRequest =
- (): Codec<AddBackupProviderRequest> =>
- buildCodecForObject<AddBackupProviderRequest>()
- .property("backupProviderBaseUrl", codecForString())
- .property("name", codecForString())
- .property("activate", codecOptional(codecForBoolean()))
- .build("AddBackupProviderRequest");
-
-export type AddBackupProviderResponse =
- | AddBackupProviderOk
- | AddBackupProviderPaymentRequired;
-
-interface AddBackupProviderOk {
- status: "ok";
-}
-interface AddBackupProviderPaymentRequired {
- status: "payment-required";
- talerUri?: string;
-}
-
-export const codecForAddBackupProviderOk = (): Codec<AddBackupProviderOk> =>
- buildCodecForObject<AddBackupProviderOk>()
- .property("status", codecForConstString("ok"))
- .build("AddBackupProviderOk");
-
-export const codecForAddBackupProviderPaymenrRequired =
- (): Codec<AddBackupProviderPaymentRequired> =>
- buildCodecForObject<AddBackupProviderPaymentRequired>()
- .property("status", codecForConstString("payment-required"))
- .property("talerUri", codecOptional(codecForString()))
- .build("AddBackupProviderPaymentRequired");
-
-export const codecForAddBackupProviderResponse =
- (): Codec<AddBackupProviderResponse> =>
- buildCodecForUnion<AddBackupProviderResponse>()
- .discriminateOn("status")
- .alternative("ok", codecForAddBackupProviderOk())
- .alternative(
- "payment-required",
- codecForAddBackupProviderPaymenrRequired(),
- )
- .build("AddBackupProviderResponse");
-
-export async function addBackupProvider(
- ws: InternalWalletState,
- req: AddBackupProviderRequest,
-): Promise<AddBackupProviderResponse> {
- logger.info(`adding backup provider ${j2s(req)}`);
- await provideBackupState(ws);
- const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const oldProv = await tx.backupProviders.get(canonUrl);
- if (oldProv) {
- logger.info("old backup provider found");
- if (req.activate) {
- oldProv.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- ),
- };
- logger.info("setting existing backup provider to active");
- await tx.backupProviders.put(oldProv);
- }
- return;
- }
- });
- const termsUrl = new URL("config", canonUrl);
- const resp = await ws.http.fetch(termsUrl.href);
- const terms = await readSuccessResponseJsonOrThrow(
- resp,
- codecForSyncTermsOfServiceResponse(),
- );
- await ws.db.runReadWriteTx(["backupProviders"], async (tx) => {
- let state: BackupProviderState;
- //FIXME: what is the difference provisional and ready?
- if (req.activate) {
- state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
- } else {
- state = {
- tag: BackupProviderStateTag.Provisional,
- };
- }
- await tx.backupProviders.put({
- state,
- name: req.name,
- terms: {
- annualFee: terms.annual_fee,
- storageLimitInMegabytes: terms.storage_limit_in_megabytes,
- supportedProtocolVersion: terms.version,
- },
- shouldRetryFreshProposal: false,
- paymentProposalIds: [],
- baseUrl: canonUrl,
- uids: [encodeCrock(getRandomBytes(32))],
- });
- });
-
- return await runFirstBackupCycleForProvider(ws, {
- backupProviderBaseUrl: canonUrl,
- });
-}
-
-async function runFirstBackupCycleForProvider(
- ws: InternalWalletState,
- args: BackupForProviderArgs,
-): Promise<AddBackupProviderResponse> {
- throw Error("not implemented");
- // const resp = await runBackupCycleForProvider(ws, args);
- // switch (resp.type) {
- // case TaskRunResultType.Error:
- // throw TalerError.fromDetail(
- // TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- // resp.errorDetail as any, //FIXME create an error for backup problems
- // );
- // case TaskRunResultType.Finished:
- // return {
- // status: "ok",
- // };
- // case TaskRunResultType.Pending:
- // return {
- // status: "payment-required",
- // talerUri: "FIXME",
- // //talerUri: resp.result.talerUri,
- // };
- // default:
- // assertUnreachable(resp);
- // }
-}
-
-export async function restoreFromRecoverySecret(): Promise<void> {
- return;
-}
-
-/**
- * Information about one provider.
- *
- * We don't store the account key here,
- * as that's derived from the wallet root key.
- */
-export interface ProviderInfo {
- active: boolean;
- syncProviderBaseUrl: string;
- name: string;
- terms?: BackupProviderTerms;
- /**
- * Last communication issue with the provider.
- */
- lastError?: TalerErrorDetail;
- lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp;
- lastAttemptedBackupTimestamp?: TalerPreciseTimestamp;
- paymentProposalIds: string[];
- backupProblem?: BackupProblem;
- paymentStatus: ProviderPaymentStatus;
-}
-
-export type BackupProblem =
- | BackupUnreadableProblem
- | BackupConflictingDeviceProblem;
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupConflictingDeviceProblem {
- type: "backup-conflicting-device";
- otherDeviceId: string;
- myDeviceId: string;
- backupTimestamp: AbsoluteTime;
-}
-
-export type ProviderPaymentStatus =
- | ProviderPaymentTermsChanged
- | ProviderPaymentPaid
- | ProviderPaymentInsufficientBalance
- | ProviderPaymentUnpaid
- | ProviderPaymentPending;
-
-export interface BackupInfo {
- walletRootPub: string;
- deviceId: string;
- providers: ProviderInfo[];
-}
-
-export enum ProviderPaymentType {
- Unpaid = "unpaid",
- Pending = "pending",
- InsufficientBalance = "insufficient-balance",
- Paid = "paid",
- TermsChanged = "terms-changed",
-}
-
-export interface ProviderPaymentUnpaid {
- type: ProviderPaymentType.Unpaid;
-}
-
-export interface ProviderPaymentInsufficientBalance {
- type: ProviderPaymentType.InsufficientBalance;
- amount: AmountString;
-}
-
-export interface ProviderPaymentPending {
- type: ProviderPaymentType.Pending;
- talerUri?: string;
-}
-
-export interface ProviderPaymentPaid {
- type: ProviderPaymentType.Paid;
- paidUntil: AbsoluteTime;
-}
-
-export interface ProviderPaymentTermsChanged {
- type: ProviderPaymentType.TermsChanged;
- paidUntil: AbsoluteTime;
- oldTerms: BackupProviderTerms;
- newTerms: BackupProviderTerms;
-}
-
-async function getProviderPaymentInfo(
- ws: InternalWalletState,
- provider: BackupProviderRecord,
-): Promise<ProviderPaymentStatus> {
- throw Error("not implemented");
- // if (!provider.currentPaymentProposalId) {
- // return {
- // type: ProviderPaymentType.Unpaid,
- // };
- // }
- // const status = await checkPaymentByProposalId(
- // ws,
- // provider.currentPaymentProposalId,
- // ).catch(() => undefined);
-
- // if (!status) {
- // return {
- // type: ProviderPaymentType.Unpaid,
- // };
- // }
-
- // switch (status.status) {
- // case PreparePayResultType.InsufficientBalance:
- // return {
- // type: ProviderPaymentType.InsufficientBalance,
- // amount: status.amountRaw,
- // };
- // case PreparePayResultType.PaymentPossible:
- // return {
- // type: ProviderPaymentType.Pending,
- // talerUri: status.talerUri,
- // };
- // case PreparePayResultType.AlreadyConfirmed:
- // if (status.paid) {
- // return {
- // type: ProviderPaymentType.Paid,
- // paidUntil: AbsoluteTime.addDuration(
- // AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp),
- // durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
- // ),
- // };
- // } else {
- // return {
- // type: ProviderPaymentType.Pending,
- // talerUri: status.talerUri,
- // };
- // }
- // default:
- // assertUnreachable(status);
- // }
-}
-
-/**
- * Get information about the current state of wallet backups.
- */
-export async function getBackupInfo(
- ws: InternalWalletState,
-): Promise<BackupInfo> {
- const backupConfig = await provideBackupState(ws);
- const providerRecords = await ws.db.runReadOnlyTx(
- ["backupProviders", "operationRetries"],
- async (tx) => {
- return await tx.backupProviders.iter().mapAsync(async (bp) => {
- const opId = TaskIdentifiers.forBackup(bp);
- const retryRecord = await tx.operationRetries.get(opId);
- return {
- provider: bp,
- retryRecord,
- };
- });
- },
- );
- const providers: ProviderInfo[] = [];
- for (const x of providerRecords) {
- providers.push({
- active: x.provider.state.tag !== BackupProviderStateTag.Provisional,
- syncProviderBaseUrl: x.provider.baseUrl,
- lastSuccessfulBackupTimestamp: timestampOptionalPreciseFromDb(
- x.provider.lastBackupCycleTimestamp,
- ),
- paymentProposalIds: x.provider.paymentProposalIds,
- lastError:
- x.provider.state.tag === BackupProviderStateTag.Retrying
- ? x.retryRecord?.lastError
- : undefined,
- paymentStatus: await getProviderPaymentInfo(ws, x.provider),
- terms: x.provider.terms,
- name: x.provider.name,
- });
- }
- return {
- deviceId: backupConfig.deviceId,
- walletRootPub: backupConfig.walletRootPub,
- providers,
- };
-}
-
-/**
- * Get backup recovery information, including the wallet's
- * private key.
- */
-export async function getBackupRecovery(
- ws: InternalWalletState,
-): Promise<BackupRecovery> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- return await tx.backupProviders.iter().toArray();
- },
- );
- return {
- providers: providers
- .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
- .map((x) => {
- return {
- name: x.name,
- url: x.baseUrl,
- };
- }),
- walletRootPriv: bs.walletRootPriv,
- };
-}
-
-async function backupRecoveryTheirs(
- ws: InternalWalletState,
- br: BackupRecovery,
-) {
- await ws.db.runReadWriteTx(["backupProviders", "config"], async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- checkDbInvariant(!!backupStateEntry);
- checkDbInvariant(
- backupStateEntry.key === ConfigRecordKey.WalletBackupState,
- );
- backupStateEntry.value.lastBackupNonce = undefined;
- backupStateEntry.value.lastBackupTimestamp = undefined;
- backupStateEntry.value.lastBackupCheckTimestamp = undefined;
- backupStateEntry.value.lastBackupPlainHash = undefined;
- backupStateEntry.value.walletRootPriv = br.walletRootPriv;
- backupStateEntry.value.walletRootPub = encodeCrock(
- eddsaGetPublic(decodeCrock(br.walletRootPriv)),
- );
- await tx.config.put(backupStateEntry);
- for (const prov of br.providers) {
- const existingProv = await tx.backupProviders.get(prov.url);
- if (!existingProv) {
- await tx.backupProviders.put({
- baseUrl: prov.url,
- name: prov.name,
- paymentProposalIds: [],
- shouldRetryFreshProposal: false,
- state: {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- ),
- },
- uids: [encodeCrock(getRandomBytes(32))],
- });
- }
- }
- const providers = await tx.backupProviders.iter().toArray();
- for (const prov of providers) {
- prov.lastBackupCycleTimestamp = undefined;
- prov.lastBackupHash = undefined;
- await tx.backupProviders.put(prov);
- }
- });
-}
-
-async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) {
- throw Error("not implemented");
-}
-
-export async function loadBackupRecovery(
- ws: InternalWalletState,
- br: RecoveryLoadRequest,
-): Promise<void> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db.runReadOnlyTx(
- ["backupProviders"],
- async (tx) => {
- return await tx.backupProviders.iter().toArray();
- },
- );
- let strategy = br.strategy;
- if (
- br.recovery.walletRootPriv != bs.walletRootPriv &&
- providers.length > 0 &&
- !strategy
- ) {
- throw Error(
- "recovery load strategy must be specified for wallet with existing providers",
- );
- } else if (!strategy) {
- // Default to using the new key if we don't have providers yet.
- strategy = RecoveryMergeStrategy.Theirs;
- }
- if (strategy === RecoveryMergeStrategy.Theirs) {
- return backupRecoveryTheirs(ws, br.recovery);
- } else {
- return backupRecoveryOurs(ws, br.recovery);
- }
-}
-
-export async function decryptBackup(
- backupConfig: WalletBackupConfState,
- data: Uint8Array,
-): Promise<any> {
- const rMagic = bytesToString(data.slice(0, 8));
- if (rMagic != magic) {
- throw Error("invalid backup file (magic tag mismatch)");
- }
-
- const nonce = data.slice(8, 8 + 24);
- const box = data.slice(8 + 24);
- const secret = deriveBlobSecret(backupConfig);
- const dataCompressed = secretbox_open(box, nonce, secret);
- if (!dataCompressed) {
- throw Error("decryption failed");
- }
- return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
-}
-
-export async function provideBackupState(
- ws: InternalWalletState,
-): Promise<WalletBackupConfState> {
- const bs: ConfigRecord | undefined = await ws.db.runReadOnlyTx(
- ["config"],
- async (tx) => {
- return await tx.config.get(ConfigRecordKey.WalletBackupState);
- },
- );
- if (bs) {
- checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
- return bs.value;
- }
- // We need to generate the key outside of the transaction
- // due to how IndexedDB works.
- const k = await ws.cryptoApi.createEddsaKeypair({});
- const d = getRandomBytes(5);
- // FIXME: device ID should be configured when wallet is initialized
- // and be based on hostname
- const deviceId = `wallet-core-${encodeCrock(d)}`;
- return await ws.db.runReadWriteTx(["config"], async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- if (!backupStateEntry) {
- backupStateEntry = {
- key: ConfigRecordKey.WalletBackupState,
- value: {
- deviceId,
- walletRootPub: k.pub,
- walletRootPriv: k.priv,
- lastBackupPlainHash: undefined,
- },
- };
- await tx.config.put(backupStateEntry);
- }
- checkDbInvariant(
- backupStateEntry.key === ConfigRecordKey.WalletBackupState,
- );
- return backupStateEntry.value;
- });
-}
-
-export async function getWalletBackupState(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<["config"]>,
-): Promise<WalletBackupConfState> {
- const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
- checkDbInvariant(!!bs, "wallet backup state should be in DB");
- checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
- return bs.value;
-}
-
-export async function setWalletDeviceId(
- ws: InternalWalletState,
- deviceId: string,
-): Promise<void> {
- await provideBackupState(ws);
- await ws.db.runReadWriteTx(["config"], async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- if (
- !backupStateEntry ||
- backupStateEntry.key !== ConfigRecordKey.WalletBackupState
- ) {
- return;
- }
- backupStateEntry.value.deviceId = deviceId;
- await tx.config.put(backupStateEntry);
- });
-}
-
-export async function getWalletDeviceId(
- ws: InternalWalletState,
-): Promise<string> {
- const bs = await provideBackupState(ws);
- return bs.deviceId;
-}
diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts
@@ -1,730 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Functions to compute the wallet's balance.
- *
- * There are multiple definition of the wallet's balance.
- * We use the following terminology:
- *
- * - "available": Balance that is available
- * for spending from transactions in their final state and
- * expected to be available from pending refreshes.
- *
- * - "pending-incoming": Expected (positive!) delta
- * to the available balance that we expect to have
- * after pending operations reach the "done" state.
- *
- * - "pending-outgoing": Amount that is currently allocated
- * to be spent, but the spend operation could still be aborted
- * and part of the pending-outgoing amount could be recovered.
- *
- * - "material": Balance that the wallet believes it could spend *right now*,
- * without waiting for any operations to complete.
- * This balance type is important when showing "insufficient balance" error messages.
- *
- * - "age-acceptable": Subset of the material balance that can be spent
- * with age restrictions applied.
- *
- * - "merchant-acceptable": Subset of the material balance that can be spent with a particular
- * merchant (restricted via min age, exchange, auditor, wire_method).
- *
- * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
- * can accept via their supported wire methods.
- */
-
-/**
- * Imports.
- */
-import { GlobalIDB } from "@gnu-taler/idb-bridge";
-import {
- AllowedAuditorInfo,
- AllowedExchangeInfo,
- AmountJson,
- AmountLike,
- Amounts,
- BalanceFlag,
- BalancesResponse,
- canonicalizeBaseUrl,
- GetBalanceDetailRequest,
- Logger,
- parsePaytoUri,
- ScopeInfo,
- ScopeType,
-} from "@gnu-taler/taler-util";
-import {
- DepositOperationStatus,
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- RefreshGroupRecord,
- RefreshOperationStatus,
- WalletDbReadOnlyTransaction,
- WithdrawalGroupStatus,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import {
- getExchangeScopeInfo,
- getExchangeWireDetailsInTx,
-} from "./exchanges.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("operations/balance.ts");
-
-interface WalletBalance {
- scopeInfo: ScopeInfo;
- available: AmountJson;
- pendingIncoming: AmountJson;
- pendingOutgoing: AmountJson;
- flagIncomingKyc: boolean;
- flagIncomingAml: boolean;
- flagIncomingConfirmation: boolean;
- flagOutgoingKyc: boolean;
-}
-
-/**
- * Compute the available amount that the wallet expects to get
- * out of a refresh group.
- */
-function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
- // Don't count finished refreshes, since the refresh already resulted
- // in coins being added to the wallet.
- let available = Amounts.zeroOfCurrency(r.currency);
- if (r.timestampFinished) {
- return available;
- }
- for (let i = 0; i < r.oldCoinPubs.length; i++) {
- available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount;
- }
- return available;
-}
-
-function getBalanceKey(scopeInfo: ScopeInfo): string {
- switch (scopeInfo.type) {
- case ScopeType.Auditor:
- return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
- case ScopeType.Exchange:
- return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
- case ScopeType.Global:
- return `${scopeInfo.type};${scopeInfo.currency}`;
- }
-}
-
-class BalancesStore {
- private exchangeScopeCache: Record<string, ScopeInfo> = {};
- private balanceStore: Record<string, WalletBalance> = {};
-
- constructor(
- private ws: InternalWalletState,
- private tx: WalletDbReadOnlyTransaction<
- [
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- "exchanges",
- "exchangeDetails",
- ]
- >,
- ) {}
-
- /**
- * Add amount to a balance field, both for
- * the slicing by exchange and currency.
- */
- private async initBalance(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<WalletBalance> {
- let scopeInfo: ScopeInfo | undefined =
- this.exchangeScopeCache[exchangeBaseUrl];
- if (!scopeInfo) {
- scopeInfo = await getExchangeScopeInfo(
- this.tx,
- exchangeBaseUrl,
- currency,
- );
- this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo;
- }
- const balanceKey = getBalanceKey(scopeInfo);
- const b = this.balanceStore[balanceKey];
- if (!b) {
- const zero = Amounts.zeroOfCurrency(currency);
- this.balanceStore[balanceKey] = {
- scopeInfo,
- available: zero,
- pendingIncoming: zero,
- pendingOutgoing: zero,
- flagIncomingAml: false,
- flagIncomingConfirmation: false,
- flagIncomingKyc: false,
- flagOutgoingKyc: false,
- };
- }
- return this.balanceStore[balanceKey];
- }
-
- async addAvailable(
- currency: string,
- exchangeBaseUrl: string,
- amount: AmountLike,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.available = Amounts.add(b.available, amount).amount;
- }
-
- async addPendingIncoming(
- currency: string,
- exchangeBaseUrl: string,
- amount: AmountLike,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.pendingIncoming = Amounts.add(b.available, amount).amount;
- }
-
- async setFlagIncomingAml(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.flagIncomingAml = true;
- }
-
- async setFlagIncomingKyc(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.flagIncomingKyc = true;
- }
-
- async setFlagIncomingConfirmation(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.flagIncomingConfirmation = true;
- }
-
- async setFlagOutgoingKyc(
- currency: string,
- exchangeBaseUrl: string,
- ): Promise<void> {
- const b = await this.initBalance(currency, exchangeBaseUrl);
- b.flagOutgoingKyc = true;
- }
-
- toBalancesResponse(): BalancesResponse {
- const balancesResponse: BalancesResponse = {
- balances: [],
- };
-
- const balanceStore = this.balanceStore;
-
- Object.keys(balanceStore)
- .sort()
- .forEach((c) => {
- const v = balanceStore[c];
- const flags: BalanceFlag[] = [];
- if (v.flagIncomingAml) {
- flags.push(BalanceFlag.IncomingAml);
- }
- if (v.flagIncomingKyc) {
- flags.push(BalanceFlag.IncomingKyc);
- }
- if (v.flagIncomingConfirmation) {
- flags.push(BalanceFlag.IncomingConfirmation);
- }
- if (v.flagOutgoingKyc) {
- flags.push(BalanceFlag.OutgoingKyc);
- }
- balancesResponse.balances.push({
- scopeInfo: v.scopeInfo,
- available: Amounts.stringify(v.available),
- pendingIncoming: Amounts.stringify(v.pendingIncoming),
- pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
- // FIXME: This field is basically not implemented, do we even need it?
- hasPendingTransactions: false,
- // FIXME: This field is basically not implemented, do we even need it?
- requiresUserInput: false,
- flags,
- });
- });
- return balancesResponse;
- }
-}
-
-/**
- * Get balance information.
- */
-export async function getBalancesInsideTransaction(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<
- [
- "exchanges",
- "exchangeDetails",
- "coinAvailability",
- "refreshGroups",
- "depositGroups",
- "withdrawalGroups",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ]
- >,
-): Promise<BalancesResponse> {
- const balanceStore: BalancesStore = new BalancesStore(ws, tx);
-
- const keyRangeActive = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
-
- await tx.coinAvailability.iter().forEachAsync(async (ca) => {
- const count = ca.visibleCoinCount ?? 0;
- for (let i = 0; i < count; i++) {
- await balanceStore.addAvailable(
- ca.currency,
- ca.exchangeBaseUrl,
- ca.value,
- );
- }
- });
-
- await tx.refreshGroups.iter().forEachAsync(async (r) => {
- switch (r.operationStatus) {
- case RefreshOperationStatus.Pending:
- case RefreshOperationStatus.Suspended:
- break;
- default:
- return;
- }
- const perExchange = r.infoPerExchange;
- if (!perExchange) {
- return;
- }
- for (const [e, x] of Object.entries(perExchange)) {
- await balanceStore.addAvailable(r.currency, e, x.outputEffective);
- }
- });
-
- await tx.withdrawalGroups.indexes.byStatus
- .iter(keyRangeActive)
- .forEachAsync(async (wgRecord) => {
- const currency = Amounts.currencyOf(wgRecord.denomsSel.totalCoinValue);
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.FailedAbortingBank:
- case WithdrawalGroupStatus.FailedBankAborted:
- case WithdrawalGroupStatus.Done:
- // Does not count as pendingIncoming
- return;
- case WithdrawalGroupStatus.PendingReady:
- case WithdrawalGroupStatus.AbortingBank:
- case WithdrawalGroupStatus.PendingQueryingStatus:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- // Pending, but no special flag.
- break;
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.PendingKyc:
- await balanceStore.setFlagIncomingKyc(
- currency,
- wgRecord.exchangeBaseUrl,
- );
- break;
- case WithdrawalGroupStatus.PendingAml:
- case WithdrawalGroupStatus.SuspendedAml:
- await balanceStore.setFlagIncomingAml(
- currency,
- wgRecord.exchangeBaseUrl,
- );
- break;
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- await balanceStore.setFlagIncomingConfirmation(
- currency,
- wgRecord.exchangeBaseUrl,
- );
- break;
- default:
- assertUnreachable(wgRecord.status);
- }
- await balanceStore.addPendingIncoming(
- currency,
- wgRecord.exchangeBaseUrl,
- wgRecord.denomsSel.totalCoinValue,
- );
- });
-
- await tx.depositGroups.indexes.byStatus
- .iter(keyRangeActive)
- .forEachAsync(async (dgRecord) => {
- const perExchange = dgRecord.infoPerExchange;
- if (!perExchange) {
- return;
- }
- for (const [e, x] of Object.entries(perExchange)) {
- const currency = Amounts.currencyOf(dgRecord.amount);
- switch (dgRecord.operationStatus) {
- case DepositOperationStatus.SuspendedKyc:
- case DepositOperationStatus.PendingKyc:
- await balanceStore.setFlagOutgoingKyc(currency, e);
- }
- }
- });
-
- return balanceStore.toBalancesResponse();
-}
-
-/**
- * Get detailed balance information, sliced by exchange and by currency.
- */
-export async function getBalances(
- ws: InternalWalletState,
-): Promise<BalancesResponse> {
- logger.trace("starting to compute balance");
-
- const wbal = await ws.db.runReadWriteTx(
- [
- "coinAvailability",
- "coins",
- "depositGroups",
- "exchangeDetails",
- "exchanges",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- "purchases",
- "refreshGroups",
- "withdrawalGroups",
- ],
- async (tx) => {
- return getBalancesInsideTransaction(ws, tx);
- },
- );
-
- logger.trace("finished computing wallet balance");
-
- return wbal;
-}
-
-/**
- * Information about the balance for a particular payment to a particular
- * merchant.
- */
-export interface MerchantPaymentBalanceDetails {
- balanceAvailable: AmountJson;
-}
-
-export interface MerchantPaymentRestrictionsForBalance {
- currency: string;
- minAge: number;
- acceptedExchanges: AllowedExchangeInfo[];
- acceptedAuditors: AllowedAuditorInfo[];
- acceptedWireMethods: string[];
-}
-
-export interface AcceptableExchanges {
- /**
- * Exchanges accepted by the merchant, but wire method might not match.
- */
- acceptableExchanges: string[];
-
- /**
- * Exchanges accepted by the merchant, including a matching
- * wire method, i.e. the merchant can deposit coins there.
- */
- depositableExchanges: string[];
-}
-
-/**
- * Get all exchanges that are acceptable for a particular payment.
- */
-export async function getAcceptableExchangeBaseUrls(
- ws: InternalWalletState,
- req: MerchantPaymentRestrictionsForBalance,
-): Promise<AcceptableExchanges> {
- const acceptableExchangeUrls = new Set<string>();
- const depositableExchangeUrls = new Set<string>();
- await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
- // FIXME: We should have a DB index to look up all exchanges
- // for a particular auditor ...
-
- const canonExchanges = new Set<string>();
- const canonAuditors = new Set<string>();
-
- for (const exchangeHandle of req.acceptedExchanges) {
- const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl);
- canonExchanges.add(normUrl);
- }
-
- for (const auditorHandle of req.acceptedAuditors) {
- const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl);
- canonAuditors.add(normUrl);
- }
-
- await tx.exchanges.iter().forEachAsync(async (exchange) => {
- const dp = exchange.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([
- exchange.baseUrl,
- currency,
- masterPublicKey,
- ]);
- if (!exchangeDetails) {
- return;
- }
-
- let acceptable = false;
-
- if (canonExchanges.has(exchange.baseUrl)) {
- acceptableExchangeUrls.add(exchange.baseUrl);
- acceptable = true;
- }
- for (const exchangeAuditor of exchangeDetails.auditors) {
- if (canonAuditors.has(exchangeAuditor.auditor_url)) {
- acceptableExchangeUrls.add(exchange.baseUrl);
- acceptable = true;
- break;
- }
- }
-
- if (!acceptable) {
- return;
- }
- // FIXME: Also consider exchange and auditor public key
- // instead of just base URLs?
-
- let wireMethodSupported = false;
- for (const acc of exchangeDetails.wireInfo.accounts) {
- const pp = parsePaytoUri(acc.payto_uri);
- checkLogicInvariant(!!pp);
- for (const wm of req.acceptedWireMethods) {
- if (pp.targetType === wm) {
- wireMethodSupported = true;
- break;
- }
- if (wireMethodSupported) {
- break;
- }
- }
- }
-
- acceptableExchangeUrls.add(exchange.baseUrl);
- if (wireMethodSupported) {
- depositableExchangeUrls.add(exchange.baseUrl);
- }
- });
- });
- return {
- acceptableExchanges: [...acceptableExchangeUrls],
- depositableExchanges: [...depositableExchangeUrls],
- };
-}
-
-export interface MerchantPaymentBalanceDetails {
- /**
- * Balance of type "available" (see balance.ts for definition).
- */
- balanceAvailable: AmountJson;
-
- /**
- * Balance of type "material" (see balance.ts for definition).
- */
- balanceMaterial: AmountJson;
-
- /**
- * Balance of type "age-acceptable" (see balance.ts for definition).
- */
- balanceAgeAcceptable: AmountJson;
-
- /**
- * Balance of type "merchant-acceptable" (see balance.ts for definition).
- */
- balanceMerchantAcceptable: AmountJson;
-
- /**
- * Balance of type "merchant-depositable" (see balance.ts for definition).
- */
- balanceMerchantDepositable: AmountJson;
-}
-
-export async function getMerchantPaymentBalanceDetails(
- ws: InternalWalletState,
- req: MerchantPaymentRestrictionsForBalance,
-): Promise<MerchantPaymentBalanceDetails> {
- const acceptability = await getAcceptableExchangeBaseUrls(ws, req);
-
- const d: MerchantPaymentBalanceDetails = {
- balanceAvailable: Amounts.zeroOfCurrency(req.currency),
- balanceMaterial: Amounts.zeroOfCurrency(req.currency),
- balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
- balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency),
- balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency),
- };
-
- await ws.db.runReadOnlyTx(
- ["coinAvailability", "refreshGroups"],
- async (tx) => {
- await tx.coinAvailability.iter().forEach((ca) => {
- if (ca.currency != req.currency) {
- return;
- }
- const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
- const coinAmount: AmountJson = Amounts.mult(
- singleCoinAmount,
- ca.freshCoinCount,
- ).amount;
- d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
- d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
- if (ca.maxAge === 0 || ca.maxAge > req.minAge) {
- d.balanceAgeAcceptable = Amounts.add(
- d.balanceAgeAcceptable,
- coinAmount,
- ).amount;
- if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) {
- d.balanceMerchantAcceptable = Amounts.add(
- d.balanceMerchantAcceptable,
- coinAmount,
- ).amount;
- if (
- acceptability.depositableExchanges.includes(ca.exchangeBaseUrl)
- ) {
- d.balanceMerchantDepositable = Amounts.add(
- d.balanceMerchantDepositable,
- coinAmount,
- ).amount;
- }
- }
- }
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- if (r.currency != req.currency) {
- return;
- }
- d.balanceAvailable = Amounts.add(
- d.balanceAvailable,
- computeRefreshGroupAvailableAmount(r),
- ).amount;
- });
- },
- );
-
- return d;
-}
-
-export async function getBalanceDetail(
- ws: InternalWalletState,
- req: GetBalanceDetailRequest,
-): Promise<MerchantPaymentBalanceDetails> {
- const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
- const wires = new Array<string>();
- await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || req.currency !== details.currency) {
- continue;
- }
- details.wireInfo.accounts.forEach((a) => {
- const payto = parsePaytoUri(a.payto_uri);
- if (payto && !wires.includes(payto.targetType)) {
- wires.push(payto.targetType);
- }
- });
- exchanges.push({
- exchangePub: details.masterPublicKey,
- exchangeBaseUrl: e.baseUrl,
- });
- }
- });
-
- return await getMerchantPaymentBalanceDetails(ws, {
- currency: req.currency,
- acceptedAuditors: [],
- acceptedExchanges: exchanges,
- acceptedWireMethods: wires,
- minAge: 0,
- });
-}
-
-export interface PeerPaymentRestrictionsForBalance {
- currency: string;
- restrictExchangeTo?: string;
-}
-
-export interface PeerPaymentBalanceDetails {
- /**
- * Balance of type "available" (see balance.ts for definition).
- */
- balanceAvailable: AmountJson;
-
- /**
- * Balance of type "material" (see balance.ts for definition).
- */
- balanceMaterial: AmountJson;
-}
-
-export async function getPeerPaymentBalanceDetailsInTx(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<["coinAvailability", "refreshGroups"]>,
- req: PeerPaymentRestrictionsForBalance,
-): Promise<PeerPaymentBalanceDetails> {
- let balanceAvailable = Amounts.zeroOfCurrency(req.currency);
- let balanceMaterial = Amounts.zeroOfCurrency(req.currency);
-
- await tx.coinAvailability.iter().forEach((ca) => {
- if (ca.currency != req.currency) {
- return;
- }
- if (
- req.restrictExchangeTo &&
- req.restrictExchangeTo !== ca.exchangeBaseUrl
- ) {
- return;
- }
- const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
- const coinAmount: AmountJson = Amounts.mult(
- singleCoinAmount,
- ca.freshCoinCount,
- ).amount;
- balanceAvailable = Amounts.add(balanceAvailable, coinAmount).amount;
- balanceMaterial = Amounts.add(balanceMaterial, coinAmount).amount;
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- if (r.currency != req.currency) {
- return;
- }
- balanceAvailable = Amounts.add(
- balanceAvailable,
- computeRefreshGroupAvailableAmount(r),
- ).amount;
- });
-
- return {
- balanceAvailable,
- balanceMaterial,
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts
@@ -1,693 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AmountJson,
- Amounts,
- CoinRefreshRequest,
- CoinStatus,
- Duration,
- ExchangeEntryState,
- ExchangeEntryStatus,
- ExchangeTosStatus,
- ExchangeUpdateStatus,
- Logger,
- RefreshReason,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TombstoneIdStr,
- TransactionIdStr,
- durationMul,
-} from "@gnu-taler/taler-util";
-import {
- BackupProviderRecord,
- CoinRecord,
- DbPreciseTimestamp,
- DepositGroupRecord,
- ExchangeEntryDbRecordStatus,
- ExchangeEntryDbUpdateStatus,
- ExchangeEntryRecord,
- PeerPullCreditRecord,
- PeerPullPaymentIncomingRecord,
- PeerPushDebitRecord,
- PeerPushPaymentIncomingRecord,
- PurchaseRecord,
- RecoupGroupRecord,
- RefreshGroupRecord,
- RewardRecord,
- WalletDbReadWriteTransaction,
- WithdrawalGroupRecord,
- timestampPreciseToDb,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import { createRefreshGroup } from "./refresh.js";
-
-const logger = new Logger("operations/common.ts");
-
-export interface CoinsSpendInfo {
- coinPubs: string[];
- contributions: AmountJson[];
- refreshReason: RefreshReason;
- /**
- * Identifier for what the coin has been spent for.
- */
- allocationId: TransactionIdStr;
-}
-
-export async function makeCoinsVisible(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>,
- transactionId: string,
-): Promise<void> {
- const coins =
- await tx.coins.indexes.bySourceTransactionId.getAll(transactionId);
- for (const coinRecord of coins) {
- if (!coinRecord.visible) {
- coinRecord.visible = 1;
- await tx.coins.put(coinRecord);
- const ageRestriction = coinRecord.maxAge;
- const car = await tx.coinAvailability.get([
- coinRecord.exchangeBaseUrl,
- coinRecord.denomPubHash,
- ageRestriction,
- ]);
- if (!car) {
- logger.error("missing coin availability record");
- continue;
- }
- const visCount = car.visibleCoinCount ?? 0;
- car.visibleCoinCount = visCount + 1;
- await tx.coinAvailability.put(car);
- }
- }
-}
-
-export async function makeCoinAvailable(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["coins", "coinAvailability", "denominations"]
- >,
- coinRecord: CoinRecord,
-): Promise<void> {
- checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
- const existingCoin = await tx.coins.get(coinRecord.coinPub);
- if (existingCoin) {
- return;
- }
- const denom = await tx.denominations.get([
- coinRecord.exchangeBaseUrl,
- coinRecord.denomPubHash,
- ]);
- checkDbInvariant(!!denom);
- const ageRestriction = coinRecord.maxAge;
- let car = await tx.coinAvailability.get([
- coinRecord.exchangeBaseUrl,
- coinRecord.denomPubHash,
- ageRestriction,
- ]);
- if (!car) {
- car = {
- maxAge: ageRestriction,
- value: denom.value,
- currency: denom.currency,
- denomPubHash: denom.denomPubHash,
- exchangeBaseUrl: denom.exchangeBaseUrl,
- freshCoinCount: 0,
- visibleCoinCount: 0,
- };
- }
- car.freshCoinCount++;
- await tx.coins.put(coinRecord);
- await tx.coinAvailability.put(car);
-}
-
-export async function spendCoins(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["coins", "coinAvailability", "refreshGroups", "denominations"]
- >,
- csi: CoinsSpendInfo,
-): Promise<void> {
- if (csi.coinPubs.length != csi.contributions.length) {
- throw Error("assertion failed");
- }
- if (csi.coinPubs.length === 0) {
- return;
- }
- let refreshCoinPubs: CoinRefreshRequest[] = [];
- for (let i = 0; i < csi.coinPubs.length; i++) {
- const coin = await tx.coins.get(csi.coinPubs[i]);
- if (!coin) {
- throw Error("coin allocated for payment doesn't exist anymore");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(!!denom);
- const coinAvailability = await tx.coinAvailability.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- coin.maxAge,
- ]);
- checkDbInvariant(!!coinAvailability);
- const contrib = csi.contributions[i];
- if (coin.status !== CoinStatus.Fresh) {
- const alloc = coin.spendAllocation;
- if (!alloc) {
- continue;
- }
- if (alloc.id !== csi.allocationId) {
- // FIXME: assign error code
- logger.info("conflicting coin allocation ID");
- logger.info(`old ID: ${alloc.id}, new ID: ${csi.allocationId}`);
- throw Error("conflicting coin allocation (id)");
- }
- if (0 !== Amounts.cmp(alloc.amount, contrib)) {
- // FIXME: assign error code
- throw Error("conflicting coin allocation (contrib)");
- }
- continue;
- }
- coin.status = CoinStatus.Dormant;
- coin.spendAllocation = {
- id: csi.allocationId,
- amount: Amounts.stringify(contrib),
- };
- const remaining = Amounts.sub(denom.value, contrib);
- if (remaining.saturated) {
- throw Error("not enough remaining balance on coin for payment");
- }
- refreshCoinPubs.push({
- amount: Amounts.stringify(remaining.amount),
- coinPub: coin.coinPub,
- });
- checkDbInvariant(!!coinAvailability);
- if (coinAvailability.freshCoinCount === 0) {
- throw Error(
- `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
- );
- }
- coinAvailability.freshCoinCount--;
- if (coin.visible) {
- if (!coinAvailability.visibleCoinCount) {
- logger.error("coin availability inconsistent");
- } else {
- coinAvailability.visibleCoinCount--;
- }
- }
- await tx.coins.put(coin);
- await tx.coinAvailability.put(coinAvailability);
- }
-
- await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(csi.contributions[0]),
- refreshCoinPubs,
- csi.refreshReason,
- csi.allocationId,
- );
-}
-
-export enum TombstoneTag {
- DeleteWithdrawalGroup = "delete-withdrawal-group",
- DeleteReserve = "delete-reserve",
- DeletePayment = "delete-payment",
- DeleteReward = "delete-reward",
- DeleteRefreshGroup = "delete-refresh-group",
- DeleteDepositGroup = "delete-deposit-group",
- DeleteRefund = "delete-refund",
- DeletePeerPullDebit = "delete-peer-pull-debit",
- DeletePeerPushDebit = "delete-peer-push-debit",
- DeletePeerPullCredit = "delete-peer-pull-credit",
- DeletePeerPushCredit = "delete-peer-push-credit",
-}
-
-export function getExchangeTosStatusFromRecord(
- exchange: ExchangeEntryRecord,
-): ExchangeTosStatus {
- if (!exchange.tosAcceptedEtag) {
- return ExchangeTosStatus.Proposed;
- }
- if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) {
- return ExchangeTosStatus.Accepted;
- }
- return ExchangeTosStatus.Proposed;
-}
-
-export function getExchangeUpdateStatusFromRecord(
- r: ExchangeEntryRecord,
-): ExchangeUpdateStatus {
- switch (r.updateStatus) {
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- return ExchangeUpdateStatus.UnavailableUpdate;
- case ExchangeEntryDbUpdateStatus.Initial:
- return ExchangeUpdateStatus.Initial;
- case ExchangeEntryDbUpdateStatus.InitialUpdate:
- return ExchangeUpdateStatus.InitialUpdate;
- case ExchangeEntryDbUpdateStatus.Ready:
- return ExchangeUpdateStatus.Ready;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- return ExchangeUpdateStatus.ReadyUpdate;
- case ExchangeEntryDbUpdateStatus.Suspended:
- return ExchangeUpdateStatus.Suspended;
- }
-}
-
-export function getExchangeEntryStatusFromRecord(
- r: ExchangeEntryRecord,
-): ExchangeEntryStatus {
- switch (r.entryStatus) {
- case ExchangeEntryDbRecordStatus.Ephemeral:
- return ExchangeEntryStatus.Ephemeral;
- case ExchangeEntryDbRecordStatus.Preset:
- return ExchangeEntryStatus.Preset;
- case ExchangeEntryDbRecordStatus.Used:
- return ExchangeEntryStatus.Used;
- }
-}
-
-/**
- * Compute the state of an exchange entry from the DB
- * record.
- */
-export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState {
- return {
- exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
- exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
- tosStatus: getExchangeTosStatusFromRecord(r),
- };
-}
-
-export type ParsedTombstone =
- | {
- tag: TombstoneTag.DeleteWithdrawalGroup;
- withdrawalGroupId: string;
- }
- | { tag: TombstoneTag.DeleteRefund; refundGroupId: string }
- | { tag: TombstoneTag.DeleteReserve; reservePub: string }
- | { tag: TombstoneTag.DeleteRefreshGroup; refreshGroupId: string }
- | { tag: TombstoneTag.DeleteReward; walletTipId: string }
- | { tag: TombstoneTag.DeletePayment; proposalId: string };
-
-export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
- switch (p.tag) {
- case TombstoneTag.DeleteWithdrawalGroup:
- return `tmb:${p.tag}:${p.withdrawalGroupId}` as TombstoneIdStr;
- case TombstoneTag.DeleteRefund:
- return `tmb:${p.tag}:${p.refundGroupId}` as TombstoneIdStr;
- case TombstoneTag.DeleteReserve:
- return `tmb:${p.tag}:${p.reservePub}` as TombstoneIdStr;
- case TombstoneTag.DeletePayment:
- return `tmb:${p.tag}:${p.proposalId}` as TombstoneIdStr;
- case TombstoneTag.DeleteRefreshGroup:
- return `tmb:${p.tag}:${p.refreshGroupId}` as TombstoneIdStr;
- case TombstoneTag.DeleteReward:
- return `tmb:${p.tag}:${p.walletTipId}` as TombstoneIdStr;
- default:
- assertUnreachable(p);
- }
-}
-
-/**
- * Uniform interface for a particular wallet transaction.
- */
-export interface TransactionManager {
- get taskId(): TaskId;
- get transactionId(): TransactionIdStr;
- fail(): Promise<void>;
- abort(): Promise<void>;
- suspend(): Promise<void>;
- resume(): Promise<void>;
- process(): Promise<TaskRunResult>;
-}
-
-export enum TaskRunResultType {
- Finished = "finished",
- Backoff = "backoff",
- Progress = "progress",
- Error = "error",
- ScheduleLater = "schedule-later",
-}
-
-export type TaskRunResult =
- | TaskRunFinishedResult
- | TaskRunErrorResult
- | TaskRunBackoffResult
- | TaskRunProgressResult
- | TaskRunScheduleLaterResult;
-
-export namespace TaskRunResult {
- /**
- * Task is finished and does not need to be processed again.
- */
- export function finished(): TaskRunResult {
- return {
- type: TaskRunResultType.Finished,
- };
- }
- /**
- * Task is waiting for something, should be invoked
- * again with exponentiall back-off until some other
- * result is returned.
- */
- export function backoff(): TaskRunResult {
- return {
- type: TaskRunResultType.Backoff,
- };
- }
- /**
- * Task made progress and should be processed again.
- */
- export function progress(): TaskRunResult {
- return {
- type: TaskRunResultType.Progress,
- };
- }
- /**
- * Run the task again at a fixed time in the future.
- */
- export function runAgainAt(runAt: AbsoluteTime): TaskRunResult {
- return {
- type: TaskRunResultType.ScheduleLater,
- runAt,
- };
- }
-}
-
-export interface TaskRunFinishedResult {
- type: TaskRunResultType.Finished;
-}
-
-export interface TaskRunBackoffResult {
- type: TaskRunResultType.Backoff;
-}
-
-export interface TaskRunProgressResult {
- type: TaskRunResultType.Progress;
-}
-
-export interface TaskRunScheduleLaterResult {
- type: TaskRunResultType.ScheduleLater;
- runAt: AbsoluteTime;
-}
-
-export interface TaskRunErrorResult {
- type: TaskRunResultType.Error;
- errorDetail: TalerErrorDetail;
-}
-
-export interface DbRetryInfo {
- firstTry: DbPreciseTimestamp;
- nextRetry: DbPreciseTimestamp;
- retryCounter: number;
-}
-
-export interface RetryPolicy {
- readonly backoffDelta: Duration;
- readonly backoffBase: number;
- readonly maxTimeout: Duration;
-}
-
-const defaultRetryPolicy: RetryPolicy = {
- backoffBase: 1.5,
- backoffDelta: Duration.fromSpec({ seconds: 1 }),
- maxTimeout: Duration.fromSpec({ minutes: 2 }),
-};
-
-function updateTimeout(
- r: DbRetryInfo,
- p: RetryPolicy = defaultRetryPolicy,
-): void {
- const now = AbsoluteTime.now();
- if (now.t_ms === "never") {
- throw Error("assertion failed");
- }
- if (p.backoffDelta.d_ms === "forever") {
- r.nextRetry = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- );
- return;
- }
-
- const nextIncrement =
- p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
-
- const t =
- now.t_ms +
- (p.maxTimeout.d_ms === "forever"
- ? nextIncrement
- : Math.min(p.maxTimeout.d_ms, nextIncrement));
- r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
-}
-
-export namespace DbRetryInfo {
- export function getDuration(
- r: DbRetryInfo | undefined,
- p: RetryPolicy = defaultRetryPolicy,
- ): Duration {
- if (!r) {
- // If we don't have any retry info, run immediately.
- return { d_ms: 0 };
- }
- if (p.backoffDelta.d_ms === "forever") {
- return { d_ms: "forever" };
- }
- const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
- return {
- d_ms:
- p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t),
- };
- }
-
- export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo {
- const now = TalerPreciseTimestamp.now();
- const info: DbRetryInfo = {
- firstTry: timestampPreciseToDb(now),
- nextRetry: timestampPreciseToDb(now),
- retryCounter: 0,
- };
- updateTimeout(info, p);
- return info;
- }
-
- export function increment(
- r: DbRetryInfo | undefined,
- p: RetryPolicy = defaultRetryPolicy,
- ): DbRetryInfo {
- if (!r) {
- return reset(p);
- }
- const r2 = { ...r };
- r2.retryCounter++;
- updateTimeout(r2, p);
- return r2;
- }
-}
-
-/**
- * Timestamp after which the wallet would do an auto-refresh.
- */
-export function getAutoRefreshExecuteThreshold(d: {
- stampExpireWithdraw: TalerProtocolTimestamp;
- stampExpireDeposit: TalerProtocolTimestamp;
-}): AbsoluteTime {
- const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
- d.stampExpireWithdraw,
- );
- const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
- d.stampExpireDeposit,
- );
- const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
- const deltaDiv = durationMul(delta, 0.5);
- return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
-}
-
-/**
- * Parsed representation of task identifiers.
- */
-export type ParsedTaskIdentifier =
- | {
- tag: PendingTaskType.Withdraw;
- withdrawalGroupId: string;
- }
- | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
- | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
- | { tag: PendingTaskType.Deposit; depositGroupId: string }
- | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string }
- | { tag: PendingTaskType.PeerPullCredit; pursePub: string }
- | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string }
- | { tag: PendingTaskType.PeerPushDebit; pursePub: string }
- | { tag: PendingTaskType.Purchase; proposalId: string }
- | { tag: PendingTaskType.Recoup; recoupGroupId: string }
- | { tag: PendingTaskType.RewardPickup; walletRewardId: string }
- | { tag: PendingTaskType.Refresh; refreshGroupId: string };
-
-export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
- const task = x.split(":");
-
- if (task.length < 2) {
- throw Error("task id should have al least 2 parts separated by ':'");
- }
-
- const [type, ...rest] = task;
- switch (type) {
- case PendingTaskType.Backup:
- return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) };
- case PendingTaskType.Deposit:
- return { tag: type, depositGroupId: rest[0] };
- case PendingTaskType.ExchangeUpdate:
- return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
- case PendingTaskType.PeerPullCredit:
- return { tag: type, pursePub: rest[0] };
- case PendingTaskType.PeerPullDebit:
- return { tag: type, peerPullDebitId: rest[0] };
- case PendingTaskType.PeerPushCredit:
- return { tag: type, peerPushCreditId: rest[0] };
- case PendingTaskType.PeerPushDebit:
- return { tag: type, pursePub: rest[0] };
- case PendingTaskType.Purchase:
- return { tag: type, proposalId: rest[0] };
- case PendingTaskType.Recoup:
- return { tag: type, recoupGroupId: rest[0] };
- case PendingTaskType.Refresh:
- return { tag: type, refreshGroupId: rest[0] };
- case PendingTaskType.RewardPickup:
- return { tag: type, walletRewardId: rest[0] };
- case PendingTaskType.Withdraw:
- return { tag: type, withdrawalGroupId: rest[0] };
- default:
- throw Error("invalid task identifier");
- }
-}
-
-export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskId {
- switch (p.tag) {
- case PendingTaskType.Backup:
- return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId;
- case PendingTaskType.Deposit:
- return `${p.tag}:${p.depositGroupId}` as TaskId;
- case PendingTaskType.ExchangeUpdate:
- return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskId;
- case PendingTaskType.PeerPullDebit:
- return `${p.tag}:${p.peerPullDebitId}` as TaskId;
- case PendingTaskType.PeerPushCredit:
- return `${p.tag}:${p.peerPushCreditId}` as TaskId;
- case PendingTaskType.PeerPullCredit:
- return `${p.tag}:${p.pursePub}` as TaskId;
- case PendingTaskType.PeerPushDebit:
- return `${p.tag}:${p.pursePub}` as TaskId;
- case PendingTaskType.Purchase:
- return `${p.tag}:${p.proposalId}` as TaskId;
- case PendingTaskType.Recoup:
- return `${p.tag}:${p.recoupGroupId}` as TaskId;
- case PendingTaskType.Refresh:
- return `${p.tag}:${p.refreshGroupId}` as TaskId;
- case PendingTaskType.RewardPickup:
- return `${p.tag}:${p.walletRewardId}` as TaskId;
- case PendingTaskType.Withdraw:
- return `${p.tag}:${p.withdrawalGroupId}` as TaskId;
- default:
- assertUnreachable(p);
- }
-}
-
-export namespace TaskIdentifiers {
- export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId {
- return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId;
- }
- export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId {
- return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
- exch.baseUrl,
- )}` as TaskId;
- }
- export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId {
- return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
- exchBaseUrl,
- )}` as TaskId;
- }
- export function forTipPickup(tipRecord: RewardRecord): TaskId {
- return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId;
- }
- export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId {
- return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId;
- }
- export function forPay(purchaseRecord: PurchaseRecord): TaskId {
- return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId;
- }
- export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId {
- return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId;
- }
- export function forDeposit(depositRecord: DepositGroupRecord): TaskId {
- return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId;
- }
- export function forBackup(backupRecord: BackupProviderRecord): TaskId {
- return `${PendingTaskType.Backup}:${encodeURIComponent(
- backupRecord.baseUrl,
- )}` as TaskId;
- }
- export function forPeerPushPaymentInitiation(
- ppi: PeerPushDebitRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId;
- }
- export function forPeerPullPaymentInitiation(
- ppi: PeerPullCreditRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId;
- }
- export function forPeerPullPaymentDebit(
- ppi: PeerPullPaymentIncomingRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskId;
- }
- export function forPeerPushCredit(
- ppi: PeerPushPaymentIncomingRecord,
- ): TaskId {
- return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId;
- }
-}
-
-/**
- * Result of a transaction transition.
- */
-export enum TransitionResult {
- Transition = 1,
- Stay = 2,
-}
-
-/**
- * Transaction context.
- * Uniform interface to all transactions.
- */
-export interface TransactionContext {
- abortTransaction(): Promise<void>;
- suspendTransaction(): Promise<void>;
- resumeTransaction(): Promise<void>;
- failTransaction(): Promise<void>;
- deleteTransaction(): Promise<void>;
-}
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts
@@ -1,1598 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the deposit transaction.
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AmountJson,
- Amounts,
- BatchDepositRequestCoin,
- CancellationToken,
- CoinRefreshRequest,
- CreateDepositGroupRequest,
- CreateDepositGroupResponse,
- DepositGroupFees,
- Duration,
- ExchangeBatchDepositRequest,
- ExchangeRefundRequest,
- HttpStatusCode,
- Logger,
- MerchantContractTerms,
- NotificationType,
- PayCoinSelection,
- PrepareDepositRequest,
- PrepareDepositResponse,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TrackTransaction,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- WireFee,
- canonicalJson,
- codecForBatchDepositSuccess,
- codecForTackTransactionAccepted,
- codecForTackTransactionWired,
- durationFromSpec,
- encodeCrock,
- getRandomBytes,
- hashTruncate32,
- hashWire,
- j2s,
- parsePaytoUri,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import { DepositElementStatus, DepositGroupRecord } from "../db.js";
-import {
- DepositOperationStatus,
- DepositTrackingInfo,
- KycPendingInfo,
- PendingTaskType,
- RefreshOperationStatus,
- TaskId,
- createRefreshGroup,
- getCandidateWithdrawalDenomsTx,
- getTotalRefreshCost,
- timestampPreciseToDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { selectPayCoinsNew } from "../util/coinSelection.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
- spendCoins,
-} from "./common.js";
-import { getExchangeWireDetailsInTx } from "./exchanges.js";
-import {
- extractContractData,
- generateDepositPermissions,
- getTotalPaymentCost,
-} from "./pay-merchant.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
-} from "./transactions.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("deposits.ts");
-
-export class DepositTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
- constructor(
- public ws: InternalWalletState,
- public depositGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const depositGroupId = this.depositGroupId;
- const ws = this.ws;
- // FIXME: We should check first if we are in a final state
- // where deletion is allowed.
- await ws.db.runReadWriteTx(["depositGroups", "tombstones"], async (tx) => {
- const tipRecord = await tx.depositGroups.get(depositGroupId);
- if (tipRecord) {
- await tx.depositGroups.delete(depositGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
- });
- }
- });
- return;
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- let newOpStatus: DepositOperationStatus | undefined;
- switch (dg.operationStatus) {
- case DepositOperationStatus.PendingDeposit:
- newOpStatus = DepositOperationStatus.SuspendedDeposit;
- break;
- case DepositOperationStatus.PendingKyc:
- newOpStatus = DepositOperationStatus.SuspendedKyc;
- break;
- case DepositOperationStatus.PendingTrack:
- newOpStatus = DepositOperationStatus.SuspendedTrack;
- break;
- case DepositOperationStatus.Aborting:
- newOpStatus = DepositOperationStatus.SuspendedAborting;
- break;
- }
- if (!newOpStatus) {
- return undefined;
- }
- dg.operationStatus = newOpStatus;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- switch (dg.operationStatus) {
- case DepositOperationStatus.Finished:
- return undefined;
- case DepositOperationStatus.PendingDeposit: {
- dg.operationStatus = DepositOperationStatus.Aborting;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- }
- case DepositOperationStatus.SuspendedDeposit:
- // FIXME: Can we abort a suspended transaction?!
- return undefined;
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return;
- }
- const oldState = computeDepositTransactionStatus(dg);
- let newOpStatus: DepositOperationStatus | undefined;
- switch (dg.operationStatus) {
- case DepositOperationStatus.SuspendedDeposit:
- newOpStatus = DepositOperationStatus.PendingDeposit;
- break;
- case DepositOperationStatus.SuspendedAborting:
- newOpStatus = DepositOperationStatus.Aborting;
- break;
- case DepositOperationStatus.SuspendedKyc:
- newOpStatus = DepositOperationStatus.PendingKyc;
- break;
- case DepositOperationStatus.SuspendedTrack:
- newOpStatus = DepositOperationStatus.PendingTrack;
- break;
- }
- if (!newOpStatus) {
- return undefined;
- }
- dg.operationStatus = newOpStatus;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, depositGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- logger.warn(
- `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeDepositTransactionStatus(dg);
- switch (dg.operationStatus) {
- case DepositOperationStatus.SuspendedAborting:
- case DepositOperationStatus.Aborting: {
- dg.operationStatus = DepositOperationStatus.Failed;
- await tx.depositGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeDepositTransactionStatus(dg),
- };
- }
- }
- return undefined;
- },
- );
- // FIXME: Also cancel ongoing work (via cancellation token, once implemented)
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-}
-
-/**
- * Get the (DD37-style) transaction status based on the
- * database record of a deposit group.
- */
-export function computeDepositTransactionStatus(
- dg: DepositGroupRecord,
-): TransactionState {
- switch (dg.operationStatus) {
- case DepositOperationStatus.Finished:
- return {
- major: TransactionMajorState.Done,
- };
- case DepositOperationStatus.PendingDeposit:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Deposit,
- };
- case DepositOperationStatus.PendingKyc:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.KycRequired,
- };
- case DepositOperationStatus.PendingTrack:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Track,
- };
- case DepositOperationStatus.SuspendedKyc:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.KycRequired,
- };
- case DepositOperationStatus.SuspendedTrack:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Track,
- };
- case DepositOperationStatus.SuspendedDeposit:
- return {
- major: TransactionMajorState.Suspended,
- };
- case DepositOperationStatus.Aborting:
- return {
- major: TransactionMajorState.Aborting,
- };
- case DepositOperationStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case DepositOperationStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case DepositOperationStatus.SuspendedAborting:
- return {
- major: TransactionMajorState.SuspendedAborting,
- };
- default:
- assertUnreachable(dg.operationStatus);
- }
-}
-
-/**
- * Compute the possible actions possible on a deposit transaction
- * based on the current transaction state.
- */
-export function computeDepositTransactionActions(
- dg: DepositGroupRecord,
-): TransactionAction[] {
- switch (dg.operationStatus) {
- case DepositOperationStatus.Finished:
- return [TransactionAction.Delete];
- case DepositOperationStatus.PendingDeposit:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case DepositOperationStatus.SuspendedDeposit:
- return [TransactionAction.Resume];
- case DepositOperationStatus.Aborting:
- return [TransactionAction.Fail, TransactionAction.Suspend];
- case DepositOperationStatus.Aborted:
- return [TransactionAction.Delete];
- case DepositOperationStatus.Failed:
- return [TransactionAction.Delete];
- case DepositOperationStatus.SuspendedAborting:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case DepositOperationStatus.PendingKyc:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case DepositOperationStatus.PendingTrack:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case DepositOperationStatus.SuspendedKyc:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case DepositOperationStatus.SuspendedTrack:
- return [TransactionAction.Resume, TransactionAction.Abort];
- default:
- assertUnreachable(dg.operationStatus);
- }
-}
-
-/**
- * Check whether the refresh associated with the
- * aborting deposit group is done.
- *
- * If done, mark the deposit transaction as aborted.
- *
- * Otherwise continue waiting.
- *
- * FIXME: Wait for the refresh group notifications instead of periodically
- * checking the refresh group status.
- * FIXME: This is just one transaction, can't we do this in the initial
- * transaction of processDepositGroup?
- */
-async function waitForRefreshOnDepositGroup(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
-): Promise<TaskRunResult> {
- const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: depositGroup.depositGroupId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups", "refreshGroups"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: DepositOperationStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into aborted.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = DepositOperationStatus.Aborted;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = DepositOperationStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = DepositOperationStatus.Aborted;
- }
- }
- if (newOpState) {
- const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
- if (!newDg) {
- return;
- }
- const oldTxState = computeDepositTransactionStatus(newDg);
- newDg.operationStatus = newOpState;
- const newTxState = computeDepositTransactionStatus(newDg);
- await tx.depositGroups.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
-}
-
-async function refundDepositGroup(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
-): Promise<TaskRunResult> {
- const newTxPerCoin = [...depositGroup.statusPerCoin];
- logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`);
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- const st = depositGroup.statusPerCoin[i];
- switch (st) {
- case DepositElementStatus.RefundFailed:
- case DepositElementStatus.RefundSuccess:
- break;
- default: {
- const coinPub = depositGroup.payCoinSelection.coinPubs[i];
- const coinExchange = await ws.db.runReadOnlyTx(
- ["coins"],
- async (tx) => {
- const coinRecord = await tx.coins.get(coinPub);
- checkDbInvariant(!!coinRecord);
- return coinRecord.exchangeBaseUrl;
- },
- );
- const refundAmount = depositGroup.payCoinSelection.coinContributions[i];
- // We use a constant refund transaction ID, since there can
- // only be one refund.
- const rtid = 1;
- const sig = await ws.cryptoApi.signRefund({
- coinPub,
- contractTermsHash: depositGroup.contractTermsHash,
- merchantPriv: depositGroup.merchantPriv,
- merchantPub: depositGroup.merchantPub,
- refundAmount: refundAmount,
- rtransactionId: rtid,
- });
- const refundReq: ExchangeRefundRequest = {
- h_contract_terms: depositGroup.contractTermsHash,
- merchant_pub: depositGroup.merchantPub,
- merchant_sig: sig.sig,
- refund_amount: refundAmount,
- rtransaction_id: rtid,
- };
- const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
- const httpResp = await ws.http.fetch(refundUrl.href, {
- method: "POST",
- body: refundReq,
- });
- logger.info(
- `coin ${i} refund HTTP status for coin: ${httpResp.status}`,
- );
- let newStatus: DepositElementStatus;
- if (httpResp.status === 200) {
- // FIXME: validate response
- newStatus = DepositElementStatus.RefundSuccess;
- } else {
- // FIXME: Store problem somewhere!
- newStatus = DepositElementStatus.RefundFailed;
- }
- // FIXME: Handle case where refund request needs to be tried again
- newTxPerCoin[i] = newStatus;
- break;
- }
- }
- }
- let isDone = true;
- for (let i = 0; i < newTxPerCoin.length; i++) {
- if (
- newTxPerCoin[i] != DepositElementStatus.RefundFailed &&
- newTxPerCoin[i] != DepositElementStatus.RefundSuccess
- ) {
- isDone = false;
- }
- }
-
- const currency = Amounts.currencyOf(depositGroup.totalPayCost);
-
- await ws.db.runReadWriteTx(
- [
- "depositGroups",
- "refreshGroups",
- "coins",
- "denominations",
- "coinAvailability",
- ],
- async (tx) => {
- const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
- if (!newDg) {
- return;
- }
- newDg.statusPerCoin = newTxPerCoin;
- const refreshCoins: CoinRefreshRequest[] = [];
- for (let i = 0; i < newTxPerCoin.length; i++) {
- refreshCoins.push({
- amount: depositGroup.payCoinSelection.coinContributions[i],
- coinPub: depositGroup.payCoinSelection.coinPubs[i],
- });
- }
- if (isDone) {
- const rgid = await createRefreshGroup(
- ws,
- tx,
- currency,
- refreshCoins,
- RefreshReason.AbortDeposit,
- constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: newDg.depositGroupId,
- }),
- );
- newDg.abortRefreshGroupId = rgid.refreshGroupId;
- }
- await tx.depositGroups.put(newDg);
- },
- );
-
- return TaskRunResult.backoff();
-}
-
-async function processDepositGroupAborting(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
-): Promise<TaskRunResult> {
- logger.info("processing deposit tx in 'aborting'");
- const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
- if (!abortRefreshGroupId) {
- logger.info("refunding deposit group");
- return refundDepositGroup(ws, depositGroup);
- }
- logger.info("waiting for refresh");
- return waitForRefreshOnDepositGroup(ws, depositGroup);
-}
-
-async function processDepositGroupPendingKyc(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const { depositGroupId } = depositGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId,
- });
-
- const kycInfo = depositGroup.kycInfo;
- const userType = "individual";
-
- if (!kycInfo) {
- throw Error("invalid DB state, in pending(kyc), but no kycInfo present");
- }
-
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- kycInfo.exchangeBaseUrl,
- );
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const newDg = await tx.depositGroups.get(depositGroupId);
- if (!newDg) {
- return;
- }
- if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) {
- return;
- }
- const oldTxState = computeDepositTransactionStatus(newDg);
- newDg.operationStatus = DepositOperationStatus.PendingTrack;
- const newTxState = computeDepositTransactionStatus(newDg);
- await tx.depositGroups.put(newDg);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-/**
- * Tracking information from the exchange indicated that
- * KYC is required. We need to check the KYC info
- * and transition the transaction to the KYC required state.
- */
-async function transitionToKycRequired(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- kycInfo: KycPendingInfo,
- exchangeUrl: string,
-): Promise<TaskRunResult> {
- const { depositGroupId } = depositGroup;
- const userType = "individual";
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
-
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- logger.info(`kyc url ${url.href}`);
- const kycStatusReq = await ws.http.fetch(url.href, {
- method: "GET",
- });
- if (kycStatusReq.status === HttpStatusCode.Ok) {
- logger.warn("kyc requested, but already fulfilled");
- return TaskRunResult.backoff();
- } else if (kycStatusReq.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusReq.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return undefined;
- }
- if (dg.operationStatus !== DepositOperationStatus.PendingTrack) {
- return undefined;
- }
- const oldTxState = computeDepositTransactionStatus(dg);
- dg.kycInfo = {
- exchangeBaseUrl: exchangeUrl,
- kycUrl: kycStatus.kyc_url,
- paytoHash: kycInfo.paytoHash,
- requirementRow: kycInfo.requirementRow,
- };
- await tx.depositGroups.put(dg);
- const newTxState = computeDepositTransactionStatus(dg);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
- }
-}
-
-async function processDepositGroupPendingTrack(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- cancellationToken?: CancellationToken,
-): Promise<TaskRunResult> {
- const { depositGroupId } = depositGroup;
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- const coinPub = depositGroup.payCoinSelection.coinPubs[i];
- // FIXME: Make the URL part of the coin selection?
- const exchangeBaseUrl = await ws.db.runReadWriteTx(
- ["coins"],
- async (tx) => {
- const coinRecord = await tx.coins.get(coinPub);
- checkDbInvariant(!!coinRecord);
- return coinRecord.exchangeBaseUrl;
- },
- );
-
- let updatedTxStatus: DepositElementStatus | undefined = undefined;
- let newWiredCoin:
- | {
- id: string;
- value: DepositTrackingInfo;
- }
- | undefined;
-
- if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) {
- const track = await trackDeposit(
- ws,
- depositGroup,
- coinPub,
- exchangeBaseUrl,
- );
-
- if (track.type === "accepted") {
- if (!track.kyc_ok && track.requirement_row !== undefined) {
- const paytoHash = encodeCrock(
- hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")),
- );
- const { requirement_row: requirementRow } = track;
- const kycInfo: KycPendingInfo = {
- paytoHash,
- requirementRow,
- };
- return transitionToKycRequired(
- ws,
- depositGroup,
- kycInfo,
- exchangeBaseUrl,
- );
- } else {
- updatedTxStatus = DepositElementStatus.Tracking;
- }
- } else if (track.type === "wired") {
- updatedTxStatus = DepositElementStatus.Wired;
-
- const payto = parsePaytoUri(depositGroup.wire.payto_uri);
- if (!payto) {
- throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`);
- }
-
- const fee = await getExchangeWireFee(
- ws,
- payto.targetType,
- exchangeBaseUrl,
- track.execution_time,
- );
- const raw = Amounts.parseOrThrow(track.coin_contribution);
- const wireFee = Amounts.parseOrThrow(fee.wireFee);
-
- newWiredCoin = {
- value: {
- amountRaw: Amounts.stringify(raw),
- wireFee: Amounts.stringify(wireFee),
- exchangePub: track.exchange_pub,
- timestampExecuted: timestampProtocolToDb(track.execution_time),
- wireTransferId: track.wtid,
- },
- id: track.exchange_sig,
- };
- } else {
- updatedTxStatus = DepositElementStatus.DepositPending;
- }
- }
-
- if (updatedTxStatus !== undefined) {
- await ws.db.runReadWriteTx(["depositGroups"], async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- if (updatedTxStatus !== undefined) {
- dg.statusPerCoin[i] = updatedTxStatus;
- }
- if (newWiredCoin) {
- /**
- * FIXME: if there is a new wire information from the exchange
- * it should add up to the previous tracking states.
- *
- * This may loose information by overriding prev state.
- *
- * And: add checks to integration tests
- */
- if (!dg.trackingState) {
- dg.trackingState = {};
- }
-
- dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
- }
- await tx.depositGroups.put(dg);
- });
- }
- }
-
- let allWired = true;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return undefined;
- }
- const oldTxState = computeDepositTransactionStatus(dg);
- for (let i = 0; i < depositGroup.statusPerCoin.length; i++) {
- if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) {
- allWired = false;
- break;
- }
- }
- if (allWired) {
- dg.timestampFinished = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- dg.operationStatus = DepositOperationStatus.Finished;
- await tx.depositGroups.put(dg);
- }
- const newTxState = computeDepositTransactionStatus(dg);
- return { oldTxState, newTxState };
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- if (allWired) {
- return TaskRunResult.finished();
- } else {
- // FIXME: Use long-polling.
- return TaskRunResult.backoff();
- }
-}
-
-async function processDepositGroupPendingDeposit(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- cancellationToken?: CancellationToken,
-): Promise<TaskRunResult> {
- logger.info("processing deposit group in pending(deposit)");
- const depositGroupId = depositGroup.depositGroupId;
- const contractTermsRec = await ws.db.runReadOnlyTx(
- ["contractTerms"],
- async (tx) => {
- return tx.contractTerms.get(depositGroup.contractTermsHash);
- },
- );
- if (!contractTermsRec) {
- throw Error("contract terms for deposit not found in database");
- }
- const contractTerms: MerchantContractTerms =
- contractTermsRec.contractTermsRaw;
- const contractData = extractContractData(
- contractTermsRec.contractTermsRaw,
- depositGroup.contractTermsHash,
- "",
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId,
- });
-
- // Check for cancellation before expensive operations.
- cancellationToken?.throwIfCancelled();
-
- // FIXME: Cache these!
- const depositPermissions = await generateDepositPermissions(
- ws,
- depositGroup.payCoinSelection,
- contractData,
- );
-
- // Exchanges involved in the deposit
- const exchanges: Set<string> = new Set();
-
- for (const dp of depositPermissions) {
- exchanges.add(dp.exchange_url);
- }
-
- // We need to do one batch per exchange.
- for (const exchangeUrl of exchanges.values()) {
- const coins: BatchDepositRequestCoin[] = [];
- const batchIndexes: number[] = [];
-
- const batchReq: ExchangeBatchDepositRequest = {
- coins,
- h_contract_terms: depositGroup.contractTermsHash,
- merchant_payto_uri: depositGroup.wire.payto_uri,
- merchant_pub: contractTerms.merchant_pub,
- timestamp: contractTerms.timestamp,
- wire_salt: depositGroup.wire.salt,
- wire_transfer_deadline: contractTerms.wire_transfer_deadline,
- refund_deadline: contractTerms.refund_deadline,
- };
-
- for (let i = 0; i < depositPermissions.length; i++) {
- const perm = depositPermissions[i];
- if (perm.exchange_url != exchangeUrl) {
- continue;
- }
- coins.push({
- coin_pub: perm.coin_pub,
- coin_sig: perm.coin_sig,
- contribution: Amounts.stringify(perm.contribution),
- denom_pub_hash: perm.h_denom,
- ub_sig: perm.ub_sig,
- h_age_commitment: perm.h_age_commitment,
- });
- batchIndexes.push(i);
- }
-
- // Check for cancellation before making network request.
- cancellationToken?.throwIfCancelled();
- const url = new URL(`batch-deposit`, exchangeUrl);
- logger.info(`depositing to ${url.href}`);
- logger.trace(`deposit request: ${j2s(batchReq)}`);
- const httpResp = await ws.http.fetch(url.href, {
- method: "POST",
- body: batchReq,
- cancellationToken: cancellationToken,
- });
- await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForBatchDepositSuccess(),
- );
-
- await ws.db.runReadWriteTx(["depositGroups"], async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- for (const batchIndex of batchIndexes) {
- const coinStatus = dg.statusPerCoin[batchIndex];
- switch (coinStatus) {
- case DepositElementStatus.DepositPending:
- dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
- await tx.depositGroups.put(dg);
- }
- }
- });
- }
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["depositGroups"],
- async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return undefined;
- }
- const oldTxState = computeDepositTransactionStatus(dg);
- dg.operationStatus = DepositOperationStatus.PendingTrack;
- await tx.depositGroups.put(dg);
- const newTxState = computeDepositTransactionStatus(dg);
- return { oldTxState, newTxState };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.progress();
-}
-
-/**
- * Process a deposit group that is not in its final state yet.
- */
-export async function processDepositGroup(
- ws: InternalWalletState,
- depositGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const depositGroup = await ws.db.runReadOnlyTx(
- ["depositGroups"],
- async (tx) => {
- return tx.depositGroups.get(depositGroupId);
- },
- );
- if (!depositGroup) {
- logger.warn(`deposit group ${depositGroupId} not found`);
- return TaskRunResult.finished();
- }
-
- switch (depositGroup.operationStatus) {
- case DepositOperationStatus.PendingTrack:
- return processDepositGroupPendingTrack(
- ws,
- depositGroup,
- cancellationToken,
- );
- case DepositOperationStatus.PendingKyc:
- return processDepositGroupPendingKyc(ws, depositGroup, cancellationToken);
- case DepositOperationStatus.PendingDeposit:
- return processDepositGroupPendingDeposit(
- ws,
- depositGroup,
- cancellationToken,
- );
- case DepositOperationStatus.Aborting:
- return processDepositGroupAborting(ws, depositGroup);
- }
-
- return TaskRunResult.finished();
-}
-
-/**
- * FIXME: Consider moving this to exchanges.ts.
- */
-async function getExchangeWireFee(
- ws: InternalWalletState,
- wireType: string,
- baseUrl: string,
- time: TalerProtocolTimestamp,
-): Promise<WireFee> {
- const exchangeDetails = await ws.db.runReadOnlyTx(
- ["exchangeDetails", "exchanges"],
- async (tx) => {
- const ex = await tx.exchanges.get(baseUrl);
- if (!ex || !ex.detailsPointer) return undefined;
- return await tx.exchangeDetails.indexes.byPointer.get([
- baseUrl,
- ex.detailsPointer.currency,
- ex.detailsPointer.masterPublicKey,
- ]);
- },
- );
-
- if (!exchangeDetails) {
- throw Error(`exchange missing: ${baseUrl}`);
- }
-
- const fees = exchangeDetails.wireInfo.feesForType[wireType];
- if (!fees || fees.length === 0) {
- throw Error(
- `exchange ${baseUrl} doesn't have fees for wire type ${wireType}`,
- );
- }
- const fee = fees.find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.fromProtocolTimestamp(time),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- });
- if (!fee) {
- throw Error(
- `exchange ${exchangeDetails.exchangeBaseUrl} doesn't have fees for wire type ${wireType} at ${time.t_s}`,
- );
- }
-
- return fee;
-}
-
-async function trackDeposit(
- ws: InternalWalletState,
- depositGroup: DepositGroupRecord,
- coinPub: string,
- exchangeUrl: string,
-): Promise<TrackTransaction> {
- const wireHash = hashWire(
- depositGroup.wire.payto_uri,
- depositGroup.wire.salt,
- );
-
- const url = new URL(
- `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
- exchangeUrl,
- );
- const sigResp = await ws.cryptoApi.signTrackTransaction({
- coinPub,
- contractTermsHash: depositGroup.contractTermsHash,
- merchantPriv: depositGroup.merchantPriv,
- merchantPub: depositGroup.merchantPub,
- wireHash,
- });
- url.searchParams.set("merchant_sig", sigResp.sig);
- const httpResp = await ws.http.fetch(url.href, { method: "GET" });
- logger.trace(`deposits response status: ${httpResp.status}`);
- switch (httpResp.status) {
- case HttpStatusCode.Accepted: {
- const accepted = await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForTackTransactionAccepted(),
- );
- return { type: "accepted", ...accepted };
- }
- case HttpStatusCode.Ok: {
- const wired = await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForTackTransactionWired(),
- );
- return { type: "wired", ...wired };
- }
- default: {
- throw Error(
- `unexpected response from track-transaction (${httpResp.status})`,
- );
- }
- }
-}
-
-/**
- * Check if creating a deposit group is possible and calculate
- * the associated fees.
- *
- * FIXME: This should be renamed to checkDepositGroup,
- * as it doesn't prepare anything
- */
-export async function prepareDepositGroup(
- ws: InternalWalletState,
- req: PrepareDepositRequest,
-): Promise<PrepareDepositResponse> {
- const p = parsePaytoUri(req.depositPaytoUri);
- if (!p) {
- throw Error("invalid payto URI");
- }
- const amount = Amounts.parseOrThrow(req.amount);
-
- const exchangeInfos: { url: string; master_pub: string }[] = [];
-
- await ws.db.runReadOnlyTx(["exchangeDetails", "exchanges"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || amount.currency !== details.currency) {
- continue;
- }
- exchangeInfos.push({
- master_pub: details.masterPublicKey,
- url: e.baseUrl,
- });
- }
- });
-
- const now = AbsoluteTime.now();
- const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
- const contractTerms: MerchantContractTerms = {
- exchanges: exchangeInfos,
- amount: req.amount,
- max_fee: Amounts.stringify(amount),
- max_wire_fee: Amounts.stringify(amount),
- wire_method: p.targetType,
- timestamp: nowRounded,
- merchant_base_url: "",
- summary: "",
- nonce: "",
- wire_transfer_deadline: nowRounded,
- order_id: "",
- h_wire: "",
- pay_deadline: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
- ),
- merchant: {
- name: "(wallet)",
- },
- merchant_pub: "",
- refund_deadline: TalerProtocolTimestamp.zero(),
- };
-
- const { h: contractTermsHash } = await ws.cryptoApi.hashString({
- str: canonicalJson(contractTerms),
- });
-
- const contractData = extractContractData(
- contractTerms,
- contractTermsHash,
- "",
- );
-
- const payCoinSel = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins: [],
- });
-
- if (payCoinSel.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
- }
-
- const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
-
- const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
- ws,
- p.targetType,
- payCoinSel.coinSel,
- );
-
- const fees = await getTotalFeesForDepositAmount(
- ws,
- p.targetType,
- amount,
- payCoinSel.coinSel,
- );
-
- return {
- totalDepositCost: Amounts.stringify(totalDepositCost),
- effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
- fees,
- };
-}
-
-export function generateDepositGroupTxId(): string {
- const depositGroupId = encodeCrock(getRandomBytes(32));
- return constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: depositGroupId,
- });
-}
-
-export async function createDepositGroup(
- ws: InternalWalletState,
- req: CreateDepositGroupRequest,
-): Promise<CreateDepositGroupResponse> {
- const p = parsePaytoUri(req.depositPaytoUri);
- if (!p) {
- throw Error("invalid payto URI");
- }
-
- const amount = Amounts.parseOrThrow(req.amount);
-
- const exchangeInfos: { url: string; master_pub: string }[] = [];
-
- await ws.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || amount.currency !== details.currency) {
- continue;
- }
- exchangeInfos.push({
- master_pub: details.masterPublicKey,
- url: e.baseUrl,
- });
- }
- });
-
- const now = AbsoluteTime.now();
- const wireDeadline = AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
- );
- const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
- const noncePair = await ws.cryptoApi.createEddsaKeypair({});
- const merchantPair = await ws.cryptoApi.createEddsaKeypair({});
- const wireSalt = encodeCrock(getRandomBytes(16));
- const wireHash = hashWire(req.depositPaytoUri, wireSalt);
- const contractTerms: MerchantContractTerms = {
- exchanges: exchangeInfos,
- amount: req.amount,
- max_fee: Amounts.stringify(amount),
- max_wire_fee: Amounts.stringify(amount),
- wire_method: p.targetType,
- timestamp: nowRounded,
- merchant_base_url: "",
- summary: "",
- nonce: noncePair.pub,
- wire_transfer_deadline: wireDeadline,
- order_id: "",
- h_wire: wireHash,
- pay_deadline: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
- ),
- merchant: {
- name: "(wallet)",
- },
- merchant_pub: merchantPair.pub,
- refund_deadline: TalerProtocolTimestamp.zero(),
- };
-
- const { h: contractTermsHash } = await ws.cryptoApi.hashString({
- str: canonicalJson(contractTerms),
- });
-
- const contractData = extractContractData(
- contractTerms,
- contractTermsHash,
- "",
- );
-
- const payCoinSel = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins: [],
- });
-
- if (payCoinSel.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
- },
- );
- }
-
- const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
-
- let depositGroupId: string;
- if (req.transactionId) {
- const txId = parseTransactionIdentifier(req.transactionId);
- if (!txId || txId.tag !== TransactionType.Deposit) {
- throw Error("invalid transaction ID");
- }
- depositGroupId = txId.depositGroupId;
- } else {
- depositGroupId = encodeCrock(getRandomBytes(32));
- }
-
- const counterpartyEffectiveDepositAmount =
- await getCounterpartyEffectiveDepositAmount(
- ws,
- p.targetType,
- payCoinSel.coinSel,
- );
-
- const depositGroup: DepositGroupRecord = {
- contractTermsHash,
- depositGroupId,
- currency: Amounts.currencyOf(totalDepositCost),
- amount: contractData.amount,
- noncePriv: noncePair.priv,
- noncePub: noncePair.pub,
- timestampCreated: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(now),
- ),
- timestampFinished: undefined,
- statusPerCoin: payCoinSel.coinSel.coinPubs.map(
- () => DepositElementStatus.DepositPending,
- ),
- payCoinSelection: payCoinSel.coinSel,
- payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
- merchantPriv: merchantPair.priv,
- merchantPub: merchantPair.pub,
- totalPayCost: Amounts.stringify(totalDepositCost),
- counterpartyEffectiveDepositAmount: Amounts.stringify(
- counterpartyEffectiveDepositAmount,
- ),
- wireTransferDeadline: timestampProtocolToDb(
- contractTerms.wire_transfer_deadline,
- ),
- wire: {
- payto_uri: req.depositPaytoUri,
- salt: wireSalt,
- },
- operationStatus: DepositOperationStatus.PendingDeposit,
- };
-
- const ctx = new DepositTransactionContext(ws, depositGroupId);
- const transactionId = ctx.transactionId;
-
- const newTxState = await ws.db.runReadWriteTx(
- [
- "depositGroups",
- "coins",
- "recoupGroups",
- "denominations",
- "refreshGroups",
- "coinAvailability",
- "contractTerms",
- ],
- async (tx) => {
- await spendCoins(ws, tx, {
- allocationId: transactionId,
- coinPubs: payCoinSel.coinSel.coinPubs,
- contributions: payCoinSel.coinSel.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
- ),
- refreshReason: RefreshReason.PayDeposit,
- });
- await tx.depositGroups.put(depositGroup);
- await tx.contractTerms.put({
- contractTermsRaw: contractTerms,
- h: contractTermsHash,
- });
- return computeDepositTransactionStatus(depositGroup);
- },
- );
-
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- transactionId,
- oldTxState: {
- major: TransactionMajorState.None,
- },
- newTxState,
- });
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- depositGroupId,
- transactionId,
- };
-}
-
-/**
- * Get the amount that will be deposited on the users bank
- * account after depositing, not considering aggregation.
- */
-export async function getCounterpartyEffectiveDepositAmount(
- ws: InternalWalletState,
- wireType: string,
- pcs: PayCoinSelection,
-): Promise<AmountJson> {
- const amt: AmountJson[] = [];
- const fees: AmountJson[] = [];
- const exchangeSet: Set<string> = new Set();
-
- await ws.db.runReadOnlyTx(
- ["coins", "denominations", "exchangeDetails", "exchanges"],
- async (tx) => {
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate deposit amount, coin not found");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("can't find denomination to calculate deposit amount");
- }
- amt.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
- fees.push(Amounts.parseOrThrow(denom.feeDeposit));
- exchangeSet.add(coin.exchangeBaseUrl);
- }
-
- for (const exchangeUrl of exchangeSet.values()) {
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- exchangeUrl,
- );
- if (!exchangeDetails) {
- continue;
- }
-
- // FIXME/NOTE: the line below _likely_ throws exception
- // about "find method not found on undefined" when the wireType
- // is not supported by the Exchange.
- const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- })?.wireFee;
- if (fee) {
- fees.push(Amounts.parseOrThrow(fee));
- }
- }
- },
- );
- return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
-}
-
-/**
- * Get the fee amount that will be charged when trying to deposit the
- * specified amount using the selected coins and the wire method.
- */
-async function getTotalFeesForDepositAmount(
- ws: InternalWalletState,
- wireType: string,
- total: AmountJson,
- pcs: PayCoinSelection,
-): Promise<DepositGroupFees> {
- const wireFee: AmountJson[] = [];
- const coinFee: AmountJson[] = [];
- const refreshFee: AmountJson[] = [];
- const exchangeSet: Set<string> = new Set();
- const currency = Amounts.currencyOf(total);
-
- await ws.db.runReadOnlyTx(
- ["coins", "denominations", "exchanges", "exchangeDetails"],
- async (tx) => {
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate deposit amount, coin not found");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("can't find denomination to calculate deposit amount");
- }
- coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
- exchangeSet.add(coin.exchangeBaseUrl);
-
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coinContributions[i],
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- ws.config.testing.denomselAllowLate,
- );
- refreshFee.push(refreshCost);
- }
-
- for (const exchangeUrl of exchangeSet.values()) {
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- exchangeUrl,
- );
- if (!exchangeDetails) {
- continue;
- }
- const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
- (x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- },
- )?.wireFee;
- if (fee) {
- wireFee.push(Amounts.parseOrThrow(fee));
- }
- }
- },
- );
-
- return {
- coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
- wire: Amounts.stringify(Amounts.sumOrZero(total.currency, wireFee).amount),
- refresh: Amounts.stringify(
- Amounts.sumOrZero(total.currency, refreshFee).amount,
- ),
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -1,2007 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * @fileoverview
- * Implementation of exchange entry management in wallet-core.
- * The details of exchange entry management are specified in DD48.
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AgeRestriction,
- Amounts,
- AsyncFlag,
- CancellationToken,
- CoinRefreshRequest,
- CoinStatus,
- DeleteExchangeRequest,
- DenomKeyType,
- DenomOperationMap,
- DenominationInfo,
- DenominationPubKey,
- Duration,
- EddsaPublicKeyString,
- ExchangeAuditor,
- ExchangeDetailedResponse,
- ExchangeGlobalFees,
- ExchangeListItem,
- ExchangeSignKeyJson,
- ExchangeTosStatus,
- ExchangeWireAccount,
- ExchangesListResponse,
- FeeDescription,
- GetExchangeEntryByUrlRequest,
- GetExchangeResourcesResponse,
- GetExchangeTosResult,
- GlobalFees,
- LibtoolVersion,
- Logger,
- NotificationType,
- OperationErrorInfo,
- Recoup,
- RefreshReason,
- ScopeInfo,
- ScopeType,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolDuration,
- TalerProtocolTimestamp,
- URL,
- WalletNotification,
- WireFee,
- WireFeeMap,
- WireFeesJson,
- WireInfo,
- assertUnreachable,
- canonicalizeBaseUrl,
- codecForExchangeKeysJson,
- durationFromSpec,
- durationMul,
- encodeCrock,
- hashDenomPub,
- j2s,
- makeErrorDetail,
- parsePaytoUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- getExpiry,
- readSuccessResponseJsonOrThrow,
- readSuccessResponseTextOrThrow,
-} from "@gnu-taler/taler-util/http";
-import {
- DenominationRecord,
- DenominationVerificationStatus,
- ExchangeDetailsRecord,
- ExchangeEntryRecord,
- WalletStoresV1,
-} from "../db.js";
-import {
- ExchangeEntryDbRecordStatus,
- ExchangeEntryDbUpdateStatus,
- PendingTaskType,
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
- createRefreshGroup,
- createTimeline,
- isWithdrawableDenom,
- selectBestForOverlappingDenominations,
- selectMinimumFee,
- timestampAbsoluteFromDb,
- timestampOptionalPreciseFromDb,
- timestampPreciseFromDb,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { DbReadOnlyTransaction } from "../util/query.js";
-import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
-import {
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
- constructTaskIdentifier,
- getAutoRefreshExecuteThreshold,
- getExchangeEntryStatusFromRecord,
- getExchangeState,
- getExchangeTosStatusFromRecord,
- getExchangeUpdateStatusFromRecord,
-} from "./common.js";
-
-const logger = new Logger("exchanges.ts");
-
-function getExchangeRequestTimeout(): Duration {
- return Duration.fromSpec({
- seconds: 15,
- });
-}
-
-interface ExchangeTosDownloadResult {
- tosText: string;
- tosEtag: string;
- tosContentType: string;
- tosContentLanguage: string | undefined;
- tosAvailableLanguages: string[];
-}
-
-async function downloadExchangeWithTermsOfService(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
- acceptFormat: string,
- acceptLanguage: string | undefined,
-): Promise<ExchangeTosDownloadResult> {
- logger.trace(`downloading exchange tos (type ${acceptFormat})`);
- const reqUrl = new URL("terms", exchangeBaseUrl);
- const headers: {
- Accept: string;
- "Accept-Language"?: string;
- } = {
- Accept: acceptFormat,
- };
-
- if (acceptLanguage) {
- headers["Accept-Language"] = acceptLanguage;
- }
-
- const resp = await http.fetch(reqUrl.href, {
- headers,
- timeout,
- });
- const tosText = await readSuccessResponseTextOrThrow(resp);
- const tosEtag = resp.headers.get("etag") || "unknown";
- const tosContentLanguage = resp.headers.get("content-language") || undefined;
- const tosContentType = resp.headers.get("content-type") || "text/plain";
- const availLangStr = resp.headers.get("avail-languages") || "";
- // Work around exchange bug that reports the same language multiple times.
- const availLangSet = new Set<string>(
- availLangStr.split(",").map((x) => x.trim()),
- );
- const tosAvailableLanguages = [...availLangSet];
-
- return {
- tosText,
- tosEtag,
- tosContentType,
- tosContentLanguage,
- tosAvailableLanguages,
- };
-}
-
-/**
- * Get exchange details from the database.
- */
-async function getExchangeRecordsInternal(
- tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
- exchangeBaseUrl: string,
-): Promise<ExchangeDetailsRecord | undefined> {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- return;
- }
- const dp = r.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- return await tx.exchangeDetails.indexes.byPointer.get([
- r.baseUrl,
- currency,
- masterPublicKey,
- ]);
-}
-
-export async function getExchangeScopeInfo(
- tx: WalletDbReadOnlyTransaction<
- [
- "exchanges",
- "exchangeDetails",
- "globalCurrencyExchanges",
- "globalCurrencyAuditors",
- ]
- >,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<ScopeInfo> {
- const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
- if (!det) {
- return {
- type: ScopeType.Exchange,
- currency: currency,
- url: exchangeBaseUrl,
- };
- }
- return internalGetExchangeScopeInfo(tx, det);
-}
-
-async function internalGetExchangeScopeInfo(
- tx: WalletDbReadOnlyTransaction<
- ["globalCurrencyExchanges", "globalCurrencyAuditors"]
- >,
- exchangeDetails: ExchangeDetailsRecord,
-): Promise<ScopeInfo> {
- const globalExchangeRec =
- await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
- exchangeDetails.currency,
- exchangeDetails.exchangeBaseUrl,
- exchangeDetails.masterPublicKey,
- ]);
- if (globalExchangeRec) {
- return {
- currency: exchangeDetails.currency,
- type: ScopeType.Global,
- };
- } else {
- for (const aud of exchangeDetails.auditors) {
- const globalAuditorRec =
- await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
- exchangeDetails.currency,
- aud.auditor_url,
- aud.auditor_pub,
- ]);
- if (globalAuditorRec) {
- return {
- currency: exchangeDetails.currency,
- type: ScopeType.Auditor,
- url: aud.auditor_url,
- };
- }
- }
- }
- return {
- currency: exchangeDetails.currency,
- type: ScopeType.Exchange,
- url: exchangeDetails.exchangeBaseUrl,
- };
-}
-
-async function makeExchangeListItem(
- tx: WalletDbReadOnlyTransaction<
- ["globalCurrencyExchanges", "globalCurrencyAuditors"]
- >,
- r: ExchangeEntryRecord,
- exchangeDetails: ExchangeDetailsRecord | undefined,
- lastError: TalerErrorDetail | undefined,
-): Promise<ExchangeListItem> {
- const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
- ? {
- error: lastError,
- }
- : undefined;
-
- let scopeInfo: ScopeInfo | undefined = undefined;
-
- if (exchangeDetails) {
- scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
- }
-
- return {
- exchangeBaseUrl: r.baseUrl,
- currency: exchangeDetails?.currency ?? r.presetCurrencyHint,
- exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
- exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
- tosStatus: getExchangeTosStatusFromRecord(r),
- ageRestrictionOptions: exchangeDetails?.ageMask
- ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
- : [],
- paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
- lastUpdateErrorInfo,
- scopeInfo,
- };
-}
-
-export interface ExchangeWireDetails {
- currency: string;
- masterPublicKey: EddsaPublicKeyString;
- wireInfo: WireInfo;
- exchangeBaseUrl: string;
- auditors: ExchangeAuditor[];
- globalFees: ExchangeGlobalFees[];
-}
-
-export async function getExchangeWireDetailsInTx(
- tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
- exchangeBaseUrl: string,
-): Promise<ExchangeWireDetails | undefined> {
- const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
- if (!det) {
- return undefined;
- }
- return {
- currency: det.currency,
- masterPublicKey: det.masterPublicKey,
- wireInfo: det.wireInfo,
- exchangeBaseUrl: det.exchangeBaseUrl,
- auditors: det.auditors,
- globalFees: det.globalFees,
- };
-}
-
-export async function lookupExchangeByUri(
- ws: InternalWalletState,
- req: GetExchangeEntryByUrlRequest,
-): Promise<ExchangeListItem> {
- return await ws.db.runReadOnlyTx(
- [
- "exchanges",
- "exchangeDetails",
- "operationRetries",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
- async (tx) => {
- const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
- if (!exchangeRec) {
- throw Error("exchange not found");
- }
- const exchangeDetails = await getExchangeRecordsInternal(
- tx,
- exchangeRec.baseUrl,
- );
- const opRetryRecord = await tx.operationRetries.get(
- TaskIdentifiers.forExchangeUpdate(exchangeRec),
- );
- return await makeExchangeListItem(
- tx,
- exchangeRec,
- exchangeDetails,
- opRetryRecord?.lastError,
- );
- },
- );
-}
-
-/**
- * Mark the current ToS version as accepted by the user.
- */
-export async function acceptExchangeTermsOfService(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- const notif = await ws.db.runReadWriteTx(
- ["exchangeDetails", "exchanges"],
- async (tx) => {
- const exch = await tx.exchanges.get(exchangeBaseUrl);
- if (exch && exch.tosCurrentEtag) {
- const oldExchangeState = getExchangeState(exch);
- exch.tosAcceptedEtag = exch.tosCurrentEtag;
- exch.tosAcceptedTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- await tx.exchanges.put(exch);
- const newExchangeState = getExchangeState(exch);
- return {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl,
- newExchangeState: newExchangeState,
- oldExchangeState: oldExchangeState,
- } satisfies WalletNotification;
- }
- return undefined;
- },
- );
- if (notif) {
- ws.notify(notif);
- }
-}
-
-/**
- * Validate wire fees and wire accounts.
- *
- * Throw an exception if they are invalid.
- */
-async function validateWireInfo(
- ws: InternalWalletState,
- versionCurrent: number,
- wireInfo: ExchangeKeysDownloadResult,
- masterPublicKey: string,
-): Promise<WireInfo> {
- for (const a of wireInfo.accounts) {
- logger.trace("validating exchange acct");
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidWireAccount({
- masterPub: masterPublicKey,
- paytoUri: a.payto_uri,
- sig: a.master_sig,
- versionCurrent,
- conversionUrl: a.conversion_url,
- creditRestrictions: a.credit_restrictions,
- debitRestrictions: a.debit_restrictions,
- });
- isValid = v;
- }
- if (!isValid) {
- throw Error("exchange acct signature invalid");
- }
- }
- logger.trace("account validation done");
- const feesForType: WireFeeMap = {};
- for (const wireMethod of Object.keys(wireInfo.wireFees)) {
- const feeList: WireFee[] = [];
- for (const x of wireInfo.wireFees[wireMethod]) {
- const startStamp = x.start_date;
- const endStamp = x.end_date;
- const fee: WireFee = {
- closingFee: Amounts.stringify(x.closing_fee),
- endStamp,
- sig: x.sig,
- startStamp,
- wireFee: Amounts.stringify(x.wire_fee),
- };
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidWireFee({
- masterPub: masterPublicKey,
- type: wireMethod,
- wf: fee,
- });
- isValid = v;
- }
- if (!isValid) {
- throw Error("exchange wire fee signature invalid");
- }
- feeList.push(fee);
- }
- feesForType[wireMethod] = feeList;
- }
-
- return {
- accounts: wireInfo.accounts,
- feesForType,
- };
-}
-
-/**
- * Validate global fees.
- *
- * Throw an exception if they are invalid.
- */
-async function validateGlobalFees(
- ws: InternalWalletState,
- fees: GlobalFees[],
- masterPub: string,
-): Promise<ExchangeGlobalFees[]> {
- const egf: ExchangeGlobalFees[] = [];
- for (const gf of fees) {
- logger.trace("validating exchange global fees");
- let isValid = false;
- if (ws.config.testing.insecureTrustExchange) {
- isValid = true;
- } else {
- const { valid: v } = await ws.cryptoApi.isValidGlobalFees({
- masterPub,
- gf,
- });
- isValid = v;
- }
-
- if (!isValid) {
- throw Error("exchange global fees signature invalid: " + gf.master_sig);
- }
- egf.push({
- accountFee: Amounts.stringify(gf.account_fee),
- historyFee: Amounts.stringify(gf.history_fee),
- purseFee: Amounts.stringify(gf.purse_fee),
- startDate: gf.start_date,
- endDate: gf.end_date,
- signature: gf.master_sig,
- historyTimeout: gf.history_expiration,
- purseLimit: gf.purse_account_limit,
- purseTimeout: gf.purse_timeout,
- });
- }
-
- return egf;
-}
-
-/**
- * Add an exchange entry to the wallet database in the
- * entry state "preset".
- *
- * Returns the notification to the caller that should be emitted
- * if the DB transaction succeeds.
- */
-export async function addPresetExchangeEntry(
- tx: WalletDbReadWriteTransaction<["exchanges"]>,
- exchangeBaseUrl: string,
- currencyHint?: string,
-): Promise<{ notification?: WalletNotification }> {
- let exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange) {
- const r: ExchangeEntryRecord = {
- entryStatus: ExchangeEntryDbRecordStatus.Preset,
- updateStatus: ExchangeEntryDbUpdateStatus.Initial,
- baseUrl: exchangeBaseUrl,
- presetCurrencyHint: currencyHint,
- detailsPointer: undefined,
- lastUpdate: undefined,
- lastKeysEtag: undefined,
- nextRefreshCheckStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- nextUpdateStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- tosAcceptedEtag: undefined,
- tosAcceptedTimestamp: undefined,
- tosCurrentEtag: undefined,
- };
- await tx.exchanges.put(r);
- return {
- notification: {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: exchangeBaseUrl,
- // Exchange did not exist yet
- oldExchangeState: undefined,
- newExchangeState: getExchangeState(r),
- },
- };
- }
- return {};
-}
-
-async function provideExchangeRecordInTx(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
- baseUrl: string,
-): Promise<{
- exchange: ExchangeEntryRecord;
- exchangeDetails: ExchangeDetailsRecord | undefined;
- notification?: WalletNotification;
-}> {
- let notification: WalletNotification | undefined = undefined;
- let exchange = await tx.exchanges.get(baseUrl);
- if (!exchange) {
- const r: ExchangeEntryRecord = {
- entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
- updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
- baseUrl: baseUrl,
- detailsPointer: undefined,
- lastUpdate: undefined,
- nextUpdateStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- nextRefreshCheckStamp: timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
- ),
- lastKeysEtag: undefined,
- tosAcceptedEtag: undefined,
- tosAcceptedTimestamp: undefined,
- tosCurrentEtag: undefined,
- };
- await tx.exchanges.put(r);
- exchange = r;
- notification = {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: r.baseUrl,
- oldExchangeState: undefined,
- newExchangeState: getExchangeState(r),
- };
- }
- const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
- return { exchange, exchangeDetails, notification };
-}
-
-export interface ExchangeKeysDownloadResult {
- baseUrl: string;
- masterPublicKey: string;
- currency: string;
- auditors: ExchangeAuditor[];
- currentDenominations: DenominationRecord[];
- protocolVersion: string;
- signingKeys: ExchangeSignKeyJson[];
- reserveClosingDelay: TalerProtocolDuration;
- expiry: TalerProtocolTimestamp;
- recoup: Recoup[];
- listIssueDate: TalerProtocolTimestamp;
- globalFees: GlobalFees[];
- accounts: ExchangeWireAccount[];
- wireFees: { [methodName: string]: WireFeesJson[] };
-}
-
-/**
- * Download and validate an exchange's /keys data.
- */
-async function downloadExchangeKeysInfo(
- baseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
- cancellationToken: CancellationToken,
-): Promise<ExchangeKeysDownloadResult> {
- const keysUrl = new URL("keys", baseUrl);
-
- const resp = await http.fetch(keysUrl.href, {
- timeout,
- cancellationToken,
- });
-
- logger.info("got response to /keys request");
-
- // We must make sure to parse out the protocol version
- // before we validate the body.
- // Otherwise the parser might complain with a hard to understand
- // message about some other field, when it is just a version
- // incompatibility.
-
- const keysJson = await resp.json();
-
- const protocolVersion = keysJson.version;
- if (typeof protocolVersion !== "string") {
- throw Error("bad exchange, does not even specify protocol version");
- }
-
- const versionRes = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- protocolVersion,
- );
- if (!versionRes) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- {
- requestUrl: resp.requestUrl,
- httpStatusCode: resp.status,
- requestMethod: resp.requestMethod,
- },
- "exchange protocol version malformed",
- );
- }
- if (!versionRes.compatible) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- exchangeProtocolVersion: protocolVersion,
- walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- },
- "exchange protocol version not compatible with wallet",
- );
- }
-
- const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeKeysJson(),
- );
-
- if (exchangeKeysJsonUnchecked.denominations.length === 0) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
- {
- exchangeBaseUrl: baseUrl,
- },
- "exchange doesn't offer any denominations",
- );
- }
-
- const currency = exchangeKeysJsonUnchecked.currency;
-
- const currentDenominations: DenominationRecord[] = [];
-
- for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
- switch (denomGroup.cipher) {
- case "RSA":
- case "RSA+age_restricted": {
- let ageMask = 0;
- if (denomGroup.cipher === "RSA+age_restricted") {
- ageMask = denomGroup.age_mask;
- }
- for (const denomIn of denomGroup.denoms) {
- const denomPub: DenominationPubKey = {
- age_mask: ageMask,
- cipher: DenomKeyType.Rsa,
- rsa_public_key: denomIn.rsa_pub,
- };
- const denomPubHash = encodeCrock(hashDenomPub(denomPub));
- const value = Amounts.parseOrThrow(denomGroup.value);
- const rec: DenominationRecord = {
- denomPub,
- denomPubHash,
- exchangeBaseUrl: baseUrl,
- exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
- isOffered: true,
- isRevoked: false,
- value: Amounts.stringify(value),
- currency: value.currency,
- stampExpireDeposit: timestampProtocolToDb(
- denomIn.stamp_expire_deposit,
- ),
- stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
- stampExpireWithdraw: timestampProtocolToDb(
- denomIn.stamp_expire_withdraw,
- ),
- stampStart: timestampProtocolToDb(denomIn.stamp_start),
- verificationStatus: DenominationVerificationStatus.Unverified,
- masterSig: denomIn.master_sig,
- listIssueDate: timestampProtocolToDb(
- exchangeKeysJsonUnchecked.list_issue_date,
- ),
- fees: {
- feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
- feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
- feeRefund: Amounts.stringify(denomGroup.fee_refund),
- feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
- },
- };
- currentDenominations.push(rec);
- }
- break;
- }
- case "CS+age_restricted":
- case "CS":
- logger.warn("Clause-Schnorr denominations not supported");
- continue;
- default:
- logger.warn(
- `denomination type ${(denomGroup as any).cipher} not supported`,
- );
- continue;
- }
- }
-
- return {
- masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
- currency,
- baseUrl: exchangeKeysJsonUnchecked.base_url,
- auditors: exchangeKeysJsonUnchecked.auditors,
- currentDenominations,
- protocolVersion: exchangeKeysJsonUnchecked.version,
- signingKeys: exchangeKeysJsonUnchecked.signkeys,
- reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
- expiry: AbsoluteTime.toProtocolTimestamp(
- getExpiry(resp, {
- minDuration: Duration.fromSpec({ hours: 1 }),
- }),
- ),
- recoup: exchangeKeysJsonUnchecked.recoup ?? [],
- listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
- globalFees: exchangeKeysJsonUnchecked.global_fees,
- accounts: exchangeKeysJsonUnchecked.accounts,
- wireFees: exchangeKeysJsonUnchecked.wire_fees,
- };
-}
-
-async function downloadTosFromAcceptedFormat(
- ws: InternalWalletState,
- baseUrl: string,
- timeout: Duration,
- acceptedFormat?: string[],
- acceptLanguage?: string,
-): Promise<ExchangeTosDownloadResult> {
- let tosFound: ExchangeTosDownloadResult | undefined;
- // Remove this when exchange supports multiple content-type in accept header
- if (acceptedFormat)
- for (const format of acceptedFormat) {
- const resp = await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- format,
- acceptLanguage,
- );
- if (resp.tosContentType === format) {
- tosFound = resp;
- break;
- }
- }
- if (tosFound !== undefined) {
- return tosFound;
- }
- // If none of the specified format was found try text/plain
- return await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- "text/plain",
- acceptLanguage,
- );
-}
-
-/**
- * Transition an exchange into an updating state.
- *
- * If the update is forced, the exchange is put into an updating state
- * even if the old information should still be up to date.
- *
- * If the exchange entry doesn't exist,
- * a new ephemeral entry is created.
- */
-async function startUpdateExchangeEntry(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- options: { forceUpdate?: boolean } = {},
-): Promise<void> {
- const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
- logger.info(
- `starting update of exchange entry ${canonBaseUrl}, forced=${
- options.forceUpdate ?? false
- }`,
- );
-
- const { notification } = await ws.db.runReadWriteTx(
- ["exchanges", "exchangeDetails"],
- async (tx) => {
- return provideExchangeRecordInTx(ws, tx, exchangeBaseUrl);
- },
- );
-
- if (notification) {
- ws.notify(notification);
- }
-
- const { oldExchangeState, newExchangeState, taskId } =
- await ws.db.runReadWriteTx(
- ["exchanges", "operationRetries"],
- async (tx) => {
- const r = await tx.exchanges.get(canonBaseUrl);
- if (!r) {
- throw Error("exchange not found");
- }
- const oldExchangeState = getExchangeState(r);
- switch (r.updateStatus) {
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- break;
- case ExchangeEntryDbUpdateStatus.Suspended:
- break;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- break;
- case ExchangeEntryDbUpdateStatus.Ready: {
- const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
- timestampPreciseFromDb(r.nextUpdateStamp),
- );
- // Only update if entry is outdated or update is forced.
- if (
- options.forceUpdate ||
- AbsoluteTime.isExpired(nextUpdateTimestamp)
- ) {
- r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
- }
- break;
- }
- case ExchangeEntryDbUpdateStatus.Initial:
- r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
- break;
- }
- await tx.exchanges.put(r);
- const newExchangeState = getExchangeState(r);
- // Reset retries for updating the exchange entry.
- const taskId = TaskIdentifiers.forExchangeUpdate(r);
- await tx.operationRetries.delete(taskId);
- return { oldExchangeState, newExchangeState, taskId };
- },
- );
- ws.notify({
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: canonBaseUrl,
- newExchangeState: newExchangeState,
- oldExchangeState: oldExchangeState,
- });
- await ws.taskScheduler.resetTaskRetries(taskId);
-}
-
-/**
- * Basic information about an exchange in a ready state.
- */
-export interface ReadyExchangeSummary {
- exchangeBaseUrl: string;
- currency: string;
- masterPub: string;
- tosStatus: ExchangeTosStatus;
- tosAcceptedEtag: string | undefined;
- tosCurrentEtag: string | undefined;
- wireInfo: WireInfo;
- protocolVersionRange: string;
- tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
- scopeInfo: ScopeInfo;
-}
-
-async function internalWaitReadyExchange(
- ws: InternalWalletState,
- canonUrl: string,
- exchangeNotifFlag: AsyncFlag,
- options: {
- cancellationToken?: CancellationToken;
- forceUpdate?: boolean;
- expectedMasterPub?: string;
- } = {},
-): Promise<ReadyExchangeSummary> {
- const operationId = constructTaskIdentifier({
- tag: PendingTaskType.ExchangeUpdate,
- exchangeBaseUrl: canonUrl,
- });
- while (true) {
- logger.info(`waiting for ready exchange ${canonUrl}`);
- const { exchange, exchangeDetails, retryInfo, scopeInfo } =
- await ws.db.runReadOnlyTx(
- [
- "exchanges",
- "exchangeDetails",
- "operationRetries",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
- async (tx) => {
- const exchange = await tx.exchanges.get(canonUrl);
- const exchangeDetails = await getExchangeRecordsInternal(
- tx,
- canonUrl,
- );
- const retryInfo = await tx.operationRetries.get(operationId);
- let scopeInfo: ScopeInfo | undefined = undefined;
- if (exchange && exchangeDetails) {
- scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
- }
- return { exchange, exchangeDetails, retryInfo, scopeInfo };
- },
- );
-
- if (!exchange) {
- throw Error("exchange entry does not exist anymore");
- }
-
- let ready = false;
-
- switch (exchange.updateStatus) {
- case ExchangeEntryDbUpdateStatus.Ready:
- ready = true;
- break;
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- // If the update is forced,
- // we wait until we're in a full "ready" state,
- // as we're not happy with the stale information.
- if (!options.forceUpdate) {
- ready = true;
- }
- break;
- default: {
- if (retryInfo) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
- {
- exchangeBaseUrl: canonUrl,
- innerError: retryInfo?.lastError,
- },
- );
- }
- }
- }
-
- if (!ready) {
- logger.info("waiting for exchange update notification");
- await exchangeNotifFlag.wait();
- logger.info("done waiting for exchange update notification");
- exchangeNotifFlag.reset();
- continue;
- }
-
- if (!exchangeDetails) {
- throw Error("invariant failed");
- }
-
- if (!scopeInfo) {
- throw Error("invariant failed");
- }
-
- const res: ReadyExchangeSummary = {
- currency: exchangeDetails.currency,
- exchangeBaseUrl: canonUrl,
- masterPub: exchangeDetails.masterPublicKey,
- tosStatus: getExchangeTosStatusFromRecord(exchange),
- tosAcceptedEtag: exchange.tosAcceptedEtag,
- wireInfo: exchangeDetails.wireInfo,
- protocolVersionRange: exchangeDetails.protocolVersionRange,
- tosCurrentEtag: exchange.tosCurrentEtag,
- tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
- exchange.tosAcceptedTimestamp,
- ),
- scopeInfo,
- };
-
- if (options.expectedMasterPub) {
- if (res.masterPub !== options.expectedMasterPub) {
- throw Error(
- "public key of the exchange does not match expected public key",
- );
- }
- }
- return res;
- }
-}
-
-/**
- * Ensure that a fresh exchange entry exists for the given
- * exchange base URL.
- *
- * The cancellation token can be used to abort waiting for the
- * updated exchange entry.
- *
- * If an exchange entry for the database doesn't exist in the
- * DB, it will be added ephemerally.
- *
- * If the expectedMasterPub is given and does not match the actual
- * master pub, an exception will be thrown. However, the exchange
- * will still have been added as an ephemeral exchange entry.
- */
-export async function fetchFreshExchange(
- ws: InternalWalletState,
- baseUrl: string,
- options: {
- cancellationToken?: CancellationToken;
- forceUpdate?: boolean;
- expectedMasterPub?: string;
- } = {},
-): Promise<ReadyExchangeSummary> {
- const canonUrl = canonicalizeBaseUrl(baseUrl);
-
- ws.ensureTaskLoopRunning();
-
- await startUpdateExchangeEntry(ws, canonUrl, {
- forceUpdate: options.forceUpdate,
- });
-
- return waitReadyExchange(ws, canonUrl, options);
-}
-
-async function waitReadyExchange(
- ws: InternalWalletState,
- canonUrl: string,
- options: {
- cancellationToken?: CancellationToken;
- forceUpdate?: boolean;
- expectedMasterPub?: string;
- } = {},
-): Promise<ReadyExchangeSummary> {
- // FIXME: We should use Symbol.dispose magic here for cleanup!
-
- const exchangeNotifFlag = new AsyncFlag();
- // Raise exchangeNotifFlag whenever we get a notification
- // about our exchange.
- const cancelNotif = ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.ExchangeStateTransition &&
- notif.exchangeBaseUrl === canonUrl
- ) {
- logger.info(`raising update notification: ${j2s(notif)}`);
- exchangeNotifFlag.raise();
- }
- });
-
- try {
- const res = await internalWaitReadyExchange(
- ws,
- canonUrl,
- exchangeNotifFlag,
- options,
- );
- logger.info("done waiting for ready exchange");
- return res;
- } finally {
- cancelNotif();
- }
-}
-
-/**
- * Update an exchange entry in the wallet's database
- * by fetching the /keys and /wire information.
- * Optionally link the reserve entry to the new or existing
- * exchange entry in then DB.
- */
-export async function updateExchangeFromUrlHandler(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
- exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
- const oldExchangeRec = await ws.db.runReadOnlyTx(
- ["exchanges"],
- async (tx) => {
- return tx.exchanges.get(exchangeBaseUrl);
- },
- );
-
- if (!oldExchangeRec) {
- logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
- return TaskRunResult.finished();
- }
-
- let updateRequestedExplicitly = false;
-
- switch (oldExchangeRec.updateStatus) {
- case ExchangeEntryDbUpdateStatus.Suspended:
- logger.info(`not updating exchange in status "suspended"`);
- return TaskRunResult.finished();
- case ExchangeEntryDbUpdateStatus.Initial:
- logger.info(`not updating exchange in status "initial"`);
- return TaskRunResult.finished();
- case ExchangeEntryDbUpdateStatus.InitialUpdate:
- case ExchangeEntryDbUpdateStatus.ReadyUpdate:
- case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
- updateRequestedExplicitly = true;
- break;
- case ExchangeEntryDbUpdateStatus.Ready:
- break;
- default:
- assertUnreachable(oldExchangeRec.updateStatus);
- }
-
- let refreshCheckNecessary = true;
-
- if (!updateRequestedExplicitly) {
- // If the update wasn't requested explicitly,
- // check if we really need to update.
-
- let nextUpdateStamp = timestampAbsoluteFromDb(
- oldExchangeRec.nextUpdateStamp,
- );
-
- let nextRefreshCheckStamp = timestampAbsoluteFromDb(
- oldExchangeRec.nextRefreshCheckStamp,
- );
-
- let updateNecessary = true;
-
- if (
- !AbsoluteTime.isNever(nextUpdateStamp) &&
- !AbsoluteTime.isExpired(nextUpdateStamp)
- ) {
- logger.info(
- `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
- nextUpdateStamp,
- )}`,
- );
- updateNecessary = false;
- }
-
- if (
- !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
- !AbsoluteTime.isExpired(nextRefreshCheckStamp)
- ) {
- logger.info(
- `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
- nextRefreshCheckStamp,
- )}`,
- );
- refreshCheckNecessary = false;
- }
-
- if (!(updateNecessary || refreshCheckNecessary)) {
- return TaskRunResult.runAgainAt(
- AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
- );
- }
- }
-
- // When doing the auto-refresh check, we always update
- // the key info before that.
-
- logger.trace("updating exchange /keys info");
-
- const timeout = getExchangeRequestTimeout();
-
- const keysInfo = await downloadExchangeKeysInfo(
- exchangeBaseUrl,
- ws.http,
- timeout,
- cancellationToken,
- );
-
- logger.trace("validating exchange wire info");
-
- const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
- if (!version) {
- // Should have been validated earlier.
- throw Error("unexpected invalid version");
- }
-
- const wireInfo = await validateWireInfo(
- ws,
- version.current,
- keysInfo,
- keysInfo.masterPublicKey,
- );
-
- const globalFees = await validateGlobalFees(
- ws,
- keysInfo.globalFees,
- keysInfo.masterPublicKey,
- );
- if (keysInfo.baseUrl != exchangeBaseUrl) {
- logger.warn("exchange base URL mismatch");
- const errorDetail: TalerErrorDetail = makeErrorDetail(
- TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
- {
- urlWallet: exchangeBaseUrl,
- urlExchange: keysInfo.baseUrl,
- },
- );
- return {
- type: TaskRunResultType.Error,
- errorDetail,
- };
- }
-
- logger.trace("finished validating exchange /wire info");
-
- // We download the text/plain version here,
- // because that one needs to exist, and we
- // will get the current etag from the response.
- const tosDownload = await downloadTosFromAcceptedFormat(
- ws,
- exchangeBaseUrl,
- timeout,
- ["text/plain"],
- );
-
- let recoupGroupId: string | undefined;
-
- logger.trace("updating exchange info in database");
-
- let detailsPointerChanged = false;
-
- let ageMask = 0;
- for (const x of keysInfo.currentDenominations) {
- if (
- isWithdrawableDenom(x, ws.config.testing.denomselAllowLate) &&
- x.denomPub.age_mask != 0
- ) {
- ageMask = x.denomPub.age_mask;
- break;
- }
- }
-
- const updated = await ws.db.runReadWriteTx(
- [
- "exchanges",
- "exchangeDetails",
- "exchangeSignKeys",
- "denominations",
- "coins",
- "refreshGroups",
- "recoupGroups",
- ],
- async (tx) => {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
- return;
- }
- const oldExchangeState = getExchangeState(r);
- const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
- if (!existingDetails) {
- detailsPointerChanged = true;
- }
- if (existingDetails) {
- if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
- detailsPointerChanged = true;
- }
- if (existingDetails.currency !== keysInfo.currency) {
- detailsPointerChanged = true;
- }
- // FIXME: We need to do some consistency checks!
- }
- const newDetails: ExchangeDetailsRecord = {
- auditors: keysInfo.auditors,
- currency: keysInfo.currency,
- masterPublicKey: keysInfo.masterPublicKey,
- protocolVersionRange: keysInfo.protocolVersion,
- reserveClosingDelay: keysInfo.reserveClosingDelay,
- globalFees,
- exchangeBaseUrl: r.baseUrl,
- wireInfo,
- ageMask,
- };
- r.tosCurrentEtag = tosDownload.tosEtag;
- if (existingDetails?.rowId) {
- newDetails.rowId = existingDetails.rowId;
- }
- r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
- r.nextUpdateStamp = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(
- AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
- ),
- );
- // New denominations might be available.
- r.nextRefreshCheckStamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- if (detailsPointerChanged) {
- r.detailsPointer = {
- currency: newDetails.currency,
- masterPublicKey: newDetails.masterPublicKey,
- updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
- }
- r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
- await tx.exchanges.put(r);
- const drRowId = await tx.exchangeDetails.put(newDetails);
- checkDbInvariant(typeof drRowId.key === "number");
-
- for (const sk of keysInfo.signingKeys) {
- // FIXME: validate signing keys before inserting them
- await tx.exchangeSignKeys.put({
- exchangeDetailsRowId: drRowId.key,
- masterSig: sk.master_sig,
- signkeyPub: sk.key,
- stampEnd: timestampProtocolToDb(sk.stamp_end),
- stampExpire: timestampProtocolToDb(sk.stamp_expire),
- stampStart: timestampProtocolToDb(sk.stamp_start),
- });
- }
-
- logger.trace("updating denominations in database");
- const currentDenomSet = new Set<string>(
- keysInfo.currentDenominations.map((x) => x.denomPubHash),
- );
- for (const currentDenom of keysInfo.currentDenominations) {
- const oldDenom = await tx.denominations.get([
- exchangeBaseUrl,
- currentDenom.denomPubHash,
- ]);
- if (oldDenom) {
- // FIXME: Do consistency check, report to auditor if necessary.
- } else {
- await tx.denominations.put(currentDenom);
- }
- }
-
- // Update list issue date for all denominations,
- // and mark non-offered denominations as such.
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(r.baseUrl)
- .forEachAsync(async (x) => {
- if (!currentDenomSet.has(x.denomPubHash)) {
- // FIXME: Here, an auditor report should be created, unless
- // the denomination is really legally expired.
- if (x.isOffered) {
- x.isOffered = false;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=false`,
- );
- }
- } else {
- x.listIssueDate = timestampProtocolToDb(keysInfo.listIssueDate);
- if (!x.isOffered) {
- x.isOffered = true;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=true`,
- );
- }
- }
- await tx.denominations.put(x);
- });
-
- logger.trace("done updating denominations in database");
-
- // Handle recoup
- const recoupDenomList = keysInfo.recoup;
- const newlyRevokedCoinPubs: string[] = [];
- logger.trace("recoup list from exchange", recoupDenomList);
- for (const recoupInfo of recoupDenomList) {
- const oldDenom = await tx.denominations.get([
- r.baseUrl,
- recoupInfo.h_denom_pub,
- ]);
- if (!oldDenom) {
- // We never even knew about the revoked denomination, all good.
- continue;
- }
- if (oldDenom.isRevoked) {
- // We already marked the denomination as revoked,
- // this implies we revoked all coins
- logger.trace("denom already revoked");
- continue;
- }
- logger.info("revoking denom", recoupInfo.h_denom_pub);
- oldDenom.isRevoked = true;
- await tx.denominations.put(oldDenom);
- const affectedCoins = await tx.coins.indexes.byDenomPubHash
- .iter(recoupInfo.h_denom_pub)
- .toArray();
- for (const ac of affectedCoins) {
- newlyRevokedCoinPubs.push(ac.coinPub);
- }
- }
- if (newlyRevokedCoinPubs.length != 0) {
- logger.info("recouping coins", newlyRevokedCoinPubs);
- recoupGroupId = await ws.recoupOps.createRecoupGroup(
- ws,
- tx,
- exchangeBaseUrl,
- newlyRevokedCoinPubs,
- );
- }
-
- const newExchangeState = getExchangeState(r);
-
- return {
- exchange: r,
- exchangeDetails: newDetails,
- oldExchangeState,
- newExchangeState,
- };
- },
- );
-
- if (recoupGroupId) {
- const recoupTaskId = constructTaskIdentifier({
- tag: PendingTaskType.Recoup,
- recoupGroupId,
- });
- // Asynchronously start recoup. This doesn't need to finish
- // for the exchange update to be considered finished.
- ws.taskScheduler.startShepherdTask(recoupTaskId);
- }
-
- if (!updated) {
- throw Error("something went wrong with updating the exchange");
- }
-
- logger.trace("done updating exchange info in database");
-
- logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
-
- let minCheckThreshold = AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- durationFromSpec({ days: 1 }),
- );
-
- if (refreshCheckNecessary) {
- // Do auto-refresh.
- await ws.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "coinAvailability",
- "refreshGroups",
- "exchanges",
- ],
- async (tx) => {
- const exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange || !exchange.detailsPointer) {
- return;
- }
- const coins = await tx.coins.indexes.byBaseUrl
- .iter(exchangeBaseUrl)
- .toArray();
- const refreshCoins: CoinRefreshRequest[] = [];
- for (const coin of coins) {
- if (coin.status !== CoinStatus.Fresh) {
- continue;
- }
- const denom = await tx.denominations.get([
- exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination not in database");
- continue;
- }
- const executeThreshold =
- getAutoRefreshExecuteThresholdForDenom(denom);
- if (AbsoluteTime.isExpired(executeThreshold)) {
- refreshCoins.push({
- coinPub: coin.coinPub,
- amount: denom.value,
- });
- } else {
- const checkThreshold = getAutoRefreshCheckThreshold(denom);
- minCheckThreshold = AbsoluteTime.min(
- minCheckThreshold,
- checkThreshold,
- );
- }
- }
- if (refreshCoins.length > 0) {
- const res = await createRefreshGroup(
- ws,
- tx,
- exchange.detailsPointer?.currency,
- refreshCoins,
- RefreshReason.Scheduled,
- undefined,
- );
- logger.trace(
- `created refresh group for auto-refresh (${res.refreshGroupId})`,
- );
- }
- logger.trace(
- `next refresh check at ${AbsoluteTime.toIsoString(
- minCheckThreshold,
- )}`,
- );
- exchange.nextRefreshCheckStamp = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
- );
- await tx.exchanges.put(exchange);
- },
- );
- }
-
- ws.notify({
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl,
- newExchangeState: updated.newExchangeState,
- oldExchangeState: updated.oldExchangeState,
- });
-
- // Next invocation will cause the task to be run again
- // at the necessary time.
- return TaskRunResult.progress();
-}
-
-function getAutoRefreshExecuteThresholdForDenom(
- d: DenominationRecord,
-): AbsoluteTime {
- return getAutoRefreshExecuteThreshold({
- stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
- stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
- });
-}
-
-/**
- * Timestamp after which the wallet would do the next check for an auto-refresh.
- */
-function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
- const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(d.stampExpireWithdraw),
- );
- const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(d.stampExpireDeposit),
- );
- const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
- const deltaDiv = durationMul(delta, 0.75);
- return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
-}
-
-/**
- * Find a payto:// URI of the exchange that is of one
- * of the given target types.
- *
- * Throws if no matching account was found.
- */
-export async function getExchangePaytoUri(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- supportedTargetTypes: string[],
-): Promise<string> {
- // We do the update here, since the exchange might not even exist
- // yet in our database.
- const details = await ws.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails"],
- async (tx) => {
- return getExchangeRecordsInternal(tx, exchangeBaseUrl);
- },
- );
- const accounts = details?.wireInfo.accounts ?? [];
- for (const account of accounts) {
- const res = parsePaytoUri(account.payto_uri);
- if (!res) {
- continue;
- }
- if (supportedTargetTypes.includes(res.targetType)) {
- return account.payto_uri;
- }
- }
- throw Error(
- `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
- supportedTargetTypes,
- )}`,
- );
-}
-
-/**
- * Get the exchange ToS in the requested format.
- * Try to download in the accepted format not cached.
- */
-export async function getExchangeTos(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- acceptedFormat?: string[],
- acceptLanguage?: string,
-): Promise<GetExchangeTosResult> {
- const exch = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- const tosDownload = await downloadTosFromAcceptedFormat(
- ws,
- exchangeBaseUrl,
- getExchangeRequestTimeout(),
- acceptedFormat,
- acceptLanguage,
- );
-
- await ws.db.runReadWriteTx(["exchanges"], async (tx) => {
- const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
- if (updateExchangeEntry) {
- updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
- await tx.exchanges.put(updateExchangeEntry);
- }
- });
-
- return {
- acceptedEtag: exch.tosAcceptedEtag,
- currentEtag: tosDownload.tosEtag,
- content: tosDownload.tosText,
- contentType: tosDownload.tosContentType,
- contentLanguage: tosDownload.tosContentLanguage,
- tosStatus: exch.tosStatus,
- tosAvailableLanguages: tosDownload.tosAvailableLanguages,
- };
-}
-
-/**
- * Parsed information about an exchange,
- * obtained by requesting /keys.
- */
-export interface ExchangeInfo {
- keys: ExchangeKeysDownloadResult;
-}
-
-/**
- * Helper function to download the exchange /keys info.
- *
- * Only used for testing / dbless wallet.
- */
-export async function downloadExchangeInfo(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
-): Promise<ExchangeInfo> {
- const keysInfo = await downloadExchangeKeysInfo(
- exchangeBaseUrl,
- http,
- Duration.getForever(),
- CancellationToken.CONTINUE,
- );
- return {
- keys: keysInfo,
- };
-}
-
-/**
- * List all exchange entries known to the wallet.
- */
-export async function listExchanges(
- ws: InternalWalletState,
-): Promise<ExchangesListResponse> {
- const exchanges: ExchangeListItem[] = [];
- await ws.db.runReadOnlyTx(
- [
- "exchanges",
- "operationRetries",
- "exchangeDetails",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
- async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.ExchangeUpdate,
- exchangeBaseUrl: r.baseUrl,
- });
- const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
- const opRetryRecord = await tx.operationRetries.get(taskId);
- exchanges.push(
- await makeExchangeListItem(
- tx,
- r,
- exchangeDetails,
- opRetryRecord?.lastError,
- ),
- );
- }
- },
- );
- return { exchanges };
-}
-
-/**
- * Transition an exchange to the "used" entry state if necessary.
- *
- * Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
- *
- * The caller should emit the returned notification iff the current transaction
- * succeeded.
- */
-export async function markExchangeUsed(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<["exchanges"]>,
- exchangeBaseUrl: string,
-): Promise<{ notif: WalletNotification | undefined }> {
- exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
- logger.info(`marking exchange ${exchangeBaseUrl} as used`);
- const exch = await tx.exchanges.get(exchangeBaseUrl);
- if (!exch) {
- return {
- notif: undefined,
- };
- }
- const oldExchangeState = getExchangeState(exch);
- switch (exch.entryStatus) {
- case ExchangeEntryDbRecordStatus.Ephemeral:
- case ExchangeEntryDbRecordStatus.Preset: {
- exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
- await tx.exchanges.put(exch);
- const newExchangeState = getExchangeState(exch);
- return {
- notif: {
- type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl,
- newExchangeState: newExchangeState,
- oldExchangeState: oldExchangeState,
- } satisfies WalletNotification,
- };
- }
- default:
- return {
- notif: undefined,
- };
- }
-}
-
-/**
- * Get detailed information about the exchange including a timeline
- * for the fees charged by the exchange.
- */
-export async function getExchangeDetailedInfo(
- ws: InternalWalletState,
- exchangeBaseurl: string,
-): Promise<ExchangeDetailedResponse> {
- const exchange = await ws.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails", "denominations"],
- async (tx) => {
- const ex = await tx.exchanges.get(exchangeBaseurl);
- const dp = ex?.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency } = dp;
- const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
- if (!exchangeDetails) {
- return;
- }
- const denominationRecords =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
-
- if (!denominationRecords) {
- return;
- }
-
- const denominations: DenominationInfo[] = denominationRecords.map((x) =>
- DenominationRecord.toDenomInfo(x),
- );
-
- return {
- info: {
- exchangeBaseUrl: ex.baseUrl,
- currency,
- paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
- auditors: exchangeDetails.auditors,
- wireInfo: exchangeDetails.wireInfo,
- globalFees: exchangeDetails.globalFees,
- },
- denominations,
- };
- },
- );
-
- if (!exchange) {
- throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
- }
-
- const denoms = exchange.denominations.map((d) => ({
- ...d,
- group: Amounts.stringifyValue(d.value),
- }));
- const denomFees: DenomOperationMap<FeeDescription[]> = {
- deposit: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireDeposit",
- "feeDeposit",
- "group",
- selectBestForOverlappingDenominations,
- ),
- refresh: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeRefresh",
- "group",
- selectBestForOverlappingDenominations,
- ),
- refund: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeRefund",
- "group",
- selectBestForOverlappingDenominations,
- ),
- withdraw: createTimeline(
- denoms,
- "denomPubHash",
- "stampStart",
- "stampExpireWithdraw",
- "feeWithdraw",
- "group",
- selectBestForOverlappingDenominations,
- ),
- };
-
- const transferFees = Object.entries(
- exchange.info.wireInfo.feesForType,
- ).reduce(
- (prev, [wireType, infoForType]) => {
- const feesByGroup = [
- ...infoForType.map((w) => ({
- ...w,
- fee: Amounts.stringify(w.closingFee),
- group: "closing",
- })),
- ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
- ];
- prev[wireType] = createTimeline(
- feesByGroup,
- "sig",
- "startStamp",
- "endStamp",
- "fee",
- "group",
- selectMinimumFee,
- );
- return prev;
- },
- {} as Record<string, FeeDescription[]>,
- );
-
- const globalFeesByGroup = [
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.accountFee,
- group: "account",
- })),
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.historyFee,
- group: "history",
- })),
- ...exchange.info.globalFees.map((w) => ({
- ...w,
- fee: w.purseFee,
- group: "purse",
- })),
- ];
-
- const globalFees = createTimeline(
- globalFeesByGroup,
- "signature",
- "startDate",
- "endDate",
- "fee",
- "group",
- selectMinimumFee,
- );
-
- return {
- exchange: {
- ...exchange.info,
- denomFees,
- transferFees,
- globalFees,
- },
- };
-}
-
-async function internalGetExchangeResources(
- ws: InternalWalletState,
- tx: DbReadOnlyTransaction<
- typeof WalletStoresV1,
- ["exchanges", "coins", "withdrawalGroups"]
- >,
- exchangeBaseUrl: string,
-): Promise<GetExchangeResourcesResponse> {
- let numWithdrawals = 0;
- let numCoins = 0;
- numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
- numWithdrawals =
- await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
- const total = numWithdrawals + numCoins;
- return {
- hasResources: total != 0,
- };
-}
-
-export async function deleteExchange(
- ws: InternalWalletState,
- req: DeleteExchangeRequest,
-): Promise<void> {
- let inUse: boolean = false;
- const exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl);
- await ws.db.runReadWriteTx(
- ["exchanges", "coins", "withdrawalGroups", "exchangeDetails"],
- async (tx) => {
- const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchangeRec) {
- // Nothing to delete!
- logger.info("no exchange found to delete");
- return;
- }
- const res = await internalGetExchangeResources(ws, tx, exchangeBaseUrl);
- if (res.hasResources) {
- if (req.purge) {
- const detRecs =
- await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
- for (const r of detRecs) {
- if (r.rowId == null) {
- // Should never happen, as rowId is the primary key.
- continue;
- }
- await tx.exchangeDetails.delete(r.rowId);
- }
- // FIXME: Also remove records related to transactions?
- } else {
- inUse = true;
- return;
- }
- }
- await tx.exchanges.delete(exchangeBaseUrl);
- },
- );
-
- if (inUse) {
- throw TalerError.fromUncheckedDetail({
- code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
- hint: "Exchange in use.",
- });
- }
-}
-
-export async function getExchangeResources(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<GetExchangeResourcesResponse> {
- // Withdrawals include internal withdrawals from peer transactions
- const res = await ws.db.runReadOnlyTx(
- ["exchanges", "withdrawalGroups", "coins"],
- async (tx) => {
- const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchangeRecord) {
- return undefined;
- }
- return internalGetExchangeResources(ws, tx, exchangeBaseUrl);
- },
- );
- if (!res) {
- throw Error("exchange not found");
- }
- return res;
-}
diff --git a/packages/taler-wallet-core/src/operations/merchants.ts b/packages/taler-wallet-core/src/operations/merchants.ts
@@ -1,66 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A..
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- canonicalizeBaseUrl,
- Logger,
- URL,
- codecForMerchantConfigResponse,
- LibtoolVersion,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-
-const logger = new Logger("taler-wallet-core:merchants.ts");
-
-export async function getMerchantInfo(
- ws: InternalWalletState,
- merchantBaseUrl: string,
-): Promise<MerchantInfo> {
- const canonBaseUrl = canonicalizeBaseUrl(merchantBaseUrl);
-
- const existingInfo = ws.merchantInfoCache[canonBaseUrl];
- if (existingInfo) {
- return existingInfo;
- }
-
- const configUrl = new URL("config", canonBaseUrl);
- const resp = await ws.http.fetch(configUrl.href);
-
- const configResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantConfigResponse(),
- );
-
- logger.info(
- `merchant "${canonBaseUrl}" reports protocol ${configResp.version}"`,
- );
-
- const parsedVersion = LibtoolVersion.parseVersion(configResp.version);
- if (!parsedVersion) {
- throw Error("invalid merchant version");
- }
-
- const merchantInfo: MerchantInfo = {
- protocolVersionCurrent: parsedVersion.current,
- };
-
- ws.merchantInfoCache[canonBaseUrl] = merchantInfo;
- return merchantInfo;
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts
@@ -1,3232 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the payment operation, including downloading and
- * claiming of proposals.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
- AbortingCoin,
- AbortRequest,
- AbsoluteTime,
- AmountJson,
- Amounts,
- AmountString,
- AsyncFlag,
- codecForAbortResponse,
- codecForMerchantContractTerms,
- codecForMerchantOrderRefundPickupResponse,
- codecForMerchantOrderStatusPaid,
- codecForMerchantPayResponse,
- codecForMerchantPostOrderResponse,
- codecForProposal,
- CoinDepositPermission,
- CoinRefreshRequest,
- ConfirmPayResult,
- ConfirmPayResultType,
- ContractTermsUtil,
- Duration,
- encodeCrock,
- ForcedCoinSel,
- getRandomBytes,
- HttpStatusCode,
- j2s,
- Logger,
- makeErrorDetail,
- makePendingOperationFailedError,
- MerchantCoinRefundStatus,
- MerchantContractTerms,
- MerchantPayResponse,
- MerchantUsingTemplateDetails,
- NotificationType,
- parsePayTemplateUri,
- parsePayUri,
- parseTalerUri,
- PayCoinSelection,
- PreparePayResult,
- PreparePayResultType,
- PreparePayTemplateRequest,
- randomBytes,
- RefreshReason,
- SharePaymentResult,
- StartRefundQueryForUriResponse,
- stringifyPayUri,
- stringifyTalerUri,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolViolationError,
- TalerUriAction,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- WalletContractData,
-} from "@gnu-taler/taler-util";
-import {
- getHttpResponseErrorDetails,
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
- readUnexpectedResponseDetails,
- throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
-import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
-import {
- CoinRecord,
- DenominationRecord,
- PurchaseRecord,
- PurchaseStatus,
- RefundReason,
- WalletStoresV1,
-} from "../db.js";
-import {
- getCandidateWithdrawalDenomsTx,
- PendingTaskType,
- RefundGroupRecord,
- RefundGroupStatus,
- RefundItemRecord,
- RefundItemStatus,
- TaskId,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
-} from "../index.js";
-import {
- EXCHANGE_COINS_LOCK,
- InternalWalletState,
-} from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { DbReadWriteTransaction, StoreNames } from "../util/query.js";
-import {
- constructTaskIdentifier,
- DbRetryInfo,
- spendCoins,
- TaskIdentifiers,
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- TransitionResult,
-} from "./common.js";
-import {
- calculateRefreshOutput,
- createRefreshGroup,
- getTotalRefreshCost,
-} from "./refresh.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
-} from "./transactions.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("pay-merchant.ts");
-
-export class PayMerchantTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public proposalId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
- }
-
- /**
- * Transition a payment transition.
- */
- async transition(
- f: (rec: PurchaseRecord) => Promise<TransitionResult>,
- ): Promise<void> {
- return this.transitionExtra(
- {
- extraStores: [],
- },
- f,
- );
- }
-
- /**
- * Transition a payment transition.
- * Extra object stores may be accessed during the transition.
- */
- async transitionExtra<
- StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
- >(
- opts: { extraStores: StoreNameArray },
- f: (
- rec: PurchaseRecord,
- tx: DbReadWriteTransaction<
- typeof WalletStoresV1,
- ["purchases", ...StoreNameArray]
- >,
- ) => Promise<TransitionResult>,
- ): Promise<void> {
- const ws = this.ws;
- const extraStores = opts.extraStores ?? [];
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases", ...extraStores],
- async (tx) => {
- const purchaseRec = await tx.purchases.get(this.proposalId);
- if (!purchaseRec) {
- throw Error("purchase not found anymore");
- }
- const oldTxState = computePayMerchantTransactionState(purchaseRec);
- const res = await f(purchaseRec, tx);
- switch (res) {
- case TransitionResult.Transition: {
- await tx.purchases.put(purchaseRec);
- const newTxState = computePayMerchantTransactionState(purchaseRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- notifyTransition(ws, this.transactionId, transitionInfo);
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, proposalId } = this;
- await ws.db.runReadWriteTx(["purchases", "tombstones"], async (tx) => {
- let found = false;
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- found = true;
- await tx.purchases.delete(proposalId);
- }
- if (found) {
- await tx.tombstones.put({
- id: TombstoneTag.DeletePayment + ":" + proposalId,
- });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, proposalId, transactionId } = this;
- ws.taskScheduler.stopShepherdTask(this.taskId);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newStatus = transitionSuspend[purchase.purchaseStatus];
- if (!newStatus) {
- return undefined;
- }
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, proposalId, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "purchases",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- "operationRetries",
- ],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- const oldStatus = purchase.purchaseStatus;
- if (purchase.timestampFirstSuccessfulPay) {
- // No point in aborting it. We don't even report an error.
- logger.warn(`tried to abort successful payment`);
- return;
- }
- switch (oldStatus) {
- case PurchaseStatus.Done:
- return;
- case PurchaseStatus.PendingPaying:
- case PurchaseStatus.SuspendedPaying: {
- purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
- if (purchase.payInfo) {
- const coinSel = purchase.payInfo.payCoinSelection;
- const currency = Amounts.currencyOf(
- purchase.payInfo.totalPayCost,
- );
- const refreshCoins: CoinRefreshRequest[] = [];
- for (let i = 0; i < coinSel.coinPubs.length; i++) {
- refreshCoins.push({
- amount: coinSel.coinContributions[i],
- coinPub: coinSel.coinPubs[i],
- });
- }
- await createRefreshGroup(
- ws,
- tx,
- currency,
- refreshCoins,
- RefreshReason.AbortPay,
- this.transactionId,
- );
- }
- break;
- }
- case PurchaseStatus.DialogProposed:
- purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
- break;
- }
- await tx.purchases.put(purchase);
- await tx.operationRetries.delete(this.taskId);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- ws.taskScheduler.stopShepherdTask(this.taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, proposalId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newStatus = transitionResume[purchase.purchaseStatus];
- if (!newStatus) {
- return undefined;
- }
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, proposalId, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "purchases",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- "operationRetries",
- ],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- let newState: PurchaseStatus | undefined = undefined;
- switch (purchase.purchaseStatus) {
- case PurchaseStatus.AbortingWithRefund:
- newState = PurchaseStatus.FailedAbort;
- break;
- }
- if (newState) {
- purchase.purchaseStatus = newState;
- await tx.purchases.put(purchase);
- }
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.stopShepherdTask(this.taskId);
- }
-}
-
-export class RefundTransactionContext implements TransactionContext {
- public transactionId: string;
- constructor(
- public ws: InternalWalletState,
- public refundGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refund,
- refundGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, refundGroupId, transactionId } = this;
- await ws.db.runReadWriteTx(["refundGroups", "tombstones"], async (tx) => {
- const refundRecord = await tx.refundGroups.get(refundGroupId);
- if (!refundRecord) {
- return;
- }
- await tx.refundGroups.delete(refundGroupId);
- await tx.tombstones.put({ id: transactionId });
- // FIXME: Also tombstone the refund items, so that they won't reappear.
- });
- }
-
- suspendTransaction(): Promise<void> {
- throw new Error("Unsupported operation");
- }
-
- abortTransaction(): Promise<void> {
- throw new Error("Unsupported operation");
- }
-
- resumeTransaction(): Promise<void> {
- throw new Error("Unsupported operation");
- }
-
- failTransaction(): Promise<void> {
- throw new Error("Unsupported operation");
- }
-}
-
-/**
- * Compute the total cost of a payment to the customer.
- *
- * This includes the amount taken by the merchant, fees (wire/deposit) contributed
- * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
- * of coins that are too small to spend.
- */
-export async function getTotalPaymentCost(
- ws: InternalWalletState,
- pcs: PayCoinSelection,
-): Promise<AmountJson> {
- const currency = Amounts.currencyOf(pcs.paymentAmount);
- return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
- );
- }
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denom.value,
- pcs.coinContributions[i],
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- ws.config.testing.denomselAllowLate,
- );
- costs.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
- costs.push(refreshCost);
- }
- const zero = Amounts.zeroOfAmount(pcs.paymentAmount);
- return Amounts.sum([zero, ...costs]).amount;
- });
-}
-
-async function failProposalPermanently(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetail,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- // FIXME: We don't store the error detail here?!
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-function getProposalRequestTimeout(retryInfo?: DbRetryInfo): Duration {
- return Duration.clamp({
- lower: Duration.fromSpec({ seconds: 1 }),
- upper: Duration.fromSpec({ seconds: 60 }),
- value: retryInfo
- ? DbRetryInfo.getDuration(retryInfo)
- : Duration.fromSpec({}),
- });
-}
-
-function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
- return Duration.multiply(
- { d_ms: 15000 },
- 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
- );
-}
-
-/**
- * Return the proposal download data for a purchase, throw if not available.
- */
-export async function expectProposalDownload(
- ws: InternalWalletState,
- p: PurchaseRecord,
- parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>,
-): Promise<{
- contractData: WalletContractData;
- contractTermsRaw: any;
-}> {
- if (!p.download) {
- throw Error("expected proposal to be downloaded");
- }
- const download = p.download;
-
- async function getFromTransaction(
- tx: Exclude<typeof parentTx, undefined>,
- ): Promise<ReturnType<typeof expectProposalDownload>> {
- const contractTerms = await tx.contractTerms.get(
- download.contractTermsHash,
- );
- if (!contractTerms) {
- throw Error("contract terms not found");
- }
- return {
- contractData: extractContractData(
- contractTerms.contractTermsRaw,
- download.contractTermsHash,
- download.contractTermsMerchantSig,
- ),
- contractTermsRaw: contractTerms.contractTermsRaw,
- };
- }
-
- if (parentTx) {
- return getFromTransaction(parentTx);
- }
- return await ws.db.runReadOnlyTx(["contractTerms"], getFromTransaction);
-}
-
-export function extractContractData(
- parsedContractTerms: MerchantContractTerms,
- contractTermsHash: string,
- merchantSig: string,
-): WalletContractData {
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.zeroOfCurrency(amount.currency);
- }
- return {
- amount: Amounts.stringify(amount),
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee: Amounts.stringify(maxWireFee),
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- summaryI18n: parsedContractTerms.summary_i18n,
- minimumAge: parsedContractTerms.minimum_age,
- };
-}
-
-async function processDownloadProposal(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<TaskRunResult> {
- const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return await tx.purchases.get(proposalId);
- });
-
- if (!proposal) {
- return TaskRunResult.finished();
- }
-
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
-
- if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
- logger.error(
- `unexpected state ${proposal.purchaseStatus}/${
- PurchaseStatus[proposal.purchaseStatus]
- } for ${ctx.transactionId} in processDownloadProposal`,
- );
- return TaskRunResult.finished();
- }
-
- const transactionId = ctx.transactionId;
-
- const orderClaimUrl = new URL(
- `orders/${proposal.orderId}/claim`,
- proposal.merchantBaseUrl,
- ).href;
- logger.trace("downloading contract from '" + orderClaimUrl + "'");
-
- const requestBody: {
- nonce: string;
- token?: string;
- } = {
- nonce: proposal.noncePub,
- };
- if (proposal.claimToken) {
- requestBody.token = proposal.claimToken;
- }
-
- const opId = TaskIdentifiers.forPay(proposal);
- const retryRecord = await ws.db.runReadOnlyTx(
- ["operationRetries"],
- async (tx) => {
- return tx.operationRetries.get(opId);
- },
- );
-
- const httpResponse = await ws.http.fetch(orderClaimUrl, {
- method: "POST",
- body: requestBody,
- timeout: getProposalRequestTimeout(retryRecord?.retryInfo),
- });
- const r = await readSuccessResponseJsonOrErrorCode(
- httpResponse,
- codecForProposal(),
- );
- if (r.isError) {
- switch (r.talerErrorResponse.code) {
- case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
- {
- orderId: proposal.orderId,
- claimUrl: orderClaimUrl,
- },
- "order already claimed (likely by other wallet)",
- );
- default:
- throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
- }
- }
- const proposalResp = r.response;
-
- // The proposalResp contains the contract terms as raw JSON,
- // as the code to parse them doesn't necessarily round-trip.
- // We need this raw JSON to compute the contract terms hash.
-
- // FIXME: Do better error handling, check if the
- // contract terms have all their forgettable information still
- // present. The wallet should never accept contract terms
- // with missing information from the merchant.
-
- const isWellFormed = ContractTermsUtil.validateForgettable(
- proposalResp.contract_terms,
- );
-
- if (!isWellFormed) {
- logger.trace(
- `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
- );
- const err = makeErrorDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
- {},
- "validation for well-formedness failed",
- );
- await failProposalPermanently(ws, proposalId, err);
- throw makePendingOperationFailedError(
- err,
- TransactionType.Payment,
- proposalId,
- );
- }
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(
- proposalResp.contract_terms,
- );
-
- logger.info(`Contract terms hash: ${contractTermsHash}`);
-
- let parsedContractTerms: MerchantContractTerms;
-
- try {
- parsedContractTerms = codecForMerchantContractTerms().decode(
- proposalResp.contract_terms,
- );
- } catch (e) {
- const err = makeErrorDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
- {},
- `schema validation failed: ${e}`,
- );
- await failProposalPermanently(ws, proposalId, err);
- throw makePendingOperationFailedError(
- err,
- TransactionType.Payment,
- proposalId,
- );
- }
-
- const sigValid = await ws.cryptoApi.isValidContractTermsSignature({
- contractTermsHash,
- merchantPub: parsedContractTerms.merchant_pub,
- sig: proposalResp.sig,
- });
-
- if (!sigValid) {
- const err = makeErrorDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
- {
- merchantPub: parsedContractTerms.merchant_pub,
- orderId: parsedContractTerms.order_id,
- },
- "merchant's signature on contract terms is invalid",
- );
- await failProposalPermanently(ws, proposalId, err);
- throw makePendingOperationFailedError(
- err,
- TransactionType.Payment,
- proposalId,
- );
- }
-
- const fulfillmentUrl = parsedContractTerms.fulfillment_url;
-
- const baseUrlForDownload = proposal.merchantBaseUrl;
- const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
-
- if (baseUrlForDownload !== baseUrlFromContractTerms) {
- const err = makeErrorDetail(
- TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
- {
- baseUrlForDownload,
- baseUrlFromContractTerms,
- },
- "merchant base URL mismatch",
- );
- await failProposalPermanently(ws, proposalId, err);
- throw makePendingOperationFailedError(
- err,
- TransactionType.Payment,
- proposalId,
- );
- }
-
- const contractData = extractContractData(
- parsedContractTerms,
- contractTermsHash,
- proposalResp.sig,
- );
-
- logger.trace(`extracted contract data: ${j2s(contractData)}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases", "contractTerms"],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.download = {
- contractTermsHash,
- contractTermsMerchantSig: contractData.merchantSig,
- currency: Amounts.currencyOf(contractData.amount),
- fulfillmentUrl: contractData.fulfillmentUrl,
- };
- await tx.contractTerms.put({
- h: contractTermsHash,
- contractTermsRaw: proposalResp.contract_terms,
- });
- const isResourceFulfillmentUrl =
- fulfillmentUrl &&
- (fulfillmentUrl.startsWith("http://") ||
- fulfillmentUrl.startsWith("https://"));
- let otherPurchase: PurchaseRecord | undefined;
- if (isResourceFulfillmentUrl) {
- otherPurchase =
- await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
- }
- // FIXME: Adjust this to account for refunds, don't count as repurchase
- // if original order is refunded.
- if (otherPurchase && otherPurchase.refundAmountAwaiting === undefined) {
- logger.warn("repurchase detected");
- p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
- p.repurchaseProposalId = otherPurchase.proposalId;
- await tx.purchases.put(p);
- } else {
- p.purchaseStatus = p.shared
- ? PurchaseStatus.DialogShared
- : PurchaseStatus.DialogProposed;
- await tx.purchases.put(p);
- }
- const newTxState = computePayMerchantTransactionState(p);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- return TaskRunResult.progress();
-}
-
-/**
- * Create a new purchase transaction if necessary. If a purchase
- * record for the provided arguments already exists,
- * return the old proposal ID.
- */
-async function createOrReusePurchase(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- orderId: string,
- sessionId: string | undefined,
- claimToken: string | undefined,
- noncePriv: string | undefined,
-): Promise<string> {
- const oldProposals = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.getAll([
- merchantBaseUrl,
- orderId,
- ]);
- });
-
- const oldProposal = oldProposals.find((p) => {
- return (
- p.downloadSessionId === sessionId &&
- (!noncePriv || p.noncePriv === noncePriv) &&
- p.claimToken === claimToken
- );
- });
- // If we have already claimed this proposal with the same sessionId
- // nonce and claim token, reuse it. */
- if (
- oldProposal &&
- oldProposal.downloadSessionId === sessionId &&
- (!noncePriv || oldProposal.noncePriv === noncePriv) &&
- oldProposal.claimToken === claimToken
- ) {
- logger.info(
- `Found old proposal (status=${
- PurchaseStatus[oldProposal.purchaseStatus]
- }) for order ${orderId} at ${merchantBaseUrl}`,
- );
- if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) {
- const download = await expectProposalDownload(ws, oldProposal);
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
- logger.info(`old proposal paid: ${paid}`);
- if (paid) {
- // if this transaction was shared and the order is paid then it
- // means that another wallet already paid the proposal
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(oldProposal.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: oldProposal.proposalId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- }
- }
- return oldProposal.proposalId;
- }
-
- let noncePair: EddsaKeypair;
- let shared = false;
- if (noncePriv) {
- shared = true;
- noncePair = {
- priv: noncePriv,
- pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
- };
- } else {
- noncePair = await ws.cryptoApi.createEddsaKeypair({});
- }
-
- const { priv, pub } = noncePair;
- const proposalId = encodeCrock(getRandomBytes(32));
-
- const proposalRecord: PurchaseRecord = {
- download: undefined,
- noncePriv: priv,
- noncePub: pub,
- claimToken,
- timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- merchantBaseUrl,
- orderId,
- proposalId: proposalId,
- purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
- repurchaseProposalId: undefined,
- downloadSessionId: sessionId,
- autoRefundDeadline: undefined,
- lastSessionId: undefined,
- merchantPaySig: undefined,
- payInfo: undefined,
- refundAmountAwaiting: undefined,
- timestampAccept: undefined,
- timestampFirstSuccessfulPay: undefined,
- timestampLastRefundStatus: undefined,
- pendingRemovedCoinPubs: undefined,
- posConfirmation: undefined,
- shared: shared,
- };
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- await tx.purchases.put(proposalRecord);
- const oldTxState: TransactionState = {
- major: TransactionMajorState.None,
- };
- const newTxState = computePayMerchantTransactionState(proposalRecord);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return proposalId;
-}
-
-async function storeFirstPaySuccess(
- ws: InternalWalletState,
- proposalId: string,
- sessionId: string | undefined,
- payResponse: MerchantPayResponse,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const transitionInfo = await ws.db.runReadWriteTx(
- ["contractTerms", "purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
-
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (!isFirst) {
- logger.warn("payment success already stored");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) {
- purchase.purchaseStatus = PurchaseStatus.Done;
- }
- purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(now);
- purchase.lastSessionId = sessionId;
- purchase.merchantPaySig = payResponse.sig;
- purchase.posConfirmation = payResponse.pos_confirmation;
- const dl = purchase.download;
- checkDbInvariant(!!dl);
- const contractTermsRecord = await tx.contractTerms.get(
- dl.contractTermsHash,
- );
- checkDbInvariant(!!contractTermsRecord);
- const contractData = extractContractData(
- contractTermsRecord.contractTermsRaw,
- dl.contractTermsHash,
- dl.contractTermsMerchantSig,
- );
- const protoAr = contractData.autoRefund;
- if (protoAr) {
- const ar = Duration.fromTalerProtocolDuration(protoAr);
- logger.info("auto_refund present");
- purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
- purchase.autoRefundDeadline = timestampProtocolToDb(
- AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
- ),
- );
- }
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function storePayReplaySuccess(
- ws: InternalWalletState,
- proposalId: string,
- sessionId: string | undefined,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
-
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (isFirst) {
- throw Error("invalid payment state");
- }
- const oldTxState = computePayMerchantTransactionState(purchase);
- if (
- purchase.purchaseStatus === PurchaseStatus.PendingPaying ||
- purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay
- ) {
- purchase.purchaseStatus = PurchaseStatus.Done;
- }
- purchase.lastSessionId = sessionId;
- await tx.purchases.put(purchase);
- const newTxState = computePayMerchantTransactionState(purchase);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-/**
- * Handle a 409 Conflict response from the merchant.
- *
- * We do this by going through the coin history provided by the exchange and
- * (1) verifying the signatures from the exchange
- * (2) adjusting the remaining coin value and refreshing it
- * (3) re-do coin selection with the bad coin removed
- */
-async function handleInsufficientFunds(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetail,
-): Promise<void> {
- logger.trace("handling insufficient funds, trying to re-select coins");
-
- const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!proposal) {
- return;
- }
-
- logger.trace(`got error details: ${j2s(err)}`);
-
- const exchangeReply = (err as any).exchange_reply;
- if (
- exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
- ) {
- // FIXME: set as failed
- if (logger.shouldLogTrace()) {
- logger.trace("got exchange error reply (see below)");
- logger.trace(j2s(exchangeReply));
- }
- throw Error(`unable to handle /pay error response (${exchangeReply.code})`);
- }
-
- const brokenCoinPub = (exchangeReply as any).coin_pub;
- logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
-
- if (!brokenCoinPub) {
- throw new TalerProtocolViolationError();
- }
-
- const { contractData } = await expectProposalDownload(ws, proposal);
-
- const prevPayCoins: PreviousPayCoins = [];
-
- const payInfo = proposal.payInfo;
- if (!payInfo) {
- return;
- }
-
- const payCoinSelection = payInfo.payCoinSelection;
-
- await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- if (coinPub === brokenCoinPub) {
- continue;
- }
- const contrib = payCoinSelection.coinContributions[i];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- continue;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- continue;
- }
- prevPayCoins.push({
- coinPub,
- contribution: Amounts.parseOrThrow(contrib),
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
- });
- }
- });
-
- const res = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins,
- requiredMinimumAge: contractData.minimumAge,
- });
-
- if (res.type !== "success") {
- logger.trace("insufficient funds for coin re-selection");
- return;
- }
-
- logger.trace("re-selected coins");
-
- await ws.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- ],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- const payInfo = p.payInfo;
- if (!payInfo) {
- return;
- }
- payInfo.payCoinSelection = res.coinSel;
- payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
- await tx.purchases.put(p);
- await spendCoins(ws, tx, {
- // allocationId: `txn:proposal:${p.proposalId}`,
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: proposalId,
- }),
- coinPubs: payInfo.payCoinSelection.coinPubs,
- contributions: payInfo.payCoinSelection.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
- ),
- refreshReason: RefreshReason.PayMerchant,
- });
- },
- );
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- }),
- });
-}
-
-// FIXME: Should take a transaction ID instead of a proposal ID
-// FIXME: Does way more than checking the payment
-// FIXME: Should return immediately.
-async function checkPaymentByProposalId(
- ws: InternalWalletState,
- proposalId: string,
- sessionId?: string,
-): Promise<PreparePayResult> {
- let proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!proposal) {
- throw Error(`could not get proposal ${proposalId}`);
- }
- if (proposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) {
- const existingProposalId = proposal.repurchaseProposalId;
- if (existingProposalId) {
- logger.trace("using existing purchase for same product");
- const oldProposal = await ws.db.runReadOnlyTx(
- ["purchases"],
- async (tx) => {
- return tx.purchases.get(existingProposalId);
- },
- );
- if (oldProposal) {
- proposal = oldProposal;
- }
- }
- }
- const d = await expectProposalDownload(ws, proposal);
- const contractData = d.contractData;
- const merchantSig = d.contractData.merchantSig;
- if (!merchantSig) {
- throw Error("BUG: proposal is in invalid state");
- }
-
- proposalId = proposal.proposalId;
-
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
-
- const transactionId = ctx.transactionId;
-
- const talerUri = stringifyTalerUri({
- type: TalerUriAction.Pay,
- merchantBaseUrl: proposal.merchantBaseUrl,
- orderId: proposal.orderId,
- sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
- claimToken: proposal.claimToken,
- });
-
- // First check if we already paid for it.
- const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (
- !purchase ||
- purchase.purchaseStatus === PurchaseStatus.DialogProposed ||
- purchase.purchaseStatus === PurchaseStatus.DialogShared
- ) {
- // If not already paid, check if we could pay for it.
- const res = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins: [],
- requiredMinimumAge: contractData.minimumAge,
- wireMethod: contractData.wireMethod,
- });
-
- if (res.type !== "success") {
- logger.info("not allowing payment, insufficient coins");
- logger.info(
- `insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`,
- );
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- transactionId,
- amountRaw: Amounts.stringify(d.contractData.amount),
- talerUri,
- balanceDetails: res.insufficientBalanceDetails,
- };
- }
-
- const totalCost = await getTotalPaymentCost(ws, res.coinSel);
- logger.trace("costInfo", totalCost);
- logger.trace("coinsForPayment", res);
-
- return {
- status: PreparePayResultType.PaymentPossible,
- contractTerms: d.contractTermsRaw,
- transactionId,
- proposalId: proposal.proposalId,
- amountEffective: Amounts.stringify(totalCost),
- amountRaw: Amounts.stringify(res.coinSel.paymentAmount),
- contractTermsHash: d.contractData.contractTermsHash,
- talerUri,
- };
- }
-
- if (
- purchase.purchaseStatus === PurchaseStatus.Done &&
- purchase.lastSessionId !== sessionId
- ) {
- logger.trace(
- "automatically re-submitting payment with different session ID",
- );
- logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.lastSessionId = sessionId;
- p.purchaseStatus = PurchaseStatus.PendingPayingReplay;
- await tx.purchases.put(p);
- const newTxState = computePayMerchantTransactionState(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- // FIXME: Consider changing the API here so that we don't have to
- // wait inline for the repurchase.
-
- await waitPaymentResult(ws, proposalId, sessionId);
- const download = await expectProposalDownload(ws, purchase);
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: download.contractTermsRaw,
- contractTermsHash: download.contractData.contractTermsHash,
- paid: true,
- amountRaw: Amounts.stringify(download.contractData.amount),
- amountEffective: purchase.payInfo
- ? Amounts.stringify(purchase.payInfo.totalPayCost)
- : undefined,
- transactionId,
- proposalId,
- talerUri,
- };
- } else if (!purchase.timestampFirstSuccessfulPay) {
- const download = await expectProposalDownload(ws, purchase);
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: download.contractTermsRaw,
- contractTermsHash: download.contractData.contractTermsHash,
- paid: false,
- amountRaw: Amounts.stringify(download.contractData.amount),
- amountEffective: purchase.payInfo
- ? Amounts.stringify(purchase.payInfo.totalPayCost)
- : undefined,
- transactionId,
- proposalId,
- talerUri,
- };
- } else {
- const paid =
- purchase.purchaseStatus === PurchaseStatus.Done ||
- purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
- purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
- const download = await expectProposalDownload(ws, purchase);
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: download.contractTermsRaw,
- contractTermsHash: download.contractData.contractTermsHash,
- paid,
- amountRaw: Amounts.stringify(download.contractData.amount),
- amountEffective: purchase.payInfo
- ? Amounts.stringify(purchase.payInfo.totalPayCost)
- : undefined,
- ...(paid ? { nextUrl: download.contractData.orderId } : {}),
- transactionId,
- proposalId,
- talerUri,
- };
- }
-}
-
-export async function getContractTermsDetails(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<WalletContractData> {
- const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!proposal) {
- throw Error(`proposal with id ${proposalId} not found`);
- }
-
- const d = await expectProposalDownload(ws, proposal);
-
- return d.contractData;
-}
-
-/**
- * Check if a payment for the given taler://pay/ URI is possible.
- *
- * If the payment is possible, the signature are already generated but not
- * yet send to the merchant.
- */
-export async function preparePayForUri(
- ws: InternalWalletState,
- talerPayUri: string,
-): Promise<PreparePayResult> {
- const uriResult = parsePayUri(talerPayUri);
-
- if (!uriResult) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
- {
- talerPayUri,
- },
- `invalid taler://pay URI (${talerPayUri})`,
- );
- }
-
- const proposalId = await createOrReusePurchase(
- ws,
- uriResult.merchantBaseUrl,
- uriResult.orderId,
- uriResult.sessionId,
- uriResult.claimToken,
- uriResult.noncePriv,
- );
-
- await waitProposalDownloaded(ws, proposalId);
-
- return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
-}
-
-/**
- * Wait until a proposal is at least downloaded.
- */
-async function waitProposalDownloaded(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
-
- logger.info(`waiting for ${ctx.transactionId} to be downloaded`);
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- // FIXME: We should use Symbol.dispose magic here for cleanup!
-
- const payNotifFlag = new AsyncFlag();
- // Raise exchangeNotifFlag whenever we get a notification
- // about our exchange.
- const cancelNotif = ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.TransactionStateTransition &&
- notif.transactionId === ctx.transactionId
- ) {
- logger.info(`raising update notification: ${j2s(notif)}`);
- payNotifFlag.raise();
- }
- });
-
- try {
- await internalWaitProposalDownloaded(ctx, payNotifFlag);
- logger.info(`done waiting for ${ctx.transactionId} to be downloaded`);
- } finally {
- cancelNotif();
- }
-}
-
-async function internalWaitProposalDownloaded(
- ctx: PayMerchantTransactionContext,
- payNotifFlag: AsyncFlag,
-): Promise<void> {
- while (true) {
- const { purchase, retryInfo } = await ctx.ws.db.runReadOnlyTx(
- ["purchases", "operationRetries"],
- async (tx) => {
- return {
- purchase: await tx.purchases.get(ctx.proposalId),
- retryInfo: await tx.operationRetries.get(ctx.taskId),
- };
- },
- );
- if (!purchase) {
- throw Error("purchase does not exist anymore");
- }
- if (purchase.download) {
- return;
- }
- if (retryInfo) {
- if (retryInfo.lastError) {
- throw TalerError.fromUncheckedDetail(retryInfo.lastError);
- } else {
- throw Error("transient error while waiting for proposal download");
- }
- }
- await payNotifFlag.wait();
- payNotifFlag.reset();
- }
-}
-
-export async function preparePayForTemplate(
- ws: InternalWalletState,
- req: PreparePayTemplateRequest,
-): Promise<PreparePayResult> {
- const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
- const templateDetails: MerchantUsingTemplateDetails = {};
- if (!parsedUri) {
- throw Error("invalid taler-template URI");
- }
- logger.trace(`parsed URI: ${j2s(parsedUri)}`);
-
- const amountFromUri = parsedUri.templateParams.amount;
- if (amountFromUri != null) {
- const templateParamsAmount = req.templateParams?.amount;
- if (templateParamsAmount != null) {
- templateDetails.amount = templateParamsAmount as AmountString;
- } else {
- if (Amounts.isCurrency(amountFromUri)) {
- throw Error(
- "Amount from template URI only has a currency without value. The value must be provided in the templateParams.",
- );
- } else {
- templateDetails.amount = amountFromUri as AmountString;
- }
- }
- }
- if (
- parsedUri.templateParams.summary !== undefined &&
- typeof parsedUri.templateParams.summary === "string"
- ) {
- templateDetails.summary =
- req.templateParams?.summary ?? parsedUri.templateParams.summary;
- }
- const reqUrl = new URL(
- `templates/${parsedUri.templateId}`,
- parsedUri.merchantBaseUrl,
- );
- const httpReq = await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: templateDetails,
- });
- const resp = await readSuccessResponseJsonOrThrow(
- httpReq,
- codecForMerchantPostOrderResponse(),
- );
-
- const payUri = stringifyPayUri({
- merchantBaseUrl: parsedUri.merchantBaseUrl,
- orderId: resp.order_id,
- sessionId: "",
- claimToken: resp.token,
- });
-
- return await preparePayForUri(ws, payUri);
-}
-
-/**
- * Generate deposit permissions for a purchase.
- *
- * Accesses the database and the crypto worker.
- */
-export async function generateDepositPermissions(
- ws: InternalWalletState,
- payCoinSel: PayCoinSelection,
- contractData: WalletContractData,
-): Promise<CoinDepositPermission[]> {
- const depositPermissions: CoinDepositPermission[] = [];
- const coinWithDenom: Array<{
- coin: CoinRecord;
- denom: DenominationRecord;
- }> = [];
- await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
- const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
- if (!coin) {
- throw Error("can't pay, allocated coin not found anymore");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't pay, denomination of allocated coin not found anymore",
- );
- }
- coinWithDenom.push({ coin, denom });
- }
- });
-
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
- const { coin, denom } = coinWithDenom[i];
- let wireInfoHash: string;
- wireInfoHash = contractData.wireInfoHash;
- logger.trace(
- `signing deposit permission for coin with ageRestriction=${j2s(
- coin.ageCommitmentProof,
- )}`,
- );
- const dp = await ws.cryptoApi.signDepositPermission({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contractTermsHash: contractData.contractTermsHash,
- denomPubHash: coin.denomPubHash,
- denomKeyType: denom.denomPub.cipher,
- denomSig: coin.denomSig,
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
- merchantPub: contractData.merchantPub,
- refundDeadline: contractData.refundDeadline,
- spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
- timestamp: contractData.timestamp,
- wireInfoHash,
- ageCommitmentProof: coin.ageCommitmentProof,
- requiredMinimumAge: contractData.minimumAge,
- });
- depositPermissions.push(dp);
- }
- return depositPermissions;
-}
-
-async function internalWaitPaymentResult(
- ctx: PayMerchantTransactionContext,
- purchaseNotifFlag: AsyncFlag,
- waitSessionId?: string,
-): Promise<ConfirmPayResult> {
- while (true) {
- const txRes = await ctx.ws.db.runReadOnlyTx(
- ["purchases", "operationRetries"],
- async (tx) => {
- const purchase = await tx.purchases.get(ctx.proposalId);
- const retryRecord = await tx.operationRetries.get(ctx.taskId);
- return { purchase, retryRecord };
- },
- );
-
- if (!txRes.purchase) {
- throw Error("purchase gone");
- }
-
- const purchase = txRes.purchase;
-
- logger.info(
- `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
- );
-
- const d = await expectProposalDownload(ctx.ws, purchase);
-
- if (txRes.purchase.timestampFirstSuccessfulPay) {
- if (
- waitSessionId == null ||
- txRes.purchase.lastSessionId === waitSessionId
- ) {
- return {
- type: ConfirmPayResultType.Done,
- contractTerms: d.contractTermsRaw,
- transactionId: ctx.transactionId,
- };
- }
- }
-
- if (txRes.retryRecord) {
- return {
- type: ConfirmPayResultType.Pending,
- lastError: txRes.retryRecord.lastError,
- transactionId: ctx.transactionId,
- };
- }
-
- if (txRes.purchase.purchaseStatus > PurchaseStatus.Done) {
- return {
- type: ConfirmPayResultType.Done,
- contractTerms: d.contractTermsRaw,
- transactionId: ctx.transactionId,
- };
- }
-
- await purchaseNotifFlag.wait();
- purchaseNotifFlag.reset();
- }
-}
-
-/**
- * Wait until either:
- * a) the payment succeeded (if provided under the {@param waitSessionId}), or
- * b) the attempt to pay failed (merchant unavailable, etc.)
- */
-async function waitPaymentResult(
- ws: InternalWalletState,
- proposalId: string,
- waitSessionId?: string,
-): Promise<ConfirmPayResult> {
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
-
- ws.ensureTaskLoopRunning();
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
- const purchaseNotifFlag = new AsyncFlag();
- // Raise purchaseNotifFlag whenever we get a notification
- // about our purchase.
- const cancelNotif = ws.addNotificationListener((notif) => {
- if (
- notif.type === NotificationType.TransactionStateTransition &&
- notif.transactionId === ctx.transactionId
- ) {
- purchaseNotifFlag.raise();
- }
- });
-
- try {
- logger.info(`waiting for first payment success on ${ctx.transactionId}`);
- const res = await internalWaitPaymentResult(
- ctx,
- purchaseNotifFlag,
- waitSessionId,
- );
- logger.info(
- `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`,
- );
- return res;
- } finally {
- cancelNotif();
- }
-}
-
-/**
- * Confirm payment for a proposal previously claimed by the wallet.
- */
-export async function confirmPay(
- ws: InternalWalletState,
- transactionId: string,
- sessionIdOverride?: string,
- forcedCoinSel?: ForcedCoinSel,
-): Promise<ConfirmPayResult> {
- const parsedTx = parseTransactionIdentifier(transactionId);
- if (parsedTx?.tag !== TransactionType.Payment) {
- throw Error("expected payment transaction ID");
- }
- const proposalId = parsedTx.proposalId;
- logger.trace(
- `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
- );
- const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!proposal) {
- throw Error(`proposal with id ${proposalId} not found`);
- }
-
- const d = await expectProposalDownload(ws, proposal);
- if (!d) {
- throw Error("proposal is in invalid state");
- }
-
- const existingPurchase = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (
- purchase &&
- sessionIdOverride !== undefined &&
- sessionIdOverride != purchase.lastSessionId
- ) {
- logger.trace(`changing session ID to ${sessionIdOverride}`);
- purchase.lastSessionId = sessionIdOverride;
- if (purchase.purchaseStatus === PurchaseStatus.Done) {
- purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
- }
- await tx.purchases.put(purchase);
- }
- return purchase;
- },
- );
-
- if (existingPurchase && existingPurchase.payInfo) {
- logger.trace("confirmPay: submitting payment for existing purchase");
- const ctx = new PayMerchantTransactionContext(
- ws,
- existingPurchase.proposalId,
- );
- await ws.taskScheduler.resetTaskRetries(ctx.taskId);
- return waitPaymentResult(ws, proposalId);
- }
-
- logger.trace("confirmPay: purchase record does not exist yet");
-
- const contractData = d.contractData;
-
- const selectCoinsResult = await selectPayCoinsNew(ws, {
- auditors: [],
- exchanges: contractData.allowedExchanges,
- wireMethod: contractData.wireMethod,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
- prevPayCoins: [],
- requiredMinimumAge: contractData.minimumAge,
- forcedSelection: forcedCoinSel,
- });
-
- logger.trace("coin selection result", selectCoinsResult);
-
- if (selectCoinsResult.type === "failure") {
- // Should not happen, since checkPay should be called first
- // FIXME: Actually, this should be handled gracefully,
- // and the status should be stored in the DB.
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
- }
-
- const coinSelection = selectCoinsResult.coinSel;
- const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
-
- let sessionId: string | undefined;
- if (sessionIdOverride) {
- sessionId = sessionIdOverride;
- } else {
- sessionId = proposal.downloadSessionId;
- }
-
- logger.trace(
- `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
- );
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- ],
- async (tx) => {
- const p = await tx.purchases.get(proposal.proposalId);
- if (!p) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- switch (p.purchaseStatus) {
- case PurchaseStatus.DialogShared:
- case PurchaseStatus.DialogProposed:
- p.payInfo = {
- payCoinSelection: coinSelection,
- payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
- totalPayCost: Amounts.stringify(payCostInfo),
- };
- p.lastSessionId = sessionId;
- p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
- p.purchaseStatus = PurchaseStatus.PendingPaying;
- await tx.purchases.put(p);
- await spendCoins(ws, tx, {
- //`txn:proposal:${p.proposalId}`
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: proposalId,
- }),
- coinPubs: coinSelection.coinPubs,
- contributions: coinSelection.coinContributions.map((x) =>
- Amounts.parseOrThrow(x),
- ),
- refreshReason: RefreshReason.PayMerchant,
- });
- break;
- case PurchaseStatus.Done:
- case PurchaseStatus.PendingPaying:
- default:
- break;
- }
- const newTxState = computePayMerchantTransactionState(p);
- return { oldTxState, newTxState };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- // Wait until we have completed the first attempt to pay.
- return waitPaymentResult(ws, proposalId);
-}
-
-export async function processPurchase(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<TaskRunResult> {
- const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: {
- // FIXME: allocate more specific error code
- code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- when: AbsoluteTime.now(),
- hint: `trying to pay for purchase that is not in the database`,
- proposalId: proposalId,
- },
- };
- }
-
- switch (purchase.purchaseStatus) {
- case PurchaseStatus.PendingDownloadingProposal:
- return processDownloadProposal(ws, proposalId);
- case PurchaseStatus.PendingPaying:
- case PurchaseStatus.PendingPayingReplay:
- return processPurchasePay(ws, proposalId);
- case PurchaseStatus.PendingQueryingRefund:
- return processPurchaseQueryRefund(ws, purchase);
- case PurchaseStatus.PendingQueryingAutoRefund:
- return processPurchaseAutoRefund(ws, purchase);
- case PurchaseStatus.AbortingWithRefund:
- return processPurchaseAbortingRefund(ws, purchase);
- case PurchaseStatus.PendingAcceptRefund:
- return processPurchaseAcceptRefund(ws, purchase);
- case PurchaseStatus.DialogShared:
- return processPurchaseDialogShared(ws, purchase);
- case PurchaseStatus.FailedClaim:
- case PurchaseStatus.Done:
- case PurchaseStatus.DoneRepurchaseDetected:
- case PurchaseStatus.DialogProposed:
- case PurchaseStatus.AbortedProposalRefused:
- case PurchaseStatus.AbortedIncompletePayment:
- case PurchaseStatus.AbortedOrderDeleted:
- case PurchaseStatus.AbortedRefunded:
- case PurchaseStatus.SuspendedAbortingWithRefund:
- case PurchaseStatus.SuspendedDownloadingProposal:
- case PurchaseStatus.SuspendedPaying:
- case PurchaseStatus.SuspendedPayingReplay:
- case PurchaseStatus.SuspendedPendingAcceptRefund:
- case PurchaseStatus.SuspendedQueryingAutoRefund:
- case PurchaseStatus.SuspendedQueryingRefund:
- case PurchaseStatus.FailedAbort:
- return TaskRunResult.finished();
- default:
- assertUnreachable(purchase.purchaseStatus);
- // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`);
- }
-}
-
-async function processPurchasePay(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<TaskRunResult> {
- const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: {
- // FIXME: allocate more specific error code
- code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- when: AbsoluteTime.now(),
- hint: `trying to pay for purchase that is not in the database`,
- proposalId: proposalId,
- },
- };
- }
- switch (purchase.purchaseStatus) {
- case PurchaseStatus.PendingPaying:
- case PurchaseStatus.PendingPayingReplay:
- break;
- default:
- return TaskRunResult.finished();
- }
- logger.trace(`processing purchase pay ${proposalId}`);
-
- const sessionId = purchase.lastSessionId;
-
- logger.trace(`paying with session ID ${sessionId}`);
- const payInfo = purchase.payInfo;
- checkDbInvariant(!!payInfo, "payInfo");
-
- const download = await expectProposalDownload(ws, purchase);
-
- if (purchase.shared) {
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
-
- if (paid) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, {
- orderId: purchase.orderId,
- fulfillmentUrl: download.contractData.fulfillmentUrl,
- }),
- };
- }
- }
-
- if (!purchase.merchantPaySig) {
- const payUrl = new URL(
- `orders/${download.contractData.orderId}/pay`,
- download.contractData.merchantBaseUrl,
- ).href;
-
- let depositPermissions: CoinDepositPermission[];
- // FIXME: Cache!
- depositPermissions = await generateDepositPermissions(
- ws,
- payInfo.payCoinSelection,
- download.contractData,
- );
-
- const reqBody = {
- coins: depositPermissions,
- session_id: purchase.lastSessionId,
- };
-
- logger.trace(
- "making pay request ... ",
- JSON.stringify(reqBody, undefined, 2),
- );
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.fetch(payUrl, {
- method: "POST",
- body: reqBody,
- timeout: getPayRequestTimeout(purchase),
- }),
- );
-
- logger.trace(`got resp ${JSON.stringify(resp)}`);
-
- if (resp.status >= 500 && resp.status <= 599) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
- {
- requestError: errDetails,
- },
- ),
- };
- }
-
- if (resp.status === HttpStatusCode.Conflict) {
- const err = await readTalerErrorResponse(resp);
- if (
- err.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
- ) {
- // Do this in the background, as it might take some time
- handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
- logger.error("handling insufficient funds failed");
- logger.error(`${e.toString()}`);
- });
-
- // FIXME: Should we really consider this to be pending?
-
- return TaskRunResult.backoff();
- }
- }
-
- if (resp.status >= 400 && resp.status <= 499) {
- logger.trace("got generic 4xx from merchant");
- const err = await readTalerErrorResponse(resp);
- throwUnexpectedRequestError(resp, err);
- }
-
- const merchantResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantPayResponse(),
- );
-
- logger.trace("got success from pay URL", merchantResp);
-
- const merchantPub = download.contractData.merchantPub;
- const { valid } = await ws.cryptoApi.isValidPaymentSignature({
- contractHash: download.contractData.contractTermsHash,
- merchantPub,
- sig: merchantResp.sig,
- });
-
- if (!valid) {
- logger.error("merchant payment signature invalid");
- // FIXME: properly display error
- throw Error("merchant payment signature invalid");
- }
-
- await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp);
- } else {
- const payAgainUrl = new URL(
- `orders/${download.contractData.orderId}/paid`,
- download.contractData.merchantBaseUrl,
- ).href;
- const reqBody = {
- sig: purchase.merchantPaySig,
- h_contract: download.contractData.contractTermsHash,
- session_id: sessionId ?? "",
- };
- logger.trace(`/paid request body: ${j2s(reqBody)}`);
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.fetch(payAgainUrl, { method: "POST", body: reqBody }),
- );
- logger.trace(`/paid response status: ${resp.status}`);
- if (
- resp.status !== HttpStatusCode.NoContent &&
- resp.status != HttpStatusCode.Ok
- ) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- getHttpResponseErrorDetails(resp),
- "/paid failed",
- );
- }
- await storePayReplaySuccess(ws, proposalId, sessionId);
- }
-
- return TaskRunResult.progress();
-}
-
-export async function refuseProposal(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const proposal = await tx.purchases.get(proposalId);
- if (!proposal) {
- logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
- return undefined;
- }
- if (
- proposal.purchaseStatus !== PurchaseStatus.DialogProposed &&
- proposal.purchaseStatus !== PurchaseStatus.DialogShared
- ) {
- return undefined;
- }
- const oldTxState = computePayMerchantTransactionState(proposal);
- proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
- const newTxState = computePayMerchantTransactionState(proposal);
- await tx.purchases.put(proposal);
- return { oldTxState, newTxState };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-const transitionSuspend: {
- [x in PurchaseStatus]?: {
- next: PurchaseStatus | undefined;
- };
-} = {
- [PurchaseStatus.PendingDownloadingProposal]: {
- next: PurchaseStatus.SuspendedDownloadingProposal,
- },
- [PurchaseStatus.AbortingWithRefund]: {
- next: PurchaseStatus.SuspendedAbortingWithRefund,
- },
- [PurchaseStatus.PendingPaying]: {
- next: PurchaseStatus.SuspendedPaying,
- },
- [PurchaseStatus.PendingPayingReplay]: {
- next: PurchaseStatus.SuspendedPayingReplay,
- },
- [PurchaseStatus.PendingQueryingAutoRefund]: {
- next: PurchaseStatus.SuspendedQueryingAutoRefund,
- },
-};
-
-const transitionResume: {
- [x in PurchaseStatus]?: {
- next: PurchaseStatus | undefined;
- };
-} = {
- [PurchaseStatus.SuspendedDownloadingProposal]: {
- next: PurchaseStatus.PendingDownloadingProposal,
- },
- [PurchaseStatus.SuspendedAbortingWithRefund]: {
- next: PurchaseStatus.AbortingWithRefund,
- },
- [PurchaseStatus.SuspendedPaying]: {
- next: PurchaseStatus.PendingPaying,
- },
- [PurchaseStatus.SuspendedPayingReplay]: {
- next: PurchaseStatus.PendingPayingReplay,
- },
- [PurchaseStatus.SuspendedQueryingAutoRefund]: {
- next: PurchaseStatus.PendingQueryingAutoRefund,
- },
-};
-
-export function computePayMerchantTransactionState(
- purchaseRecord: PurchaseRecord,
-): TransactionState {
- switch (purchaseRecord.purchaseStatus) {
- // Pending States
- case PurchaseStatus.PendingDownloadingProposal:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.PendingPaying:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.SubmitPayment,
- };
- case PurchaseStatus.PendingPayingReplay:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.RebindSession,
- };
- case PurchaseStatus.PendingQueryingAutoRefund:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AutoRefund,
- };
- case PurchaseStatus.PendingQueryingRefund:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CheckRefund,
- };
- case PurchaseStatus.PendingAcceptRefund:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AcceptRefund,
- };
- // Suspended Pending States
- case PurchaseStatus.SuspendedDownloadingProposal:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.SuspendedPaying:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.SubmitPayment,
- };
- case PurchaseStatus.SuspendedPayingReplay:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.RebindSession,
- };
- case PurchaseStatus.SuspendedQueryingAutoRefund:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.AutoRefund,
- };
- case PurchaseStatus.SuspendedQueryingRefund:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.CheckRefund,
- };
- case PurchaseStatus.SuspendedPendingAcceptRefund:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.AcceptRefund,
- };
- // Aborting States
- case PurchaseStatus.AbortingWithRefund:
- return {
- major: TransactionMajorState.Aborting,
- };
- // Suspended Aborting States
- case PurchaseStatus.SuspendedAbortingWithRefund:
- return {
- major: TransactionMajorState.SuspendedAborting,
- };
- // Dialog States
- case PurchaseStatus.DialogProposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.MerchantOrderProposed,
- };
- case PurchaseStatus.DialogShared:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.MerchantOrderProposed,
- };
- // Final States
- case PurchaseStatus.AbortedProposalRefused:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.Refused,
- };
- case PurchaseStatus.AbortedOrderDeleted:
- case PurchaseStatus.AbortedRefunded:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PurchaseStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PurchaseStatus.DoneRepurchaseDetected:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.Repurchase,
- };
- case PurchaseStatus.AbortedIncompletePayment:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PurchaseStatus.FailedClaim:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.FailedAbort:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.AbortingBank,
- };
- }
-}
-
-export function computePayMerchantTransactionActions(
- purchaseRecord: PurchaseRecord,
-): TransactionAction[] {
- switch (purchaseRecord.purchaseStatus) {
- // Pending States
- case PurchaseStatus.PendingDownloadingProposal:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingPaying:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingPayingReplay:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingQueryingAutoRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingQueryingRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case PurchaseStatus.PendingAcceptRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Suspend, TransactionAction.Abort];
- // Suspended Pending States
- case PurchaseStatus.SuspendedDownloadingProposal:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedPaying:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedPayingReplay:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedQueryingAutoRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedQueryingRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PurchaseStatus.SuspendedPendingAcceptRefund:
- // Special "abort" since it goes back to "done".
- return [TransactionAction.Resume, TransactionAction.Abort];
- // Aborting States
- case PurchaseStatus.AbortingWithRefund:
- return [TransactionAction.Fail, TransactionAction.Suspend];
- case PurchaseStatus.SuspendedAbortingWithRefund:
- return [TransactionAction.Fail, TransactionAction.Resume];
- // Dialog States
- case PurchaseStatus.DialogProposed:
- return [];
- case PurchaseStatus.DialogShared:
- return [];
- // Final States
- case PurchaseStatus.AbortedProposalRefused:
- case PurchaseStatus.AbortedOrderDeleted:
- case PurchaseStatus.AbortedRefunded:
- return [TransactionAction.Delete];
- case PurchaseStatus.Done:
- return [TransactionAction.Delete];
- case PurchaseStatus.DoneRepurchaseDetected:
- return [TransactionAction.Delete];
- case PurchaseStatus.AbortedIncompletePayment:
- return [TransactionAction.Delete];
- case PurchaseStatus.FailedClaim:
- return [TransactionAction.Delete];
- case PurchaseStatus.FailedAbort:
- return [TransactionAction.Delete];
- }
-}
-
-export async function sharePayment(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- orderId: string,
-): Promise<SharePaymentResult> {
- const result = await ws.db.runReadWriteTx(["purchases"], async (tx) => {
- const p = await tx.purchases.indexes.byUrlAndOrderId.get([
- merchantBaseUrl,
- orderId,
- ]);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return undefined;
- }
- if (
- p.purchaseStatus !== PurchaseStatus.DialogProposed &&
- p.purchaseStatus !== PurchaseStatus.DialogShared
- ) {
- // FIXME: purchase can be shared before being paid
- return undefined;
- }
- if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
- p.purchaseStatus = PurchaseStatus.DialogShared;
- p.shared = true;
- tx.purchases.put(p);
- }
-
- return {
- nonce: p.noncePriv,
- session: p.lastSessionId ?? p.downloadSessionId,
- token: p.claimToken,
- };
- });
-
- if (result === undefined) {
- throw Error("This purchase can't be shared");
- }
- const privatePayUri = stringifyPayUri({
- merchantBaseUrl,
- orderId,
- sessionId: result.session ?? "",
- noncePriv: result.nonce,
- claimToken: result.token,
- });
- return { privatePayUri };
-}
-
-async function checkIfOrderIsAlreadyPaid(
- ws: InternalWalletState,
- contract: WalletContractData,
-) {
- const requestUrl = new URL(
- `orders/${contract.orderId}`,
- contract.merchantBaseUrl,
- );
- requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
-
- requestUrl.searchParams.set("timeout_ms", "1000");
-
- const resp = await ws.http.fetch(requestUrl.href);
- if (
- resp.status === HttpStatusCode.Ok ||
- resp.status === HttpStatusCode.Accepted ||
- resp.status === HttpStatusCode.Found
- ) {
- return true;
- } else if (resp.status === HttpStatusCode.PaymentRequired) {
- return false;
- }
- //forbidden, not found, not acceptable
- throw Error(`this order cant be paid: ${resp.status}`);
-}
-
-async function processPurchaseDialogShared(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
- logger.trace(`processing dialog-shared for proposal ${proposalId}`);
- const download = await expectProposalDownload(ws, purchase);
-
- if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) {
- return TaskRunResult.finished();
- }
-
- const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
- if (paid) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- return TaskRunResult.backoff();
-}
-
-async function processPurchaseAutoRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
- logger.trace(`processing auto-refund for proposal ${proposalId}`);
-
- const taskId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId,
- });
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- const download = await expectProposalDownload(ws, purchase);
-
- if (
- !purchase.autoRefundDeadline ||
- AbsoluteTime.isExpired(
- AbsoluteTime.fromProtocolTimestamp(
- timestampProtocolFromDb(purchase.autoRefundDeadline),
- ),
- )
- ) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.Done;
- p.refundAmountAwaiting = undefined;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}`,
- download.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- download.contractData.contractTermsHash,
- );
-
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
-
- const resp = await ws.http.fetch(requestUrl.href);
-
- // FIXME: Check other status codes!
-
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
-
- if (orderStatus.refund_pending) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- return TaskRunResult.backoff();
-}
-
-async function processPurchaseAbortingRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
- const download = await expectProposalDownload(ws, purchase);
- logger.trace(`processing aborting-refund for proposal ${proposalId}`);
-
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}/abort`,
- download.contractData.merchantBaseUrl,
- );
-
- const abortingCoins: AbortingCoin[] = [];
-
- const payCoinSelection = purchase.payInfo?.payCoinSelection;
- if (!payCoinSelection) {
- throw Error("can't abort, no coins selected");
- }
-
- await ws.db.runReadOnlyTx(["coins"], async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- const coin = await tx.coins.get(coinPub);
- checkDbInvariant(!!coin, "expected coin to be present");
- abortingCoins.push({
- coin_pub: coinPub,
- contribution: Amounts.stringify(payCoinSelection.coinContributions[i]),
- exchange_url: coin.exchangeBaseUrl,
- });
- }
- });
-
- const abortReq: AbortRequest = {
- h_contract: download.contractData.contractTermsHash,
- coins: abortingCoins,
- };
-
- logger.trace(`making order abort request to ${requestUrl.href}`);
-
- const abortHttpResp = await ws.http.fetch(requestUrl.href, {
- method: "POST",
- body: abortReq,
- });
-
- if (abortHttpResp.status === HttpStatusCode.NotFound) {
- const err = await readTalerErrorResponse(abortHttpResp);
- if (
- err.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
- ) {
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
- await ctx.transition(async (rec) => {
- if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
- rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
- return TransitionResult.Transition;
- }
- return TransitionResult.Stay;
- });
- }
- }
-
- const abortResp = await readSuccessResponseJsonOrThrow(
- abortHttpResp,
- codecForAbortResponse(),
- );
-
- const refunds: MerchantCoinRefundStatus[] = [];
-
- if (abortResp.refunds.length != abortingCoins.length) {
- // FIXME: define error code!
- throw Error("invalid order abort response");
- }
-
- for (let i = 0; i < abortResp.refunds.length; i++) {
- const r = abortResp.refunds[i];
- refunds.push({
- ...r,
- coin_pub: payCoinSelection.coinPubs[i],
- refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
- rtransaction_id: 0,
- execution_time: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.fromProtocolTimestamp(download.contractData.timestamp),
- Duration.fromSpec({ seconds: 1 }),
- ),
- ),
- });
- }
- return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund);
-}
-
-async function processPurchaseQueryRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const proposalId = purchase.proposalId;
- logger.trace(`processing query-refund for proposal ${proposalId}`);
-
- const download = await expectProposalDownload(ws, purchase);
-
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}`,
- download.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- download.contractData.contractTermsHash,
- );
-
- const resp = await ws.http.fetch(requestUrl.href);
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
-
- if (!orderStatus.refund_pending) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return undefined;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
- return undefined;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.Done;
- p.refundAmountAwaiting = undefined;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.progress();
- } else {
- const refundAwaiting = Amounts.sub(
- Amounts.parseOrThrow(orderStatus.refund_amount),
- Amounts.parseOrThrow(orderStatus.refund_taken),
- ).amount;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(purchase.proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
- p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.progress();
- }
-}
-
-async function processPurchaseAcceptRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
-): Promise<TaskRunResult> {
- const download = await expectProposalDownload(ws, purchase);
-
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}/refund`,
- download.contractData.merchantBaseUrl,
- );
-
- logger.trace(`making refund request to ${requestUrl.href}`);
-
- const request = await ws.http.fetch(requestUrl.href, {
- method: "POST",
- body: {
- h_contract: download.contractData.contractTermsHash,
- },
- });
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderRefundPickupResponse(),
- );
- return await storeRefunds(
- ws,
- purchase,
- refundResponse.refunds,
- RefundReason.AbortRefund,
- );
-}
-
-export async function startRefundQueryForUri(
- ws: InternalWalletState,
- talerUri: string,
-): Promise<StartRefundQueryForUriResponse> {
- const parsedUri = parseTalerUri(talerUri);
- if (!parsedUri) {
- throw Error("invalid taler:// URI");
- }
- if (parsedUri.type !== TalerUriAction.Refund) {
- throw Error("expected taler://refund URI");
- }
- const purchaseRecord = await ws.db.runReadOnlyTx(
- ["purchases"],
- async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.get([
- parsedUri.merchantBaseUrl,
- parsedUri.orderId,
- ]);
- },
- );
- if (!purchaseRecord) {
- logger.error(
- `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`,
- );
- throw Error("no purchase found, can't refund");
- }
- const proposalId = purchaseRecord.proposalId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId,
- });
- await startQueryRefund(ws, proposalId);
- return {
- transactionId,
- };
-}
-
-export async function startQueryRefund(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const ctx = new PayMerchantTransactionContext(ws, proposalId);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases"],
- async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.warn(`purchase ${proposalId} does not exist anymore`);
- return;
- }
- if (p.purchaseStatus !== PurchaseStatus.Done) {
- return;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- p.purchaseStatus = PurchaseStatus.PendingQueryingRefund;
- const newTxState = computePayMerchantTransactionState(p);
- await tx.purchases.put(p);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, ctx.transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-}
-
-async function computeRefreshRequest(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<["coins", "denominations"]>,
- items: RefundItemRecord[],
-): Promise<CoinRefreshRequest[]> {
- const refreshCoins: CoinRefreshRequest[] = [];
- for (const item of items) {
- const coin = await tx.coins.get(item.coinPub);
- if (!coin) {
- throw Error("coin not found");
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denomInfo) {
- throw Error("denom not found");
- }
- if (item.status === RefundItemStatus.Done) {
- const refundedAmount = Amounts.sub(
- item.refundAmount,
- denomInfo.feeRefund,
- ).amount;
- refreshCoins.push({
- amount: Amounts.stringify(refundedAmount),
- coinPub: item.coinPub,
- });
- }
- }
- return refreshCoins;
-}
-
-/**
- * Compute the refund item status based on the merchant's response.
- */
-function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
- if (rf.type === "success") {
- return RefundItemStatus.Done;
- } else {
- if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
- return RefundItemStatus.Pending;
- } else {
- return RefundItemStatus.Failed;
- }
- }
-}
-
-/**
- * Store refunds, possibly creating a new refund group.
- */
-async function storeRefunds(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
- refunds: MerchantCoinRefundStatus[],
- reason: RefundReason,
-): Promise<TaskRunResult> {
- logger.info(`storing refunds: ${j2s(refunds)}`);
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: purchase.proposalId,
- });
-
- const newRefundGroupId = encodeCrock(randomBytes(32));
- const now = TalerPreciseTimestamp.now();
-
- const download = await expectProposalDownload(ws, purchase);
- const currency = Amounts.currencyOf(download.contractData.amount);
-
- const result = await ws.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "purchases",
- "refundItems",
- "refundGroups",
- "denominations",
- "coins",
- "coinAvailability",
- "refreshGroups",
- ],
- async (tx) => {
- const myPurchase = await tx.purchases.get(purchase.proposalId);
- if (!myPurchase) {
- logger.warn("purchase group not found anymore");
- return;
- }
- let isAborting: boolean;
- switch (myPurchase.purchaseStatus) {
- case PurchaseStatus.PendingAcceptRefund:
- isAborting = false;
- break;
- case PurchaseStatus.AbortingWithRefund:
- isAborting = true;
- break;
- default:
- logger.warn("wrong state, not accepting refund");
- return;
- }
-
- let newGroup: RefundGroupRecord | undefined = undefined;
- // Pending, but not part of an aborted refund group.
- let numPendingItemsTotal = 0;
- const newGroupRefunds: RefundItemRecord[] = [];
-
- for (const rf of refunds) {
- const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
- rf.coin_pub,
- rf.rtransaction_id,
- ]);
- if (oldItem) {
- logger.info("already have refund in database");
- if (oldItem.status === RefundItemStatus.Done) {
- continue;
- }
- if (rf.type === "success") {
- oldItem.status = RefundItemStatus.Done;
- } else {
- if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
- oldItem.status = RefundItemStatus.Pending;
- numPendingItemsTotal += 1;
- } else {
- oldItem.status = RefundItemStatus.Failed;
- }
- }
- await tx.refundItems.put(oldItem);
- } else {
- // Put refund item into a new group!
- if (!newGroup) {
- newGroup = {
- proposalId: purchase.proposalId,
- refundGroupId: newRefundGroupId,
- status: RefundGroupStatus.Pending,
- timestampCreated: timestampPreciseToDb(now),
- amountEffective: Amounts.stringify(
- Amounts.zeroOfCurrency(currency),
- ),
- amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
- };
- }
- const status: RefundItemStatus = getItemStatus(rf);
- const newItem: RefundItemRecord = {
- coinPub: rf.coin_pub,
- executionTime: timestampProtocolToDb(rf.execution_time),
- obtainedTime: timestampPreciseToDb(now),
- refundAmount: rf.refund_amount,
- refundGroupId: newGroup.refundGroupId,
- rtxid: rf.rtransaction_id,
- status,
- };
- if (status === RefundItemStatus.Pending) {
- numPendingItemsTotal += 1;
- }
- newGroupRefunds.push(newItem);
- await tx.refundItems.put(newItem);
- }
- }
-
- // Now that we know all the refunds for the new refund group,
- // we can compute the raw/effective amounts.
- if (newGroup) {
- const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
- const refreshCoins = await computeRefreshRequest(
- ws,
- tx,
- newGroupRefunds,
- );
- const outInfo = await calculateRefreshOutput(
- ws,
- tx,
- currency,
- refreshCoins,
- );
- newGroup.amountEffective = Amounts.stringify(
- Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
- );
- newGroup.amountRaw = Amounts.stringify(
- Amounts.sumOrZero(currency, amountsRaw).amount,
- );
- await tx.refundGroups.put(newGroup);
- }
-
- const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
- myPurchase.proposalId,
- );
-
- for (const refundGroup of refundGroups) {
- switch (refundGroup.status) {
- case RefundGroupStatus.Aborted:
- case RefundGroupStatus.Expired:
- case RefundGroupStatus.Failed:
- case RefundGroupStatus.Done:
- continue;
- case RefundGroupStatus.Pending:
- break;
- default:
- assertUnreachable(refundGroup.status);
- }
- const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
- refundGroup.refundGroupId,
- ]);
- let numPending = 0;
- let numFailed = 0;
- for (const item of items) {
- if (item.status === RefundItemStatus.Pending) {
- numPending++;
- }
- if (item.status === RefundItemStatus.Failed) {
- numFailed++;
- }
- }
- if (numPending === 0) {
- // We're done for this refund group!
- if (numFailed === 0) {
- refundGroup.status = RefundGroupStatus.Done;
- } else {
- refundGroup.status = RefundGroupStatus.Failed;
- }
- await tx.refundGroups.put(refundGroup);
- const refreshCoins = await computeRefreshRequest(ws, tx, items);
- await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(download.contractData.amount),
- refreshCoins,
- RefreshReason.Refund,
- // Since refunds are really just pseudo-transactions,
- // the originating transaction for the refresh is the payment transaction.
- constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: myPurchase.proposalId,
- }),
- );
- }
- }
-
- const oldTxState = computePayMerchantTransactionState(myPurchase);
- if (numPendingItemsTotal === 0) {
- if (isAborting) {
- myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
- } else {
- myPurchase.purchaseStatus = PurchaseStatus.Done;
- }
- myPurchase.refundAmountAwaiting = undefined;
- }
- await tx.purchases.put(myPurchase);
- const newTxState = computePayMerchantTransactionState(myPurchase);
-
- return {
- numPendingItemsTotal,
- transitionInfo: {
- oldTxState,
- newTxState,
- },
- };
- },
- );
-
- if (!result) {
- return TaskRunResult.finished();
- }
-
- notifyTransition(ws, transactionId, result.transitionInfo);
-
- if (result.numPendingItemsTotal > 0) {
- return TaskRunResult.backoff();
- } else {
- return TaskRunResult.progress();
- }
-}
-
-export function computeRefundTransactionState(
- refundGroupRecord: RefundGroupRecord,
-): TransactionState {
- switch (refundGroupRecord.status) {
- case RefundGroupStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case RefundGroupStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case RefundGroupStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case RefundGroupStatus.Pending:
- return {
- major: TransactionMajorState.Pending,
- };
- case RefundGroupStatus.Expired:
- return {
- major: TransactionMajorState.Expired,
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts
@@ -1,172 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AmountJson,
- AmountString,
- Amounts,
- Codec,
- Logger,
- TalerProtocolTimestamp,
- buildCodecForObject,
- codecForAmountString,
- codecForTimestamp,
- codecOptional,
-} from "@gnu-taler/taler-util";
-import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
-import { PeerPushPaymentCoinSelection, ReserveRecord } from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import type { SelectedPeerCoin } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { getTotalRefreshCost } from "./refresh.js";
-import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
-
-const logger = new Logger("operations/peer-to-peer.ts");
-
-/**
- * Get information about the coin selected for signatures.
- */
-export async function queryCoinInfosForSelection(
- ws: InternalWalletState,
- csel: PeerPushPaymentCoinSelection,
-): Promise<SpendCoinDetails[]> {
- let infos: SpendCoinDetails[] = [];
- await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < csel.coinPubs.length; i++) {
- const coin = await tx.coins.get(csel.coinPubs[i]);
- if (!coin) {
- throw Error("coin not found anymore");
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("denom for coin not found anymore");
- }
- infos.push({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- ageCommitmentProof: coin.ageCommitmentProof,
- contribution: csel.contributions[i],
- });
- }
- });
- return infos;
-}
-
-export async function getTotalPeerPaymentCost(
- ws: InternalWalletState,
- pcs: SelectedPeerCoin[],
-): Promise<AmountJson> {
- const currency = Amounts.currencyOf(pcs[0].contribution);
- return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.length; i++) {
- const coin = await tx.coins.get(pcs[i].coinPub);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denomInfo) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
- );
- }
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- coin.exchangeBaseUrl,
- currency,
- );
- const amountLeft = Amounts.sub(
- denomInfo.value,
- pcs[i].contribution,
- ).amount;
- const refreshCost = getTotalRefreshCost(
- allDenoms,
- denomInfo,
- amountLeft,
- ws.config.testing.denomselAllowLate,
- );
- costs.push(Amounts.parseOrThrow(pcs[i].contribution));
- costs.push(refreshCost);
- }
- const zero = Amounts.zeroOfAmount(pcs[0].contribution);
- return Amounts.sum([zero, ...costs]).amount;
- });
-}
-
-interface ExchangePurseStatus {
- balance: AmountString;
- deposit_timestamp?: TalerProtocolTimestamp;
- merge_timestamp?: TalerProtocolTimestamp;
-}
-
-export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
- buildCodecForObject<ExchangePurseStatus>()
- .property("balance", codecForAmountString())
- .property("deposit_timestamp", codecOptional(codecForTimestamp))
- .property("merge_timestamp", codecOptional(codecForTimestamp))
- .build("ExchangePurseStatus");
-
-export async function getMergeReserveInfo(
- ws: InternalWalletState,
- req: {
- exchangeBaseUrl: string;
- },
-): Promise<ReserveRecord> {
- // We have to eagerly create the key pair outside of the transaction,
- // due to the async crypto API.
- const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
-
- const mergeReserveRecord: ReserveRecord = await ws.db.runReadWriteTx(
- ["exchanges", "reserves"],
- async (tx) => {
- const ex = await tx.exchanges.get(req.exchangeBaseUrl);
- checkDbInvariant(!!ex);
- if (ex.currentMergeReserveRowId != null) {
- const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
- checkDbInvariant(!!reserve);
- return reserve;
- }
- const reserve: ReserveRecord = {
- reservePriv: newReservePair.priv,
- reservePub: newReservePair.pub,
- };
- const insertResp = await tx.reserves.put(reserve);
- checkDbInvariant(typeof insertResp.key === "number");
- reserve.rowId = insertResp.key;
- ex.currentMergeReserveRowId = reserve.rowId;
- await tx.exchanges.put(ex);
- return reserve;
- },
- );
-
- return mergeReserveRecord;
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts
@@ -1,1204 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- AbsoluteTime,
- Amounts,
- CancellationToken,
- CheckPeerPullCreditRequest,
- CheckPeerPullCreditResponse,
- ContractTermsUtil,
- ExchangeReservePurseRequest,
- HttpStatusCode,
- InitiatePeerPullCreditRequest,
- InitiatePeerPullCreditResponse,
- Logger,
- NotificationType,
- PeerContractTerms,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TalerUriAction,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- WalletAccountMergeFlags,
- WalletKycUuid,
- codecForAny,
- codecForWalletKycUuid,
- encodeCrock,
- getRandomBytes,
- j2s,
- makeErrorDetail,
- stringifyTalerUri,
- talerPaytoFromExchangeReserve,
-} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import {
- KycPendingInfo,
- KycUserType,
- PeerPullCreditRecord,
- PeerPullPaymentCreditStatus,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
- fetchFreshExchange,
- timestampOptionalPreciseFromDb,
- timestampPreciseFromDb,
- timestampPreciseToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import {
- codecForExchangePurseStatus,
- getMergeReserveInfo,
-} from "./pay-peer-common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-import {
- getExchangeWithdrawalInfo,
- internalCreateWithdrawalGroup,
-} from "./withdraw.js";
-
-const logger = new Logger("pay-peer-pull-credit.ts");
-
-export class PeerPullCreditTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly retryTag: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public pursePub: string,
- ) {
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, pursePub } = this;
- await ws.db.runReadWriteTx(
- ["withdrawalGroups", "peerPullCredit", "tombstones"],
- async (tx) => {
- const pullIni = await tx.peerPullCredit.get(pursePub);
- if (!pullIni) {
- return;
- }
- if (pullIni.withdrawalGroupId) {
- const withdrawalGroupId = pullIni.withdrawalGroupId;
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- }
- }
- await tx.peerPullCredit.delete(pursePub);
- await tx.tombstones.put({
- id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
- });
- },
- );
-
- return;
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, pursePub, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse;
- break;
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired;
- break;
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing;
- break;
- case PeerPullPaymentCreditStatus.PendingReady:
- newStatus = PeerPullPaymentCreditStatus.SuspendedReady;
- break;
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- newStatus =
- PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, pursePub, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- case PeerPullPaymentCreditStatus.PendingReady:
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- break;
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPullPaymentCreditStatus.Failed;
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.stopShepherdTask(retryTag);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, pursePub, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- case PeerPullPaymentCreditStatus.PendingReady:
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.Aborted:
- break;
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse;
- break;
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
- break;
- case PeerPullPaymentCreditStatus.SuspendedReady:
- newStatus = PeerPullPaymentCreditStatus.PendingReady;
- break;
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing;
- break;
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, pursePub, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pullCreditRec = await tx.peerPullCredit.get(pursePub);
- if (!pullCreditRec) {
- logger.warn(`peer pull credit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
- switch (pullCreditRec.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- throw Error("can't abort anymore");
- case PeerPullPaymentCreditStatus.PendingReady:
- newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
- break;
- case PeerPullPaymentCreditStatus.Done:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- break;
- default:
- assertUnreachable(pullCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- pullCreditRec.status = newStatus;
- const newTxState =
- computePeerPullCreditTransactionState(pullCreditRec);
- await tx.peerPullCredit.put(pullCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-async function queryPurseForPeerPullCredit(
- ws: InternalWalletState,
- pullIni: PeerPullCreditRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const purseDepositUrl = new URL(
- `purses/${pullIni.pursePub}/deposit`,
- pullIni.exchangeBaseUrl,
- );
- purseDepositUrl.searchParams.set("timeout_ms", "30000");
- logger.info(`querying purse status via ${purseDepositUrl.href}`);
- const resp = await ws.http.fetch(purseDepositUrl.href, {
- timeout: { d_ms: 60000 },
- cancellationToken,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullIni.pursePub,
- });
-
- logger.info(`purse status code: HTTP ${resp.status}`);
-
- switch (resp.status) {
- case HttpStatusCode.Gone: {
- // Exchange says that purse doesn't exist anymore => expired!
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
- if (!finPi) {
- logger.warn("peerPullCredit not found anymore");
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(finPi);
- if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
- finPi.status = PeerPullPaymentCreditStatus.Expired;
- }
- await tx.peerPullCredit.put(finPi);
- const newTxState = computePeerPullCreditTransactionState(finPi);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
- }
- case HttpStatusCode.NotFound:
- return TaskRunResult.backoff();
- }
-
- const result = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangePurseStatus(),
- );
-
- logger.trace(`purse status: ${j2s(result)}`);
-
- const depositTimestamp = result.deposit_timestamp;
-
- if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) {
- logger.info("purse not ready yet (no deposit)");
- return TaskRunResult.backoff();
- }
-
- const reserve = await ws.db.runReadOnlyTx(["reserves"], async (tx) => {
- return await tx.reserves.get(pullIni.mergeReserveRowId);
- });
-
- if (!reserve) {
- throw Error("reserve for peer pull credit not found in wallet DB");
- }
-
- await internalCreateWithdrawalGroup(ws, {
- amount: Amounts.parseOrThrow(pullIni.amount),
- wgInfo: {
- withdrawalType: WithdrawalRecordType.PeerPullCredit,
- contractPriv: pullIni.contractPriv,
- },
- forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
- exchangeBaseUrl: pullIni.exchangeBaseUrl,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair: {
- priv: reserve.reservePriv,
- pub: reserve.reservePub,
- },
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
- if (!finPi) {
- logger.warn("peerPullCredit not found anymore");
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(finPi);
- if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
- finPi.status = PeerPullPaymentCreditStatus.PendingWithdrawing;
- }
- await tx.peerPullCredit.put(finPi);
- const newTxState = computePeerPullCreditTransactionState(finPi);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
-}
-
-async function longpollKycStatus(
- ws: InternalWalletState,
- pursePub: string,
- exchangeUrl: string,
- kycInfo: KycPendingInfo,
- userType: KycUserType,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
-
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const peerIni = await tx.peerPullCredit.get(pursePub);
- if (!peerIni) {
- return;
- }
- if (
- peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired
- ) {
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(peerIni);
- peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse;
- const newTxState = computePeerPullCreditTransactionState(peerIni);
- await tx.peerPullCredit.put(peerIni);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-async function processPeerPullCreditAbortingDeletePurse(
- ws: InternalWalletState,
- peerPullIni: PeerPullCreditRecord,
-): Promise<TaskRunResult> {
- const { pursePub, pursePriv } = peerPullIni;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub,
- });
-
- const sigResp = await ws.cryptoApi.signDeletePurse({
- pursePriv,
- });
- const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl);
- const resp = await ws.http.fetch(purseUrl.href, {
- method: "DELETE",
- headers: {
- "taler-purse-signature": sigResp.sig,
- },
- });
- logger.info(`deleted purse with response status ${resp.status}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "peerPullCredit",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- ],
- async (tx) => {
- const ppiRec = await tx.peerPullCredit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) {
- return undefined;
- }
- const oldTxState = computePeerPullCreditTransactionState(ppiRec);
- ppiRec.status = PeerPullPaymentCreditStatus.Aborted;
- await tx.peerPullCredit.put(ppiRec);
- const newTxState = computePeerPullCreditTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-
- return TaskRunResult.backoff();
-}
-
-async function handlePeerPullCreditWithdrawing(
- ws: InternalWalletState,
- pullIni: PeerPullCreditRecord,
-): Promise<TaskRunResult> {
- if (!pullIni.withdrawalGroupId) {
- throw Error("invalid db state (withdrawing, but no withdrawal group ID");
- }
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullIni.pursePub,
- });
- const wgId = pullIni.withdrawalGroupId;
- let finished: boolean = false;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit", "withdrawalGroups"],
- async (tx) => {
- const ppi = await tx.peerPullCredit.get(pullIni.pursePub);
- if (!ppi) {
- finished = true;
- return;
- }
- if (ppi.status !== PeerPullPaymentCreditStatus.PendingWithdrawing) {
- finished = true;
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(ppi);
- const wg = await tx.withdrawalGroups.get(wgId);
- if (!wg) {
- // FIXME: Fail the operation instead?
- return undefined;
- }
- switch (wg.status) {
- case WithdrawalGroupStatus.Done:
- finished = true;
- ppi.status = PeerPullPaymentCreditStatus.Done;
- break;
- // FIXME: Also handle other final states!
- }
- await tx.peerPullCredit.put(ppi);
- const newTxState = computePeerPullCreditTransactionState(ppi);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- if (finished) {
- return TaskRunResult.finished();
- } else {
- // FIXME: Return indicator that we depend on the other operation!
- return TaskRunResult.backoff();
- }
-}
-
-async function handlePeerPullCreditCreatePurse(
- ws: InternalWalletState,
- pullIni: PeerPullCreditRecord,
-): Promise<TaskRunResult> {
- const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
- const pursePub = pullIni.pursePub;
- const mergeReserve = await ws.db.runReadOnlyTx(["reserves"], async (tx) => {
- return tx.reserves.get(pullIni.mergeReserveRowId);
- });
-
- if (!mergeReserve) {
- throw Error("merge reserve for peer pull payment not found in database");
- }
-
- const contractTermsRecord = await ws.db.runReadOnlyTx(
- ["contractTerms"],
- async (tx) => {
- return tx.contractTerms.get(pullIni.contractTermsHash);
- },
- );
-
- if (!contractTermsRecord) {
- throw Error("contract terms for peer pull payment not found in database");
- }
-
- const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw;
-
- const reservePayto = talerPaytoFromExchangeReserve(
- pullIni.exchangeBaseUrl,
- mergeReserve.reservePub,
- );
-
- const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
- contractPriv: pullIni.contractPriv,
- contractPub: pullIni.contractPub,
- contractTerms: contractTermsRecord.contractTermsRaw,
- pursePriv: pullIni.pursePriv,
- pursePub: pullIni.pursePub,
- nonce: pullIni.contractEncNonce,
- });
-
- const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp);
-
- const purseExpiration = contractTerms.purse_expiration;
- const sigRes = await ws.cryptoApi.signReservePurseCreate({
- contractTermsHash: pullIni.contractTermsHash,
- flags: WalletAccountMergeFlags.CreateWithPurseFee,
- mergePriv: pullIni.mergePriv,
- mergeTimestamp: TalerPreciseTimestamp.round(mergeTimestamp),
- purseAmount: pullIni.amount,
- purseExpiration: purseExpiration,
- purseFee: purseFee,
- pursePriv: pullIni.pursePriv,
- pursePub: pullIni.pursePub,
- reservePayto,
- reservePriv: mergeReserve.reservePriv,
- });
-
- const reservePurseReqBody: ExchangeReservePurseRequest = {
- merge_sig: sigRes.mergeSig,
- merge_timestamp: TalerPreciseTimestamp.round(mergeTimestamp),
- h_contract_terms: pullIni.contractTermsHash,
- merge_pub: pullIni.mergePub,
- min_age: 0,
- purse_expiration: purseExpiration,
- purse_fee: purseFee,
- purse_pub: pullIni.pursePub,
- purse_sig: sigRes.purseSig,
- purse_value: pullIni.amount,
- reserve_sig: sigRes.accountSig,
- econtract: econtractResp.econtract,
- };
-
- logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
-
- const reservePurseMergeUrl = new URL(
- `reserves/${mergeReserve.reservePub}/purse`,
- pullIni.exchangeBaseUrl,
- );
-
- const httpResp = await ws.http.fetch(reservePurseMergeUrl.href, {
- method: "POST",
- body: reservePurseReqBody,
- });
-
- if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
- const respJson = await httpResp.json();
- const kycPending = codecForWalletKycUuid().decode(respJson);
- logger.info(`kyc uuid response: ${j2s(kycPending)}`);
- return processPeerPullCreditKycRequired(ws, pullIni, kycPending);
- }
-
- const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
-
- logger.info(`reserve merge response: ${j2s(resp)}`);
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullIni.pursePub,
- });
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const pi2 = await tx.peerPullCredit.get(pursePub);
- if (!pi2) {
- return;
- }
- const oldTxState = computePeerPullCreditTransactionState(pi2);
- pi2.status = PeerPullPaymentCreditStatus.PendingReady;
- await tx.peerPullCredit.put(pi2);
- const newTxState = computePeerPullCreditTransactionState(pi2);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
-}
-
-export async function processPeerPullCredit(
- ws: InternalWalletState,
- pursePub: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const pullIni = await ws.db.runReadOnlyTx(["peerPullCredit"], async (tx) => {
- return tx.peerPullCredit.get(pursePub);
- });
- if (!pullIni) {
- throw Error("peer pull payment initiation not found in database");
- }
-
- const retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub,
- });
-
- logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
-
- switch (pullIni.status) {
- case PeerPullPaymentCreditStatus.Done: {
- return TaskRunResult.finished();
- }
- case PeerPullPaymentCreditStatus.PendingReady:
- return queryPurseForPeerPullCredit(ws, pullIni, cancellationToken);
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired: {
- if (!pullIni.kycInfo) {
- throw Error("invalid state, kycInfo required");
- }
- return await longpollKycStatus(
- ws,
- pursePub,
- pullIni.exchangeBaseUrl,
- pullIni.kycInfo,
- "individual",
- cancellationToken,
- );
- }
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- return handlePeerPullCreditCreatePurse(ws, pullIni);
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- return await processPeerPullCreditAbortingDeletePurse(ws, pullIni);
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- return handlePeerPullCreditWithdrawing(ws, pullIni);
- case PeerPullPaymentCreditStatus.Aborted:
- case PeerPullPaymentCreditStatus.Failed:
- case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- break;
- default:
- assertUnreachable(pullIni.status);
- }
-
- return TaskRunResult.finished();
-}
-
-async function processPeerPullCreditKycRequired(
- ws: InternalWalletState,
- peerIni: PeerPullCreditRecord,
- kycPending: WalletKycUuid,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: peerIni.pursePub,
- });
- const { pursePub } = peerIni;
-
- const userType = "individual";
- const url = new URL(
- `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
- peerIni.exchangeBaseUrl,
- );
-
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- });
-
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- logger.warn("kyc requested, but already fulfilled");
- return TaskRunResult.backoff();
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const { transitionInfo, result } = await ws.db.runReadWriteTx(
- ["peerPullCredit"],
- async (tx) => {
- const peerInc = await tx.peerPullCredit.get(pursePub);
- if (!peerInc) {
- return {
- transitionInfo: undefined,
- result: TaskRunResult.finished(),
- };
- }
- const oldTxState = computePeerPullCreditTransactionState(peerInc);
- peerInc.kycInfo = {
- paytoHash: kycPending.h_payto,
- requirementRow: kycPending.requirement_row,
- };
- peerInc.kycUrl = kycStatus.kyc_url;
- peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
- const newTxState = computePeerPullCreditTransactionState(peerInc);
- await tx.peerPullCredit.put(peerInc);
- // We'll remove this eventually! New clients should rely on the
- // kycUrl field of the transaction, not the error code.
- const res: TaskRunResult = {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
- {
- kycUrl: kycStatus.kyc_url,
- },
- ),
- };
- return {
- transitionInfo: { oldTxState, newTxState },
- result: res,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
-}
-
-/**
- * Check fees and available exchanges for a peer push payment initiation.
- */
-export async function checkPeerPullPaymentInitiation(
- ws: InternalWalletState,
- req: CheckPeerPullCreditRequest,
-): Promise<CheckPeerPullCreditResponse> {
- // FIXME: We don't support exchanges with purse fees yet.
- // Select an exchange where we have money in the specified currency
- // FIXME: How do we handle regional currency scopes here? Is it an additional input?
-
- logger.trace("checking peer-pull-credit fees");
-
- const currency = Amounts.currencyOf(req.amount);
- let exchangeUrl;
- if (req.exchangeBaseUrl) {
- exchangeUrl = req.exchangeBaseUrl;
- } else {
- exchangeUrl = await getPreferredExchangeForCurrency(ws, currency);
- }
-
- if (!exchangeUrl) {
- throw Error("no exchange found for initiating a peer pull payment");
- }
-
- logger.trace(`found ${exchangeUrl} as preferred exchange`);
-
- const wi = await getExchangeWithdrawalInfo(
- ws,
- exchangeUrl,
- Amounts.parseOrThrow(req.amount),
- undefined,
- );
-
- logger.trace(`got withdrawal info`);
-
- let numCoins = 0;
- for (let i = 0; i < wi.selectedDenoms.selectedDenoms.length; i++) {
- numCoins += wi.selectedDenoms.selectedDenoms[i].count;
- }
-
- return {
- exchangeBaseUrl: exchangeUrl,
- amountEffective: wi.withdrawalAmountEffective,
- amountRaw: req.amount,
- numCoins,
- };
-}
-
-/**
- * Find a preferred exchange based on when we withdrew last from this exchange.
- */
-async function getPreferredExchangeForCurrency(
- ws: InternalWalletState,
- currency: string,
-): Promise<string | undefined> {
- // Find an exchange with the matching currency.
- // Prefer exchanges with the most recent withdrawal.
- const url = await ws.db.runReadOnlyTx(["exchanges"], async (tx) => {
- const exchanges = await tx.exchanges.iter().toArray();
- let candidate = undefined;
- for (const e of exchanges) {
- if (e.detailsPointer?.currency !== currency) {
- continue;
- }
- if (!candidate) {
- candidate = e;
- continue;
- }
- if (candidate.lastWithdrawal && !e.lastWithdrawal) {
- continue;
- }
- const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
- e.lastWithdrawal,
- );
- const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
- candidate.lastWithdrawal,
- );
- if (exchangeLastWithdrawal && candidateLastWithdrawal) {
- if (
- AbsoluteTime.cmp(
- AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
- AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
- ) > 0
- ) {
- candidate = e;
- }
- }
- }
- if (candidate) {
- return candidate.baseUrl;
- }
- return undefined;
- });
- return url;
-}
-
-/**
- * Initiate a peer pull payment.
- */
-export async function initiatePeerPullPayment(
- ws: InternalWalletState,
- req: InitiatePeerPullCreditRequest,
-): Promise<InitiatePeerPullCreditResponse> {
- const currency = Amounts.currencyOf(req.partialContractTerms.amount);
- let maybeExchangeBaseUrl: string | undefined;
- if (req.exchangeBaseUrl) {
- maybeExchangeBaseUrl = req.exchangeBaseUrl;
- } else {
- maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency);
- }
-
- if (!maybeExchangeBaseUrl) {
- throw Error("no exchange found for initiating a peer pull payment");
- }
-
- const exchangeBaseUrl = maybeExchangeBaseUrl;
-
- await fetchFreshExchange(ws, exchangeBaseUrl);
-
- const mergeReserveInfo = await getMergeReserveInfo(ws, {
- exchangeBaseUrl: exchangeBaseUrl,
- });
-
- const pursePair = await ws.cryptoApi.createEddsaKeypair({});
- const mergePair = await ws.cryptoApi.createEddsaKeypair({});
-
- const contractTerms = req.partialContractTerms;
-
- const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
-
- const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
-
- const withdrawalGroupId = encodeCrock(getRandomBytes(32));
-
- const mergeReserveRowId = mergeReserveInfo.rowId;
- checkDbInvariant(!!mergeReserveRowId);
-
- const contractEncNonce = encodeCrock(getRandomBytes(24));
-
- const wi = await getExchangeWithdrawalInfo(
- ws,
- exchangeBaseUrl,
- Amounts.parseOrThrow(req.partialContractTerms.amount),
- undefined,
- );
-
- const mergeTimestamp = TalerPreciseTimestamp.now();
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullCredit", "contractTerms"],
- async (tx) => {
- const ppi: PeerPullCreditRecord = {
- amount: req.partialContractTerms.amount,
- contractTermsHash: hContractTerms,
- exchangeBaseUrl: exchangeBaseUrl,
- pursePriv: pursePair.priv,
- pursePub: pursePair.pub,
- mergePriv: mergePair.priv,
- mergePub: mergePair.pub,
- status: PeerPullPaymentCreditStatus.PendingCreatePurse,
- mergeTimestamp: timestampPreciseToDb(mergeTimestamp),
- contractEncNonce,
- mergeReserveRowId: mergeReserveRowId,
- contractPriv: contractKeyPair.priv,
- contractPub: contractKeyPair.pub,
- withdrawalGroupId,
- estimatedAmountEffective: wi.withdrawalAmountEffective,
- };
- await tx.peerPullCredit.put(ppi);
- const oldTxState: TransactionState = {
- major: TransactionMajorState.None,
- };
- const newTxState = computePeerPullCreditTransactionState(ppi);
- await tx.contractTerms.put({
- contractTermsRaw: contractTerms,
- h: hContractTerms,
- });
- return { oldTxState, newTxState };
- },
- );
-
- const ctx = new PeerPullCreditTransactionContext(ws, pursePair.pub);
-
- // The pending-incoming balance has changed.
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: ctx.transactionId,
- });
-
- notifyTransition(ws, ctx.transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(ctx.retryTag);
-
- return {
- talerUri: stringifyTalerUri({
- type: TalerUriAction.PayPull,
- exchangeBaseUrl: exchangeBaseUrl,
- contractPriv: contractKeyPair.priv,
- }),
- transactionId: ctx.transactionId,
- };
-}
-
-export function computePeerPullCreditTransactionState(
- pullCreditRecord: PeerPullCreditRecord,
-): TransactionState {
- switch (pullCreditRecord.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.MergeKycRequired,
- };
- case PeerPullPaymentCreditStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Ready,
- };
- case PeerPullPaymentCreditStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Withdraw,
- };
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPullPaymentCreditStatus.SuspendedReady:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Ready,
- };
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Withdraw,
- };
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.MergeKycRequired,
- };
- case PeerPullPaymentCreditStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.DeletePurse,
- };
- case PeerPullPaymentCreditStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case PeerPullPaymentCreditStatus.Expired:
- return {
- major: TransactionMajorState.Expired,
- };
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.DeletePurse,
- };
- }
-}
-
-export function computePeerPullCreditTransactionActions(
- pullCreditRecord: PeerPullCreditRecord,
-): TransactionAction[] {
- switch (pullCreditRecord.status) {
- case PeerPullPaymentCreditStatus.PendingCreatePurse:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullPaymentCreditStatus.PendingReady:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullPaymentCreditStatus.Done:
- return [TransactionAction.Delete];
- case PeerPullPaymentCreditStatus.PendingWithdrawing:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPullPaymentCreditStatus.SuspendedReady:
- return [TransactionAction.Abort, TransactionAction.Resume];
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPullPaymentCreditStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPullPaymentCreditStatus.AbortingDeletePurse:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPullPaymentCreditStatus.Failed:
- return [TransactionAction.Delete];
- case PeerPullPaymentCreditStatus.Expired:
- return [TransactionAction.Delete];
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- return [TransactionAction.Resume, TransactionAction.Fail];
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts
@@ -1,883 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2024 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * @fileoverview
- * Implementation of the peer-pull-debit transaction, i.e.
- * paying for an invoice the wallet received from another wallet.
- */
-
-/**
- * Imports.
- */
-import {
- AcceptPeerPullPaymentResponse,
- Amounts,
- CoinRefreshRequest,
- ConfirmPeerPullDebitRequest,
- ContractTermsUtil,
- ExchangePurseDeposits,
- HttpStatusCode,
- Logger,
- NotificationType,
- PeerContractTerms,
- PreparePeerPullDebitRequest,
- PreparePeerPullDebitResponse,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolViolationError,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- codecForAny,
- codecForExchangeGetContractResponse,
- codecForPeerContractTerms,
- decodeCrock,
- eddsaGetPublic,
- encodeCrock,
- getRandomBytes,
- j2s,
- parsePayPullUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpResponse,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "@gnu-taler/taler-util/http";
-import {
- DbReadWriteTransaction,
- InternalWalletState,
- PeerPullDebitRecordStatus,
- PeerPullPaymentIncomingRecord,
- PendingTaskType,
- RefreshOperationStatus,
- StoreNames,
- TaskId,
- WalletStoresV1,
- createRefreshGroup,
- timestampPreciseToDb,
-} from "../index.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TransactionContext,
- TransitionResult,
- constructTaskIdentifier,
- spendCoins,
-} from "./common.js";
-import {
- codecForExchangePurseStatus,
- getTotalPeerPaymentCost,
- queryCoinInfosForSelection,
-} from "./pay-peer-common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
-} from "./transactions.js";
-
-const logger = new Logger("pay-peer-pull-debit.ts");
-
-/**
- * Common context for a peer-pull-debit transaction.
- */
-export class PeerPullDebitTransactionContext implements TransactionContext {
- ws: InternalWalletState;
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
- peerPullDebitId: string;
-
- constructor(ws: InternalWalletState, peerPullDebitId: string) {
- this.ws = ws;
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPullDebit,
- peerPullDebitId,
- });
- this.peerPullDebitId = peerPullDebitId;
- }
-
- async deleteTransaction(): Promise<void> {
- const transactionId = this.transactionId;
- const ws = this.ws;
- const peerPullDebitId = this.peerPullDebitId;
- await ws.db.runReadWriteTx(["peerPullDebit", "tombstones"], async (tx) => {
- const debit = await tx.peerPullDebit.get(peerPullDebitId);
- if (debit) {
- await tx.peerPullDebit.delete(peerPullDebitId);
- await tx.tombstones.put({ id: transactionId });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const taskId = this.taskId;
- const transactionId = this.transactionId;
- const ws = this.ws;
- const peerPullDebitId = this.peerPullDebitId;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullDebit"],
- async (tx) => {
- const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pullDebitRec) {
- logger.warn(`peer pull debit ${peerPullDebitId} not found`);
- return;
- }
- let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
- switch (pullDebitRec.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- break;
- case PeerPullDebitRecordStatus.Done:
- break;
- case PeerPullDebitRecordStatus.PendingDeposit:
- newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
- break;
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- break;
- case PeerPullDebitRecordStatus.Aborted:
- break;
- case PeerPullDebitRecordStatus.AbortingRefresh:
- newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
- break;
- case PeerPullDebitRecordStatus.Failed:
- break;
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- break;
- default:
- assertUnreachable(pullDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
- pullDebitRec.status = newStatus;
- const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
- await tx.peerPullDebit.put(pullDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.stopShepherdTask(taskId);
- }
-
- async resumeTransaction(): Promise<void> {
- const ctx = this;
- await ctx.transition(async (pi) => {
- switch (pi.status) {
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- pi.status = PeerPullDebitRecordStatus.PendingDeposit;
- return TransitionResult.Transition;
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
- return TransitionResult.Transition;
- case PeerPullDebitRecordStatus.Aborted:
- case PeerPullDebitRecordStatus.AbortingRefresh:
- case PeerPullDebitRecordStatus.Failed:
- case PeerPullDebitRecordStatus.DialogProposed:
- case PeerPullDebitRecordStatus.Done:
- case PeerPullDebitRecordStatus.PendingDeposit:
- return TransitionResult.Stay;
- }
- });
- this.ws.taskScheduler.startShepherdTask(this.taskId);
- }
-
- async failTransaction(): Promise<void> {
- const ctx = this;
- await ctx.transition(async (pi) => {
- switch (pi.status) {
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- case PeerPullDebitRecordStatus.PendingDeposit:
- case PeerPullDebitRecordStatus.AbortingRefresh:
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- // FIXME: Should we also abort the corresponding refresh session?!
- pi.status = PeerPullDebitRecordStatus.Failed;
- return TransitionResult.Transition;
- default:
- return TransitionResult.Stay;
- }
- });
- this.ws.taskScheduler.stopShepherdTask(this.taskId);
- }
-
- async abortTransaction(): Promise<void> {
- const ctx = this;
- await ctx.transitionExtra(
- {
- extraStores: [
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "coins",
- "coinAvailability",
- ],
- },
- async (pi, tx) => {
- switch (pi.status) {
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- case PeerPullDebitRecordStatus.PendingDeposit:
- break;
- default:
- return TransitionResult.Stay;
- }
- const currency = Amounts.currencyOf(pi.totalCostEstimated);
- const coinPubs: CoinRefreshRequest[] = [];
-
- if (!pi.coinSel) {
- throw Error("invalid db state");
- }
-
- for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: pi.coinSel.contributions[i],
- coinPub: pi.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ctx.ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPullDebit,
- this.transactionId,
- );
-
- pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
- pi.abortRefreshGroupId = refresh.refreshGroupId;
- return TransitionResult.Transition;
- },
- );
- }
-
- async transition(
- f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResult>,
- ): Promise<void> {
- return this.transitionExtra(
- {
- extraStores: [],
- },
- f,
- );
- }
-
- async transitionExtra<
- StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
- >(
- opts: { extraStores: StoreNameArray },
- f: (
- rec: PeerPullPaymentIncomingRecord,
- tx: DbReadWriteTransaction<
- typeof WalletStoresV1,
- ["peerPullDebit", ...StoreNameArray]
- >,
- ) => Promise<TransitionResult>,
- ): Promise<void> {
- const ws = this.ws;
- const extraStores = opts.extraStores ?? [];
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullDebit", ...extraStores],
- async (tx) => {
- const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
- if (!pi) {
- throw Error("peer pull payment not found anymore");
- }
- const oldTxState = computePeerPullDebitTransactionState(pi);
- const res = await f(pi, tx);
- switch (res) {
- case TransitionResult.Transition: {
- await tx.peerPullDebit.put(pi);
- const newTxState = computePeerPullDebitTransactionState(pi);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- ws.taskScheduler.stopShepherdTask(this.taskId);
- notifyTransition(ws, this.transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-}
-
-async function handlePurseCreationConflict(
- ctx: PeerPullDebitTransactionContext,
- peerPullInc: PeerPullPaymentIncomingRecord,
- resp: HttpResponse,
-): Promise<TaskRunResult> {
- const ws = ctx.ws;
- const errResp = await readTalerErrorResponse(resp);
- if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await ctx.failTransaction();
- return TaskRunResult.finished();
- }
-
- // FIXME: Properly parse!
- const brokenCoinPub = (errResp as any).coin_pub;
- logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
-
- if (!brokenCoinPub) {
- // FIXME: Details!
- throw new TalerProtocolViolationError();
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
-
- const sel = peerPullInc.coinSel;
- if (!sel) {
- throw Error("invalid state (coin selection expected)");
- }
-
- const repair: PeerCoinRepair = {
- coinPubs: [],
- contribs: [],
- exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
- };
-
- for (let i = 0; i < sel.coinPubs.length; i++) {
- if (sel.coinPubs[i] != brokenCoinPub) {
- repair.coinPubs.push(sel.coinPubs[i]);
- repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
- }
- }
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
-
- if (coinSelRes.type == "failure") {
- // FIXME: Details!
- throw Error(
- "insufficient balance to re-select coins to repair double spending",
- );
- }
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- await ws.db.runReadWriteTx(["peerPullDebit"], async (tx) => {
- const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
- if (!myPpi) {
- return;
- }
- switch (myPpi.status) {
- case PeerPullDebitRecordStatus.PendingDeposit:
- case PeerPullDebitRecordStatus.SuspendedDeposit: {
- const sel = coinSelRes.result;
- myPpi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- totalCost: Amounts.stringify(totalAmount),
- };
- break;
- }
- default:
- return;
- }
- await tx.peerPullDebit.put(myPpi);
- });
- return TaskRunResult.backoff();
-}
-
-async function processPeerPullDebitPendingDeposit(
- ws: InternalWalletState,
- peerPullInc: PeerPullPaymentIncomingRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPullInc.pursePub;
-
- const coinSel = peerPullInc.coinSel;
- if (!coinSel) {
- throw Error("invalid state, no coins selected");
- }
-
- const coins = await queryCoinInfosForSelection(ws, coinSel);
-
- const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
- exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
- pursePub: peerPullInc.pursePub,
- coins,
- });
-
- const purseDepositUrl = new URL(
- `purses/${pursePub}/deposit`,
- peerPullInc.exchangeBaseUrl,
- );
-
- const depositPayload: ExchangePurseDeposits = {
- deposits: depositSigsResp.deposits,
- };
-
- if (logger.shouldLogTrace()) {
- logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
- }
-
- const httpResp = await ws.http.fetch(purseDepositUrl.href, {
- method: "POST",
- body: depositPayload,
- });
-
- const ctx = new PeerPullDebitTransactionContext(
- ws,
- peerPullInc.peerPullDebitId,
- );
-
- switch (httpResp.status) {
- case HttpStatusCode.Ok: {
- const resp = await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForAny(),
- );
- logger.trace(`purse deposit response: ${j2s(resp)}`);
-
- await ctx.transition(async (r) => {
- if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
- return TransitionResult.Stay;
- }
- r.status = PeerPullDebitRecordStatus.Done;
- return TransitionResult.Transition;
- });
- return TaskRunResult.finished();
- }
- case HttpStatusCode.Gone: {
- await ctx.abortTransaction();
- return TaskRunResult.backoff();
- }
- case HttpStatusCode.Conflict: {
- return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
- }
- default: {
- const errResp = await readTalerErrorResponse(httpResp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: errResp,
- };
- }
- }
-}
-
-async function processPeerPullDebitAbortingRefresh(
- ws: InternalWalletState,
- peerPullInc: PeerPullPaymentIncomingRecord,
-): Promise<TaskRunResult> {
- const peerPullDebitId = peerPullInc.peerPullDebitId;
- const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPullDebit", "refreshGroups"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPullDebitRecordStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPullDebitRecordStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPullDebitRecordStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPullDebitRecordStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPullDebit.get(peerPullDebitId);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPullDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPullDebitTransactionState(newDg);
- await tx.peerPullDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.backoff();
-}
-
-export async function processPeerPullDebit(
- ws: InternalWalletState,
- peerPullDebitId: string,
-): Promise<TaskRunResult> {
- const peerPullInc = await ws.db.runReadOnlyTx(
- ["peerPullDebit"],
- async (tx) => {
- return tx.peerPullDebit.get(peerPullDebitId);
- },
- );
- if (!peerPullInc) {
- throw Error("peer pull debit not found");
- }
-
- switch (peerPullInc.status) {
- case PeerPullDebitRecordStatus.PendingDeposit:
- return await processPeerPullDebitPendingDeposit(ws, peerPullInc);
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return await processPeerPullDebitAbortingRefresh(ws, peerPullInc);
- }
- return TaskRunResult.finished();
-}
-
-export async function confirmPeerPullDebit(
- ws: InternalWalletState,
- req: ConfirmPeerPullDebitRequest,
-): Promise<AcceptPeerPullPaymentResponse> {
- let peerPullDebitId: string;
-
- if (req.transactionId) {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
- if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
- throw Error("invalid peer-pull-debit transaction identifier");
- }
- peerPullDebitId = parsedTx.peerPullDebitId;
- } else if (req.peerPullDebitId) {
- peerPullDebitId = req.peerPullDebitId;
- } else {
- throw Error("invalid request, transactionId or peerPullDebitId required");
- }
-
- const peerPullInc = await ws.db.runReadOnlyTx(
- ["peerPullDebit"],
- async (tx) => {
- return tx.peerPullDebit.get(peerPullDebitId);
- },
- );
-
- if (!peerPullInc) {
- throw Error(
- `can't accept unknown incoming p2p pull payment (${req.peerPullDebitId})`,
- );
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (logger.shouldLogTrace()) {
- logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
- }
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const sel = coinSelRes.result;
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- await ws.db.runReadWriteTx(
- [
- "exchanges",
- "coins",
- "denominations",
- "refreshGroups",
- "peerPullDebit",
- "coinAvailability",
- ],
- async (tx) => {
- await spendCoins(ws, tx, {
- // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId,
- }),
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayPeerPull,
- });
-
- const pi = await tx.peerPullDebit.get(peerPullDebitId);
- if (!pi) {
- throw Error();
- }
- if (pi.status === PeerPullDebitRecordStatus.DialogProposed) {
- pi.status = PeerPullDebitRecordStatus.PendingDeposit;
- pi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- totalCost: Amounts.stringify(totalAmount),
- };
- }
- await tx.peerPullDebit.put(pi);
- },
- );
-
- const ctx = new PeerPullDebitTransactionContext(ws, peerPullDebitId);
-
- const transactionId = ctx.transactionId;
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- transactionId,
- };
-}
-
-/**
- * Look up information about an incoming peer pull payment.
- * Store the results in the wallet DB.
- */
-export async function preparePeerPullDebit(
- ws: InternalWalletState,
- req: PreparePeerPullDebitRequest,
-): Promise<PreparePeerPullDebitResponse> {
- const uri = parsePayPullUri(req.talerUri);
-
- if (!uri) {
- throw Error("got invalid taler://pay-pull URI");
- }
-
- const existing = await ws.db.runReadOnlyTx(
- ["peerPullDebit", "contractTerms"],
- async (tx) => {
- const peerPullDebitRecord =
- await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
- uri.exchangeBaseUrl,
- uri.contractPriv,
- ]);
- if (!peerPullDebitRecord) {
- return;
- }
- const contractTerms = await tx.contractTerms.get(
- peerPullDebitRecord.contractTermsHash,
- );
- if (!contractTerms) {
- return;
- }
- return { peerPullDebitRecord, contractTerms };
- },
- );
-
- if (existing) {
- return {
- amount: existing.peerPullDebitRecord.amount,
- amountRaw: existing.peerPullDebitRecord.amount,
- amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
- contractTerms: existing.contractTerms.contractTermsRaw,
- peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
- }),
- };
- }
-
- const exchangeBaseUrl = uri.exchangeBaseUrl;
- const contractPriv = uri.contractPriv;
- const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
-
- const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
-
- const contractHttpResp = await ws.http.fetch(getContractUrl.href);
-
- const contractResp = await readSuccessResponseJsonOrThrow(
- contractHttpResp,
- codecForExchangeGetContractResponse(),
- );
-
- const pursePub = contractResp.purse_pub;
-
- const dec = await ws.cryptoApi.decryptContractForDeposit({
- ciphertext: contractResp.econtract,
- contractPriv: contractPriv,
- pursePub: pursePub,
- });
-
- const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
-
- const purseHttpResp = await ws.http.fetch(getPurseUrl.href);
-
- const purseStatus = await readSuccessResponseJsonOrThrow(
- purseHttpResp,
- codecForExchangePurseStatus(),
- );
-
- const peerPullDebitId = encodeCrock(getRandomBytes(32));
-
- let contractTerms: PeerContractTerms;
-
- if (dec.contractTerms) {
- contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
- // FIXME: Check that the purseStatus balance matches contract terms amount
- } else {
- // FIXME: In this case, where do we get the purse expiration from?!
- // https://bugs.gnunet.org/view.php?id=7706
- throw Error("pull payments without contract terms not supported yet");
- }
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
-
- // FIXME: Why don't we compute the totalCost here?!
-
- const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (logger.shouldLogTrace()) {
- logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
- }
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- await ws.db.runReadWriteTx(["peerPullDebit", "contractTerms"], async (tx) => {
- await tx.contractTerms.put({
- h: contractTermsHash,
- contractTermsRaw: contractTerms,
- }),
- await tx.peerPullDebit.add({
- peerPullDebitId,
- contractPriv: contractPriv,
- exchangeBaseUrl: exchangeBaseUrl,
- pursePub: pursePub,
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- contractTermsHash,
- amount: contractTerms.amount,
- status: PeerPullDebitRecordStatus.DialogProposed,
- totalCostEstimated: Amounts.stringify(totalAmount),
- });
- });
-
- return {
- amount: contractTerms.amount,
- amountEffective: Amounts.stringify(totalAmount),
- amountRaw: contractTerms.amount,
- contractTerms: contractTerms,
- peerPullDebitId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: peerPullDebitId,
- }),
- };
-}
-
-export function computePeerPullDebitTransactionState(
- pullDebitRecord: PeerPullPaymentIncomingRecord,
-): TransactionState {
- switch (pullDebitRecord.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case PeerPullDebitRecordStatus.PendingDeposit:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Deposit,
- };
- case PeerPullDebitRecordStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Deposit,
- };
- case PeerPullDebitRecordStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPullDebitRecordStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Refresh,
- };
- }
-}
-
-export function computePeerPullDebitTransactionActions(
- pullDebitRecord: PeerPullPaymentIncomingRecord,
-): TransactionAction[] {
- switch (pullDebitRecord.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- return [];
- case PeerPullDebitRecordStatus.PendingDeposit:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPullDebitRecordStatus.Done:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.SuspendedDeposit:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPullDebitRecordStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return [TransactionAction.Fail, TransactionAction.Suspend];
- case PeerPullDebitRecordStatus.Failed:
- return [TransactionAction.Delete];
- case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
- return [TransactionAction.Resume, TransactionAction.Fail];
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts
@@ -1,1037 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- AcceptPeerPushPaymentResponse,
- Amounts,
- CancellationToken,
- ConfirmPeerPushCreditRequest,
- ContractTermsUtil,
- ExchangePurseMergeRequest,
- HttpStatusCode,
- Logger,
- NotificationType,
- PeerContractTerms,
- PreparePeerPushCreditRequest,
- PreparePeerPushCreditResponse,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TransactionAction,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- WalletAccountMergeFlags,
- WalletKycUuid,
- codecForAny,
- codecForExchangeGetContractResponse,
- codecForPeerContractTerms,
- codecForWalletKycUuid,
- decodeCrock,
- eddsaGetPublic,
- encodeCrock,
- getRandomBytes,
- j2s,
- makeErrorDetail,
- parsePayPushUri,
- talerPaytoFromExchangeReserve,
-} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import {
- InternalWalletState,
- KycPendingInfo,
- KycUserType,
- PeerPushCreditStatus,
- PeerPushPaymentIncomingRecord,
- PendingTaskType,
- TaskId,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
- timestampPreciseToDb,
-} from "../index.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import { fetchFreshExchange } from "./exchanges.js";
-import {
- codecForExchangePurseStatus,
- getMergeReserveInfo,
-} from "./pay-peer-common.js";
-import {
- TransitionInfo,
- constructTransactionIdentifier,
- notifyTransition,
- parseTransactionIdentifier,
-} from "./transactions.js";
-import {
- PerformCreateWithdrawalGroupResult,
- getExchangeWithdrawalInfo,
- internalPerformCreateWithdrawalGroup,
- internalPrepareCreateWithdrawalGroup,
-} from "./withdraw.js";
-
-const logger = new Logger("pay-peer-push-credit.ts");
-
-export class PeerPushCreditTransactionContext implements TransactionContext {
- readonly transactionId: string;
- readonly retryTag: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public peerPushCreditId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushCredit,
- peerPushCreditId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, peerPushCreditId } = this;
- await ws.db.runReadWriteTx(
- ["withdrawalGroups", "peerPushCredit", "tombstones"],
- async (tx) => {
- const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushInc) {
- return;
- }
- if (pushInc.withdrawalGroupId) {
- const withdrawalGroupId = pushInc.withdrawalGroupId;
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- }
- }
- await tx.peerPushCredit.delete(peerPushCreditId);
- await tx.tombstones.put({
- id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
- });
- },
- );
- return;
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, peerPushCreditId, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- case PeerPushCreditStatus.Done:
- case PeerPushCreditStatus.SuspendedMerge:
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- case PeerPushCreditStatus.SuspendedWithdrawing:
- break;
- case PeerPushCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
- break;
- case PeerPushCreditStatus.PendingMerge:
- newStatus = PeerPushCreditStatus.SuspendedMerge;
- break;
- case PeerPushCreditStatus.PendingWithdrawing:
- // FIXME: Suspend internal withdrawal transaction!
- newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.stopShepherdTask(retryTag);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, peerPushCreditId, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.Done:
- break;
- case PeerPushCreditStatus.SuspendedMerge:
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- case PeerPushCreditStatus.SuspendedWithdrawing:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingMergeKycRequired:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingMerge:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.PendingWithdrawing:
- newStatus = PeerPushCreditStatus.Aborted;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, peerPushCreditId, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.DialogProposed:
- case PeerPushCreditStatus.Done:
- case PeerPushCreditStatus.PendingMergeKycRequired:
- case PeerPushCreditStatus.PendingMerge:
- case PeerPushCreditStatus.PendingWithdrawing:
- case PeerPushCreditStatus.SuspendedMerge:
- newStatus = PeerPushCreditStatus.PendingMerge;
- break;
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
- break;
- case PeerPushCreditStatus.SuspendedWithdrawing:
- // FIXME: resume underlying "internal-withdrawal" transaction.
- newStatus = PeerPushCreditStatus.PendingWithdrawing;
- break;
- case PeerPushCreditStatus.Aborted:
- break;
- case PeerPushCreditStatus.Failed:
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, peerPushCreditId, retryTag, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushCreditRec) {
- logger.warn(`peer push credit ${peerPushCreditId} not found`);
- return;
- }
- let newStatus: PeerPushCreditStatus | undefined = undefined;
- switch (pushCreditRec.status) {
- case PeerPushCreditStatus.Done:
- case PeerPushCreditStatus.Aborted:
- case PeerPushCreditStatus.Failed:
- // Already in a final state.
- return;
- case PeerPushCreditStatus.DialogProposed:
- case PeerPushCreditStatus.PendingMergeKycRequired:
- case PeerPushCreditStatus.PendingMerge:
- case PeerPushCreditStatus.PendingWithdrawing:
- case PeerPushCreditStatus.SuspendedMerge:
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- case PeerPushCreditStatus.SuspendedWithdrawing:
- newStatus = PeerPushCreditStatus.Failed;
- break;
- default:
- assertUnreachable(pushCreditRec.status);
- }
- if (newStatus != null) {
- const oldTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- pushCreditRec.status = newStatus;
- const newTxState =
- computePeerPushCreditTransactionState(pushCreditRec);
- await tx.peerPushCredit.put(pushCreditRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-export async function preparePeerPushCredit(
- ws: InternalWalletState,
- req: PreparePeerPushCreditRequest,
-): Promise<PreparePeerPushCreditResponse> {
- const uri = parsePayPushUri(req.talerUri);
-
- if (!uri) {
- throw Error("got invalid taler://pay-push URI");
- }
-
- const existing = await ws.db.runReadOnlyTx(
- ["contractTerms", "peerPushCredit"],
- async (tx) => {
- const existingPushInc =
- await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
- uri.exchangeBaseUrl,
- uri.contractPriv,
- ]);
- if (!existingPushInc) {
- return;
- }
- const existingContractTermsRec = await tx.contractTerms.get(
- existingPushInc.contractTermsHash,
- );
- if (!existingContractTermsRec) {
- throw Error(
- "contract terms for peer push payment credit not found in database",
- );
- }
- const existingContractTerms = codecForPeerContractTerms().decode(
- existingContractTermsRec.contractTermsRaw,
- );
- return { existingPushInc, existingContractTerms };
- },
- );
-
- if (existing) {
- return {
- amount: existing.existingContractTerms.amount,
- amountEffective: existing.existingPushInc.estimatedAmountEffective,
- amountRaw: existing.existingContractTerms.amount,
- contractTerms: existing.existingContractTerms,
- peerPushCreditId: existing.existingPushInc.peerPushCreditId,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: existing.existingPushInc.peerPushCreditId,
- }),
- exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl,
- };
- }
-
- const exchangeBaseUrl = uri.exchangeBaseUrl;
-
- await fetchFreshExchange(ws, exchangeBaseUrl);
-
- const contractPriv = uri.contractPriv;
- const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
-
- const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
-
- const contractHttpResp = await ws.http.fetch(getContractUrl.href);
-
- const contractResp = await readSuccessResponseJsonOrThrow(
- contractHttpResp,
- codecForExchangeGetContractResponse(),
- );
-
- const pursePub = contractResp.purse_pub;
-
- const dec = await ws.cryptoApi.decryptContractForMerge({
- ciphertext: contractResp.econtract,
- contractPriv: contractPriv,
- pursePub: pursePub,
- });
-
- const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
-
- const purseHttpResp = await ws.http.fetch(getPurseUrl.href);
-
- const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
-
- const purseStatus = await readSuccessResponseJsonOrThrow(
- purseHttpResp,
- codecForExchangePurseStatus(),
- );
-
- logger.info(
- `peer push credit, purse balance ${purseStatus.balance}, contract amount ${contractTerms.amount}`,
- );
-
- const peerPushCreditId = encodeCrock(getRandomBytes(32));
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(
- dec.contractTerms,
- );
-
- const withdrawalGroupId = encodeCrock(getRandomBytes(32));
-
- const wi = await getExchangeWithdrawalInfo(
- ws,
- exchangeBaseUrl,
- Amounts.parseOrThrow(purseStatus.balance),
- undefined,
- );
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
- async (tx) => {
- const rec: PeerPushPaymentIncomingRecord = {
- peerPushCreditId,
- contractPriv: contractPriv,
- exchangeBaseUrl: exchangeBaseUrl,
- mergePriv: dec.mergePriv,
- pursePub: pursePub,
- timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- contractTermsHash,
- status: PeerPushCreditStatus.DialogProposed,
- withdrawalGroupId,
- currency: Amounts.currencyOf(purseStatus.balance),
- estimatedAmountEffective: Amounts.stringify(
- wi.withdrawalAmountEffective,
- ),
- };
- await tx.peerPushCredit.add(rec);
- await tx.contractTerms.put({
- h: contractTermsHash,
- contractTermsRaw: dec.contractTerms,
- });
-
- const newTxState = computePeerPushCreditTransactionState(rec);
-
- return {
- oldTxState: {
- major: TransactionMajorState.None,
- },
- newTxState,
- } satisfies TransitionInfo;
- },
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- return {
- amount: purseStatus.balance,
- amountEffective: wi.withdrawalAmountEffective,
- amountRaw: purseStatus.balance,
- contractTerms: dec.contractTerms,
- peerPushCreditId,
- transactionId,
- exchangeBaseUrl,
- };
-}
-
-async function longpollKycStatus(
- ws: InternalWalletState,
- peerPushCreditId: string,
- exchangeUrl: string,
- kycInfo: KycPendingInfo,
- userType: KycUserType,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- url.searchParams.set("timeout_ms", "10000");
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return;
- }
- if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
- return;
- }
- const oldTxState = computePeerPushCreditTransactionState(peerInc);
- peerInc.status = PeerPushCreditStatus.PendingMerge;
- const newTxState = computePeerPushCreditTransactionState(peerInc);
- await tx.peerPushCredit.put(peerInc);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- // FIXME: Do we have to update the URL here?
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-async function processPeerPushCreditKycRequired(
- ws: InternalWalletState,
- peerInc: PeerPushPaymentIncomingRecord,
- kycPending: WalletKycUuid,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: peerInc.peerPushCreditId,
- });
- const { peerPushCreditId } = peerInc;
-
- const userType = "individual";
- const url = new URL(
- `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
- peerInc.exchangeBaseUrl,
- );
-
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- });
-
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- logger.warn("kyc requested, but already fulfilled");
- return TaskRunResult.finished();
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const { transitionInfo, result } = await ws.db.runReadWriteTx(
- ["peerPushCredit"],
- async (tx) => {
- const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return {
- transitionInfo: undefined,
- result: TaskRunResult.finished(),
- };
- }
- const oldTxState = computePeerPushCreditTransactionState(peerInc);
- peerInc.kycInfo = {
- paytoHash: kycPending.h_payto,
- requirementRow: kycPending.requirement_row,
- };
- peerInc.kycUrl = kycStatus.kyc_url;
- peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
- const newTxState = computePeerPushCreditTransactionState(peerInc);
- await tx.peerPushCredit.put(peerInc);
- // We'll remove this eventually! New clients should rely on the
- // kycUrl field of the transaction, not the error code.
- const res: TaskRunResult = {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
- {
- kycUrl: kycStatus.kyc_url,
- },
- ),
- };
- return {
- transitionInfo: { oldTxState, newTxState },
- result: res,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return result;
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
-}
-
-async function handlePendingMerge(
- ws: InternalWalletState,
- peerInc: PeerPushPaymentIncomingRecord,
- contractTerms: PeerContractTerms,
-): Promise<TaskRunResult> {
- const { peerPushCreditId } = peerInc;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
-
- const amount = Amounts.parseOrThrow(contractTerms.amount);
-
- const mergeReserveInfo = await getMergeReserveInfo(ws, {
- exchangeBaseUrl: peerInc.exchangeBaseUrl,
- });
-
- const mergeTimestamp = TalerProtocolTimestamp.now();
-
- const reservePayto = talerPaytoFromExchangeReserve(
- peerInc.exchangeBaseUrl,
- mergeReserveInfo.reservePub,
- );
-
- const sigRes = await ws.cryptoApi.signPurseMerge({
- contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
- flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
- mergePriv: peerInc.mergePriv,
- mergeTimestamp: mergeTimestamp,
- purseAmount: Amounts.stringify(amount),
- purseExpiration: contractTerms.purse_expiration,
- purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
- pursePub: peerInc.pursePub,
- reservePayto,
- reservePriv: mergeReserveInfo.reservePriv,
- });
-
- const mergePurseUrl = new URL(
- `purses/${peerInc.pursePub}/merge`,
- peerInc.exchangeBaseUrl,
- );
-
- const mergeReq: ExchangePurseMergeRequest = {
- payto_uri: reservePayto,
- merge_timestamp: mergeTimestamp,
- merge_sig: sigRes.mergeSig,
- reserve_sig: sigRes.accountSig,
- };
-
- const mergeHttpResp = await ws.http.fetch(mergePurseUrl.href, {
- method: "POST",
- body: mergeReq,
- });
-
- if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
- const respJson = await mergeHttpResp.json();
- const kycPending = codecForWalletKycUuid().decode(respJson);
- logger.info(`kyc uuid response: ${j2s(kycPending)}`);
- return processPeerPushCreditKycRequired(ws, peerInc, kycPending);
- }
-
- logger.trace(`merge request: ${j2s(mergeReq)}`);
- const res = await readSuccessResponseJsonOrThrow(
- mergeHttpResp,
- codecForAny(),
- );
- logger.trace(`merge response: ${j2s(res)}`);
-
- const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(ws, {
- amount,
- wgInfo: {
- withdrawalType: WithdrawalRecordType.PeerPushCredit,
- },
- forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
- exchangeBaseUrl: peerInc.exchangeBaseUrl,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair: {
- priv: mergeReserveInfo.reservePriv,
- pub: mergeReserveInfo.reservePub,
- },
- });
-
- const txRes = await ws.db.runReadWriteTx(
- [
- "contractTerms",
- "peerPushCredit",
- "withdrawalGroups",
- "reserves",
- "exchanges",
- "exchangeDetails",
- ],
- async (tx) => {
- const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return undefined;
- }
- const oldTxState = computePeerPushCreditTransactionState(peerInc);
- let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined =
- undefined;
- switch (peerInc.status) {
- case PeerPushCreditStatus.PendingMerge:
- case PeerPushCreditStatus.PendingMergeKycRequired: {
- peerInc.status = PeerPushCreditStatus.PendingWithdrawing;
- wgCreateRes = await internalPerformCreateWithdrawalGroup(
- ws,
- tx,
- withdrawalGroupPrep,
- );
- peerInc.withdrawalGroupId =
- wgCreateRes.withdrawalGroup.withdrawalGroupId;
- break;
- }
- }
- await tx.peerPushCredit.put(peerInc);
- const newTxState = computePeerPushCreditTransactionState(peerInc);
- return {
- peerPushCreditTransition: { oldTxState, newTxState },
- wgCreateRes,
- };
- },
- );
- // Transaction was committed, now we can emit notifications.
- if (txRes?.wgCreateRes?.exchangeNotif) {
- ws.notify(txRes.wgCreateRes.exchangeNotif);
- }
- notifyTransition(
- ws,
- withdrawalGroupPrep.transactionId,
- txRes?.wgCreateRes?.transitionInfo,
- );
- notifyTransition(ws, transactionId, txRes?.peerPushCreditTransition);
-
- return TaskRunResult.backoff();
-}
-
-async function handlePendingWithdrawing(
- ws: InternalWalletState,
- peerInc: PeerPushPaymentIncomingRecord,
-): Promise<TaskRunResult> {
- if (!peerInc.withdrawalGroupId) {
- throw Error("invalid db state (withdrawing, but no withdrawal group ID");
- }
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: peerInc.peerPushCreditId,
- });
- const wgId = peerInc.withdrawalGroupId;
- let finished: boolean = false;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushCredit", "withdrawalGroups"],
- async (tx) => {
- const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
- if (!ppi) {
- finished = true;
- return;
- }
- if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) {
- finished = true;
- return;
- }
- const oldTxState = computePeerPushCreditTransactionState(ppi);
- const wg = await tx.withdrawalGroups.get(wgId);
- if (!wg) {
- // FIXME: Fail the operation instead?
- return undefined;
- }
- switch (wg.status) {
- case WithdrawalGroupStatus.Done:
- finished = true;
- ppi.status = PeerPushCreditStatus.Done;
- break;
- // FIXME: Also handle other final states!
- }
- await tx.peerPushCredit.put(ppi);
- const newTxState = computePeerPushCreditTransactionState(ppi);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- if (finished) {
- return TaskRunResult.finished();
- } else {
- // FIXME: Return indicator that we depend on the other operation!
- return TaskRunResult.backoff();
- }
-}
-
-export async function processPeerPushCredit(
- ws: InternalWalletState,
- peerPushCreditId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- let peerInc: PeerPushPaymentIncomingRecord | undefined;
- let contractTerms: PeerContractTerms | undefined;
- await ws.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
- async (tx) => {
- peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return;
- }
- const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
- if (ctRec) {
- contractTerms = ctRec.contractTermsRaw;
- }
- await tx.peerPushCredit.put(peerInc);
- },
- );
-
- if (!peerInc) {
- throw Error(
- `can't accept unknown incoming p2p push payment (${peerPushCreditId})`,
- );
- }
-
- logger.info(
- `processing peerPushCredit in state ${peerInc.status.toString(16)}`,
- );
-
- checkDbInvariant(!!contractTerms);
-
- switch (peerInc.status) {
- case PeerPushCreditStatus.PendingMergeKycRequired: {
- if (!peerInc.kycInfo) {
- throw Error("invalid state, kycInfo required");
- }
- return await longpollKycStatus(
- ws,
- peerPushCreditId,
- peerInc.exchangeBaseUrl,
- peerInc.kycInfo,
- "individual",
- cancellationToken,
- );
- }
-
- case PeerPushCreditStatus.PendingMerge:
- return handlePendingMerge(ws, peerInc, contractTerms);
-
- case PeerPushCreditStatus.PendingWithdrawing:
- return handlePendingWithdrawing(ws, peerInc);
-
- default:
- return TaskRunResult.finished();
- }
-}
-
-export async function confirmPeerPushCredit(
- ws: InternalWalletState,
- req: ConfirmPeerPushCreditRequest,
-): Promise<AcceptPeerPushPaymentResponse> {
- let peerInc: PeerPushPaymentIncomingRecord | undefined;
- let peerPushCreditId: string;
- if (req.peerPushCreditId) {
- peerPushCreditId = req.peerPushCreditId;
- } else if (req.transactionId) {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
- if (!parsedTx) {
- throw Error("invalid transaction ID");
- }
- if (parsedTx.tag !== TransactionType.PeerPushCredit) {
- throw Error("invalid transaction ID type");
- }
- peerPushCreditId = parsedTx.peerPushCreditId;
- } else {
- throw Error("no transaction ID (or deprecated peerPushCreditId) provided");
- }
-
- await ws.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
- async (tx) => {
- peerInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!peerInc) {
- return;
- }
- if (peerInc.status === PeerPushCreditStatus.DialogProposed) {
- peerInc.status = PeerPushCreditStatus.PendingMerge;
- }
- await tx.peerPushCredit.put(peerInc);
- },
- );
-
- if (!peerInc) {
- throw Error(
- `can't accept unknown incoming p2p push payment (${req.peerPushCreditId})`,
- );
- }
-
- const ctx = new PeerPushCreditTransactionContext(ws, peerPushCreditId);
-
- ws.taskScheduler.startShepherdTask(ctx.retryTag);
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId,
- });
-
- return {
- transactionId,
- };
-}
-
-export function computePeerPushCreditTransactionState(
- pushCreditRecord: PeerPushPaymentIncomingRecord,
-): TransactionState {
- switch (pushCreditRecord.status) {
- case PeerPushCreditStatus.DialogProposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case PeerPushCreditStatus.PendingMerge:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Merge,
- };
- case PeerPushCreditStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPushCreditStatus.PendingMergeKycRequired:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.KycRequired,
- };
- case PeerPushCreditStatus.PendingWithdrawing:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Withdraw,
- };
- case PeerPushCreditStatus.SuspendedMerge:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Merge,
- };
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.MergeKycRequired,
- };
- case PeerPushCreditStatus.SuspendedWithdrawing:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Withdraw,
- };
- case PeerPushCreditStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPushCreditStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- default:
- assertUnreachable(pushCreditRecord.status);
- }
-}
-
-export function computePeerPushCreditTransactionActions(
- pushCreditRecord: PeerPushPaymentIncomingRecord,
-): TransactionAction[] {
- switch (pushCreditRecord.status) {
- case PeerPushCreditStatus.DialogProposed:
- return [TransactionAction.Delete];
- case PeerPushCreditStatus.PendingMerge:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushCreditStatus.Done:
- return [TransactionAction.Delete];
- case PeerPushCreditStatus.PendingMergeKycRequired:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushCreditStatus.PendingWithdrawing:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushCreditStatus.SuspendedMerge:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushCreditStatus.SuspendedMergeKycRequired:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushCreditStatus.SuspendedWithdrawing:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushCreditStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPushCreditStatus.Failed:
- return [TransactionAction.Delete];
- default:
- assertUnreachable(pushCreditRecord.status);
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts
@@ -1,1150 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022-2023 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- Amounts,
- CancellationToken,
- CheckPeerPushDebitRequest,
- CheckPeerPushDebitResponse,
- CoinRefreshRequest,
- ContractTermsUtil,
- HttpStatusCode,
- InitiatePeerPushDebitRequest,
- InitiatePeerPushDebitResponse,
- Logger,
- NotificationType,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TalerProtocolViolationError,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- encodeCrock,
- getRandomBytes,
- j2s,
-} from "@gnu-taler/taler-util";
-import {
- HttpResponse,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "@gnu-taler/taler-util/http";
-import { EncryptContractRequest } from "../crypto/cryptoTypes.js";
-import {
- PeerPushDebitRecord,
- PeerPushDebitStatus,
- RefreshOperationStatus,
- createRefreshGroup,
- timestampPreciseToDb,
- timestampProtocolFromDb,
- timestampProtocolToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TransactionContext,
- constructTaskIdentifier,
- spendCoins,
-} from "./common.js";
-import {
- codecForExchangePurseStatus,
- getTotalPeerPaymentCost,
- queryCoinInfosForSelection,
-} from "./pay-peer-common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-const logger = new Logger("pay-peer-push-debit.ts");
-
-export class PeerPushDebitTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly retryTag: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public pursePub: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, pursePub, transactionId } = this;
- await ws.db.runReadWriteTx(["peerPushDebit", "tombstones"], async (tx) => {
- const debit = await tx.peerPushDebit.get(pursePub);
- if (debit) {
- await tx.peerPushDebit.delete(pursePub);
- await tx.tombstones.put({ id: transactionId });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, pursePub, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- newStatus = PeerPushDebitStatus.SuspendedCreatePurse;
- break;
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshDeleted;
- break;
- case PeerPushDebitStatus.AbortingRefreshExpired:
- newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshExpired;
- break;
- case PeerPushDebitStatus.AbortingDeletePurse:
- newStatus = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
- break;
- case PeerPushDebitStatus.PendingReady:
- newStatus = PeerPushDebitStatus.SuspendedReady;
- break;
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.SuspendedReady:
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, pursePub, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.SuspendedReady:
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.PendingCreatePurse:
- // Network request might already be in-flight!
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Expired:
- case PeerPushDebitStatus.Failed:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, pursePub, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- newStatus = PeerPushDebitStatus.AbortingDeletePurse;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- newStatus = PeerPushDebitStatus.AbortingRefreshDeleted;
- break;
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- newStatus = PeerPushDebitStatus.AbortingRefreshExpired;
- break;
- case PeerPushDebitStatus.SuspendedReady:
- newStatus = PeerPushDebitStatus.PendingReady;
- break;
- case PeerPushDebitStatus.SuspendedCreatePurse:
- newStatus = PeerPushDebitStatus.PendingCreatePurse;
- break;
- case PeerPushDebitStatus.PendingCreatePurse:
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.startShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, pursePub, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const pushDebitRec = await tx.peerPushDebit.get(pursePub);
- if (!pushDebitRec) {
- logger.warn(`peer push debit ${pursePub} not found`);
- return;
- }
- let newStatus: PeerPushDebitStatus | undefined = undefined;
- switch (pushDebitRec.status) {
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- // FIXME: What to do about the refresh group?
- newStatus = PeerPushDebitStatus.Failed;
- break;
- case PeerPushDebitStatus.AbortingDeletePurse:
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- case PeerPushDebitStatus.AbortingRefreshExpired:
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.SuspendedReady:
- case PeerPushDebitStatus.SuspendedCreatePurse:
- case PeerPushDebitStatus.PendingCreatePurse:
- newStatus = PeerPushDebitStatus.Failed;
- break;
- case PeerPushDebitStatus.Done:
- case PeerPushDebitStatus.Aborted:
- case PeerPushDebitStatus.Failed:
- case PeerPushDebitStatus.Expired:
- // Do nothing
- break;
- default:
- assertUnreachable(pushDebitRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
- pushDebitRec.status = newStatus;
- const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
- await tx.peerPushDebit.put(pushDebitRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-export async function checkPeerPushDebit(
- ws: InternalWalletState,
- req: CheckPeerPushDebitRequest,
-): Promise<CheckPeerPushDebitResponse> {
- const instructedAmount = Amounts.parseOrThrow(req.amount);
- logger.trace(
- `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
- );
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
- if (coinSelRes.type === "failure") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
- logger.trace(`selected peer coins (len=${coinSelRes.result.coins.length})`);
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
- logger.trace("computed total peer payment cost");
- return {
- exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
- amountEffective: Amounts.stringify(totalAmount),
- amountRaw: req.amount,
- maxExpirationDate: coinSelRes.result.maxExpirationDate,
- };
-}
-
-async function handlePurseCreationConflict(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
- resp: HttpResponse,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const errResp = await readTalerErrorResponse(resp);
- const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
- if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await ctx.failTransaction();
- return TaskRunResult.finished();
- }
-
- // FIXME: Properly parse!
- const brokenCoinPub = (errResp as any).coin_pub;
- logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
-
- if (!brokenCoinPub) {
- // FIXME: Details!
- throw new TalerProtocolViolationError();
- }
-
- const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
- const sel = peerPushInitiation.coinSel;
-
- const repair: PeerCoinRepair = {
- coinPubs: [],
- contribs: [],
- exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
- };
-
- for (let i = 0; i < sel.coinPubs.length; i++) {
- if (sel.coinPubs[i] != brokenCoinPub) {
- repair.coinPubs.push(sel.coinPubs[i]);
- repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
- }
- }
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
-
- if (coinSelRes.type == "failure") {
- // FIXME: Details!
- throw Error(
- "insufficient balance to re-select coins to repair double spending",
- );
- }
-
- await ws.db.runReadWriteTx(["peerPushDebit"], async (tx) => {
- const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub);
- if (!myPpi) {
- return;
- }
- switch (myPpi.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- case PeerPushDebitStatus.SuspendedCreatePurse: {
- const sel = coinSelRes.result;
- myPpi.coinSel = {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- };
- break;
- }
- default:
- return;
- }
- await tx.peerPushDebit.put(myPpi);
- });
- return TaskRunResult.progress();
-}
-
-async function processPeerPushDebitCreateReserve(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const purseExpiration = peerPushInitiation.purseExpiration;
- const hContractTerms = peerPushInitiation.contractTermsHash;
- const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
- const transactionId = ctx.transactionId;
-
- logger.trace(`processing ${transactionId} pending(create-reserve)`);
-
- const contractTermsRecord = await ws.db.runReadOnlyTx(
- ["contractTerms"],
- async (tx) => {
- return tx.contractTerms.get(hContractTerms);
- },
- );
-
- if (!contractTermsRecord) {
- throw Error(
- `db invariant failed, contract terms for ${transactionId} missing`,
- );
- }
-
- const purseSigResp = await ws.cryptoApi.signPurseCreation({
- hContractTerms,
- mergePub: peerPushInitiation.mergePub,
- minAge: 0,
- purseAmount: peerPushInitiation.amount,
- purseExpiration: timestampProtocolFromDb(purseExpiration),
- pursePriv: peerPushInitiation.pursePriv,
- });
-
- const coins = await queryCoinInfosForSelection(
- ws,
- peerPushInitiation.coinSel,
- );
-
- const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
- exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
- pursePub: peerPushInitiation.pursePub,
- coins,
- });
-
- const encryptContractRequest: EncryptContractRequest = {
- contractTerms: contractTermsRecord.contractTermsRaw,
- mergePriv: peerPushInitiation.mergePriv,
- pursePriv: peerPushInitiation.pursePriv,
- pursePub: peerPushInitiation.pursePub,
- contractPriv: peerPushInitiation.contractPriv,
- contractPub: peerPushInitiation.contractPub,
- nonce: peerPushInitiation.contractEncNonce,
- };
-
- logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
-
- const econtractResp = await ws.cryptoApi.encryptContractForMerge(
- encryptContractRequest,
- );
-
- const createPurseUrl = new URL(
- `purses/${peerPushInitiation.pursePub}/create`,
- peerPushInitiation.exchangeBaseUrl,
- );
-
- const reqBody = {
- amount: peerPushInitiation.amount,
- merge_pub: peerPushInitiation.mergePub,
- purse_sig: purseSigResp.sig,
- h_contract_terms: hContractTerms,
- purse_expiration: timestampProtocolFromDb(purseExpiration),
- deposits: depositSigsResp.deposits,
- min_age: 0,
- econtract: econtractResp.econtract,
- };
-
- logger.trace(`request body: ${j2s(reqBody)}`);
-
- const httpResp = await ws.http.fetch(createPurseUrl.href, {
- method: "POST",
- body: reqBody,
- });
-
- {
- const resp = await httpResp.json();
- logger.info(`resp: ${j2s(resp)}`);
- }
-
- switch (httpResp.status) {
- case HttpStatusCode.Ok:
- break;
- case HttpStatusCode.Forbidden: {
- // FIXME: Store this error!
- await ctx.failTransaction();
- return TaskRunResult.finished();
- }
- case HttpStatusCode.Conflict: {
- // Handle double-spending
- return handlePurseCreationConflict(ws, peerPushInitiation, httpResp);
- }
- default: {
- const errResp = await readTalerErrorResponse(httpResp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: errResp,
- };
- }
- }
-
- if (httpResp.status !== HttpStatusCode.Ok) {
- // FIXME: do proper error reporting
- throw Error("got error response from exchange");
- }
-
- await transitionPeerPushDebitTransaction(ws, pursePub, {
- stFrom: PeerPushDebitStatus.PendingCreatePurse,
- stTo: PeerPushDebitStatus.PendingReady,
- });
-
- return TaskRunResult.backoff();
-}
-
-async function processPeerPushDebitAbortingDeletePurse(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const { pursePub, pursePriv } = peerPushInitiation;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
-
- const sigResp = await ws.cryptoApi.signDeletePurse({
- pursePriv,
- });
- const purseUrl = new URL(
- `purses/${pursePub}`,
- peerPushInitiation.exchangeBaseUrl,
- );
- const resp = await ws.http.fetch(purseUrl.href, {
- method: "DELETE",
- headers: {
- "taler-purse-signature": sigResp.sig,
- },
- });
- logger.info(`deleted purse with response status ${resp.status}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "peerPushDebit",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- ],
- async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
- return undefined;
- }
- const currency = Amounts.currencyOf(ppiRec.amount);
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- const coinPubs: CoinRefreshRequest[] = [];
-
- for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: ppiRec.coinSel.contributions[i],
- coinPub: ppiRec.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPushDebit,
- transactionId,
- );
- ppiRec.status = PeerPushDebitStatus.AbortingRefreshDeleted;
- ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-
- return TaskRunResult.backoff();
-}
-
-interface SimpleTransition {
- stFrom: PeerPushDebitStatus;
- stTo: PeerPushDebitStatus;
-}
-
-async function transitionPeerPushDebitTransaction(
- ws: InternalWalletState,
- pursePub: string,
- transitionSpec: SimpleTransition,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit"],
- async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== transitionSpec.stFrom) {
- return undefined;
- }
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- ppiRec.status = transitionSpec.stTo;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processPeerPushDebitAbortingRefreshDeleted(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: peerPushInitiation.pursePub,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups", "peerPushDebit"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPushDebitStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPushDebitStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPushDebitStatus.Aborted;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPushDebitStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPushDebit.get(pursePub);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPushDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPushDebitTransactionState(newDg);
- await tx.peerPushDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.backoff();
-}
-
-async function processPeerPushDebitAbortingRefreshExpired(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
-): Promise<TaskRunResult> {
- const pursePub = peerPushInitiation.pursePub;
- const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
- checkLogicInvariant(!!abortRefreshGroupId);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: peerPushInitiation.pursePub,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["peerPushDebit", "refreshGroups"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
- let newOpState: PeerPushDebitStatus | undefined;
- if (!refreshGroup) {
- // Maybe it got manually deleted? Means that we should
- // just go into failed.
- logger.warn("no aborting refresh group found for deposit group");
- newOpState = PeerPushDebitStatus.Failed;
- } else {
- if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
- newOpState = PeerPushDebitStatus.Expired;
- } else if (
- refreshGroup.operationStatus === RefreshOperationStatus.Failed
- ) {
- newOpState = PeerPushDebitStatus.Failed;
- }
- }
- if (newOpState) {
- const newDg = await tx.peerPushDebit.get(pursePub);
- if (!newDg) {
- return;
- }
- const oldTxState = computePeerPushDebitTransactionState(newDg);
- newDg.status = newOpState;
- const newTxState = computePeerPushDebitTransactionState(newDg);
- await tx.peerPushDebit.put(newDg);
- return { oldTxState, newTxState };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- // FIXME: Shouldn't this be finished in some cases?!
- return TaskRunResult.backoff();
-}
-
-/**
- * Process the "pending(ready)" state of a peer-push-debit transaction.
- */
-async function processPeerPushDebitReady(
- ws: InternalWalletState,
- peerPushInitiation: PeerPushDebitRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace("processing peer-push-debit pending(ready)");
- const pursePub = peerPushInitiation.pursePub;
- const transactionId = constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub,
- });
- const mergeUrl = new URL(
- `purses/${pursePub}/merge`,
- peerPushInitiation.exchangeBaseUrl,
- );
- mergeUrl.searchParams.set("timeout_ms", "30000");
- logger.info(`long-polling on purse status at ${mergeUrl.href}`);
- const resp = await ws.http.fetch(mergeUrl.href, {
- // timeout: getReserveRequestTimeout(withdrawalGroup),
- cancellationToken,
- });
- if (resp.status === HttpStatusCode.Ok) {
- const purseStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangePurseStatus(),
- );
- const mergeTimestamp = purseStatus.merge_timestamp;
- logger.info(`got purse status ${j2s(purseStatus)}`);
- if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) {
- return TaskRunResult.backoff();
- } else {
- await transitionPeerPushDebitTransaction(
- ws,
- peerPushInitiation.pursePub,
- {
- stFrom: PeerPushDebitStatus.PendingReady,
- stTo: PeerPushDebitStatus.Done,
- },
- );
- return TaskRunResult.finished();
- }
- } else if (resp.status === HttpStatusCode.Gone) {
- logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "peerPushDebit",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- ],
- async (tx) => {
- const ppiRec = await tx.peerPushDebit.get(pursePub);
- if (!ppiRec) {
- return undefined;
- }
- if (ppiRec.status !== PeerPushDebitStatus.PendingReady) {
- return undefined;
- }
- const currency = Amounts.currencyOf(ppiRec.amount);
- const oldTxState = computePeerPushDebitTransactionState(ppiRec);
- const coinPubs: CoinRefreshRequest[] = [];
-
- for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
- coinPubs.push({
- amount: ppiRec.coinSel.contributions[i],
- coinPub: ppiRec.coinSel.coinPubs[i],
- });
- }
-
- const refresh = await createRefreshGroup(
- ws,
- tx,
- currency,
- coinPubs,
- RefreshReason.AbortPeerPushDebit,
- transactionId,
- );
- ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired;
- ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
- await tx.peerPushDebit.put(ppiRec);
- const newTxState = computePeerPushDebitTransactionState(ppiRec);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.backoff();
- } else {
- logger.warn(`unexpected HTTP status for purse: ${resp.status}`);
- return TaskRunResult.backoff();
- }
-}
-
-export async function processPeerPushDebit(
- ws: InternalWalletState,
- pursePub: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const peerPushInitiation = await ws.db.runReadOnlyTx(
- ["peerPushDebit"],
- async (tx) => {
- return tx.peerPushDebit.get(pursePub);
- },
- );
- if (!peerPushInitiation) {
- throw Error("peer push payment not found");
- }
-
- switch (peerPushInitiation.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return processPeerPushDebitCreateReserve(ws, peerPushInitiation);
- case PeerPushDebitStatus.PendingReady:
- return processPeerPushDebitReady(
- ws,
- peerPushInitiation,
- cancellationToken,
- );
- case PeerPushDebitStatus.AbortingDeletePurse:
- return processPeerPushDebitAbortingDeletePurse(ws, peerPushInitiation);
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return processPeerPushDebitAbortingRefreshDeleted(ws, peerPushInitiation);
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return processPeerPushDebitAbortingRefreshExpired(ws, peerPushInitiation);
- default: {
- const txState = computePeerPushDebitTransactionState(peerPushInitiation);
- logger.warn(
- `not processing peer-push-debit transaction in state ${j2s(txState)}`,
- );
- }
- }
-
- return TaskRunResult.finished();
-}
-
-/**
- * Initiate sending a peer-to-peer push payment.
- */
-export async function initiatePeerPushDebit(
- ws: InternalWalletState,
- req: InitiatePeerPushDebitRequest,
-): Promise<InitiatePeerPushDebitResponse> {
- const instructedAmount = Amounts.parseOrThrow(
- req.partialContractTerms.amount,
- );
- const purseExpiration = req.partialContractTerms.purse_expiration;
- const contractTerms = req.partialContractTerms;
-
- const pursePair = await ws.cryptoApi.createEddsaKeypair({});
- const mergePair = await ws.cryptoApi.createEddsaKeypair({});
-
- const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
-
- const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
-
- const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
-
- if (coinSelRes.type !== "success") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
- {
- insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
- },
- );
- }
-
- const sel = coinSelRes.result;
-
- logger.info(`selected p2p coins (push):`);
- logger.trace(`${j2s(coinSelRes)}`);
-
- const totalAmount = await getTotalPeerPaymentCost(
- ws,
- coinSelRes.result.coins,
- );
-
- logger.info(`computed total peer payment cost`);
-
- const pursePub = pursePair.pub;
-
- const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
-
- const transactionId = ctx.transactionId;
-
- const contractEncNonce = encodeCrock(getRandomBytes(24));
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "exchanges",
- "contractTerms",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "peerPushDebit",
- ],
- async (tx) => {
- // FIXME: Instead of directly doing a spendCoin here,
- // we might want to mark the coins as used and spend them
- // after we've been able to create the purse.
- await spendCoins(ws, tx, {
- allocationId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pursePair.pub,
- }),
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- ),
- refreshReason: RefreshReason.PayPeerPush,
- });
-
- const ppi: PeerPushDebitRecord = {
- amount: Amounts.stringify(instructedAmount),
- contractPriv: contractKeyPair.priv,
- contractPub: contractKeyPair.pub,
- contractTermsHash: hContractTerms,
- exchangeBaseUrl: sel.exchangeBaseUrl,
- mergePriv: mergePair.priv,
- mergePub: mergePair.pub,
- purseExpiration: timestampProtocolToDb(purseExpiration),
- pursePriv: pursePair.priv,
- pursePub: pursePair.pub,
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- status: PeerPushDebitStatus.PendingCreatePurse,
- contractEncNonce,
- coinSel: {
- coinPubs: sel.coins.map((x) => x.coinPub),
- contributions: sel.coins.map((x) => x.contribution),
- },
- totalCost: Amounts.stringify(totalAmount),
- };
-
- await tx.peerPushDebit.add(ppi);
-
- await tx.contractTerms.put({
- h: hContractTerms,
- contractTermsRaw: contractTerms,
- });
-
- const newTxState = computePeerPushDebitTransactionState(ppi);
- return {
- oldTxState: { major: TransactionMajorState.None },
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- ws.taskScheduler.startShepherdTask(ctx.retryTag);
-
- return {
- contractPriv: contractKeyPair.priv,
- mergePriv: mergePair.priv,
- pursePub: pursePair.pub,
- exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pursePair.pub,
- }),
- };
-}
-
-export function computePeerPushDebitTransactionActions(
- ppiRecord: PeerPushDebitRecord,
-): TransactionAction[] {
- switch (ppiRecord.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushDebitStatus.PendingReady:
- return [TransactionAction.Abort, TransactionAction.Suspend];
- case PeerPushDebitStatus.Aborted:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.AbortingDeletePurse:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case PeerPushDebitStatus.SuspendedCreatePurse:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushDebitStatus.SuspendedReady:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case PeerPushDebitStatus.Done:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.Expired:
- return [TransactionAction.Delete];
- case PeerPushDebitStatus.Failed:
- return [TransactionAction.Delete];
- }
-}
-
-export function computePeerPushDebitTransactionState(
- ppiRecord: PeerPushDebitRecord,
-): TransactionState {
- switch (ppiRecord.status) {
- case PeerPushDebitStatus.PendingCreatePurse:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPushDebitStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Ready,
- };
- case PeerPushDebitStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case PeerPushDebitStatus.AbortingDeletePurse:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.DeletePurse,
- };
- case PeerPushDebitStatus.AbortingRefreshDeleted:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPushDebitStatus.AbortingRefreshExpired:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.RefreshExpired,
- };
- case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.DeletePurse,
- };
- case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.RefreshExpired,
- };
- case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Refresh,
- };
- case PeerPushDebitStatus.SuspendedCreatePurse:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.CreatePurse,
- };
- case PeerPushDebitStatus.SuspendedReady:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.Ready,
- };
- case PeerPushDebitStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case PeerPushDebitStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case PeerPushDebitStatus.Expired:
- return {
- major: TransactionMajorState.Expired,
- };
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts
@@ -1,535 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the recoup operation, which allows to recover the
- * value of coins held in a revoked denomination.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- Amounts,
- CoinStatus,
- Logger,
- RefreshReason,
- TalerPreciseTimestamp,
- TransactionType,
- URL,
- codecForRecoupConfirmation,
- codecForReserveStatus,
- encodeCrock,
- getRandomBytes,
- j2s,
-} from "@gnu-taler/taler-util";
-import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
-import {
- CoinRecord,
- CoinSourceType,
- RecoupGroupRecord,
- RecoupOperationStatus,
- RefreshCoinSource,
- WalletDbReadWriteTransaction,
- WithdrawCoinSource,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
- timestampPreciseToDb,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType } from "../pending-types.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- TaskRunResult,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import { createRefreshGroup } from "./refresh.js";
-import { constructTransactionIdentifier } from "./transactions.js";
-import { internalCreateWithdrawalGroup } from "./withdraw.js";
-
-const logger = new Logger("operations/recoup.ts");
-
-/**
- * Store a recoup group record in the database after marking
- * a coin in the group as finished.
- */
-async function putGroupAsFinished(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["recoupGroups", "denominations", "refreshGroups", "coins"]
- >,
- recoupGroup: RecoupGroupRecord,
- coinIdx: number,
-): Promise<void> {
- logger.trace(
- `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
- );
- if (recoupGroup.timestampFinished) {
- return;
- }
- recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
- await tx.recoupGroups.put(recoupGroup);
-}
-
-async function recoupRewardCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
-): Promise<void> {
- // We can't really recoup a coin we got via tipping.
- // Thus we just put the coin to sleep.
- // FIXME: somehow report this to the user
- await ws.db.runReadWriteTx(
- ["recoupGroups", "denominations", "refreshGroups", "coins"],
- async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- },
- );
-}
-
-async function recoupWithdrawCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: WithdrawCoinSource,
-): Promise<void> {
- const reservePub = cs.reservePub;
- const denomInfo = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- return denomInfo;
- });
- if (!denomInfo) {
- // FIXME: We should at least emit some pending operation / warning for this?
- return;
- }
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest({
- blindingKey: coin.blindingKey,
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- denomPub: denomInfo.denomPub,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- });
- const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
- logger.trace(`requesting recoup via ${reqUrl.href}`);
- const resp = await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: recoupRequest,
- });
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`);
-
- if (recoupConfirmation.reserve_pub !== reservePub) {
- throw Error(`Coin's reserve doesn't match reserve on recoup`);
- }
-
- // FIXME: verify that our expectations about the amount match
-
- await ws.db.runReadWriteTx(
- ["coins", "denominations", "recoupGroups", "refreshGroups"],
- async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const updatedCoin = await tx.coins.get(coin.coinPub);
- if (!updatedCoin) {
- return;
- }
- updatedCoin.status = CoinStatus.Dormant;
- await tx.coins.put(updatedCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- },
- );
-}
-
-async function recoupRefreshCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: RefreshCoinSource,
-): Promise<void> {
- const d = await ws.db.runReadOnlyTx(
- ["coins", "denominations"],
- async (tx) => {
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denomInfo) {
- return;
- }
- return { denomInfo };
- },
- );
- if (!d) {
- // FIXME: We should at least emit some pending operation / warning for this?
- return;
- }
-
- const recoupRequest = await ws.cryptoApi.createRecoupRefreshRequest({
- blindingKey: coin.blindingKey,
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- denomPub: d.denomInfo.denomPub,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- });
- const reqUrl = new URL(
- `/coins/${coin.coinPub}/recoup-refresh`,
- coin.exchangeBaseUrl,
- );
- logger.trace(`making recoup request for ${coin.coinPub}`);
-
- const resp = await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: recoupRequest,
- });
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
- throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
- }
-
- await ws.db.runReadWriteTx(
- ["coins", "denominations", "recoupGroups", "refreshGroups"],
- async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const oldCoin = await tx.coins.get(cs.oldCoinPub);
- const revokedCoin = await tx.coins.get(coin.coinPub);
- if (!revokedCoin) {
- logger.warn("revoked coin for recoup not found");
- return;
- }
- if (!oldCoin) {
- logger.warn("refresh old coin for recoup not found");
- return;
- }
- const oldCoinDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- );
- const revokedCoinDenom = await ws.getDenomInfo(
- ws,
- tx,
- revokedCoin.exchangeBaseUrl,
- revokedCoin.denomPubHash,
- );
- checkDbInvariant(!!oldCoinDenom);
- checkDbInvariant(!!revokedCoinDenom);
- revokedCoin.status = CoinStatus.Dormant;
- if (!revokedCoin.spendAllocation) {
- // We don't know what happened to this coin
- logger.error(
- `can't refresh-recoup coin ${revokedCoin.coinPub}, no spendAllocation known`,
- );
- } else {
- let residualAmount = Amounts.sub(
- revokedCoinDenom.value,
- revokedCoin.spendAllocation.amount,
- ).amount;
- recoupGroup.scheduleRefreshCoins.push({
- coinPub: oldCoin.coinPub,
- amount: Amounts.stringify(residualAmount),
- });
- }
- await tx.coins.put(revokedCoin);
- await tx.coins.put(oldCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- },
- );
-}
-
-export async function processRecoupGroup(
- ws: InternalWalletState,
- recoupGroupId: string,
-): Promise<TaskRunResult> {
- let recoupGroup = await ws.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
- return tx.recoupGroups.get(recoupGroupId);
- });
- if (!recoupGroup) {
- return TaskRunResult.finished();
- }
- if (recoupGroup.timestampFinished) {
- logger.trace("recoup group finished");
- return TaskRunResult.finished();
- }
- const ps = recoupGroup.coinPubs.map(async (x, i) => {
- try {
- await processRecoupForCoin(ws, recoupGroupId, i);
- } catch (e) {
- logger.warn(`processRecoup failed: ${e}`);
- throw e;
- }
- });
- await Promise.all(ps);
-
- recoupGroup = await ws.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
- return tx.recoupGroups.get(recoupGroupId);
- });
- if (!recoupGroup) {
- return TaskRunResult.finished();
- }
-
- for (const b of recoupGroup.recoupFinishedPerCoin) {
- if (!b) {
- return TaskRunResult.finished();
- }
- }
-
- logger.info("all recoups of recoup group are finished");
-
- const reserveSet = new Set<string>();
- const reservePrivMap: Record<string, string> = {};
- for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
- const coinPub = recoupGroup.coinPubs[i];
- await ws.db.runReadOnlyTx(["coins", "reserves"], async (tx) => {
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request recoup`);
- }
- if (coin.coinSource.type === CoinSourceType.Withdraw) {
- const reserve = await tx.reserves.indexes.byReservePub.get(
- coin.coinSource.reservePub,
- );
- if (!reserve) {
- return;
- }
- reserveSet.add(coin.coinSource.reservePub);
- reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
- }
- });
- }
-
- for (const reservePub of reserveSet) {
- const reserveUrl = new URL(
- `reserves/${reservePub}`,
- recoupGroup.exchangeBaseUrl,
- );
- logger.info(`querying reserve status for recoup via ${reserveUrl}`);
-
- const resp = await ws.http.fetch(reserveUrl.href);
-
- const result = await readSuccessResponseJsonOrThrow(
- resp,
- codecForReserveStatus(),
- );
- await internalCreateWithdrawalGroup(ws, {
- amount: Amounts.parseOrThrow(result.balance),
- exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair: {
- pub: reservePub,
- priv: reservePrivMap[reservePub],
- },
- wgInfo: {
- withdrawalType: WithdrawalRecordType.Recoup,
- },
- });
- }
-
- await ws.db.runReadWriteTx(
- [
- "recoupGroups",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "coins",
- ],
- async (tx) => {
- const rg2 = await tx.recoupGroups.get(recoupGroupId);
- if (!rg2) {
- return;
- }
- rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
- rg2.operationStatus = RecoupOperationStatus.Finished;
- if (rg2.scheduleRefreshCoins.length > 0) {
- await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
- rg2.scheduleRefreshCoins,
- RefreshReason.Recoup,
- constructTransactionIdentifier({
- tag: TransactionType.Recoup,
- recoupGroupId: rg2.recoupGroupId,
- }),
- );
- }
- await tx.recoupGroups.put(rg2);
- },
- );
- return TaskRunResult.finished();
-}
-
-export class RewardTransactionContext implements TransactionContext {
- abortTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- suspendTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- resumeTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- failTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- deleteTransaction(): Promise<void> {
- throw new Error("Method not implemented.");
- }
- public transactionId: string;
- public retryTag: string;
-
- constructor(
- public ws: InternalWalletState,
- private recoupGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Recoup,
- recoupGroupId,
- });
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.Recoup,
- recoupGroupId,
- });
- }
-}
-
-export async function createRecoupGroup(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["recoupGroups", "denominations", "refreshGroups", "coins"]
- >,
- exchangeBaseUrl: string,
- coinPubs: string[],
-): Promise<string> {
- const recoupGroupId = encodeCrock(getRandomBytes(32));
-
- const recoupGroup: RecoupGroupRecord = {
- recoupGroupId,
- exchangeBaseUrl: exchangeBaseUrl,
- coinPubs: coinPubs,
- timestampFinished: undefined,
- timestampStarted: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- recoupFinishedPerCoin: coinPubs.map(() => false),
- scheduleRefreshCoins: [],
- operationStatus: RecoupOperationStatus.Pending,
- };
-
- for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
- const coinPub = coinPubs[coinIdx];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- continue;
- }
- await tx.coins.put(coin);
- }
-
- await tx.recoupGroups.put(recoupGroup);
-
- return recoupGroupId;
-}
-
-/**
- * Run the recoup protocol for a single coin in a recoup group.
- */
-async function processRecoupForCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
-): Promise<void> {
- const coin = await ws.db.runReadOnlyTx(
- ["coins", "recoupGroups"],
- async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.timestampFinished) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
-
- const coinPub = recoupGroup.coinPubs[coinIdx];
-
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request recoup`);
- }
- return coin;
- },
- );
-
- if (!coin) {
- return;
- }
-
- const cs = coin.coinSource;
-
- switch (cs.type) {
- case CoinSourceType.Reward:
- return recoupRewardCoin(ws, recoupGroupId, coinIdx, coin);
- case CoinSourceType.Refresh:
- return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
- case CoinSourceType.Withdraw:
- return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
- default:
- throw Error("unknown coin source type");
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts
@@ -1,1430 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- AgeCommitment,
- AgeRestriction,
- AmountJson,
- Amounts,
- amountToPretty,
- CancellationToken,
- codecForExchangeMeltResponse,
- codecForExchangeRevealResponse,
- CoinPublicKeyString,
- CoinRefreshRequest,
- CoinStatus,
- DenominationInfo,
- DenomKeyType,
- Duration,
- encodeCrock,
- ExchangeMeltRequest,
- ExchangeProtocolVersion,
- ExchangeRefreshRevealRequest,
- fnutil,
- ForceRefreshRequest,
- getErrorDetailFromException,
- getRandomBytes,
- HashCodeString,
- HttpStatusCode,
- j2s,
- Logger,
- makeErrorDetail,
- NotificationType,
- RefreshGroupId,
- RefreshReason,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TransactionAction,
- TransactionMajorState,
- TransactionState,
- TransactionType,
- URL,
-} from "@gnu-taler/taler-util";
-import {
- readSuccessResponseJsonOrThrow,
- readUnexpectedResponseDetails,
-} from "@gnu-taler/taler-util/http";
-import { TalerCryptoInterface } from "../crypto/cryptoImplementation.js";
-import {
- DerivedRefreshSession,
- RefreshNewDenomInfo,
-} from "../crypto/cryptoTypes.js";
-import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js";
-import {
- CoinRecord,
- CoinSourceType,
- DenominationRecord,
- RefreshCoinStatus,
- RefreshGroupRecord,
- RefreshOperationStatus,
-} from "../db.js";
-import {
- getCandidateWithdrawalDenomsTx,
- PendingTaskType,
- RefreshGroupPerExchangeInfo,
- RefreshSessionRecord,
- TaskId,
- timestampPreciseToDb,
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
-} from "../index.js";
-import {
- EXCHANGE_COINS_LOCK,
- InternalWalletState,
-} from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { selectWithdrawalDenominations } from "../util/coinSelection.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import {
- constructTaskIdentifier,
- makeCoinAvailable,
- makeCoinsVisible,
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
-} from "./common.js";
-import { fetchFreshExchange } from "./exchanges.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-const logger = new Logger("refresh.ts");
-
-export class RefreshTransactionContext implements TransactionContext {
- public transactionId: string;
- readonly taskId: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public refreshGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Refresh,
- refreshGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const refreshGroupId = this.refreshGroupId;
- const ws = this.ws;
- await ws.db.runReadWriteTx(["refreshGroups", "tombstones"], async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (rg) {
- await tx.refreshGroups.delete(refreshGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
- });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, refreshGroupId, transactionId } = this;
- let res = await ws.db.runReadWriteTx(["refreshGroups"], async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return undefined;
- }
- const oldState = computeRefreshTransactionState(dg);
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return undefined;
- case RefreshOperationStatus.Pending: {
- dg.operationStatus = RefreshOperationStatus.Suspended;
- await tx.refreshGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- }
- case RefreshOperationStatus.Suspended:
- return undefined;
- }
- return undefined;
- });
- if (res) {
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- transactionId,
- oldTxState: res.oldTxState,
- newTxState: res.newTxState,
- });
- }
- }
-
- async abortTransaction(): Promise<void> {
- // Refresh transactions only support fail, not abort.
- throw new Error("refresh transactions cannot be aborted");
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, refreshGroupId, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups"],
- async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return;
- }
- const oldState = computeRefreshTransactionState(dg);
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return;
- case RefreshOperationStatus.Pending: {
- return;
- }
- case RefreshOperationStatus.Suspended:
- dg.operationStatus = RefreshOperationStatus.Pending;
- await tx.refreshGroups.put(dg);
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, refreshGroupId, transactionId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups"],
- async (tx) => {
- const dg = await tx.refreshGroups.get(refreshGroupId);
- if (!dg) {
- logger.warn(
- `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
- );
- return;
- }
- const oldState = computeRefreshTransactionState(dg);
- let newStatus: RefreshOperationStatus | undefined;
- switch (dg.operationStatus) {
- case RefreshOperationStatus.Finished:
- break;
- case RefreshOperationStatus.Pending:
- case RefreshOperationStatus.Suspended:
- newStatus = RefreshOperationStatus.Failed;
- break;
- case RefreshOperationStatus.Failed:
- break;
- default:
- assertUnreachable(dg.operationStatus);
- }
- if (newStatus) {
- dg.operationStatus = newStatus;
- await tx.refreshGroups.put(dg);
- }
- return {
- oldTxState: oldState,
- newTxState: computeRefreshTransactionState(dg),
- };
- },
- );
- ws.taskScheduler.stopShepherdTask(this.taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(this.taskId);
- }
-}
-
-/**
- * Get the amount that we lose when refreshing a coin of the given denomination
- * with a certain amount left.
- *
- * If the amount left is zero, then the refresh cost
- * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
- * the right denominations), then the cost is the full amount left.
- *
- * Considers refresh fees, withdrawal fees after refresh and amounts too small
- * to refresh.
- */
-export function getTotalRefreshCost(
- denoms: DenominationRecord[],
- refreshedDenom: DenominationInfo,
- amountLeft: AmountJson,
- denomselAllowLate: boolean,
-): AmountJson {
- const withdrawAmount = Amounts.sub(
- amountLeft,
- refreshedDenom.feeRefresh,
- ).amount;
- const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
- const withdrawDenoms = selectWithdrawalDenominations(
- withdrawAmount,
- denoms,
- denomselAllowLate,
- );
- const resultingAmount = Amounts.add(
- Amounts.zeroOfCurrency(withdrawAmount.currency),
- ...withdrawDenoms.selectedDenoms.map(
- (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
- ),
- ).amount;
- const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
- logger.trace(
- `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
- totalCost,
- )}`,
- );
- return totalCost;
-}
-
-function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } {
- const allFinal = fnutil.all(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
- );
- const anyFailed = fnutil.any(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Failed,
- );
- if (allFinal) {
- if (anyFailed) {
- rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
- rg.operationStatus = RefreshOperationStatus.Failed;
- } else {
- rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
- rg.operationStatus = RefreshOperationStatus.Finished;
- }
- return { final: true };
- }
- return { final: false };
-}
-
-/**
- * Create a refresh session for one particular coin inside a refresh group.
- *
- * If the session already exists, return the existing one.
- *
- * If the session doesn't need to be created (refresh group gone or session already
- * finished), return undefined.
- */
-async function provideRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<RefreshSessionRecord | undefined> {
- logger.trace(
- `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
- );
-
- const d = await ws.db.runReadWriteTx(
- ["coins", "refreshGroups", "refreshSessions"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- if (
- refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished
- ) {
- return;
- }
- const existingRefreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
- const coin = await tx.coins.get(oldCoinPub);
- if (!coin) {
- throw Error("Can't refresh, coin not found");
- }
- return { refreshGroup, coin, existingRefreshSession };
- },
- );
-
- if (!d) {
- return undefined;
- }
-
- if (d.existingRefreshSession) {
- return d.existingRefreshSession;
- }
-
- const { refreshGroup, coin } = d;
-
- const exch = await fetchFreshExchange(ws, coin.exchangeBaseUrl);
-
- // FIXME: use helper functions from withdraw.ts
- // to update and filter withdrawable denoms.
-
- const { availableAmount, availableDenoms } = await ws.db.runReadOnlyTx(
- ["denominations"],
- async (tx) => {
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- exch.exchangeBaseUrl,
- coin.denomPubHash,
- );
-
- if (!oldDenom) {
- throw Error("db inconsistent: denomination for coin not found");
- }
-
- // FIXME: Use denom groups instead of querying all denominations!
- const availableDenoms: DenominationRecord[] =
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(exch.exchangeBaseUrl)
- .toArray();
-
- const availableAmount = Amounts.sub(
- refreshGroup.inputPerCoin[coinIndex],
- oldDenom.feeRefresh,
- ).amount;
- return { availableAmount, availableDenoms };
- },
- );
-
- const newCoinDenoms = selectWithdrawalDenominations(
- availableAmount,
- availableDenoms,
- ws.config.testing.denomselAllowLate,
- );
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- if (newCoinDenoms.selectedDenoms.length === 0) {
- logger.trace(
- `not refreshing, available amount ${amountToPretty(
- availableAmount,
- )} too small`,
- );
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups", "coins", "coinAvailability"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- const updateRes = updateGroupStatus(rg);
- if (updateRes.final) {
- await makeCoinsVisible(ws, tx, transactionId);
- }
- await tx.refreshGroups.put(rg);
- const newTxState = computeRefreshTransactionState(rg);
- return { oldTxState, newTxState };
- },
- );
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return;
- }
-
- const sessionSecretSeed = encodeCrock(getRandomBytes(64));
-
- // Store refresh session for this coin in the database.
- const mySession = await ws.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- const existingSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (existingSession) {
- return existingSession;
- }
- const newSession: RefreshSessionRecord = {
- coinIndex,
- refreshGroupId,
- norevealIndex: undefined,
- sessionSecretSeed: sessionSecretSeed,
- newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
- count: x.count,
- denomPubHash: x.denomPubHash,
- })),
- amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue),
- };
- await tx.refreshSessions.put(newSession);
- return newSession;
- },
- );
- logger.trace(
- `found/created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
- );
- return mySession;
-}
-
-function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
- return Duration.fromSpec({
- seconds: 5,
- });
-}
-
-async function refreshMelt(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const d = await ws.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions", "coins", "denominations"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- return;
- }
- if (refreshSession.norevealIndex !== undefined) {
- return;
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- );
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- );
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- denomPubHash: newDenom.denomPubHash,
- feeWithdraw: newDenom.feeWithdraw,
- value: Amounts.stringify(newDenom.value),
- });
- }
- return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
- },
- );
-
- if (!d) {
- return;
- }
-
- const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
-
- let exchangeProtocolVersion: ExchangeProtocolVersion;
- switch (d.oldDenom.denomPub.cipher) {
- case DenomKeyType.Rsa: {
- exchangeProtocolVersion = ExchangeProtocolVersion.V12;
- break;
- }
- default:
- throw Error("unsupported key type");
- }
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- exchangeProtocolVersion,
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
- meltCoinMaxAge: oldCoin.maxAge,
- meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
- newCoinDenoms,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const reqUrl = new URL(
- `coins/${oldCoin.coinPub}/melt`,
- oldCoin.exchangeBaseUrl,
- );
-
- let maybeAch: HashCodeString | undefined;
- if (oldCoin.ageCommitmentProof) {
- maybeAch = AgeRestriction.hashCommitment(
- oldCoin.ageCommitmentProof.commitment,
- );
- }
-
- const meltReqBody: ExchangeMeltRequest = {
- coin_pub: oldCoin.coinPub,
- confirm_sig: derived.confirmSig,
- denom_pub_hash: oldCoin.denomPubHash,
- denom_sig: oldCoin.denomSig,
- rc: derived.hash,
- value_with_fee: Amounts.stringify(derived.meltValueWithFee),
- age_commitment_hash: maybeAch,
- };
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.fetch(reqUrl.href, {
- method: "POST",
- body: meltReqBody,
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- if (resp.status === HttpStatusCode.NotFound) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- const transitionInfo = await ws.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions", "coins", "coinAvailability"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- throw Error(
- "db invariant failed: missing refresh session in database",
- );
- }
- refreshSession.lastError = errDetails;
- const updateRes = updateGroupStatus(rg);
- if (updateRes.final) {
- await makeCoinsVisible(ws, tx, transactionId);
- }
- await tx.refreshGroups.put(rg);
- await tx.refreshSessions.put(refreshSession);
- const newTxState = computeRefreshTransactionState(rg);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
- notifyTransition(ws, transactionId, transitionInfo);
- return;
- }
-
- if (resp.status === HttpStatusCode.Conflict) {
- // Just log for better diagnostics here, error status
- // will be handled later.
- logger.error(
- `melt request for ${Amounts.stringify(
- derived.meltValueWithFee,
- )} failed in refresh group ${refreshGroupId} due to conflict`,
- );
-
- const historySig = await ws.cryptoApi.signCoinHistoryRequest({
- coinPriv: oldCoin.coinPriv,
- coinPub: oldCoin.coinPub,
- startOffset: 0,
- });
-
- const historyUrl = new URL(
- `coins/${oldCoin.coinPub}/history`,
- oldCoin.exchangeBaseUrl,
- );
-
- const historyResp = await ws.http.fetch(historyUrl.href, {
- method: "GET",
- headers: {
- "Taler-Coin-History-Signature": historySig.sig,
- },
- });
-
- const historyJson = await historyResp.json();
- logger.info(`coin history: ${j2s(historyJson)}`);
-
- // FIXME: Before failing and re-trying, analyse response and adjust amount
- }
-
- const meltResponse = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeMeltResponse(),
- );
-
- const norevealIndex = meltResponse.noreveal_index;
-
- refreshSession.norevealIndex = norevealIndex;
-
- await ws.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- if (!rs) {
- return;
- }
- if (rs.norevealIndex !== undefined) {
- return;
- }
- rs.norevealIndex = norevealIndex;
- await tx.refreshSessions.put(rs);
- },
- );
-}
-
-export async function assembleRefreshRevealRequest(args: {
- cryptoApi: TalerCryptoInterface;
- derived: DerivedRefreshSession;
- norevealIndex: number;
- oldCoinPub: CoinPublicKeyString;
- oldCoinPriv: string;
- newDenoms: {
- denomPubHash: string;
- count: number;
- }[];
- oldAgeCommitment?: AgeCommitment;
-}): Promise<ExchangeRefreshRevealRequest> {
- const {
- derived,
- norevealIndex,
- cryptoApi,
- oldCoinPriv,
- oldCoinPub,
- newDenoms,
- } = args;
- const privs = Array.from(derived.transferPrivs);
- privs.splice(norevealIndex, 1);
-
- const planchets = derived.planchetsForGammas[norevealIndex];
- if (!planchets) {
- throw Error("refresh index error");
- }
-
- const newDenomsFlat: string[] = [];
- const linkSigs: string[] = [];
-
- for (let i = 0; i < newDenoms.length; i++) {
- const dsel = newDenoms[i];
- for (let j = 0; j < dsel.count; j++) {
- const newCoinIndex = linkSigs.length;
- const linkSig = await cryptoApi.signCoinLink({
- coinEv: planchets[newCoinIndex].coinEv,
- newDenomHash: dsel.denomPubHash,
- oldCoinPriv: oldCoinPriv,
- oldCoinPub: oldCoinPub,
- transferPub: derived.transferPubs[norevealIndex],
- });
- linkSigs.push(linkSig.sig);
- newDenomsFlat.push(dsel.denomPubHash);
- }
- }
-
- const req: ExchangeRefreshRevealRequest = {
- coin_evs: planchets.map((x) => x.coinEv),
- new_denoms_h: newDenomsFlat,
- transfer_privs: privs,
- transfer_pub: derived.transferPubs[norevealIndex],
- link_sigs: linkSigs,
- old_age_commitment: args.oldAgeCommitment?.publicKeys,
- };
- return req;
-}
-
-async function refreshReveal(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`,
- );
- const d = await ws.db.runReadOnlyTx(
- ["refreshGroups", "refreshSessions", "coins", "denominations"],
- async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = await tx.refreshSessions.get([
- refreshGroupId,
- coinIndex,
- ]);
- if (!refreshSession) {
- return;
- }
- const norevealIndex = refreshSession.norevealIndex;
- if (norevealIndex === undefined) {
- throw Error("can't reveal without melting first");
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- );
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await ws.getDenomInfo(
- ws,
- tx,
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- );
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- denomPubHash: newDenom.denomPubHash,
- feeWithdraw: newDenom.feeWithdraw,
- value: Amounts.stringify(newDenom.value),
- });
- }
- return {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- };
- },
- );
-
- if (!d) {
- return;
- }
-
- const {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- } = d;
-
- let exchangeProtocolVersion: ExchangeProtocolVersion;
- switch (d.oldDenom.denomPub.cipher) {
- case DenomKeyType.Rsa: {
- exchangeProtocolVersion = ExchangeProtocolVersion.V12;
- break;
- }
- default:
- throw Error("unsupported key type");
- }
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- exchangeProtocolVersion,
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
- newCoinDenoms,
- meltCoinMaxAge: oldCoin.maxAge,
- meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const reqUrl = new URL(
- `refreshes/${derived.hash}/reveal`,
- oldCoin.exchangeBaseUrl,
- );
-
- const req = await assembleRefreshRevealRequest({
- cryptoApi: ws.cryptoApi,
- derived,
- newDenoms: newCoinDenoms,
- norevealIndex: norevealIndex,
- oldCoinPriv: oldCoin.coinPriv,
- oldCoinPub: oldCoin.coinPub,
- oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment,
- });
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.fetch(reqUrl.href, {
- body: req,
- method: "POST",
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- const reveal = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeRevealResponse(),
- );
-
- const coins: CoinRecord[] = [];
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- });
-
- for (let i = 0; i < refreshSession.newDenoms.length; i++) {
- const ncd = newCoinDenoms[i];
- for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
- const newCoinIndex = coins.length;
- const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
- if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
- throw Error("cipher unsupported");
- }
- const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
- const denomSig = await ws.cryptoApi.unblindDenominationSignature({
- planchet: {
- blindingKey: pc.blindingKey,
- denomPub: ncd.denomPub,
- },
- evSig,
- });
- const coin: CoinRecord = {
- blindingKey: pc.blindingKey,
- coinPriv: pc.coinPriv,
- coinPub: pc.coinPub,
- denomPubHash: ncd.denomPubHash,
- denomSig,
- exchangeBaseUrl: oldCoin.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Refresh,
- refreshGroupId,
- oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
- },
- sourceTransactionId: transactionId,
- coinEvHash: pc.coinEvHash,
- maxAge: pc.maxAge,
- ageCommitmentProof: pc.ageCommitmentProof,
- spendAllocation: undefined,
- };
-
- coins.push(coin);
- }
- }
-
- const transitionInfo = await ws.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "coinAvailability",
- "refreshGroups",
- "refreshSessions",
- ],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- logger.warn("no refresh session found");
- return;
- }
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- if (!rs) {
- return;
- }
- const oldTxState = computeRefreshTransactionState(rg);
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- updateGroupStatus(rg);
- for (const coin of coins) {
- await makeCoinAvailable(ws, tx, coin);
- }
- await makeCoinsVisible(ws, tx, transactionId);
- await tx.refreshGroups.put(rg);
- const newTxState = computeRefreshTransactionState(rg);
- return { oldTxState, newTxState };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- logger.trace("refresh finished (end of reveal)");
-}
-
-export async function processRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace(`processing refresh group ${refreshGroupId}`);
-
- const refreshGroup = await ws.db.runReadOnlyTx(
- ["refreshGroups"],
- async (tx) => tx.refreshGroups.get(refreshGroupId),
- );
- if (!refreshGroup) {
- return TaskRunResult.finished();
- }
- if (refreshGroup.timestampFinished) {
- return TaskRunResult.finished();
- }
- // Process refresh sessions of the group in parallel.
- logger.trace(
- `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`,
- );
- let errors: TalerErrorDetail[] = [];
- let inShutdown = false;
- const ps = refreshGroup.oldCoinPubs.map((x, i) =>
- processRefreshSession(ws, refreshGroupId, i).catch((x) => {
- if (x instanceof CryptoApiStoppedError) {
- inShutdown = true;
- logger.info(
- "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
- );
- return;
- }
- if (x instanceof TalerError) {
- logger.warn("process refresh session got exception (TalerError)");
- logger.warn(`exc ${x}`);
- logger.warn(`exc stack ${x.stack}`);
- logger.warn(`error detail: ${j2s(x.errorDetail)}`);
- } else {
- logger.warn("process refresh session got exception");
- logger.warn(`exc ${x}`);
- logger.warn(`exc stack ${x.stack}`);
- }
- errors.push(getErrorDetailFromException(x));
- }),
- );
- try {
- logger.info("waiting for refreshes");
- await Promise.all(ps);
- logger.info("refresh group finished");
- } catch (e) {
- logger.warn("process refresh sessions got exception");
- logger.warn(`exception: ${e}`);
- }
- if (inShutdown) {
- return TaskRunResult.backoff();
- }
- if (errors.length > 0) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE,
- {
- numErrors: errors.length,
- errors: errors.slice(0, 5),
- },
- ),
- };
- }
-
- return TaskRunResult.backoff();
-}
-
-async function processRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
- );
- let { refreshGroup, refreshSession } = await ws.db.runReadOnlyTx(
- ["refreshGroups", "refreshSessions"],
- async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
- return {
- refreshGroup: rg,
- refreshSession: rs,
- };
- },
- );
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
- return;
- }
- if (!refreshSession) {
- refreshSession = await provideRefreshSession(ws, refreshGroupId, coinIndex);
- }
- if (!refreshSession) {
- // We tried to create the refresh session, but didn't get a result back.
- // This means that either the session is finished, or that creating
- // one isn't necessary.
- return;
- }
- if (refreshSession.norevealIndex === undefined) {
- await refreshMelt(ws, refreshGroupId, coinIndex);
- }
- await refreshReveal(ws, refreshGroupId, coinIndex);
-}
-
-export interface RefreshOutputInfo {
- outputPerCoin: AmountJson[];
- perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>;
-}
-
-export async function calculateRefreshOutput(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<
- ["denominations", "coins", "refreshGroups", "coinAvailability"]
- >,
- currency: string,
- oldCoinPubs: CoinRefreshRequest[],
-): Promise<RefreshOutputInfo> {
- const estimatedOutputPerCoin: AmountJson[] = [];
-
- const denomsPerExchange: Record<string, DenominationRecord[]> = {};
-
- const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {};
-
- // FIXME: Use denom groups instead of querying all denominations!
- const getDenoms = async (
- exchangeBaseUrl: string,
- ): Promise<DenominationRecord[]> => {
- if (denomsPerExchange[exchangeBaseUrl]) {
- return denomsPerExchange[exchangeBaseUrl];
- }
- const allDenoms = await getCandidateWithdrawalDenomsTx(
- ws,
- tx,
- exchangeBaseUrl,
- currency,
- );
- denomsPerExchange[exchangeBaseUrl] = allDenoms;
- return allDenoms;
- };
-
- for (const ocp of oldCoinPubs) {
- const coin = await tx.coins.get(ocp.coinPub);
- checkDbInvariant(!!coin, "coin must be in database");
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(
- !!denom,
- "denomination for existing coin must be in database",
- );
- const refreshAmount = ocp.amount;
- const denoms = await getDenoms(coin.exchangeBaseUrl);
- const cost = getTotalRefreshCost(
- denoms,
- denom,
- Amounts.parseOrThrow(refreshAmount),
- ws.config.testing.denomselAllowLate,
- );
- const output = Amounts.sub(refreshAmount, cost).amount;
- let exchInfo = infoPerExchange[coin.exchangeBaseUrl];
- if (!exchInfo) {
- infoPerExchange[coin.exchangeBaseUrl] = exchInfo = {
- outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)),
- };
- }
- exchInfo.outputEffective = Amounts.stringify(
- Amounts.add(exchInfo.outputEffective, output).amount,
- );
- estimatedOutputPerCoin.push(output);
- }
-
- return {
- outputPerCoin: estimatedOutputPerCoin,
- perExchangeInfo: infoPerExchange,
- };
-}
-
-async function applyRefresh(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["denominations", "coins", "refreshGroups", "coinAvailability"]
- >,
- oldCoinPubs: CoinRefreshRequest[],
- refreshGroupId: string,
-): Promise<void> {
- for (const ocp of oldCoinPubs) {
- const coin = await tx.coins.get(ocp.coinPub);
- checkDbInvariant(!!coin, "coin must be in database");
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(
- !!denom,
- "denomination for existing coin must be in database",
- );
- switch (coin.status) {
- case CoinStatus.Dormant:
- break;
- case CoinStatus.Fresh: {
- coin.status = CoinStatus.Dormant;
- const coinAv = await tx.coinAvailability.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- coin.maxAge,
- ]);
- checkDbInvariant(!!coinAv);
- checkDbInvariant(coinAv.freshCoinCount > 0);
- coinAv.freshCoinCount--;
- await tx.coinAvailability.put(coinAv);
- break;
- }
- case CoinStatus.FreshSuspended: {
- // For suspended coins, we don't have to adjust coin
- // availability, as they are not counted as available.
- coin.status = CoinStatus.Dormant;
- break;
- }
- default:
- assertUnreachable(coin.status);
- }
- if (!coin.spendAllocation) {
- coin.spendAllocation = {
- amount: Amounts.stringify(ocp.amount),
- // id: `txn:refresh:${refreshGroupId}`,
- id: constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId,
- }),
- };
- }
- await tx.coins.put(coin);
- }
-}
-
-export interface CreateRefreshGroupResult {
- refreshGroupId: string;
-}
-
-/**
- * Create a refresh group for a list of coins.
- *
- * Refreshes the remaining amount on the coin, effectively capturing the remaining
- * value in the refresh group.
- *
- * The caller must also ensure that the coins that should be refreshed exist
- * in the current database transaction.
- */
-export async function createRefreshGroup(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["denominations", "coins", "refreshGroups", "coinAvailability"]
- >,
- currency: string,
- oldCoinPubs: CoinRefreshRequest[],
- refreshReason: RefreshReason,
- originatingTransactionId: string | undefined,
-): Promise<CreateRefreshGroupResult> {
- const refreshGroupId = encodeCrock(getRandomBytes(32));
-
- const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs);
-
- const estimatedOutputPerCoin = outInfo.outputPerCoin;
-
- await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId);
-
- const refreshGroup: RefreshGroupRecord = {
- operationStatus: RefreshOperationStatus.Pending,
- currency,
- timestampFinished: undefined,
- statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
- oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
- originatingTransactionId,
- reason: refreshReason,
- refreshGroupId,
- inputPerCoin: oldCoinPubs.map((x) => x.amount),
- expectedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
- Amounts.stringify(x),
- ),
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
-
- if (oldCoinPubs.length == 0) {
- logger.warn("created refresh group with zero coins");
- refreshGroup.timestampFinished = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- refreshGroup.operationStatus = RefreshOperationStatus.Finished;
- }
-
- await tx.refreshGroups.put(refreshGroup);
-
- logger.trace(`created refresh group ${refreshGroupId}`);
-
- const ctx = new RefreshTransactionContext(ws, refreshGroupId);
-
- // Shepherd the task.
- // If the current transaction fails to commit the refresh
- // group to the DB, the shepherd will give up.
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- refreshGroupId,
- };
-}
-
-export function computeRefreshTransactionState(
- rg: RefreshGroupRecord,
-): TransactionState {
- switch (rg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return {
- major: TransactionMajorState.Done,
- };
- case RefreshOperationStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- case RefreshOperationStatus.Pending:
- return {
- major: TransactionMajorState.Pending,
- };
- case RefreshOperationStatus.Suspended:
- return {
- major: TransactionMajorState.Suspended,
- };
- }
-}
-
-export function computeRefreshTransactionActions(
- rg: RefreshGroupRecord,
-): TransactionAction[] {
- switch (rg.operationStatus) {
- case RefreshOperationStatus.Finished:
- return [TransactionAction.Delete];
- case RefreshOperationStatus.Failed:
- return [TransactionAction.Delete];
- case RefreshOperationStatus.Pending:
- return [
- TransactionAction.Retry,
- TransactionAction.Suspend,
- TransactionAction.Fail,
- ];
- case RefreshOperationStatus.Suspended:
- return [TransactionAction.Resume, TransactionAction.Fail];
- }
-}
-
-export function getRefreshesForTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<string[]> {
- return ws.db.runReadOnlyTx(["refreshGroups"], async (tx) => {
- const groups =
- await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll(
- transactionId,
- );
- return groups.map((x) =>
- constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId: x.refreshGroupId,
- }),
- );
- });
-}
-
-export async function forceRefresh(
- ws: InternalWalletState,
- req: ForceRefreshRequest,
-): Promise<{ refreshGroupId: RefreshGroupId }> {
- if (req.coinPubList.length == 0) {
- throw Error("refusing to create empty refresh group");
- }
- const refreshGroupId = await ws.db.runReadWriteTx(
- ["refreshGroups", "coinAvailability", "denominations", "coins"],
- async (tx) => {
- let coinPubs: CoinRefreshRequest[] = [];
- for (const c of req.coinPubList) {
- const coin = await tx.coins.get(c);
- if (!coin) {
- throw Error(`coin (pubkey ${c}) not found`);
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- checkDbInvariant(!!denom);
- coinPubs.push({
- coinPub: c,
- amount: denom?.value,
- });
- }
- return await createRefreshGroup(
- ws,
- tx,
- Amounts.currencyOf(coinPubs[0].amount),
- coinPubs,
- RefreshReason.Manual,
- undefined,
- );
- },
- );
-
- return {
- refreshGroupId,
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/reward.ts b/packages/taler-wallet-core/src/operations/reward.ts
@@ -1,321 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AcceptTipResponse,
- Logger,
- PrepareTipResult,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
-} from "@gnu-taler/taler-util";
-import { RewardRecord, RewardRecordStatus } from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import {
- TaskRunResult,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import {
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-const logger = new Logger("operations/tip.ts");
-
-export class RewardTransactionContext implements TransactionContext {
- public transactionId: string;
- public retryTag: string;
-
- constructor(
- public ws: InternalWalletState,
- public walletRewardId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId,
- });
- this.retryTag = constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, walletRewardId } = this;
- await ws.db.runReadWriteTx(["rewards", "tombstones"], async (tx) => {
- const tipRecord = await tx.rewards.get(walletRewardId);
- if (tipRecord) {
- await tx.rewards.delete(walletRewardId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteReward + ":" + walletRewardId,
- });
- }
- });
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, walletRewardId, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["rewards"],
- async (tx) => {
- const tipRec = await tx.rewards.get(walletRewardId);
- if (!tipRec) {
- logger.warn(`transaction tip ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (tipRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.SuspendedPickup:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.Failed:
- break;
- case RewardRecordStatus.PendingPickup:
- newStatus = RewardRecordStatus.SuspendedPickup;
- break;
-
- default:
- assertUnreachable(tipRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(tipRec);
- tipRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(tipRec);
- await tx.rewards.put(tipRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, walletRewardId, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["rewards"],
- async (tx) => {
- const tipRec = await tx.rewards.get(walletRewardId);
- if (!tipRec) {
- logger.warn(`transaction tip ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (tipRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.PendingPickup:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.Failed:
- break;
- case RewardRecordStatus.SuspendedPickup:
- newStatus = RewardRecordStatus.Aborted;
- break;
- default:
- assertUnreachable(tipRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(tipRec);
- tipRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(tipRec);
- await tx.rewards.put(tipRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, walletRewardId, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["rewards"],
- async (tx) => {
- const rewardRec = await tx.rewards.get(walletRewardId);
- if (!rewardRec) {
- logger.warn(`transaction reward ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (rewardRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.PendingPickup:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.Failed:
- break;
- case RewardRecordStatus.SuspendedPickup:
- newStatus = RewardRecordStatus.PendingPickup;
- break;
- default:
- assertUnreachable(rewardRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(rewardRec);
- rewardRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(rewardRec);
- await tx.rewards.put(rewardRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, walletRewardId, transactionId, retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["rewards"],
- async (tx) => {
- const tipRec = await tx.rewards.get(walletRewardId);
- if (!tipRec) {
- logger.warn(`transaction tip ${walletRewardId} not found`);
- return;
- }
- let newStatus: RewardRecordStatus | undefined = undefined;
- switch (tipRec.status) {
- case RewardRecordStatus.Done:
- case RewardRecordStatus.Aborted:
- case RewardRecordStatus.Failed:
- break;
- case RewardRecordStatus.PendingPickup:
- case RewardRecordStatus.DialogAccept:
- case RewardRecordStatus.SuspendedPickup:
- newStatus = RewardRecordStatus.Failed;
- break;
- default:
- assertUnreachable(tipRec.status);
- }
- if (newStatus != null) {
- const oldTxState = computeRewardTransactionStatus(tipRec);
- tipRec.status = newStatus;
- const newTxState = computeRewardTransactionStatus(tipRec);
- await tx.rewards.put(tipRec);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- }
-}
-
-/**
- * Get the (DD37-style) transaction status based on the
- * database record of a reward.
- */
-export function computeRewardTransactionStatus(
- tipRecord: RewardRecord,
-): TransactionState {
- switch (tipRecord.status) {
- case RewardRecordStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case RewardRecordStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case RewardRecordStatus.PendingPickup:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pickup,
- };
- case RewardRecordStatus.DialogAccept:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case RewardRecordStatus.SuspendedPickup:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pickup,
- };
- case RewardRecordStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- default:
- assertUnreachable(tipRecord.status);
- }
-}
-
-export function computeTipTransactionActions(
- tipRecord: RewardRecord,
-): TransactionAction[] {
- switch (tipRecord.status) {
- case RewardRecordStatus.Done:
- return [TransactionAction.Delete];
- case RewardRecordStatus.Failed:
- return [TransactionAction.Delete];
- case RewardRecordStatus.Aborted:
- return [TransactionAction.Delete];
- case RewardRecordStatus.PendingPickup:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case RewardRecordStatus.SuspendedPickup:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case RewardRecordStatus.DialogAccept:
- return [TransactionAction.Abort];
- default:
- assertUnreachable(tipRecord.status);
- }
-}
-
-export async function prepareReward(
- ws: InternalWalletState,
- talerTipUri: string,
-): Promise<PrepareTipResult> {
- throw Error("the rewards feature is not supported anymore");
-}
-
-export async function processTip(
- ws: InternalWalletState,
- walletTipId: string,
-): Promise<TaskRunResult> {
- return TaskRunResult.finished();
-}
-
-export async function acceptTip(
- ws: InternalWalletState,
- transactionId: TransactionIdStr,
-): Promise<AcceptTipResponse> {
- throw Error("the rewards feature is not supported anymore");
-}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts
@@ -1,913 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * @file
- * Implementation of wallet-core operations that are used for testing,
- * but typically not in the production wallet.
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- addPaytoQueryParams,
- Amounts,
- AmountString,
- CheckPaymentResponse,
- codecForAny,
- codecForCheckPaymentResponse,
- ConfirmPayResultType,
- Duration,
- IntegrationTestArgs,
- IntegrationTestV2Args,
- j2s,
- Logger,
- NotificationType,
- OpenedPromise,
- openPromise,
- parsePaytoUri,
- PreparePayResultType,
- TalerCorebankApiClient,
- TestPayArgs,
- TestPayResult,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- WithdrawTestBalanceRequest,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
-} from "@gnu-taler/taler-util/http";
-import { getRefreshesForTransaction } from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { checkLogicInvariant } from "../util/invariants.js";
-import { getBalances } from "./balance.js";
-import { createDepositGroup } from "./deposits.js";
-import { fetchFreshExchange } from "./exchanges.js";
-import {
- confirmPay,
- preparePayForUri,
- startRefundQueryForUri,
-} from "./pay-merchant.js";
-import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
-import {
- confirmPeerPullDebit,
- preparePeerPullDebit,
-} from "./pay-peer-pull-debit.js";
-import {
- confirmPeerPushCredit,
- preparePeerPushCredit,
-} from "./pay-peer-push-credit.js";
-import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
-import { getTransactionById, getTransactions } from "./transactions.js";
-import { acceptWithdrawalFromUri } from "./withdraw.js";
-
-const logger = new Logger("operations/testing.ts");
-
-interface MerchantBackendInfo {
- baseUrl: string;
- authToken?: string;
-}
-
-export interface WithdrawTestBalanceResult {
- /**
- * Transaction ID of the newly created withdrawal transaction.
- */
- transactionId: string;
-
- /**
- * Account of the user registered for the withdrawal.
- */
- accountPaytoUri: string;
-}
-
-export async function withdrawTestBalance(
- ws: InternalWalletState,
- req: WithdrawTestBalanceRequest,
-): Promise<WithdrawTestBalanceResult> {
- const amount = req.amount;
- const exchangeBaseUrl = req.exchangeBaseUrl;
- const corebankApiBaseUrl = req.corebankApiBaseUrl;
-
- logger.trace(
- `Registering bank user, bank access base url ${corebankApiBaseUrl}`,
- );
-
- const corebankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
-
- const bankUser = await corebankClient.createRandomBankUser();
- logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
-
- corebankClient.setAuth(bankUser);
-
- const wresp = await corebankClient.createWithdrawalOperation(
- bankUser.username,
- amount,
- );
-
- const acceptResp = await acceptWithdrawalFromUri(ws, {
- talerWithdrawUri: wresp.taler_withdraw_uri,
- selectedExchange: exchangeBaseUrl,
- forcedDenomSel: req.forcedDenomSel,
- });
-
- await corebankClient.confirmWithdrawalOperation(bankUser.username, {
- withdrawalOperationId: wresp.withdrawal_id,
- });
-
- return {
- transactionId: acceptResp.transactionId,
- accountPaytoUri: bankUser.accountPaytoUri,
- };
-}
-
-/**
- * FIXME: User MerchantApiClient instead.
- */
-function getMerchantAuthHeader(m: MerchantBackendInfo): Record<string, string> {
- if (m.authToken) {
- return {
- Authorization: `Bearer ${m.authToken}`,
- };
- }
- return {};
-}
-
-/**
- * FIXME: User MerchantApiClient instead.
- */
-async function refund(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- orderId: string,
- reason: string,
- refundAmount: string,
-): Promise<string> {
- const reqUrl = new URL(
- `private/orders/${orderId}/refund`,
- merchantBackend.baseUrl,
- );
- const refundReq = {
- order_id: orderId,
- reason,
- refund: refundAmount,
- };
- const resp = await http.fetch(reqUrl.href, {
- method: "POST",
- body: refundReq,
- headers: getMerchantAuthHeader(merchantBackend),
- });
- const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- const refundUri = r.taler_refund_uri;
- if (!refundUri) {
- throw Error("no refund URI in response");
- }
- return refundUri;
-}
-
-/**
- * FIXME: User MerchantApiClient instead.
- */
-async function createOrder(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- amount: string,
- summary: string,
- fulfillmentUrl: string,
-): Promise<{ orderId: string }> {
- const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
- const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
- const orderReq = {
- order: {
- amount,
- summary,
- fulfillment_url: fulfillmentUrl,
- refund_deadline: { t_s: t },
- wire_transfer_deadline: { t_s: t },
- },
- };
- const resp = await http.fetch(reqUrl, {
- method: "POST",
- body: orderReq,
- headers: getMerchantAuthHeader(merchantBackend),
- });
- const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- const orderId = r.order_id;
- if (!orderId) {
- throw Error("no order id in response");
- }
- return { orderId };
-}
-
-/**
- * FIXME: User MerchantApiClient instead.
- */
-async function checkPayment(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- orderId: string,
-): Promise<CheckPaymentResponse> {
- const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
- reqUrl.searchParams.set("order_id", orderId);
- const resp = await http.fetch(reqUrl.href, {
- headers: getMerchantAuthHeader(merchantBackend),
- });
- return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
-}
-
-interface MakePaymentResult {
- orderId: string;
- paymentTransactionId: string;
-}
-
-async function makePayment(
- ws: InternalWalletState,
- merchant: MerchantBackendInfo,
- amount: string,
- summary: string,
-): Promise<MakePaymentResult> {
- const orderResp = await createOrder(
- ws.http,
- merchant,
- amount,
- summary,
- "taler://fulfillment-success/thx",
- );
-
- logger.trace("created order with orderId", orderResp.orderId);
-
- let paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
-
- logger.trace("payment status", paymentStatus);
-
- const talerPayUri = paymentStatus.taler_pay_uri;
- if (!talerPayUri) {
- throw Error("no taler://pay/ URI in payment response");
- }
-
- const preparePayResult = await preparePayForUri(ws, talerPayUri);
-
- logger.trace("prepare pay result", preparePayResult);
-
- if (preparePayResult.status != "payment-possible") {
- throw Error("payment not possible");
- }
-
- const confirmPayResult = await confirmPay(
- ws,
- preparePayResult.transactionId,
- undefined,
- );
-
- logger.trace("confirmPayResult", confirmPayResult);
-
- paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
-
- logger.trace("payment status after wallet payment:", paymentStatus);
-
- if (paymentStatus.order_status !== "paid") {
- throw Error("payment did not succeed");
- }
-
- return {
- orderId: orderResp.orderId,
- paymentTransactionId: preparePayResult.transactionId,
- };
-}
-
-export async function runIntegrationTest(
- ws: InternalWalletState,
- args: IntegrationTestArgs,
-): Promise<void> {
- logger.info("running test with arguments", args);
-
- const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
- const currency = parsedSpendAmount.currency;
-
- logger.info("withdrawing test balance");
- const withdrawRes1 = await withdrawTestBalance(ws, {
- amount: args.amountToWithdraw,
- corebankApiBaseUrl: args.corebankApiBaseUrl,
- exchangeBaseUrl: args.exchangeBaseUrl,
- });
- await waitUntilGivenTransactionsFinal(ws, [withdrawRes1.transactionId]);
- logger.info("done withdrawing test balance");
-
- const balance = await getBalances(ws);
-
- logger.trace(JSON.stringify(balance, null, 2));
-
- const myMerchant: MerchantBackendInfo = {
- baseUrl: args.merchantBaseUrl,
- authToken: args.merchantAuthToken,
- };
-
- const makePaymentRes = await makePayment(
- ws,
- myMerchant,
- args.amountToSpend,
- "hello world",
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- makePaymentRes.paymentTransactionId,
- );
-
- logger.trace("withdrawing test balance for refund");
- const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
- const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
- const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
- const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
-
- const withdrawRes2 = await withdrawTestBalance(ws, {
- amount: Amounts.stringify(withdrawAmountTwo),
- corebankApiBaseUrl: args.corebankApiBaseUrl,
- exchangeBaseUrl: args.exchangeBaseUrl,
- });
-
- await waitUntilGivenTransactionsFinal(ws, [withdrawRes2.transactionId]);
-
- const { orderId: refundOrderId } = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountTwo),
- "order that will be refunded",
- );
-
- const refundUri = await refund(
- ws.http,
- myMerchant,
- refundOrderId,
- "test refund",
- Amounts.stringify(refundAmount),
- );
-
- logger.trace("refund URI", refundUri);
-
- const refundResp = await startRefundQueryForUri(ws, refundUri);
-
- logger.trace("integration test: applied refund");
-
- // Wait until the refund is done
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- refundResp.transactionId,
- );
-
- logger.trace("integration test: making payment after refund");
-
- const paymentResp2 = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountThree),
- "payment after refund",
- );
-
- logger.trace("integration test: make payment done");
-
- await waitUntilGivenTransactionsFinal(ws, [
- paymentResp2.paymentTransactionId,
- ]);
- await waitUntilGivenTransactionsFinal(
- ws,
- await getRefreshesForTransaction(ws, paymentResp2.paymentTransactionId),
- );
-
- logger.trace("integration test: all done!");
-}
-
-/**
- * Wait until all transactions are in a final state.
- */
-export async function waitUntilAllTransactionsFinal(
- ws: InternalWalletState,
-): Promise<void> {
- logger.info("waiting until all transactions are in a final state");
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- switch (notif.newTxState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- break;
- default:
- p.resolve();
- }
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(ws, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
- }
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
- logger.info("done waiting until all transactions are in a final state");
-}
-
-/**
- * Wait until all chosen transactions are in a final state.
- */
-export async function waitUntilGivenTransactionsFinal(
- ws: InternalWalletState,
- transactionIds: string[],
-): Promise<void> {
- logger.info(
- `waiting until given ${transactionIds.length} transactions are in a final state`,
- );
- logger.info(`transaction IDs are: ${j2s(transactionIds)}`);
- if (transactionIds.length === 0) {
- return;
- }
- ws.ensureTaskLoopRunning();
- const txIdSet = new Set(transactionIds);
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- if (!txIdSet.has(notif.transactionId)) {
- return;
- }
- switch (notif.newTxState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- break;
- default:
- p.resolve();
- }
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(ws, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- if (!txIdSet.has(tx.transactionId)) {
- // Don't look at this transaction, we're not interested in it.
- continue;
- }
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
- }
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
- logger.info("done waiting until given transactions are in a final state");
-}
-
-export async function waitUntilRefreshesDone(
- ws: InternalWalletState,
-): Promise<void> {
- logger.info("waiting until all refresh transactions are in a final state");
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- switch (notif.newTxState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- break;
- default:
- p.resolve();
- }
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(ws, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- if (tx.type !== TransactionType.Refresh) {
- continue;
- }
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
- }
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
- logger.info("done waiting until all refreshes are in a final state");
-}
-
-async function waitUntilTransactionPendingReady(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- logger.info(`starting waiting for ${transactionId} to be in pending(ready)`);
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- p.resolve();
- }
- });
- while (1) {
- p = openPromise();
- const tx = await getTransactionById(ws, {
- transactionId,
- });
- if (
- tx.txState.major == TransactionMajorState.Pending &&
- tx.txState.minor === TransactionMinorState.Ready
- ) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- logger.info(`done waiting for ${transactionId} to be in pending(ready)`);
- cancelNotifs();
-}
-
-/**
- * Wait until a transaction is in a particular state.
- */
-export async function waitTransactionState(
- ws: InternalWalletState,
- transactionId: string,
- txState: TransactionState,
-): Promise<void> {
- logger.info(
- `starting waiting for ${transactionId} to be in ${JSON.stringify(
- txState,
- )})`,
- );
- ws.ensureTaskLoopRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- p.resolve();
- }
- });
- while (1) {
- p = openPromise();
- const tx = await getTransactionById(ws, {
- transactionId,
- });
- if (
- tx.txState.major === txState.major &&
- tx.txState.minor === txState.minor
- ) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- logger.info(
- `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
- );
- cancelNotifs();
-}
-
-export async function waitUntilTransactionWithAssociatedRefreshesFinal(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- await waitUntilGivenTransactionsFinal(ws, [transactionId]);
- await waitUntilGivenTransactionsFinal(
- ws,
- await getRefreshesForTransaction(ws, transactionId),
- );
-}
-
-export async function waitUntilTransactionFinal(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- await waitUntilGivenTransactionsFinal(ws, [transactionId]);
-}
-
-export async function runIntegrationTest2(
- ws: InternalWalletState,
- args: IntegrationTestV2Args,
-): Promise<void> {
- ws.ensureTaskLoopRunning();
- logger.info("running test with arguments", args);
-
- const exchangeInfo = await fetchFreshExchange(ws, args.exchangeBaseUrl);
-
- const currency = exchangeInfo.currency;
-
- const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`);
- const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);
-
- logger.info("withdrawing test balance");
- const withdrawalRes = await withdrawTestBalance(ws, {
- amount: Amounts.stringify(amountToWithdraw),
- corebankApiBaseUrl: args.corebankApiBaseUrl,
- exchangeBaseUrl: args.exchangeBaseUrl,
- });
- await waitUntilTransactionFinal(ws, withdrawalRes.transactionId);
- logger.info("done withdrawing test balance");
-
- const balance = await getBalances(ws);
-
- logger.trace(JSON.stringify(balance, null, 2));
-
- const myMerchant: MerchantBackendInfo = {
- baseUrl: args.merchantBaseUrl,
- authToken: args.merchantAuthToken,
- };
-
- const makePaymentRes = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(amountToSpend),
- "hello world",
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- makePaymentRes.paymentTransactionId,
- );
-
- logger.trace("withdrawing test balance for refund");
- const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
- const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
- const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
- const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
-
- const withdrawalRes2 = await withdrawTestBalance(ws, {
- amount: Amounts.stringify(withdrawAmountTwo),
- corebankApiBaseUrl: args.corebankApiBaseUrl,
- exchangeBaseUrl: args.exchangeBaseUrl,
- });
-
- // Wait until the withdraw is done
- await waitUntilTransactionFinal(ws, withdrawalRes2.transactionId);
-
- const { orderId: refundOrderId } = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountTwo),
- "order that will be refunded",
- );
-
- const refundUri = await refund(
- ws.http,
- myMerchant,
- refundOrderId,
- "test refund",
- Amounts.stringify(refundAmount),
- );
-
- logger.trace("refund URI", refundUri);
-
- const refundResp = await startRefundQueryForUri(ws, refundUri);
-
- logger.trace("integration test: applied refund");
-
- // Wait until the refund is done
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- refundResp.transactionId,
- );
-
- logger.trace("integration test: making payment after refund");
-
- const makePaymentRes2 = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountThree),
- "payment after refund",
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- makePaymentRes2.paymentTransactionId,
- );
-
- logger.trace("integration test: make payment done");
-
- const peerPushInit = await initiatePeerPushDebit(ws, {
- partialContractTerms: {
- amount: `${currency}:1` as AmountString,
- summary: "Payment Peer Push Test",
- purse_expiration: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- Duration.fromSpec({ hours: 1 }),
- ),
- ),
- },
- });
-
- await waitUntilTransactionPendingReady(ws, peerPushInit.transactionId);
- const txDetails = await getTransactionById(ws, {
- transactionId: peerPushInit.transactionId,
- });
-
- if (txDetails.type !== TransactionType.PeerPushDebit) {
- throw Error("internal invariant failed");
- }
-
- if (!txDetails.talerUri) {
- throw Error("internal invariant failed");
- }
-
- const peerPushCredit = await preparePeerPushCredit(ws, {
- talerUri: txDetails.talerUri,
- });
-
- await confirmPeerPushCredit(ws, {
- transactionId: peerPushCredit.transactionId,
- });
-
- const peerPullInit = await initiatePeerPullPayment(ws, {
- partialContractTerms: {
- amount: `${currency}:1` as AmountString,
- summary: "Payment Peer Pull Test",
- purse_expiration: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.now(),
- Duration.fromSpec({ hours: 1 }),
- ),
- ),
- },
- });
-
- await waitUntilTransactionPendingReady(ws, peerPullInit.transactionId);
-
- const peerPullInc = await preparePeerPullDebit(ws, {
- talerUri: peerPullInit.talerUri,
- });
-
- await confirmPeerPullDebit(ws, {
- peerPullDebitId: peerPullInc.peerPullDebitId,
- });
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- peerPullInc.transactionId,
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- peerPullInit.transactionId,
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- peerPushCredit.transactionId,
- );
-
- await waitUntilTransactionWithAssociatedRefreshesFinal(
- ws,
- peerPushInit.transactionId,
- );
-
- let depositPayto = withdrawalRes.accountPaytoUri;
-
- const parsedPayto = parsePaytoUri(depositPayto);
- if (!parsedPayto) {
- throw Error("invalid payto");
- }
-
- // Work around libeufin-bank bug where receiver-name is missing
- if (!parsedPayto.params["receiver-name"]) {
- depositPayto = addPaytoQueryParams(depositPayto, {
- "receiver-name": "Test",
- });
- }
-
- await createDepositGroup(ws, {
- amount: `${currency}:5` as AmountString,
- depositPaytoUri: depositPayto,
- });
-
- logger.trace("integration test: all done!");
-}
-
-export async function testPay(
- ws: InternalWalletState,
- args: TestPayArgs,
-): Promise<TestPayResult> {
- logger.trace("creating order");
- const merchant = {
- authToken: args.merchantAuthToken,
- baseUrl: args.merchantBaseUrl,
- };
- const orderResp = await createOrder(
- ws.http,
- merchant,
- args.amount,
- args.summary,
- "taler://fulfillment-success/thank+you",
- );
- logger.trace("created new order with order ID", orderResp.orderId);
- const checkPayResp = await checkPayment(ws.http, merchant, orderResp.orderId);
- const talerPayUri = checkPayResp.taler_pay_uri;
- if (!talerPayUri) {
- console.error("fatal: no taler pay URI received from backend");
- process.exit(1);
- }
- logger.trace("taler pay URI:", talerPayUri);
- const result = await preparePayForUri(ws, talerPayUri);
- if (result.status !== PreparePayResultType.PaymentPossible) {
- throw Error(`unexpected prepare pay status: ${result.status}`);
- }
- const r = await confirmPay(
- ws,
- result.transactionId,
- undefined,
- args.forcedCoinSel,
- );
- if (r.type != ConfirmPayResultType.Done) {
- throw Error("payment not done");
- }
- const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(result.proposalId);
- });
- checkLogicInvariant(!!purchase);
- return {
- payCoinSelection: purchase.payInfo?.payCoinSelection!,
- };
-}
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -1,2007 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalIDB } from "@gnu-taler/idb-bridge";
-import {
- AbsoluteTime,
- Amounts,
- DepositTransactionTrackingState,
- j2s,
- Logger,
- NotificationType,
- OrderShortInfo,
- PeerContractTerms,
- RefundInfoShort,
- RefundPaymentInfo,
- ScopeType,
- stringifyPayPullUri,
- stringifyPayPushUri,
- TalerErrorCode,
- TalerPreciseTimestamp,
- Transaction,
- TransactionByIdRequest,
- TransactionIdStr,
- TransactionMajorState,
- TransactionRecordFilter,
- TransactionsRequest,
- TransactionsResponse,
- TransactionState,
- TransactionType,
- TransactionWithdrawal,
- WalletContractData,
- WithdrawalTransactionByURIRequest,
- WithdrawalType,
-} from "@gnu-taler/taler-util";
-import {
- DepositElementStatus,
- DepositGroupRecord,
- OperationRetryRecord,
- PeerPullCreditRecord,
- PeerPullDebitRecordStatus,
- PeerPullPaymentIncomingRecord,
- PeerPushCreditStatus,
- PeerPushDebitRecord,
- PeerPushPaymentIncomingRecord,
- PurchaseRecord,
- PurchaseStatus,
- RefreshGroupRecord,
- RefreshOperationStatus,
- RefundGroupRecord,
- RewardRecord,
- WithdrawalGroupRecord,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
-} from "../db.js";
-import {
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- PeerPushDebitStatus,
- timestampPreciseFromDb,
- timestampProtocolFromDb,
- WalletDbReadOnlyTransaction,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import {
- constructTaskIdentifier,
- TaskIdentifiers,
- TransactionContext,
-} from "./common.js";
-import {
- computeDepositTransactionActions,
- computeDepositTransactionStatus,
- DepositTransactionContext,
-} from "./deposits.js";
-import {
- ExchangeWireDetails,
- getExchangeWireDetailsInTx,
-} from "./exchanges.js";
-import {
- computePayMerchantTransactionActions,
- computePayMerchantTransactionState,
- computeRefundTransactionState,
- expectProposalDownload,
- extractContractData,
- PayMerchantTransactionContext,
- RefundTransactionContext,
-} from "./pay-merchant.js";
-import {
- computePeerPullCreditTransactionActions,
- computePeerPullCreditTransactionState,
- PeerPullCreditTransactionContext,
-} from "./pay-peer-pull-credit.js";
-import {
- computePeerPullDebitTransactionActions,
- computePeerPullDebitTransactionState,
- PeerPullDebitTransactionContext,
-} from "./pay-peer-pull-debit.js";
-import {
- computePeerPushCreditTransactionActions,
- computePeerPushCreditTransactionState,
- PeerPushCreditTransactionContext,
-} from "./pay-peer-push-credit.js";
-import {
- computePeerPushDebitTransactionActions,
- computePeerPushDebitTransactionState,
- PeerPushDebitTransactionContext,
-} from "./pay-peer-push-debit.js";
-import {
- computeRefreshTransactionActions,
- computeRefreshTransactionState,
- RefreshTransactionContext,
-} from "./refresh.js";
-import {
- computeRewardTransactionStatus,
- computeTipTransactionActions,
- RewardTransactionContext,
-} from "./reward.js";
-import {
- augmentPaytoUrisForWithdrawal,
- computeWithdrawalTransactionActions,
- computeWithdrawalTransactionStatus,
- WithdrawTransactionContext,
-} from "./withdraw.js";
-
-const logger = new Logger("taler-wallet-core:transactions.ts");
-
-function shouldSkipCurrency(
- transactionsRequest: TransactionsRequest | undefined,
- currency: string,
- exchangesInTransaction: string[],
-): boolean {
- if (transactionsRequest?.scopeInfo) {
- const sameCurrency = Amounts.isSameCurrency(
- currency,
- transactionsRequest.scopeInfo.currency,
- );
- switch (transactionsRequest.scopeInfo.type) {
- case ScopeType.Global: {
- return !sameCurrency;
- }
- case ScopeType.Exchange: {
- return (
- !sameCurrency ||
- (exchangesInTransaction.length > 0 &&
- !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url))
- );
- }
- case ScopeType.Auditor: {
- // same currency and same auditor
- throw Error("filering balance in auditor scope is not implemented");
- }
- default:
- assertUnreachable(transactionsRequest.scopeInfo);
- }
- }
- // FIXME: remove next release
- if (transactionsRequest?.currency) {
- return (
- transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
- );
- }
- return false;
-}
-
-function shouldSkipSearch(
- transactionsRequest: TransactionsRequest | undefined,
- fields: string[],
-): boolean {
- if (!transactionsRequest?.search) {
- return false;
- }
- const needle = transactionsRequest.search.trim();
- for (const f of fields) {
- if (f.indexOf(needle) >= 0) {
- return false;
- }
- }
- return true;
-}
-
-/**
- * Fallback order of transactions that have the same timestamp.
- */
-const txOrder: { [t in TransactionType]: number } = {
- [TransactionType.Withdrawal]: 1,
- [TransactionType.Reward]: 2,
- [TransactionType.Payment]: 3,
- [TransactionType.PeerPullCredit]: 4,
- [TransactionType.PeerPullDebit]: 5,
- [TransactionType.PeerPushCredit]: 6,
- [TransactionType.PeerPushDebit]: 7,
- [TransactionType.Refund]: 8,
- [TransactionType.Deposit]: 9,
- [TransactionType.Refresh]: 10,
- [TransactionType.Recoup]: 11,
- [TransactionType.InternalWithdrawal]: 12,
-};
-
-export async function getTransactionById(
- ws: InternalWalletState,
- req: TransactionByIdRequest,
-): Promise<Transaction> {
- const parsedTx = parseTransactionIdentifier(req.transactionId);
-
- if (!parsedTx) {
- throw Error("invalid transaction ID");
- }
-
- switch (parsedTx.tag) {
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal: {
- const withdrawalGroupId = parsedTx.withdrawalGroupId;
- return await ws.db.runReadWriteTx(
- [
- "withdrawalGroups",
- "exchangeDetails",
- "exchanges",
- "operationRetries",
- ],
- async (tx) => {
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
-
- if (!withdrawalGroupRecord) throw Error("not found");
-
- const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
- const ort = await tx.operationRetries.get(opId);
-
- if (
- withdrawalGroupRecord.wgInfo.withdrawalType ===
- WithdrawalRecordType.BankIntegrated
- ) {
- return buildTransactionForBankIntegratedWithdraw(
- withdrawalGroupRecord,
- ort,
- );
- }
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- withdrawalGroupRecord.exchangeBaseUrl,
- );
- if (!exchangeDetails) throw Error("not exchange details");
-
- return buildTransactionForManualWithdraw(
- withdrawalGroupRecord,
- exchangeDetails,
- ort,
- );
- },
- );
- }
-
- case TransactionType.Recoup:
- throw new Error("not yet supported");
-
- case TransactionType.Payment: {
- const proposalId = parsedTx.proposalId;
- return await ws.db.runReadWriteTx(
- [
- "purchases",
- "tombstones",
- "operationRetries",
- "contractTerms",
- "refundGroups",
- ],
- async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) throw Error("not found");
- const download = await expectProposalDownload(ws, purchase, tx);
- const contractData = download.contractData;
- const payOpId = TaskIdentifiers.forPay(purchase);
- const payRetryRecord = await tx.operationRetries.get(payOpId);
-
- const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
- purchase.proposalId,
- );
-
- return buildTransactionForPurchase(
- purchase,
- contractData,
- refunds,
- payRetryRecord,
- );
- },
- );
- }
-
- case TransactionType.Refresh: {
- // FIXME: We should return info about the refresh here!;
- const refreshGroupId = parsedTx.refreshGroupId;
- return await ws.db.runReadOnlyTx(
- ["refreshGroups", "operationRetries"],
- async (tx) => {
- const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroupRec) {
- throw Error("not found");
- }
- const retries = await tx.operationRetries.get(
- TaskIdentifiers.forRefresh(refreshGroupRec),
- );
- return buildTransactionForRefresh(refreshGroupRec, retries);
- },
- );
- }
-
- case TransactionType.Reward: {
- const tipId = parsedTx.walletRewardId;
- return await ws.db.runReadWriteTx(
- ["rewards", "operationRetries"],
- async (tx) => {
- const tipRecord = await tx.rewards.get(tipId);
- if (!tipRecord) throw Error("not found");
-
- const retries = await tx.operationRetries.get(
- TaskIdentifiers.forTipPickup(tipRecord),
- );
- return buildTransactionForTip(tipRecord, retries);
- },
- );
- }
-
- case TransactionType.Deposit: {
- const depositGroupId = parsedTx.depositGroupId;
- return await ws.db.runReadWriteTx(
- ["depositGroups", "operationRetries"],
- async (tx) => {
- const depositRecord = await tx.depositGroups.get(depositGroupId);
- if (!depositRecord) throw Error("not found");
-
- const retries = await tx.operationRetries.get(
- TaskIdentifiers.forDeposit(depositRecord),
- );
- return buildTransactionForDeposit(depositRecord, retries);
- },
- );
- }
-
- case TransactionType.Refund: {
- return await ws.db.runReadOnlyTx(
- ["refundGroups", "purchases", "operationRetries", "contractTerms"],
- async (tx) => {
- const refundRecord = await tx.refundGroups.get(
- parsedTx.refundGroupId,
- );
- if (!refundRecord) {
- throw Error("not found");
- }
- const contractData = await lookupMaybeContractData(
- tx,
- refundRecord?.proposalId,
- );
- return buildTransactionForRefund(refundRecord, contractData);
- },
- );
- }
- case TransactionType.PeerPullDebit: {
- return await ws.db.runReadWriteTx(
- ["peerPullDebit", "contractTerms"],
- async (tx) => {
- const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
- if (!debit) throw Error("not found");
- const contractTermsRec = await tx.contractTerms.get(
- debit.contractTermsHash,
- );
- if (!contractTermsRec)
- throw Error("contract terms for peer-pull-debit not found");
- return buildTransactionForPullPaymentDebit(
- debit,
- contractTermsRec.contractTermsRaw,
- );
- },
- );
- }
-
- case TransactionType.PeerPushDebit: {
- return await ws.db.runReadWriteTx(
- ["peerPushDebit", "contractTerms"],
- async (tx) => {
- const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
- if (!debit) throw Error("not found");
- const ct = await tx.contractTerms.get(debit.contractTermsHash);
- checkDbInvariant(!!ct);
- return buildTransactionForPushPaymentDebit(
- debit,
- ct.contractTermsRaw,
- );
- },
- );
- }
-
- case TransactionType.PeerPushCredit: {
- const peerPushCreditId = parsedTx.peerPushCreditId;
- return await ws.db.runReadWriteTx(
- [
- "peerPushCredit",
- "contractTerms",
- "withdrawalGroups",
- "operationRetries",
- ],
- async (tx) => {
- const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
- if (!pushInc) throw Error("not found");
- const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
- checkDbInvariant(!!ct);
-
- let wg: WithdrawalGroupRecord | undefined = undefined;
- let wgOrt: OperationRetryRecord | undefined = undefined;
- if (pushInc.withdrawalGroupId) {
- wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
- if (wg) {
- const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
- wgOrt = await tx.operationRetries.get(withdrawalOpId);
- }
- }
- const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
- let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
-
- return buildTransactionForPeerPushCredit(
- pushInc,
- pushIncOrt,
- ct.contractTermsRaw,
- wg,
- wgOrt,
- );
- },
- );
- }
-
- case TransactionType.PeerPullCredit: {
- const pursePub = parsedTx.pursePub;
- return await ws.db.runReadWriteTx(
- [
- "peerPullCredit",
- "contractTerms",
- "withdrawalGroups",
- "operationRetries",
- ],
- async (tx) => {
- const pushInc = await tx.peerPullCredit.get(pursePub);
- if (!pushInc) throw Error("not found");
- const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
- checkDbInvariant(!!ct);
-
- let wg: WithdrawalGroupRecord | undefined = undefined;
- let wgOrt: OperationRetryRecord | undefined = undefined;
- if (pushInc.withdrawalGroupId) {
- wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
- if (wg) {
- const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
- wgOrt = await tx.operationRetries.get(withdrawalOpId);
- }
- }
- const pushIncOpId =
- TaskIdentifiers.forPeerPullPaymentInitiation(pushInc);
- let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
-
- return buildTransactionForPeerPullCredit(
- pushInc,
- pushIncOrt,
- ct.contractTermsRaw,
- wg,
- wgOrt,
- );
- },
- );
- }
- }
-}
-
-function buildTransactionForPushPaymentDebit(
- pi: PeerPushDebitRecord,
- contractTerms: PeerContractTerms,
- ort?: OperationRetryRecord,
-): Transaction {
- let talerUri: string | undefined = undefined;
- switch (pi.status) {
- case PeerPushDebitStatus.PendingReady:
- case PeerPushDebitStatus.SuspendedReady:
- talerUri = stringifyPayPushUri({
- exchangeBaseUrl: pi.exchangeBaseUrl,
- contractPriv: pi.contractPriv,
- });
- }
- const txState = computePeerPushDebitTransactionState(pi);
- return {
- type: TransactionType.PeerPushDebit,
- txState,
- txActions: computePeerPushDebitTransactionActions(pi),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost))
- : pi.totalCost,
- amountRaw: pi.amount,
- exchangeBaseUrl: pi.exchangeBaseUrl,
- info: {
- expiration: contractTerms.purse_expiration,
- summary: contractTerms.summary,
- },
- timestamp: timestampPreciseFromDb(pi.timestampCreated),
- talerUri,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushDebit,
- pursePub: pi.pursePub,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForPullPaymentDebit(
- pi: PeerPullPaymentIncomingRecord,
- contractTerms: PeerContractTerms,
- ort?: OperationRetryRecord,
-): Transaction {
- const txState = computePeerPullDebitTransactionState(pi);
- return {
- type: TransactionType.PeerPullDebit,
- txState,
- txActions: computePeerPullDebitTransactionActions(pi),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(pi.amount))
- : pi.coinSel?.totalCost
- ? pi.coinSel?.totalCost
- : Amounts.stringify(pi.amount),
- amountRaw: Amounts.stringify(pi.amount),
- exchangeBaseUrl: pi.exchangeBaseUrl,
- info: {
- expiration: contractTerms.purse_expiration,
- summary: contractTerms.summary,
- },
- timestamp: timestampPreciseFromDb(pi.timestampCreated),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: pi.peerPullDebitId,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForPeerPullCredit(
- pullCredit: PeerPullCreditRecord,
- pullCreditOrt: OperationRetryRecord | undefined,
- peerContractTerms: PeerContractTerms,
- wsr: WithdrawalGroupRecord | undefined,
- wsrOrt: OperationRetryRecord | undefined,
-): Transaction {
- if (wsr) {
- if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) {
- throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`);
- }
- /**
- * FIXME: this should be handled in the withdrawal process.
- * PeerPull withdrawal fails until reserve have funds but it is not
- * an error from the user perspective.
- */
- const silentWithdrawalErrorForInvoice =
- wsrOrt?.lastError &&
- wsrOrt.lastError.code ===
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
- Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => {
- return (
- e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
- e.httpStatusCode === 409
- );
- });
- const txState = computePeerPullCreditTransactionState(pullCredit);
- return {
- type: TransactionType.PeerPullCredit,
- txState,
- txActions: computePeerPullCreditTransactionActions(pullCredit),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
- : Amounts.stringify(wsr.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(wsr.instructedAmount),
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
- info: {
- expiration: peerContractTerms.purse_expiration,
- summary: peerContractTerms.summary,
- },
- talerUri: stringifyPayPullUri({
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- contractPriv: wsr.wgInfo.contractPriv,
- }),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullCredit.pursePub,
- }),
- kycUrl: pullCredit.kycUrl,
- ...(wsrOrt?.lastError
- ? {
- error: silentWithdrawalErrorForInvoice
- ? undefined
- : wsrOrt.lastError,
- }
- : {}),
- };
- }
-
- const txState = computePeerPullCreditTransactionState(pullCredit);
- return {
- type: TransactionType.PeerPullCredit,
- txState,
- txActions: computePeerPullCreditTransactionActions(pullCredit),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
- : Amounts.stringify(pullCredit.estimatedAmountEffective),
- amountRaw: Amounts.stringify(peerContractTerms.amount),
- exchangeBaseUrl: pullCredit.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
- info: {
- expiration: peerContractTerms.purse_expiration,
- summary: peerContractTerms.summary,
- },
- talerUri: stringifyPayPullUri({
- exchangeBaseUrl: pullCredit.exchangeBaseUrl,
- contractPriv: pullCredit.contractPriv,
- }),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPullCredit,
- pursePub: pullCredit.pursePub,
- }),
- kycUrl: pullCredit.kycUrl,
- ...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
- };
-}
-
-function buildTransactionForPeerPushCredit(
- pushInc: PeerPushPaymentIncomingRecord,
- pushOrt: OperationRetryRecord | undefined,
- peerContractTerms: PeerContractTerms,
- wsr: WithdrawalGroupRecord | undefined,
- wsrOrt: OperationRetryRecord | undefined,
-): Transaction {
- if (wsr) {
- if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
- throw Error("invalid withdrawal group type for push payment credit");
- }
-
- const txState = computePeerPushCreditTransactionState(pushInc);
- return {
- type: TransactionType.PeerPushCredit,
- txState,
- txActions: computePeerPushCreditTransactionActions(pushInc),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
- : Amounts.stringify(wsr.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(wsr.instructedAmount),
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- info: {
- expiration: peerContractTerms.purse_expiration,
- summary: peerContractTerms.summary,
- },
- timestamp: timestampPreciseFromDb(wsr.timestampStart),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: pushInc.peerPushCreditId,
- }),
- kycUrl: pushInc.kycUrl,
- ...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
- };
- }
-
- const txState = computePeerPushCreditTransactionState(pushInc);
- return {
- type: TransactionType.PeerPushCredit,
- txState,
- txActions: computePeerPushCreditTransactionActions(pushInc),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
- : // FIXME: This is wrong, needs to consider fees!
- Amounts.stringify(peerContractTerms.amount),
- amountRaw: Amounts.stringify(peerContractTerms.amount),
- exchangeBaseUrl: pushInc.exchangeBaseUrl,
- info: {
- expiration: peerContractTerms.purse_expiration,
- summary: peerContractTerms.summary,
- },
- kycUrl: pushInc.kycUrl,
- timestamp: timestampPreciseFromDb(pushInc.timestamp),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: pushInc.peerPushCreditId,
- }),
- ...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}),
- };
-}
-
-function buildTransactionForBankIntegratedWithdraw(
- wgRecord: WithdrawalGroupRecord,
- ort?: OperationRetryRecord,
-): TransactionWithdrawal {
- if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
- throw Error("");
-
- const txState = computeWithdrawalTransactionStatus(wgRecord);
- return {
- type: TransactionType.Withdrawal,
- txState,
- txActions: computeWithdrawalTransactionActions(wgRecord),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
- : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(wgRecord.instructedAmount),
- withdrawalDetails: {
- type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
- exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
- reservePub: wgRecord.reservePub,
- bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
- reserveIsReady:
- wgRecord.status === WithdrawalGroupStatus.Done ||
- wgRecord.status === WithdrawalGroupStatus.PendingReady,
- },
- kycUrl: wgRecord.kycUrl,
- exchangeBaseUrl: wgRecord.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: wgRecord.withdrawalGroupId,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function isUnsuccessfulTransaction(state: TransactionState): boolean {
- return (
- state.major === TransactionMajorState.Aborted ||
- state.major === TransactionMajorState.Expired ||
- state.major === TransactionMajorState.Aborting ||
- state.major === TransactionMajorState.Deleted ||
- state.major === TransactionMajorState.Failed
- );
-}
-
-function buildTransactionForManualWithdraw(
- withdrawalGroup: WithdrawalGroupRecord,
- exchangeDetails: ExchangeWireDetails,
- ort?: OperationRetryRecord,
-): TransactionWithdrawal {
- if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
- throw Error("");
-
- const plainPaytoUris =
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
-
- const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
- plainPaytoUris,
- withdrawalGroup.reservePub,
- withdrawalGroup.instructedAmount,
- );
-
- const txState = computeWithdrawalTransactionStatus(withdrawalGroup);
-
- return {
- type: TransactionType.Withdrawal,
- txState,
- txActions: computeWithdrawalTransactionActions(withdrawalGroup),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(
- Amounts.zeroOfAmount(withdrawalGroup.instructedAmount),
- )
- : Amounts.stringify(withdrawalGroup.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
- withdrawalDetails: {
- type: WithdrawalType.ManualTransfer,
- reservePub: withdrawalGroup.reservePub,
- exchangePaytoUris,
- exchangeCreditAccountDetails:
- withdrawalGroup.wgInfo.exchangeCreditAccounts,
- reserveIsReady:
- withdrawalGroup.status === WithdrawalGroupStatus.Done ||
- withdrawalGroup.status === WithdrawalGroupStatus.PendingReady,
- },
- kycUrl: withdrawalGroup.kycUrl,
- exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
- timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForRefund(
- refundRecord: RefundGroupRecord,
- maybeContractData: WalletContractData | undefined,
-): Transaction {
- let paymentInfo: RefundPaymentInfo | undefined = undefined;
-
- if (maybeContractData) {
- paymentInfo = {
- merchant: maybeContractData.merchant,
- summary: maybeContractData.summary,
- summary_i18n: maybeContractData.summaryI18n,
- };
- }
-
- const txState = computeRefundTransactionState(refundRecord);
- return {
- type: TransactionType.Refund,
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
- : refundRecord.amountEffective,
- amountRaw: refundRecord.amountRaw,
- refundedTransactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: refundRecord.proposalId,
- }),
- timestamp: timestampPreciseFromDb(refundRecord.timestampCreated),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Refund,
- refundGroupId: refundRecord.refundGroupId,
- }),
- txState,
- txActions: [],
- paymentInfo,
- };
-}
-
-function buildTransactionForRefresh(
- refreshGroupRecord: RefreshGroupRecord,
- ort?: OperationRetryRecord,
-): Transaction {
- const inputAmount = Amounts.sumOrZero(
- refreshGroupRecord.currency,
- refreshGroupRecord.inputPerCoin,
- ).amount;
- const outputAmount = Amounts.sumOrZero(
- refreshGroupRecord.currency,
- refreshGroupRecord.expectedOutputPerCoin,
- ).amount;
- const txState = computeRefreshTransactionState(refreshGroupRecord);
- return {
- type: TransactionType.Refresh,
- txState,
- txActions: computeRefreshTransactionActions(refreshGroupRecord),
- refreshReason: refreshGroupRecord.reason,
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(inputAmount))
- : Amounts.stringify(Amounts.sub(outputAmount, inputAmount).amount),
- amountRaw: Amounts.stringify(
- Amounts.zeroOfCurrency(refreshGroupRecord.currency),
- ),
- refreshInputAmount: Amounts.stringify(inputAmount),
- refreshOutputAmount: Amounts.stringify(outputAmount),
- originatingTransactionId: refreshGroupRecord.originatingTransactionId,
- timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Refresh,
- refreshGroupId: refreshGroupRecord.refreshGroupId,
- }),
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForDeposit(
- dg: DepositGroupRecord,
- ort?: OperationRetryRecord,
-): Transaction {
- let deposited = true;
- for (const d of dg.statusPerCoin) {
- if (d == DepositElementStatus.DepositPending) {
- deposited = false;
- }
- }
-
- const trackingState: DepositTransactionTrackingState[] = [];
-
- for (const ts of Object.values(dg.trackingState ?? {})) {
- trackingState.push({
- amountRaw: ts.amountRaw,
- timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
- wireFee: ts.wireFee,
- wireTransferId: ts.wireTransferId,
- });
- }
-
- const txState = computeDepositTransactionStatus(dg);
- return {
- type: TransactionType.Deposit,
- txState,
- txActions: computeDepositTransactionActions(dg),
- amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
- : Amounts.stringify(dg.totalPayCost),
- timestamp: timestampPreciseFromDb(dg.timestampCreated),
- targetPaytoUri: dg.wire.payto_uri,
- wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Deposit,
- depositGroupId: dg.depositGroupId,
- }),
- wireTransferProgress:
- (100 *
- dg.statusPerCoin.reduce(
- (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
- 0,
- )) /
- dg.statusPerCoin.length,
- depositGroupId: dg.depositGroupId,
- trackingState,
- deposited,
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-function buildTransactionForTip(
- tipRecord: RewardRecord,
- ort?: OperationRetryRecord,
-): Transaction {
- checkLogicInvariant(!!tipRecord.acceptedTimestamp);
-
- const txState = computeRewardTransactionStatus(tipRecord);
- return {
- type: TransactionType.Reward,
- txState,
- txActions: computeTipTransactionActions(tipRecord),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(tipRecord.rewardAmountEffective))
- : Amounts.stringify(tipRecord.rewardAmountEffective),
- amountRaw: Amounts.stringify(tipRecord.rewardAmountRaw),
- timestamp: timestampPreciseFromDb(tipRecord.acceptedTimestamp),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: tipRecord.walletRewardId,
- }),
- merchantBaseUrl: tipRecord.merchantBaseUrl,
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-async function lookupMaybeContractData(
- tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>,
- proposalId: string,
-): Promise<WalletContractData | undefined> {
- let contractData: WalletContractData | undefined = undefined;
- const purchaseTx = await tx.purchases.get(proposalId);
- if (purchaseTx && purchaseTx.download) {
- const download = purchaseTx.download;
- const contractTermsRecord = await tx.contractTerms.get(
- download.contractTermsHash,
- );
- if (!contractTermsRecord) {
- return;
- }
- contractData = extractContractData(
- contractTermsRecord?.contractTermsRaw,
- download.contractTermsHash,
- download.contractTermsMerchantSig,
- );
- }
-
- return contractData;
-}
-
-async function buildTransactionForPurchase(
- purchaseRecord: PurchaseRecord,
- contractData: WalletContractData,
- refundsInfo: RefundGroupRecord[],
- ort?: OperationRetryRecord,
-): Promise<Transaction> {
- const zero = Amounts.zeroOfAmount(contractData.amount);
-
- const info: OrderShortInfo = {
- merchant: contractData.merchant,
- orderId: contractData.orderId,
- summary: contractData.summary,
- summary_i18n: contractData.summaryI18n,
- contractTermsHash: contractData.contractTermsHash,
- };
-
- if (contractData.fulfillmentUrl !== "") {
- info.fulfillmentUrl = contractData.fulfillmentUrl;
- }
-
- const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
- amountEffective: r.amountEffective,
- amountRaw: r.amountRaw,
- timestamp: TalerPreciseTimestamp.round(
- timestampPreciseFromDb(r.timestampCreated),
- ),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Refund,
- refundGroupId: r.refundGroupId,
- }),
- }));
-
- const timestamp = purchaseRecord.timestampAccept;
- checkDbInvariant(!!timestamp);
- checkDbInvariant(!!purchaseRecord.payInfo);
-
- const txState = computePayMerchantTransactionState(purchaseRecord);
- return {
- type: TransactionType.Payment,
- txState,
- txActions: computePayMerchantTransactionActions(purchaseRecord),
- amountRaw: Amounts.stringify(contractData.amount),
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(zero)
- : Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
- totalRefundRaw: Amounts.stringify(zero), // FIXME!
- totalRefundEffective: Amounts.stringify(zero), // FIXME!
- refundPending:
- purchaseRecord.refundAmountAwaiting === undefined
- ? undefined
- : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
- refunds,
- posConfirmation: purchaseRecord.posConfirmation,
- timestamp: timestampPreciseFromDb(timestamp),
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: purchaseRecord.proposalId,
- }),
- proposalId: purchaseRecord.proposalId,
- info,
- refundQueryActive:
- purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
-
-export async function getWithdrawalTransactionByUri(
- ws: InternalWalletState,
- request: WithdrawalTransactionByURIRequest,
-): Promise<TransactionWithdrawal | undefined> {
- return await ws.db.runReadWriteTx(
- ["withdrawalGroups", "exchangeDetails", "exchanges", "operationRetries"],
- async (tx) => {
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
- request.talerWithdrawUri,
- );
-
- if (!withdrawalGroupRecord) {
- return undefined;
- }
-
- const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
- const ort = await tx.operationRetries.get(opId);
-
- if (
- withdrawalGroupRecord.wgInfo.withdrawalType ===
- WithdrawalRecordType.BankIntegrated
- ) {
- return buildTransactionForBankIntegratedWithdraw(
- withdrawalGroupRecord,
- ort,
- );
- }
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- withdrawalGroupRecord.exchangeBaseUrl,
- );
- if (!exchangeDetails) throw Error("not exchange details");
-
- return buildTransactionForManualWithdraw(
- withdrawalGroupRecord,
- exchangeDetails,
- ort,
- );
- },
- );
-}
-
-/**
- * Retrieve the full event history for this wallet.
- */
-export async function getTransactions(
- ws: InternalWalletState,
- transactionsRequest?: TransactionsRequest,
-): Promise<TransactionsResponse> {
- const transactions: Transaction[] = [];
-
- const filter: TransactionRecordFilter = {};
- if (transactionsRequest?.filterByState) {
- filter.onlyState = transactionsRequest.filterByState;
- }
-
- await ws.db.runReadOnlyTx(
- [
- "coins",
- "denominations",
- "depositGroups",
- "exchangeDetails",
- "exchanges",
- "operationRetries",
- "peerPullDebit",
- "peerPushDebit",
- "peerPushCredit",
- "peerPullCredit",
- "planchets",
- "purchases",
- "contractTerms",
- "recoupGroups",
- "rewards",
- "tombstones",
- "withdrawalGroups",
- "refreshGroups",
- "refundGroups",
- ],
- async (tx) => {
- await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
- const amount = Amounts.parseOrThrow(pi.amount);
- const exchangesInTx = [pi.exchangeBaseUrl];
- if (
- shouldSkipCurrency(
- transactionsRequest,
- amount.currency,
- exchangesInTx,
- )
- ) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- const ct = await tx.contractTerms.get(pi.contractTermsHash);
- checkDbInvariant(!!ct);
- transactions.push(
- buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw),
- );
- });
-
- await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
- const amount = Amounts.parseOrThrow(pi.amount);
- const exchangesInTx = [pi.exchangeBaseUrl];
- if (
- shouldSkipCurrency(
- transactionsRequest,
- amount.currency,
- exchangesInTx,
- )
- ) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- if (
- pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
- pi.status !== PeerPullDebitRecordStatus.Done
- ) {
- // FIXME: Why?!
- return;
- }
-
- const contractTermsRec = await tx.contractTerms.get(
- pi.contractTermsHash,
- );
- if (!contractTermsRec) {
- return;
- }
-
- transactions.push(
- buildTransactionForPullPaymentDebit(
- pi,
- contractTermsRec.contractTermsRaw,
- ),
- );
- });
-
- await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
- if (!pi.currency) {
- // Legacy transaction
- return;
- }
- const exchangesInTx = [pi.exchangeBaseUrl];
- if (
- shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx)
- ) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- if (pi.status === PeerPushCreditStatus.DialogProposed) {
- // We don't report proposed push credit transactions, user needs
- // to scan URI again and confirm to see it.
- return;
- }
- const ct = await tx.contractTerms.get(pi.contractTermsHash);
- let wg: WithdrawalGroupRecord | undefined = undefined;
- let wgOrt: OperationRetryRecord | undefined = undefined;
- if (pi.withdrawalGroupId) {
- wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
- if (wg) {
- const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
- wgOrt = await tx.operationRetries.get(withdrawalOpId);
- }
- }
- const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi);
- let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
-
- checkDbInvariant(!!ct);
- transactions.push(
- buildTransactionForPeerPushCredit(
- pi,
- pushIncOrt,
- ct.contractTermsRaw,
- wg,
- wgOrt,
- ),
- );
- });
-
- await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
- const currency = Amounts.currencyOf(pi.amount);
- const exchangesInTx = [pi.exchangeBaseUrl];
- if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- const ct = await tx.contractTerms.get(pi.contractTermsHash);
- let wg: WithdrawalGroupRecord | undefined = undefined;
- let wgOrt: OperationRetryRecord | undefined = undefined;
- if (pi.withdrawalGroupId) {
- wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
- if (wg) {
- const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
- wgOrt = await tx.operationRetries.get(withdrawalOpId);
- }
- }
- const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi);
- let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
-
- checkDbInvariant(!!ct);
- transactions.push(
- buildTransactionForPeerPullCredit(
- pi,
- pushIncOrt,
- ct.contractTermsRaw,
- wg,
- wgOrt,
- ),
- );
- });
-
- await iterRecordsForRefund(tx, filter, async (refundGroup) => {
- const currency = Amounts.currencyOf(refundGroup.amountRaw);
-
- const exchangesInTx: string[] = [];
- const p = await tx.purchases.get(refundGroup.proposalId);
- if (!p || !p.payInfo) return; //refund with no payment
-
- // FIXME: This is very slow, should become obsolete with materialized transactions.
- for (const cp of p.payInfo.payCoinSelection.coinPubs) {
- const c = await tx.coins.get(cp);
- if (c?.exchangeBaseUrl) {
- exchangesInTx.push(c.exchangeBaseUrl);
- }
- }
-
- if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
- return;
- }
- const contractData = await lookupMaybeContractData(
- tx,
- refundGroup.proposalId,
- );
- transactions.push(buildTransactionForRefund(refundGroup, contractData));
- });
-
- await iterRecordsForRefresh(tx, filter, async (rg) => {
- const exchangesInTx = rg.infoPerExchange
- ? Object.keys(rg.infoPerExchange)
- : [];
- if (
- shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx)
- ) {
- return;
- }
- let required = false;
- const opId = TaskIdentifiers.forRefresh(rg);
- if (transactionsRequest?.includeRefreshes) {
- required = true;
- } else if (rg.operationStatus !== RefreshOperationStatus.Finished) {
- const ort = await tx.operationRetries.get(opId);
- if (ort) {
- required = true;
- }
- }
- if (required) {
- const ort = await tx.operationRetries.get(opId);
- transactions.push(buildTransactionForRefresh(rg, ort));
- }
- });
-
- await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
- const exchangesInTx = [wsr.exchangeBaseUrl];
- if (
- shouldSkipCurrency(
- transactionsRequest,
- Amounts.currencyOf(wsr.rawWithdrawalAmount),
- exchangesInTx,
- )
- ) {
- return;
- }
-
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
-
- const opId = TaskIdentifiers.forWithdrawal(wsr);
- const ort = await tx.operationRetries.get(opId);
-
- switch (wsr.wgInfo.withdrawalType) {
- case WithdrawalRecordType.PeerPullCredit:
- // Will be reported by the corresponding p2p transaction.
- // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
- // FIXME: Still report if requested with verbose option?
- return;
- case WithdrawalRecordType.PeerPushCredit:
- // Will be reported by the corresponding p2p transaction.
- // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
- // FIXME: Still report if requested with verbose option?
- return;
- case WithdrawalRecordType.BankIntegrated:
- transactions.push(
- buildTransactionForBankIntegratedWithdraw(wsr, ort),
- );
- return;
- case WithdrawalRecordType.BankManual: {
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- wsr.exchangeBaseUrl,
- );
- if (!exchangeDetails) {
- // FIXME: report somehow
- return;
- }
-
- transactions.push(
- buildTransactionForManualWithdraw(wsr, exchangeDetails, ort),
- );
- return;
- }
- case WithdrawalRecordType.Recoup:
- // FIXME: Do we also report a transaction here?
- return;
- }
- });
-
- await iterRecordsForDeposit(tx, filter, async (dg) => {
- const amount = Amounts.parseOrThrow(dg.amount);
- const exchangesInTx = dg.infoPerExchange
- ? Object.keys(dg.infoPerExchange)
- : [];
- if (
- shouldSkipCurrency(
- transactionsRequest,
- amount.currency,
- exchangesInTx,
- )
- ) {
- return;
- }
- const opId = TaskIdentifiers.forDeposit(dg);
- const retryRecord = await tx.operationRetries.get(opId);
-
- transactions.push(buildTransactionForDeposit(dg, retryRecord));
- });
-
- await iterRecordsForPurchase(tx, filter, async (purchase) => {
- const download = purchase.download;
- if (!download) {
- return;
- }
- if (!purchase.payInfo) {
- return;
- }
-
- const exchangesInTx: string[] = [];
- for (const cp of purchase.payInfo.payCoinSelection.coinPubs) {
- const c = await tx.coins.get(cp);
- if (c?.exchangeBaseUrl) {
- exchangesInTx.push(c.exchangeBaseUrl);
- }
- }
-
- if (
- shouldSkipCurrency(
- transactionsRequest,
- download.currency,
- exchangesInTx,
- )
- ) {
- return;
- }
- const contractTermsRecord = await tx.contractTerms.get(
- download.contractTermsHash,
- );
- if (!contractTermsRecord) {
- return;
- }
- if (
- shouldSkipSearch(transactionsRequest, [
- contractTermsRecord?.contractTermsRaw?.summary || "",
- ])
- ) {
- return;
- }
-
- const contractData = extractContractData(
- contractTermsRecord?.contractTermsRaw,
- download.contractTermsHash,
- download.contractTermsMerchantSig,
- );
-
- const payOpId = TaskIdentifiers.forPay(purchase);
- const payRetryRecord = await tx.operationRetries.get(payOpId);
-
- const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
- purchase.proposalId,
- );
-
- transactions.push(
- await buildTransactionForPurchase(
- purchase,
- contractData,
- refunds,
- payRetryRecord,
- ),
- );
- });
-
- //FIXME: remove rewards
- await iterRecordsForReward(tx, filter, async (tipRecord) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency,
- [tipRecord.exchangeBaseUrl],
- )
- ) {
- return;
- }
- if (!tipRecord.acceptedTimestamp) {
- return;
- }
- const opId = TaskIdentifiers.forTipPickup(tipRecord);
- const retryRecord = await tx.operationRetries.get(opId);
- transactions.push(buildTransactionForTip(tipRecord, retryRecord));
- });
- //ends REMOVE REWARDS
- },
- );
-
- // One-off checks, because of a bug where the wallet previously
- // did not migrate the DB correctly and caused these amounts
- // to be missing sometimes.
- for (let tx of transactions) {
- if (!tx.amountEffective) {
- logger.warn(`missing amountEffective in ${j2s(tx)}`);
- }
- if (!tx.amountRaw) {
- logger.warn(`missing amountRaw in ${j2s(tx)}`);
- }
- if (!tx.timestamp) {
- logger.warn(`missing timestamp in ${j2s(tx)}`);
- }
- }
-
- const isPending = (x: Transaction) =>
- x.txState.major === TransactionMajorState.Pending ||
- x.txState.major === TransactionMajorState.Aborting ||
- x.txState.major === TransactionMajorState.Dialog;
-
- const txPending = transactions.filter((x) => isPending(x));
- const txNotPending = transactions.filter((x) => !isPending(x));
-
- let sortSign: number;
- if (transactionsRequest?.sort == "descending") {
- sortSign = -1;
- } else {
- sortSign = 1;
- }
-
- const txCmp = (h1: Transaction, h2: Transaction) => {
- // Order transactions by timestamp. Newest transactions come first.
- const tsCmp = AbsoluteTime.cmp(
- AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
- AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
- );
- // If the timestamp is exactly the same, order by transaction type.
- if (tsCmp === 0) {
- return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
- }
- return sortSign * tsCmp;
- };
-
- txPending.sort(txCmp);
- txNotPending.sort(txCmp);
-
- return { transactions: [...txNotPending, ...txPending] };
-}
-
-export type ParsedTransactionIdentifier =
- | { tag: TransactionType.Deposit; depositGroupId: string }
- | { tag: TransactionType.Payment; proposalId: string }
- | { tag: TransactionType.PeerPullDebit; peerPullDebitId: string }
- | { tag: TransactionType.PeerPullCredit; pursePub: string }
- | { tag: TransactionType.PeerPushCredit; peerPushCreditId: string }
- | { tag: TransactionType.PeerPushDebit; pursePub: string }
- | { tag: TransactionType.Refresh; refreshGroupId: string }
- | { tag: TransactionType.Refund; refundGroupId: string }
- | { tag: TransactionType.Reward; walletRewardId: string }
- | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
- | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string }
- | { tag: TransactionType.Recoup; recoupGroupId: string };
-
-export function constructTransactionIdentifier(
- pTxId: ParsedTransactionIdentifier,
-): TransactionIdStr {
- switch (pTxId.tag) {
- case TransactionType.Deposit:
- return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr;
- case TransactionType.Payment:
- return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr;
- case TransactionType.PeerPullCredit:
- return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
- case TransactionType.PeerPullDebit:
- return `txn:${pTxId.tag}:${pTxId.peerPullDebitId}` as TransactionIdStr;
- case TransactionType.PeerPushCredit:
- return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` as TransactionIdStr;
- case TransactionType.PeerPushDebit:
- return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
- case TransactionType.Refresh:
- return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
- case TransactionType.Refund:
- return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
- case TransactionType.Reward:
- return `txn:${pTxId.tag}:${pTxId.walletRewardId}` as TransactionIdStr;
- case TransactionType.Withdrawal:
- return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
- case TransactionType.InternalWithdrawal:
- return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
- case TransactionType.Recoup:
- return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr;
- default:
- assertUnreachable(pTxId);
- }
-}
-
-/**
- * Parse a transaction identifier string into a typed, structured representation.
- */
-export function parseTransactionIdentifier(
- transactionId: string,
-): ParsedTransactionIdentifier | undefined {
- const txnParts = transactionId.split(":");
-
- if (txnParts.length < 3) {
- throw Error("id should have al least 3 parts separated by ':'");
- }
-
- const [prefix, type, ...rest] = txnParts;
-
- if (prefix != "txn") {
- throw Error("invalid transaction identifier");
- }
-
- switch (type) {
- case TransactionType.Deposit:
- return { tag: TransactionType.Deposit, depositGroupId: rest[0] };
- case TransactionType.Payment:
- return { tag: TransactionType.Payment, proposalId: rest[0] };
- case TransactionType.PeerPullCredit:
- return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] };
- case TransactionType.PeerPullDebit:
- return {
- tag: TransactionType.PeerPullDebit,
- peerPullDebitId: rest[0],
- };
- case TransactionType.PeerPushCredit:
- return {
- tag: TransactionType.PeerPushCredit,
- peerPushCreditId: rest[0],
- };
- case TransactionType.PeerPushDebit:
- return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] };
- case TransactionType.Refresh:
- return { tag: TransactionType.Refresh, refreshGroupId: rest[0] };
- case TransactionType.Refund:
- return {
- tag: TransactionType.Refund,
- refundGroupId: rest[0],
- };
- case TransactionType.Reward:
- return {
- tag: TransactionType.Reward,
- walletRewardId: rest[0],
- };
- case TransactionType.Withdrawal:
- return {
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: rest[0],
- };
- default:
- return undefined;
- }
-}
-
-function maybeTaskFromTransaction(transactionId: string): TaskId | undefined {
- const parsedTx = parseTransactionIdentifier(transactionId);
-
- if (!parsedTx) {
- throw Error("invalid transaction identifier");
- }
-
- // FIXME: We currently don't cancel active long-polling tasks here.
-
- switch (parsedTx.tag) {
- case TransactionType.PeerPullCredit:
- return constructTaskIdentifier({
- tag: PendingTaskType.PeerPullCredit,
- pursePub: parsedTx.pursePub,
- });
- case TransactionType.Deposit:
- return constructTaskIdentifier({
- tag: PendingTaskType.Deposit,
- depositGroupId: parsedTx.depositGroupId,
- });
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal:
- return constructTaskIdentifier({
- tag: PendingTaskType.Withdraw,
- withdrawalGroupId: parsedTx.withdrawalGroupId,
- });
- case TransactionType.Payment:
- return constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
- proposalId: parsedTx.proposalId,
- });
- case TransactionType.Reward:
- return constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId: parsedTx.walletRewardId,
- });
- case TransactionType.Refresh:
- return constructTaskIdentifier({
- tag: PendingTaskType.Refresh,
- refreshGroupId: parsedTx.refreshGroupId,
- });
- case TransactionType.PeerPullDebit:
- return constructTaskIdentifier({
- tag: PendingTaskType.PeerPullDebit,
- peerPullDebitId: parsedTx.peerPullDebitId,
- });
- case TransactionType.PeerPushCredit:
- return constructTaskIdentifier({
- tag: PendingTaskType.PeerPushCredit,
- peerPushCreditId: parsedTx.peerPushCreditId,
- });
- case TransactionType.PeerPushDebit:
- return constructTaskIdentifier({
- tag: PendingTaskType.PeerPushDebit,
- pursePub: parsedTx.pursePub,
- });
- case TransactionType.Refund:
- // Nothing to do for a refund transaction.
- return undefined;
- case TransactionType.Recoup:
- return constructTaskIdentifier({
- tag: PendingTaskType.Recoup,
- recoupGroupId: parsedTx.recoupGroupId,
- });
- default:
- assertUnreachable(parsedTx);
- }
-}
-
-/**
- * Immediately retry the underlying operation
- * of a transaction.
- */
-export async function retryTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- logger.info(`resetting retry timeout for ${transactionId}`);
- const taskId = maybeTaskFromTransaction(transactionId);
- if (taskId) {
- ws.taskScheduler.resetTaskRetries(taskId);
- }
-}
-
-async function getContextForTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<TransactionContext> {
- const tx = parseTransactionIdentifier(transactionId);
- if (!tx) {
- throw Error("invalid transaction ID");
- }
- switch (tx.tag) {
- case TransactionType.Deposit:
- return new DepositTransactionContext(ws, tx.depositGroupId);
- case TransactionType.Refresh:
- return new RefreshTransactionContext(ws, tx.refreshGroupId);
- case TransactionType.InternalWithdrawal:
- case TransactionType.Withdrawal:
- return new WithdrawTransactionContext(ws, tx.withdrawalGroupId);
- case TransactionType.Payment:
- return new PayMerchantTransactionContext(ws, tx.proposalId);
- case TransactionType.PeerPullCredit:
- return new PeerPullCreditTransactionContext(ws, tx.pursePub);
- case TransactionType.PeerPushDebit:
- return new PeerPushDebitTransactionContext(ws, tx.pursePub);
- case TransactionType.PeerPullDebit:
- return new PeerPullDebitTransactionContext(ws, tx.peerPullDebitId);
- case TransactionType.PeerPushCredit:
- return new PeerPushCreditTransactionContext(ws, tx.peerPushCreditId);
- case TransactionType.Refund:
- return new RefundTransactionContext(ws, tx.refundGroupId);
- case TransactionType.Reward:
- return new RewardTransactionContext(ws, tx.walletRewardId);
- case TransactionType.Recoup:
- throw new Error("not yet supported");
- //return new RecoupTransactionContext(ws, tx.recoupGroupId);
- default:
- assertUnreachable(tx);
- }
-}
-
-/**
- * Suspends a pending transaction, stopping any associated network activities,
- * but with a chance of trying again at a later time. This could be useful if
- * a user needs to save battery power or bandwidth and an operation is expected
- * to take longer (such as a backup, recovery or very large withdrawal operation).
- */
-export async function suspendTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.suspendTransaction();
-}
-
-export async function failTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.failTransaction();
-}
-
-/**
- * Resume a suspended transaction.
- */
-export async function resumeTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.resumeTransaction();
-}
-
-/**
- * Permanently delete a transaction based on the transaction ID.
- */
-export async function deleteTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.deleteTransaction();
-}
-
-export async function abortTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const ctx = await getContextForTransaction(ws, transactionId);
- await ctx.abortTransaction();
-}
-
-export interface TransitionInfo {
- oldTxState: TransactionState;
- newTxState: TransactionState;
-}
-
-/**
- * Notify of a state transition if necessary.
- */
-export function notifyTransition(
- ws: InternalWalletState,
- transactionId: string,
- transitionInfo: TransitionInfo | undefined,
- experimentalUserData: any = undefined,
-): void {
- if (
- transitionInfo &&
- !(
- transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
- transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
- )
- ) {
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- oldTxState: transitionInfo.oldTxState,
- newTxState: transitionInfo.newTxState,
- transactionId,
- experimentalUserData,
- });
- }
-}
-
-/**
- * Iterate refresh records based on a filter.
- */
-async function iterRecordsForRefresh(
- tx: WalletDbReadOnlyTransaction<["refreshGroups"]>,
- filter: TransactionRecordFilter,
- f: (r: RefreshGroupRecord) => Promise<void>,
-): Promise<void> {
- let refreshGroups: RefreshGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- RefreshOperationStatus.Pending,
- RefreshOperationStatus.Suspended,
- );
- refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
- } else {
- refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
- }
-
- for (const r of refreshGroups) {
- await f(r);
- }
-}
-
-async function iterRecordsForWithdrawal(
- tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>,
- filter: TransactionRecordFilter,
- f: (r: WithdrawalGroupRecord) => Promise<void>,
-): Promise<void> {
- let withdrawalGroupRecords: WithdrawalGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- withdrawalGroupRecords =
- await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange);
- } else {
- withdrawalGroupRecords =
- await tx.withdrawalGroups.indexes.byStatus.getAll();
- }
- for (const wgr of withdrawalGroupRecords) {
- await f(wgr);
- }
-}
-
-async function iterRecordsForDeposit(
- tx: WalletDbReadOnlyTransaction<["depositGroups"]>,
- filter: TransactionRecordFilter,
- f: (r: DepositGroupRecord) => Promise<void>,
-): Promise<void> {
- let dgs: DepositGroupRecord[];
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange);
- } else {
- dgs = await tx.depositGroups.indexes.byStatus.getAll();
- }
-
- for (const dg of dgs) {
- await f(dg);
- }
-}
-
-async function iterRecordsForReward(
- tx: WalletDbReadOnlyTransaction<["rewards"]>,
- filter: TransactionRecordFilter,
- f: (r: RewardRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.rewards.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.rewards.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForRefund(
- tx: WalletDbReadOnlyTransaction<["refundGroups"]>,
- filter: TransactionRecordFilter,
- f: (r: RefundGroupRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.refundGroups.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPurchase(
- tx: WalletDbReadOnlyTransaction<["purchases"]>,
- filter: TransactionRecordFilter,
- f: (r: PurchaseRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPeerPullCredit(
- tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>,
- filter: TransactionRecordFilter,
- f: (r: PeerPullCreditRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPeerPullDebit(
- tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>,
- filter: TransactionRecordFilter,
- f: (r: PeerPullPaymentIncomingRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPeerPushDebit(
- tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>,
- filter: TransactionRecordFilter,
- f: (r: PeerPushDebitRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
-
-async function iterRecordsForPeerPushCredit(
- tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>,
- filter: TransactionRecordFilter,
- f: (r: PeerPushPaymentIncomingRecord) => Promise<void>,
-): Promise<void> {
- if (filter.onlyState === "nonfinal") {
- const keyRange = GlobalIDB.KeyRange.bound(
- OPERATION_STATUS_ACTIVE_FIRST,
- OPERATION_STATUS_ACTIVE_LAST,
- );
- await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
- } else {
- await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/withdraw.test.ts b/packages/taler-wallet-core/src/operations/withdraw.test.ts
@@ -1,370 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { AmountString, Amounts, DenomKeyType } from "@gnu-taler/taler-util";
-import test from "ava";
-import {
- DenominationRecord,
- DenominationVerificationStatus,
- timestampProtocolToDb,
-} from "../db.js";
-import { selectWithdrawalDenominations } from "../util/coinSelection.js";
-
-test("withdrawal selection bug repro", (t) => {
- const amount = {
- currency: "KUDOS",
- fraction: 43000000,
- value: 23,
- };
-
- const denoms: DenominationRecord[] = [
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
- age_mask: 0,
- },
- denomPubHash:
- "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- currency: "KUDOS",
- value: "KUDOS:1000" as AmountString,
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
- age_mask: 0,
- },
-
- denomPubHash:
- "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: "KUDOS:10" as AmountString,
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
- age_mask: 0,
- },
- denomPubHash:
- "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: "KUDOS:5" as AmountString,
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
- age_mask: 0,
- },
-
- denomPubHash:
- "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: "KUDOS:1" as AmountString,
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
- age_mask: 0,
- },
- denomPubHash:
- "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: Amounts.stringify({
- currency: "KUDOS",
- fraction: 10000000,
- value: 0,
- }),
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- {
- denomPub: {
- cipher: DenomKeyType.Rsa,
- rsa_public_key:
- "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
- age_mask: 0,
- },
- denomPubHash:
- "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- fees: {
- feeDeposit: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefresh: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeRefund: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- feeWithdraw: Amounts.stringify({
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- }),
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
- stampExpireDeposit: timestampProtocolToDb({
- t_s: 1742909388,
- }),
- stampExpireLegal: timestampProtocolToDb({
- t_s: 1900589388,
- }),
- stampExpireWithdraw: timestampProtocolToDb({
- t_s: 1679837388,
- }),
- stampStart: timestampProtocolToDb({
- t_s: 1585229388,
- }),
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: "KUDOS:2" as AmountString,
- currency: "KUDOS",
- listIssueDate: timestampProtocolToDb({ t_s: 0 }),
- },
- ];
-
- const res = selectWithdrawalDenominations(amount, denoms);
-
- t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
- t.pass();
-});
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -1,2754 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2024 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AbsoluteTime,
- AcceptManualWithdrawalResult,
- AcceptWithdrawalResponse,
- AgeRestriction,
- AmountJson,
- AmountLike,
- AmountString,
- Amounts,
- BankWithdrawDetails,
- CancellationToken,
- CoinStatus,
- CurrencySpecification,
- DenomKeyType,
- DenomSelectionState,
- Duration,
- ExchangeBatchWithdrawRequest,
- ExchangeUpdateStatus,
- ExchangeWireAccount,
- ExchangeWithdrawBatchResponse,
- ExchangeWithdrawRequest,
- ExchangeWithdrawResponse,
- ExchangeWithdrawalDetails,
- ForcedDenomSel,
- HttpStatusCode,
- LibtoolVersion,
- Logger,
- NotificationType,
- TalerBankIntegrationHttpClient,
- TalerError,
- TalerErrorCode,
- TalerErrorDetail,
- TalerPreciseTimestamp,
- TalerProtocolTimestamp,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- URL,
- UnblindedSignature,
- WalletNotification,
- WithdrawUriInfoResponse,
- WithdrawalExchangeAccountDetails,
- addPaytoQueryParams,
- canonicalizeBaseUrl,
- codecForAny,
- codecForCashinConversionResponse,
- codecForConversionBankConfig,
- codecForExchangeWithdrawBatchResponse,
- codecForReserveStatus,
- codecForWalletKycUuid,
- codecForWithdrawOperationStatusResponse,
- encodeCrock,
- getErrorDetailFromException,
- getRandomBytes,
- j2s,
- makeErrorDetail,
- parseWithdrawUri,
-} from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- HttpResponse,
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- throwUnexpectedRequestError,
-} from "@gnu-taler/taler-util/http";
-import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
-import {
- CoinRecord,
- CoinSourceType,
- DenominationRecord,
- DenominationVerificationStatus,
- KycPendingInfo,
- PlanchetRecord,
- PlanchetStatus,
- WalletStoresV1,
- WgInfo,
- WithdrawalGroupRecord,
- WithdrawalGroupStatus,
- WithdrawalRecordType,
-} from "../db.js";
-import {
- WalletDbReadOnlyTransaction,
- WalletDbReadWriteTransaction,
- isWithdrawableDenom,
- timestampPreciseToDb,
-} from "../index.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import {
- TaskRunResult,
- TaskRunResultType,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
- makeCoinAvailable,
- makeCoinsVisible,
-} from "../operations/common.js";
-import { PendingTaskType, TaskId } from "../pending-types.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import {
- selectForcedWithdrawalDenominations,
- selectWithdrawalDenominations,
-} from "../util/coinSelection.js";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import { DbAccess } from "../util/query.js";
-import {
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "../versions.js";
-import {
- ReadyExchangeSummary,
- fetchFreshExchange,
- getExchangePaytoUri,
- getExchangeWireDetailsInTx,
- listExchanges,
- markExchangeUsed,
-} from "./exchanges.js";
-import {
- TransitionInfo,
- constructTransactionIdentifier,
- notifyTransition,
-} from "./transactions.js";
-
-/**
- * Logger for this file.
- */
-const logger = new Logger("operations/withdraw.ts");
-
-export class WithdrawTransactionContext implements TransactionContext {
- readonly transactionId: TransactionIdStr;
- readonly taskId: TaskId;
-
- constructor(
- public ws: InternalWalletState,
- public withdrawalGroupId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.Withdraw,
- withdrawalGroupId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- const { ws, withdrawalGroupId } = this;
- await ws.db.runReadWriteTx(
- ["withdrawalGroups", "tombstones"],
- async (tx) => {
- const withdrawalGroupRecord =
- await tx.withdrawalGroups.get(withdrawalGroupId);
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- return;
- }
- },
- );
- }
-
- async suspendTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.PendingReady:
- newStatus = WithdrawalGroupStatus.SuspendedReady;
- break;
- case WithdrawalGroupStatus.AbortingBank:
- newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
- break;
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
- break;
- case WithdrawalGroupStatus.PendingRegisteringBank:
- newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
- break;
- case WithdrawalGroupStatus.PendingQueryingStatus:
- newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
- break;
- case WithdrawalGroupStatus.PendingKyc:
- newStatus = WithdrawalGroupStatus.SuspendedKyc;
- break;
- case WithdrawalGroupStatus.PendingAml:
- newStatus = WithdrawalGroupStatus.SuspendedAml;
- break;
- default:
- logger.warn(
- `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
- );
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- }
-
- async abortTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- newStatus = WithdrawalGroupStatus.AbortingBank;
- break;
- case WithdrawalGroupStatus.SuspendedAml:
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.PendingAml:
- case WithdrawalGroupStatus.PendingKyc:
- case WithdrawalGroupStatus.PendingQueryingStatus:
- newStatus = WithdrawalGroupStatus.AbortedExchange;
- break;
- case WithdrawalGroupStatus.PendingReady:
- newStatus = WithdrawalGroupStatus.SuspendedReady;
- break;
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.AbortingBank:
- // No transition needed, but not an error
- break;
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedBankAborted:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.FailedAbortingBank:
- // Not allowed
- throw Error("abort not allowed in current state");
- default:
- assertUnreachable(wg.status);
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(taskId);
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(taskId);
- }
-
- async resumeTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedReady:
- newStatus = WithdrawalGroupStatus.PendingReady;
- break;
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- newStatus = WithdrawalGroupStatus.AbortingBank;
- break;
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
- break;
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
- break;
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
- break;
- case WithdrawalGroupStatus.SuspendedAml:
- newStatus = WithdrawalGroupStatus.PendingAml;
- break;
- case WithdrawalGroupStatus.SuspendedKyc:
- newStatus = WithdrawalGroupStatus.PendingKyc;
- break;
- default:
- logger.warn(
- `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
- );
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-
- async failTransaction(): Promise<void> {
- const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
- const stateUpdate = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return;
- }
- let newStatus: WithdrawalGroupStatus | undefined = undefined;
- switch (wg.status) {
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.AbortingBank:
- newStatus = WithdrawalGroupStatus.FailedAbortingBank;
- break;
- default:
- break;
- }
- if (newStatus != null) {
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- wg.status = newStatus;
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState,
- newTxState,
- };
- }
- return undefined;
- },
- );
- ws.taskScheduler.stopShepherdTask(retryTag);
- notifyTransition(ws, transactionId, stateUpdate);
- ws.taskScheduler.startShepherdTask(retryTag);
- }
-}
-
-/**
- * Compute the DD37 transaction state of a withdrawal transaction
- * from the database's withdrawal group record.
- */
-export function computeWithdrawalTransactionStatus(
- wgRecord: WithdrawalGroupRecord,
-): TransactionState {
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.FailedBankAborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case WithdrawalGroupStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case WithdrawalGroupStatus.PendingRegisteringBank:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.BankRegisterReserve,
- };
- case WithdrawalGroupStatus.PendingReady:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.WithdrawCoins,
- };
- case WithdrawalGroupStatus.PendingQueryingStatus:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.ExchangeWaitReserve,
- };
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.BankConfirmTransfer,
- };
- case WithdrawalGroupStatus.AbortingBank:
- return {
- major: TransactionMajorState.Aborting,
- minor: TransactionMinorState.Bank,
- };
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- return {
- major: TransactionMajorState.SuspendedAborting,
- minor: TransactionMinorState.Bank,
- };
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.ExchangeWaitReserve,
- };
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.BankRegisterReserve,
- };
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.BankConfirmTransfer,
- };
- case WithdrawalGroupStatus.SuspendedReady: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.WithdrawCoins,
- };
- }
- case WithdrawalGroupStatus.PendingAml: {
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AmlRequired,
- };
- }
- case WithdrawalGroupStatus.PendingKyc: {
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.KycRequired,
- };
- }
- case WithdrawalGroupStatus.SuspendedAml: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.AmlRequired,
- };
- }
- case WithdrawalGroupStatus.SuspendedKyc: {
- return {
- major: TransactionMajorState.Suspended,
- minor: TransactionMinorState.KycRequired,
- };
- }
- case WithdrawalGroupStatus.FailedAbortingBank:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.AbortingBank,
- };
- case WithdrawalGroupStatus.AbortedExchange:
- return {
- major: TransactionMajorState.Aborted,
- minor: TransactionMinorState.Exchange,
- };
-
- case WithdrawalGroupStatus.AbortedBank:
- return {
- major: TransactionMajorState.Aborted,
- minor: TransactionMinorState.Bank,
- };
- }
-}
-
-/**
- * Compute DD37 transaction actions for a withdrawal transaction
- * based on the database's withdrawal group record.
- */
-export function computeWithdrawalTransactionActions(
- wgRecord: WithdrawalGroupRecord,
-): TransactionAction[] {
- switch (wgRecord.status) {
- case WithdrawalGroupStatus.FailedBankAborted:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.Done:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.PendingRegisteringBank:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingReady:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingQueryingStatus:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- return [TransactionAction.Suspend, TransactionAction.Abort];
- case WithdrawalGroupStatus.AbortingBank:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedReady:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingAml:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.PendingKyc:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedAml:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.SuspendedKyc:
- return [TransactionAction.Resume, TransactionAction.Abort];
- case WithdrawalGroupStatus.FailedAbortingBank:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.AbortedExchange:
- return [TransactionAction.Delete];
- case WithdrawalGroupStatus.AbortedBank:
- return [TransactionAction.Delete];
- }
-}
-
-/**
- * Get information about a withdrawal from
- * a taler://withdraw URI by asking the bank.
- *
- * FIXME: Move into bank client.
- */
-export async function getBankWithdrawalInfo(
- http: HttpRequestLibrary,
- talerWithdrawUri: string,
-): Promise<BankWithdrawDetails> {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse URL ${talerWithdrawUri}`);
- }
-
- const bankApi = new TalerBankIntegrationHttpClient(
- uriResult.bankIntegrationApiBaseUrl,
- http,
- );
-
- const { body: config } = await bankApi.getConfig();
-
- if (!bankApi.isCompatible(config.version)) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
- {
- bankProtocolVersion: config.version,
- walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- },
- "bank integration protocol version not compatible with wallet",
- );
- }
-
- const resp = await bankApi.getWithdrawalOperationById(
- uriResult.withdrawalOperationId,
- );
-
- if (resp.type === "fail") {
- throw TalerError.fromUncheckedDetail(resp.detail);
- }
- const { body: status } = resp;
-
- logger.info(`bank withdrawal operation status: ${j2s(status)}`);
-
- return {
- operationId: uriResult.withdrawalOperationId,
- apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
- amount: Amounts.parseOrThrow(status.amount),
- confirmTransferUrl: status.confirm_transfer_url,
- senderWire: status.sender_wire,
- suggestedExchange: status.suggested_exchange,
- wireTypes: status.wire_types,
- status: status.status,
- };
-}
-
-/**
- * Return denominations that can potentially used for a withdrawal.
- */
-async function getCandidateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<DenominationRecord[]> {
- return await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency);
- });
-}
-
-export async function getCandidateWithdrawalDenomsTx(
- ws: InternalWalletState,
- tx: WalletDbReadOnlyTransaction<["denominations"]>,
- exchangeBaseUrl: string,
- currency: string,
-): Promise<DenominationRecord[]> {
- // FIXME: Use denom groups instead of querying all denominations!
- const allDenoms =
- await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
- return allDenoms
- .filter((d) => d.currency === currency)
- .filter((d) => isWithdrawableDenom(d, ws.config.testing.denomselAllowLate));
-}
-
-/**
- * Generate a planchet for a coin index in a withdrawal group.
- * Does not actually withdraw the coin yet.
- *
- * Split up so that we can parallelize the crypto, but serialize
- * the exchange requests per reserve.
- */
-async function processPlanchetGenerate(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- coinIdx: number,
-): Promise<void> {
- let planchet = await ws.db.runReadOnlyTx(["planchets"], async (tx) => {
- return tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- });
- if (planchet) {
- return;
- }
- let ci = 0;
- let maybeDenomPubHash: string | undefined;
- for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
- const d = withdrawalGroup.denomsSel.selectedDenoms[di];
- if (coinIdx >= ci && coinIdx < ci + d.count) {
- maybeDenomPubHash = d.denomPubHash;
- break;
- }
- ci += d.count;
- }
- if (!maybeDenomPubHash) {
- throw Error("invariant violated");
- }
- const denomPubHash = maybeDenomPubHash;
-
- const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- denomPubHash,
- );
- });
- checkDbInvariant(!!denom);
- const r = await ws.cryptoApi.createPlanchet({
- denomPub: denom.denomPub,
- feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
- reservePriv: withdrawalGroup.reservePriv,
- reservePub: withdrawalGroup.reservePub,
- value: Amounts.parseOrThrow(denom.value),
- coinIndex: coinIdx,
- secretSeed: withdrawalGroup.secretSeed,
- restrictAge: withdrawalGroup.restrictAge,
- });
- const newPlanchet: PlanchetRecord = {
- blindingKey: r.blindingKey,
- coinEv: r.coinEv,
- coinEvHash: r.coinEvHash,
- coinIdx,
- coinPriv: r.coinPriv,
- coinPub: r.coinPub,
- denomPubHash: r.denomPubHash,
- planchetStatus: PlanchetStatus.Pending,
- withdrawSig: r.withdrawSig,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- ageCommitmentProof: r.ageCommitmentProof,
- lastError: undefined,
- };
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- const p = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (p) {
- planchet = p;
- return;
- }
- await tx.planchets.put(newPlanchet);
- planchet = newPlanchet;
- });
-}
-
-interface WithdrawalRequestBatchArgs {
- coinStartIndex: number;
-
- batchSize: number;
-}
-
-interface WithdrawalBatchResult {
- coinIdxs: number[];
- batchResp: ExchangeWithdrawBatchResponse;
-}
-
-enum AmlStatus {
- normal = 0,
- pending = 1,
- fronzen = 2,
-}
-
-/**
- * Transition a withdrawal transaction with a (new) KYC URL.
- *
- * Emit a notification for the (self-)transition.
- */
-async function transitionKycUrlUpdate(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- kycUrl: string,
-): Promise<void> {
- let notificationKycUrl: string | undefined = undefined;
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
- const transactionId = ctx.transactionId;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingReady: {
- wg2.kycUrl = kycUrl;
- notificationKycUrl = kycUrl;
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- if (transitionInfo) {
- // Always notify, even on self-transition, as the KYC URL might have changed.
- ws.notify({
- type: NotificationType.TransactionStateTransition,
- oldTxState: transitionInfo.oldTxState,
- newTxState: transitionInfo.newTxState,
- transactionId,
- experimentalUserData: notificationKycUrl,
- });
- }
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-}
-
-async function handleKycRequired(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- resp: HttpResponse,
- startIdx: number,
- requestCoinIdxs: number[],
-): Promise<void> {
- logger.info("withdrawal requires KYC");
- const respJson = await resp.json();
- const uuidResp = codecForWalletKycUuid().decode(respJson);
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
- const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const userType = "individual";
- const kycInfo: KycPendingInfo = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- logger.info(`kyc url ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- });
- let kycUrl: string;
- let amlStatus: AmlStatus | undefined;
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- logger.warn("kyc requested, but already fulfilled");
- return;
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- kycUrl = kycStatus.kyc_url;
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- amlStatus = kycStatus.aml_status;
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
-
- let notificationKycUrl: string | undefined = undefined;
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["planchets", "withdrawalGroups"],
- async (tx) => {
- for (let i = startIdx; i < requestCoinIdxs.length; i++) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- requestCoinIdxs[i],
- ]);
- if (!planchet) {
- continue;
- }
- planchet.planchetStatus = PlanchetStatus.KycRequired;
- await tx.planchets.put(planchet);
- }
- const wg2 = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingReady: {
- wg2.kycPending = {
- paytoHash: uuidResp.h_payto,
- requirementRow: uuidResp.requirement_row,
- };
- wg2.kycUrl = kycUrl;
- wg2.status =
- amlStatus === AmlStatus.normal || amlStatus === undefined
- ? WithdrawalGroupStatus.PendingKyc
- : amlStatus === AmlStatus.pending
- ? WithdrawalGroupStatus.PendingAml
- : amlStatus === AmlStatus.fronzen
- ? WithdrawalGroupStatus.SuspendedAml
- : assertUnreachable(amlStatus);
-
- notificationKycUrl = kycUrl;
-
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- notifyTransition(ws, transactionId, transitionInfo, notificationKycUrl);
-}
-
-/**
- * Send the withdrawal request for a generated planchet to the exchange.
- *
- * The verification of the response is done asynchronously to enable parallelism.
- */
-async function processPlanchetExchangeBatchRequest(
- ws: InternalWalletState,
- wgContext: WithdrawalGroupContext,
- args: WithdrawalRequestBatchArgs,
-): Promise<WithdrawalBatchResult> {
- const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
- logger.info(
- `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
- );
-
- const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
- // Indices of coins that are included in the batch request
- const requestCoinIdxs: number[] = [];
-
- await ws.db.runReadOnlyTx(["planchets", "denominations"], async (tx) => {
- for (
- let coinIdx = args.coinStartIndex;
- coinIdx < args.coinStartIndex + args.batchSize &&
- coinIdx < wgContext.numPlanchets;
- coinIdx++
- ) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- continue;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- continue;
- }
- const denom = await ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
-
- if (!denom) {
- logger.error("db inconsistent: denom for planchet not found");
- continue;
- }
-
- const planchetReq: ExchangeWithdrawRequest = {
- denom_pub_hash: planchet.denomPubHash,
- reserve_sig: planchet.withdrawSig,
- coin_ev: planchet.coinEv,
- };
- batchReq.planchets.push(planchetReq);
- requestCoinIdxs.push(coinIdx);
- }
- });
-
- if (batchReq.planchets.length == 0) {
- logger.warn("empty withdrawal batch");
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
-
- async function storeCoinError(e: any, coinIdx: number): Promise<void> {
- const errDetail = getErrorDetailFromException(e);
- logger.trace("withdrawal request failed", e);
- logger.trace(String(e));
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = errDetail;
- await tx.planchets.put(planchet);
- });
- }
-
- // FIXME: handle individual error codes better!
-
- const reqUrl = new URL(
- `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
- withdrawalGroup.exchangeBaseUrl,
- ).href;
-
- try {
- const resp = await ws.http.fetch(reqUrl, {
- method: "POST",
- body: batchReq,
- });
- if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
- await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
- const r = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeWithdrawBatchResponse(),
- );
- return {
- coinIdxs: requestCoinIdxs,
- batchResp: r,
- };
- } catch (e) {
- await storeCoinError(e, requestCoinIdxs[0]);
- return {
- batchResp: { ev_sigs: [] },
- coinIdxs: [],
- };
- }
-}
-
-async function processPlanchetVerifyAndStoreCoin(
- ws: InternalWalletState,
- wgContext: WithdrawalGroupContext,
- coinIdx: number,
- resp: ExchangeWithdrawResponse,
-): Promise<void> {
- const withdrawalGroup = wgContext.wgRecord;
- logger.trace(`checking and storing planchet idx=${coinIdx}`);
- const d = await ws.db.runReadOnlyTx(
- ["planchets", "denominations"],
- async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- const denomInfo = await ws.getDenomInfo(
- ws,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
- if (!denomInfo) {
- return;
- }
- return {
- planchet,
- denomInfo,
- exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
- };
- },
- );
-
- if (!d) {
- return;
- }
-
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
- });
-
- const { planchet, denomInfo } = d;
-
- const planchetDenomPub = denomInfo.denomPub;
- if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
- throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
- }
-
- let evSig = resp.ev_sig;
- if (!(evSig.cipher === DenomKeyType.Rsa)) {
- throw Error("unsupported cipher");
- }
-
- const denomSigRsa = await ws.cryptoApi.rsaUnblind({
- bk: planchet.blindingKey,
- blindedSig: evSig.blinded_rsa_signature,
- pk: planchetDenomPub.rsa_public_key,
- });
-
- const isValid = await ws.cryptoApi.rsaVerify({
- hm: planchet.coinPub,
- pk: planchetDenomPub.rsa_public_key,
- sig: denomSigRsa.sig,
- });
-
- if (!isValid) {
- await ws.db.runReadWriteTx(["planchets"], async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = makeErrorDetail(
- TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
- {},
- "invalid signature from the exchange after unblinding",
- );
- await tx.planchets.put(planchet);
- });
- return;
- }
-
- let denomSig: UnblindedSignature;
- if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
- denomSig = {
- cipher: planchetDenomPub.cipher,
- rsa_signature: denomSigRsa.sig,
- };
- } else {
- throw Error("unsupported cipher");
- }
-
- const coin: CoinRecord = {
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- denomPubHash: planchet.denomPubHash,
- denomSig,
- coinEvHash: planchet.coinEvHash,
- exchangeBaseUrl: d.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Withdraw,
- coinIndex: coinIdx,
- reservePub: withdrawalGroup.reservePub,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- },
- sourceTransactionId: transactionId,
- maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
- ageCommitmentProof: planchet.ageCommitmentProof,
- spendAllocation: undefined,
- };
-
- const planchetCoinPub = planchet.coinPub;
-
- wgContext.planchetsFinished.add(planchet.coinPub);
-
- await ws.db.runReadWriteTx(
- ["planchets", "coins", "coinAvailability", "denominations"],
- async (tx) => {
- const p = await tx.planchets.get(planchetCoinPub);
- if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
- return;
- }
- p.planchetStatus = PlanchetStatus.WithdrawalDone;
- p.lastError = undefined;
- await tx.planchets.put(p);
- await makeCoinAvailable(ws, tx, coin);
- },
- );
-}
-
-/**
- * Make sure that denominations that currently can be used for withdrawal
- * are validated, and the result of validation is stored in the database.
- */
-async function updateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- logger.trace(
- `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
- );
- const exchangeDetails = await ws.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails"],
- async (tx) => {
- return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
- },
- );
- if (!exchangeDetails) {
- logger.error("exchange details not available");
- throw Error(`exchange ${exchangeBaseUrl} details not available`);
- }
- // First do a pass where the validity of candidate denominations
- // is checked and the result is stored in the database.
- logger.trace("getting candidate denominations");
- const denominations = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- exchangeDetails.currency,
- );
- logger.trace(`got ${denominations.length} candidate denominations`);
- const batchSize = 500;
- let current = 0;
-
- while (current < denominations.length) {
- const updatedDenominations: DenominationRecord[] = [];
- // Do a batch of batchSize
- for (
- let batchIdx = 0;
- batchIdx < batchSize && current < denominations.length;
- batchIdx++, current++
- ) {
- const denom = denominations[current];
- if (
- denom.verificationStatus === DenominationVerificationStatus.Unverified
- ) {
- logger.trace(
- `Validating denomination (${current + 1}/${
- denominations.length
- }) signature of ${denom.denomPubHash}`,
- );
- let valid = false;
- if (ws.config.testing.insecureTrustExchange) {
- valid = true;
- } else {
- const res = await ws.cryptoApi.isValidDenom({
- denom,
- masterPub: exchangeDetails.masterPublicKey,
- });
- valid = res.valid;
- }
- logger.trace(`Done validating ${denom.denomPubHash}`);
- if (!valid) {
- logger.warn(
- `Signature check for denomination h=${denom.denomPubHash} failed`,
- );
- denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
- } else {
- denom.verificationStatus =
- DenominationVerificationStatus.VerifiedGood;
- }
- updatedDenominations.push(denom);
- }
- }
- if (updatedDenominations.length > 0) {
- logger.trace("writing denomination batch to db");
- await ws.db.runReadWriteTx(["denominations"], async (tx) => {
- for (let i = 0; i < updatedDenominations.length; i++) {
- const denom = updatedDenominations[i];
- await tx.denominations.put(denom);
- }
- });
- logger.trace("done with DB write");
- }
- }
-}
-
-/**
- * Update the information about a reserve that is stored in the wallet
- * by querying the reserve's exchange.
- *
- * If the reserve have funds that are not allocated in a withdrawal group yet
- * and are big enough to withdraw with available denominations,
- * create a new withdrawal group for the remaining amount.
- */
-async function queryReserve(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- checkDbInvariant(!!withdrawalGroup);
- if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
- return TaskRunResult.backoff();
- }
- const reservePub = withdrawalGroup.reservePub;
-
- const reserveUrl = new URL(
- `reserves/${reservePub}`,
- withdrawalGroup.exchangeBaseUrl,
- );
- reserveUrl.searchParams.set("timeout_ms", "30000");
-
- logger.trace(`querying reserve status via ${reserveUrl.href}`);
-
- const resp = await ws.http.fetch(reserveUrl.href, {
- timeout: getReserveRequestTimeout(withdrawalGroup),
- cancellationToken,
- });
-
- logger.trace(`reserve status code: HTTP ${resp.status}`);
-
- const result = await readSuccessResponseJsonOrErrorCode(
- resp,
- codecForReserveStatus(),
- );
-
- if (result.isError) {
- logger.trace(
- `got reserve status error, EC=${result.talerErrorResponse.code}`,
- );
- if (resp.status === HttpStatusCode.NotFound) {
- return TaskRunResult.backoff();
- } else {
- throwUnexpectedRequestError(resp, result.talerErrorResponse);
- }
- }
-
- logger.trace(`got reserve status ${j2s(result.response)}`);
-
- const transitionResult = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
- return undefined;
- }
- const txStateOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.PendingReady;
- const txStateNew = computeWithdrawalTransactionStatus(wg);
- wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStateOld,
- newTxState: txStateNew,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionResult);
-
- return TaskRunResult.backoff();
-}
-
-/**
- * Withdrawal context that is kept in-memory.
- *
- * Used to store some cached info during a withdrawal operation.
- */
-export interface WithdrawalGroupContext {
- numPlanchets: number;
- planchetsFinished: Set<string>;
-
- /**
- * Cached withdrawal group record from the database.
- */
- wgRecord: WithdrawalGroupRecord;
-}
-
-async function processWithdrawalGroupAbortingBank(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const { withdrawalGroupId } = withdrawalGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- const wgInfo = withdrawalGroup.wgInfo;
- if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
- throw Error("invalid state (aborting(bank) without bank info");
- }
- const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
- logger.info(`aborting withdrawal at ${abortUrl}`);
- const abortResp = await ws.http.fetch(abortUrl, {
- method: "POST",
- body: {},
- });
- logger.info(`abort response status: ${abortResp.status}`);
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return undefined;
- }
- const txStatusOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.AbortedBank;
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- const txStatusNew = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStatusOld,
- newTxState: txStatusNew,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
-}
-
-/**
- * Store in the database that the KYC for a withdrawal is now
- * satisfied.
- */
-async function transitionKycSatisfied(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<void> {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- });
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg2 = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (!wg2) {
- return;
- }
- const oldTxState = computeWithdrawalTransactionStatus(wg2);
- switch (wg2.status) {
- case WithdrawalGroupStatus.PendingKyc: {
- delete wg2.kycPending;
- delete wg2.kycUrl;
- wg2.status = WithdrawalGroupStatus.PendingReady;
- await tx.withdrawalGroups.put(wg2);
- const newTxState = computeWithdrawalTransactionStatus(wg2);
- return {
- oldTxState,
- newTxState,
- };
- }
- default:
- return undefined;
- }
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processWithdrawalGroupPendingKyc(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- const userType = "individual";
- const kycInfo = withdrawalGroup.kycPending;
- if (!kycInfo) {
- throw Error("no kyc info available in pending(kyc)");
- }
- const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
- const url = new URL(
- `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
- exchangeUrl,
- );
- url.searchParams.set("timeout_ms", "30000");
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
-
- logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
- const kycStatusRes = await ws.http.fetch(url.href, {
- method: "GET",
- cancellationToken,
- });
- logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`);
- if (
- kycStatusRes.status === HttpStatusCode.Ok ||
- //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
- // remove after the exchange is fixed or clarified
- kycStatusRes.status === HttpStatusCode.NoContent
- ) {
- await transitionKycSatisfied(ws, withdrawalGroup);
- } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`kyc status: ${j2s(kycStatus)}`);
- const kycUrl = kycStatus.kyc_url;
- if (typeof kycUrl === "string") {
- await transitionKycUrlUpdate(ws, withdrawalGroupId, kycUrl);
- }
- } else if (
- kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
- ) {
- const kycStatus = await kycStatusRes.json();
- logger.info(`aml status: ${j2s(kycStatus)}`);
- } else {
- throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
- }
- return TaskRunResult.backoff();
-}
-
-async function processWithdrawalGroupPendingReady(
- ws: InternalWalletState,
- withdrawalGroup: WithdrawalGroupRecord,
-): Promise<TaskRunResult> {
- const { withdrawalGroupId } = withdrawalGroup;
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
-
- await fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl);
-
- if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
- logger.warn("Finishing empty withdrawal group (no denoms)");
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return undefined;
- }
- const txStatusOld = computeWithdrawalTransactionStatus(wg);
- wg.status = WithdrawalGroupStatus.Done;
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- const txStatusNew = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
- return {
- oldTxState: txStatusOld,
- newTxState: txStatusNew,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
- .map((x) => x.count)
- .reduce((a, b) => a + b);
-
- const wgContext: WithdrawalGroupContext = {
- numPlanchets: numTotalCoins,
- planchetsFinished: new Set<string>(),
- wgRecord: withdrawalGroup,
- };
-
- await ws.db.runReadOnlyTx(["planchets"], async (tx) => {
- const planchets =
- await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
- for (const p of planchets) {
- if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
- wgContext.planchetsFinished.add(p.coinPub);
- }
- }
- });
-
- // We sequentially generate planchets, so that
- // large withdrawal groups don't make the wallet unresponsive.
- for (let i = 0; i < numTotalCoins; i++) {
- await processPlanchetGenerate(ws, withdrawalGroup, i);
- }
-
- const maxBatchSize = 100;
-
- for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
- const resp = await processPlanchetExchangeBatchRequest(ws, wgContext, {
- batchSize: maxBatchSize,
- coinStartIndex: i,
- });
- let work: Promise<void>[] = [];
- work = [];
- for (let j = 0; j < resp.coinIdxs.length; j++) {
- if (!resp.batchResp.ev_sigs[j]) {
- // response may not be available when there is kyc needed
- continue;
- }
- work.push(
- processPlanchetVerifyAndStoreCoin(
- ws,
- wgContext,
- resp.coinIdxs[j],
- resp.batchResp.ev_sigs[j],
- ),
- );
- }
- await Promise.all(work);
- }
-
- let numFinished = 0;
- const errorsPerCoin: Record<number, TalerErrorDetail> = {};
- let numPlanchetErrors = 0;
- const maxReportedErrors = 5;
-
- const res = await ws.db.runReadWriteTx(
- ["coins", "coinAvailability", "withdrawalGroups", "planchets"],
- async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return;
- }
-
- await tx.planchets.indexes.byGroup
- .iter(withdrawalGroupId)
- .forEach((x) => {
- if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
- numFinished++;
- }
- if (x.lastError) {
- numPlanchetErrors++;
- if (numPlanchetErrors < maxReportedErrors) {
- errorsPerCoin[x.coinIdx] = x.lastError;
- }
- }
- });
- const oldTxState = computeWithdrawalTransactionStatus(wg);
- logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
- if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
- wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
- wg.status = WithdrawalGroupStatus.Done;
- await makeCoinsVisible(ws, tx, transactionId);
- }
-
- const newTxState = computeWithdrawalTransactionStatus(wg);
- await tx.withdrawalGroups.put(wg);
-
- return {
- kycInfo: wg.kycPending,
- transitionInfo: {
- oldTxState,
- newTxState,
- },
- };
- },
- );
-
- if (!res) {
- throw Error("withdrawal group does not exist anymore");
- }
-
- notifyTransition(ws, transactionId, res.transitionInfo);
- ws.notify({
- type: NotificationType.BalanceChange,
- hintTransactionId: transactionId,
- });
-
- if (numPlanchetErrors > 0) {
- return {
- type: TaskRunResultType.Error,
- errorDetail: makeErrorDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
- {
- errorsPerCoin,
- numErrors: numPlanchetErrors,
- },
- ),
- };
- }
-
- return TaskRunResult.backoff();
-}
-
-export async function processWithdrawalGroup(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- cancellationToken: CancellationToken,
-): Promise<TaskRunResult> {
- logger.trace("processing withdrawal group", withdrawalGroupId);
- const withdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return tx.withdrawalGroups.get(withdrawalGroupId);
- },
- );
-
- if (!withdrawalGroup) {
- throw Error(`withdrawal group ${withdrawalGroupId} not found`);
- }
-
- switch (withdrawalGroup.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- await processReserveBankStatus(ws, withdrawalGroupId);
- // FIXME: This will get called by the main task loop, why call it here?!
- return await processWithdrawalGroup(
- ws,
- withdrawalGroupId,
- cancellationToken,
- );
- case WithdrawalGroupStatus.PendingQueryingStatus: {
- return queryReserve(ws, withdrawalGroupId, cancellationToken);
- }
- case WithdrawalGroupStatus.PendingWaitConfirmBank: {
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
- case WithdrawalGroupStatus.PendingAml:
- // FIXME: Handle this case, withdrawal doesn't support AML yet.
- return TaskRunResult.backoff();
- case WithdrawalGroupStatus.PendingKyc:
- return processWithdrawalGroupPendingKyc(
- ws,
- withdrawalGroup,
- cancellationToken,
- );
- case WithdrawalGroupStatus.PendingReady:
- // Continue with the actual withdrawal!
- return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
- case WithdrawalGroupStatus.AbortingBank:
- return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup);
- case WithdrawalGroupStatus.AbortedBank:
- case WithdrawalGroupStatus.AbortedExchange:
- case WithdrawalGroupStatus.FailedAbortingBank:
- case WithdrawalGroupStatus.SuspendedAbortingBank:
- case WithdrawalGroupStatus.SuspendedAml:
- case WithdrawalGroupStatus.SuspendedKyc:
- case WithdrawalGroupStatus.SuspendedQueryingStatus:
- case WithdrawalGroupStatus.SuspendedReady:
- case WithdrawalGroupStatus.SuspendedRegisteringBank:
- case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
- case WithdrawalGroupStatus.Done:
- case WithdrawalGroupStatus.FailedBankAborted:
- // Nothing to do.
- return TaskRunResult.finished();
- default:
- assertUnreachable(withdrawalGroup.status);
- }
-}
-
-const AGE_MASK_GROUPS = "8:10:12:14:16:18"
- .split(":")
- .map((n) => parseInt(n, 10));
-
-export async function getExchangeWithdrawalInfo(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- instructedAmount: AmountJson,
- ageRestricted: number | undefined,
-): Promise<ExchangeWithdrawalDetails> {
- logger.trace("updating exchange");
- const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- if (exchange.currency != instructedAmount.currency) {
- // Specifying the amount in the conversion input currency is not yet supported.
- // We might add support for it later.
- throw new Error(
- `withdrawal only supported when specifying target currency ${exchange.currency}`,
- );
- }
-
- const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount,
- });
-
- logger.trace("updating withdrawal denoms");
- await updateWithdrawalDenoms(ws, exchangeBaseUrl);
-
- logger.trace("getting candidate denoms");
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- instructedAmount.currency,
- );
- logger.trace("selecting withdrawal denoms");
- const selectedDenoms = selectWithdrawalDenominations(
- instructedAmount,
- denoms,
- ws.config.testing.denomselAllowLate,
- );
-
- logger.trace("selection done");
-
- if (selectedDenoms.selectedDenoms.length === 0) {
- throw Error(
- `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
- instructedAmount,
- )}`,
- );
- }
-
- const exchangeWireAccounts: string[] = [];
-
- for (const account of exchange.wireInfo.accounts) {
- exchangeWireAccounts.push(account.payto_uri);
- }
-
- let hasDenomWithAgeRestriction = false;
-
- logger.trace("computing earliest deposit expiration");
-
- let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
- for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
- const ds = selectedDenoms.selectedDenoms[i];
- // FIXME: Do in one transaction!
- const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
- return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
- });
- checkDbInvariant(!!denom);
- hasDenomWithAgeRestriction =
- hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
- const expireDeposit = denom.stampExpireDeposit;
- if (!earliestDepositExpiration) {
- earliestDepositExpiration = expireDeposit;
- continue;
- }
- if (
- AbsoluteTime.cmp(
- AbsoluteTime.fromProtocolTimestamp(expireDeposit),
- AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration),
- ) < 0
- ) {
- earliestDepositExpiration = expireDeposit;
- }
- }
-
- checkLogicInvariant(!!earliestDepositExpiration);
-
- const possibleDenoms = await getCandidateWithdrawalDenoms(
- ws,
- exchangeBaseUrl,
- instructedAmount.currency,
- );
-
- let versionMatch;
- if (exchange.protocolVersionRange) {
- versionMatch = LibtoolVersion.compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- exchange.protocolVersionRange,
- );
-
- if (
- versionMatch &&
- !versionMatch.compatible &&
- versionMatch.currentCmp === -1
- ) {
- logger.warn(
- `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
- );
- }
- }
-
- let tosAccepted = false;
- if (exchange.tosAcceptedTimestamp) {
- if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
- tosAccepted = true;
- }
- }
-
- const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
- if (!paytoUris) {
- throw Error("exchange is in invalid state");
- }
-
- const ret: ExchangeWithdrawalDetails = {
- earliestDepositExpiration,
- exchangePaytoUris: paytoUris,
- exchangeWireAccounts,
- exchangeCreditAccountDetails: withdrawalAccountsList,
- exchangeVersion: exchange.protocolVersionRange || "unknown",
- numOfferedDenoms: possibleDenoms.length,
- selectedDenoms,
- // FIXME: delete this field / replace by something we can display to the user
- trustedAuditorPubs: [],
- versionMatch,
- walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- termsOfServiceAccepted: tosAccepted,
- withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
- withdrawalAmountRaw: Amounts.stringify(instructedAmount),
- // TODO: remove hardcoding, this should be calculated from the denominations info
- // force enabled for testing
- ageRestrictionOptions: hasDenomWithAgeRestriction
- ? AGE_MASK_GROUPS
- : undefined,
- scopeInfo: exchange.scopeInfo,
- };
- return ret;
-}
-
-export interface GetWithdrawalDetailsForUriOpts {
- restrictAge?: number;
- notifyChangeFromPendingTimeoutMs?: number;
-}
-
-type WithdrawalOperationMemoryMap = {
- [uri: string]: boolean | undefined;
-};
-const ongoingChecks: WithdrawalOperationMemoryMap = {};
-/**
- * Get more information about a taler://withdraw URI.
- *
- * As side effects, the bank (via the bank integration API) is queried
- * and the exchange suggested by the bank is ephemerally added
- * to the wallet's list of known exchanges.
- */
-export async function getWithdrawalDetailsForUri(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- opts: GetWithdrawalDetailsForUriOpts = {},
-): Promise<WithdrawUriInfoResponse> {
- logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
- const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
- logger.trace(`got bank info`);
- if (info.suggestedExchange) {
- try {
- // If the exchange entry doesn't exist yet,
- // it'll be created as an ephemeral entry.
- await fetchFreshExchange(ws, info.suggestedExchange);
- } catch (e) {
- // We still continued if it failed, as other exchanges might be available.
- // We don't want to fail if the bank-suggested exchange is broken/offline.
- logger.trace(
- `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
- );
- }
- }
-
- const currency = Amounts.currencyOf(info.amount);
-
- const listExchangesResp = await listExchanges(ws);
- const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
- return (
- x.currency === currency &&
- (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
- x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
- );
- });
-
- // FIXME: this should be removed after the extended version of
- // withdrawal state machine. issue #8099
- if (
- info.status === "pending" &&
- opts.notifyChangeFromPendingTimeoutMs !== undefined &&
- !ongoingChecks[talerWithdrawUri]
- ) {
- ongoingChecks[talerWithdrawUri] = true;
- const bankApi = new TalerBankIntegrationHttpClient(
- info.apiBaseUrl,
- ws.http,
- );
- console.log(
- `waiting operation (${info.operationId}) to change from pending`,
- );
- bankApi
- .getWithdrawalOperationById(info.operationId, {
- old_state: "pending",
- timeoutMs: opts.notifyChangeFromPendingTimeoutMs,
- })
- .then((resp) => {
- console.log(
- `operation (${info.operationId}) to change to ${JSON.stringify(
- resp,
- undefined,
- 2,
- )}`,
- );
- ws.notify({
- type: NotificationType.WithdrawalOperationTransition,
- operationId: info.operationId,
- state: resp.type === "fail" ? info.status : resp.body.status,
- });
- ongoingChecks[talerWithdrawUri] = false;
- });
- }
-
- return {
- operationId: info.operationId,
- confirmTransferUrl: info.confirmTransferUrl,
- status: info.status,
- amount: Amounts.stringify(info.amount),
- defaultExchangeBaseUrl: info.suggestedExchange,
- possibleExchanges,
- };
-}
-
-export function augmentPaytoUrisForWithdrawal(
- plainPaytoUris: string[],
- reservePub: string,
- instructedAmount: AmountLike,
-): string[] {
- return plainPaytoUris.map((x) =>
- addPaytoQueryParams(x, {
- amount: Amounts.stringify(instructedAmount),
- message: `Taler Withdrawal ${reservePub}`,
- }),
- );
-}
-
-/**
- * Get payto URIs that can be used to fund a withdrawal operation.
- */
-export async function getFundingPaytoUris(
- tx: WalletDbReadOnlyTransaction<
- ["withdrawalGroups", "exchanges", "exchangeDetails"]
- >,
- withdrawalGroupId: string,
-): Promise<string[]> {
- const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
- checkDbInvariant(!!withdrawalGroup);
- const exchangeDetails = await getExchangeWireDetailsInTx(
- tx,
- withdrawalGroup.exchangeBaseUrl,
- );
- if (!exchangeDetails) {
- logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
- return [];
- }
- const plainPaytoUris =
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
- if (!plainPaytoUris) {
- logger.error(
- `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
- );
- return [];
- }
- return augmentPaytoUrisForWithdrawal(
- plainPaytoUris,
- withdrawalGroup.reservePub,
- withdrawalGroup.instructedAmount,
- );
-}
-
-async function getWithdrawalGroupRecordTx(
- db: DbAccess<typeof WalletStoresV1>,
- req: {
- withdrawalGroupId: string;
- },
-): Promise<WithdrawalGroupRecord | undefined> {
- return await db.runReadOnlyTx(["withdrawalGroups"], async (tx) => {
- return tx.withdrawalGroups.get(req.withdrawalGroupId);
- });
-}
-
-export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
- return { d_ms: 60000 };
-}
-
-export function getBankStatusUrl(talerWithdrawUri: string): string {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
- }
- const url = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- return url.href;
-}
-
-export function getBankAbortUrl(talerWithdrawUri: string): string {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
- }
- const url = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- return url.href;
-}
-
-async function registerReserveWithBank(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<void> {
- const withdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return await tx.withdrawalGroups.get(withdrawalGroupId);
- },
- );
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- switch (withdrawalGroup?.status) {
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- break;
- default:
- return;
- }
- if (
- withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
- ) {
- throw Error("expecting withdrarwal type = bank integrated");
- }
- const bankInfo = withdrawalGroup.wgInfo.bankInfo;
- if (!bankInfo) {
- return;
- }
- const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
- const reqBody = {
- reserve_pub: withdrawalGroup.reservePub,
- selected_exchange: bankInfo.exchangePaytoUri,
- };
- logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
- const httpResp = await ws.http.fetch(bankStatusUrl, {
- method: "POST",
- body: reqBody,
- timeout: getReserveRequestTimeout(withdrawalGroup),
- });
- // FIXME: libeufin-bank currently doesn't return a response in the right format, so we don't validate at all.
- await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return undefined;
- }
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
- AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
- );
- const oldTxState = computeWithdrawalTransactionStatus(r);
- r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-}
-
-async function processReserveBankStatus(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<TaskRunResult> {
- const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId,
- });
- switch (withdrawalGroup?.status) {
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- case WithdrawalGroupStatus.PendingRegisteringBank:
- break;
- default:
- return TaskRunResult.backoff();
- }
-
- if (
- withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
- ) {
- throw Error("wrong withdrawal record type");
- }
- const bankInfo = withdrawalGroup.wgInfo.bankInfo;
- if (!bankInfo) {
- return TaskRunResult.backoff();
- }
-
- const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
-
- const statusResp = await ws.http.fetch(bankStatusUrl, {
- timeout: getReserveRequestTimeout(withdrawalGroup),
- });
- const status = await readSuccessResponseJsonOrThrow(
- statusResp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- if (status.aborted) {
- logger.info("bank aborted the withdrawal");
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return;
- }
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const oldTxState = computeWithdrawalTransactionStatus(r);
- r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
- r.status = WithdrawalGroupStatus.FailedBankAborted;
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
- notifyTransition(ws, transactionId, transitionInfo);
- return TaskRunResult.finished();
- }
-
- // Bank still needs to know our reserve info
- if (!status.selection_done) {
- await registerReserveWithBank(ws, withdrawalGroupId);
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
-
- // FIXME: Why do we do this?!
- if (withdrawalGroup.status === WithdrawalGroupStatus.PendingRegisteringBank) {
- await registerReserveWithBank(ws, withdrawalGroupId);
- return await processReserveBankStatus(ws, withdrawalGroupId);
- }
-
- const transitionInfo = await ws.db.runReadWriteTx(
- ["withdrawalGroups"],
- async (tx) => {
- const r = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!r) {
- return undefined;
- }
- // Re-check reserve status within transaction
- switch (r.status) {
- case WithdrawalGroupStatus.PendingRegisteringBank:
- case WithdrawalGroupStatus.PendingWaitConfirmBank:
- break;
- default:
- return undefined;
- }
- if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
- throw Error("invariant failed");
- }
- const oldTxState = computeWithdrawalTransactionStatus(r);
- if (status.transfer_done) {
- logger.info("withdrawal: transfer confirmed by bank.");
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
- r.status = WithdrawalGroupStatus.PendingQueryingStatus;
- } else {
- logger.trace("withdrawal: transfer not yet confirmed by bank");
- r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
- r.senderWire = status.sender_wire;
- }
- const newTxState = computeWithdrawalTransactionStatus(r);
- await tx.withdrawalGroups.put(r);
- return {
- oldTxState,
- newTxState,
- };
- },
- );
-
- notifyTransition(ws, transactionId, transitionInfo);
-
- if (transitionInfo) {
- return TaskRunResult.progress();
- } else {
- return TaskRunResult.backoff();
- }
-}
-
-export interface PrepareCreateWithdrawalGroupResult {
- withdrawalGroup: WithdrawalGroupRecord;
- transactionId: string;
- creationInfo?: {
- amount: AmountJson;
- canonExchange: string;
- };
-}
-
-export async function internalPrepareCreateWithdrawalGroup(
- ws: InternalWalletState,
- args: {
- reserveStatus: WithdrawalGroupStatus;
- amount: AmountJson;
- exchangeBaseUrl: string;
- forcedWithdrawalGroupId?: string;
- forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
- restrictAge?: number;
- wgInfo: WgInfo;
- },
-): Promise<PrepareCreateWithdrawalGroupResult> {
- const reserveKeyPair =
- args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
- const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
- const secretSeed = encodeCrock(getRandomBytes(32));
- const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
- const amount = args.amount;
- const currency = Amounts.currencyOf(amount);
-
- let withdrawalGroupId;
-
- if (args.forcedWithdrawalGroupId) {
- withdrawalGroupId = args.forcedWithdrawalGroupId;
- const wgId = withdrawalGroupId;
- const existingWg = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return tx.withdrawalGroups.get(wgId);
- },
- );
-
- if (existingWg) {
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: existingWg.withdrawalGroupId,
- });
- return { withdrawalGroup: existingWg, transactionId };
- }
- } else {
- withdrawalGroupId = encodeCrock(getRandomBytes(32));
- }
-
- await updateWithdrawalDenoms(ws, canonExchange);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- canonExchange,
- currency,
- );
-
- let initialDenomSel: DenomSelectionState;
- const denomSelUid = encodeCrock(getRandomBytes(16));
- if (args.forcedDenomSel) {
- logger.warn("using forced denom selection");
- initialDenomSel = selectForcedWithdrawalDenominations(
- amount,
- denoms,
- args.forcedDenomSel,
- ws.config.testing.denomselAllowLate,
- );
- } else {
- initialDenomSel = selectWithdrawalDenominations(
- amount,
- denoms,
- ws.config.testing.denomselAllowLate,
- );
- }
-
- const withdrawalGroup: WithdrawalGroupRecord = {
- denomSelUid,
- denomsSel: initialDenomSel,
- exchangeBaseUrl: canonExchange,
- instructedAmount: Amounts.stringify(amount),
- timestampStart: timestampPreciseToDb(now),
- rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
- effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
- secretSeed,
- reservePriv: reserveKeyPair.priv,
- reservePub: reserveKeyPair.pub,
- status: args.reserveStatus,
- withdrawalGroupId,
- restrictAge: args.restrictAge,
- senderWire: undefined,
- timestampFinish: undefined,
- wgInfo: args.wgInfo,
- };
-
- await fetchFreshExchange(ws, canonExchange);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
- });
-
- return {
- withdrawalGroup,
- transactionId,
- creationInfo: {
- canonExchange,
- amount,
- },
- };
-}
-
-export interface PerformCreateWithdrawalGroupResult {
- withdrawalGroup: WithdrawalGroupRecord;
- transitionInfo: TransitionInfo | undefined;
-
- /**
- * Notification for the exchange state transition.
- *
- * Should be emitted after the transaction has succeeded.
- */
- exchangeNotif: WalletNotification | undefined;
-}
-
-export async function internalPerformCreateWithdrawalGroup(
- ws: InternalWalletState,
- tx: WalletDbReadWriteTransaction<
- ["withdrawalGroups", "reserves", "exchanges"]
- >,
- prep: PrepareCreateWithdrawalGroupResult,
-): Promise<PerformCreateWithdrawalGroupResult> {
- const { withdrawalGroup } = prep;
- if (!prep.creationInfo) {
- return {
- withdrawalGroup,
- transitionInfo: undefined,
- exchangeNotif: undefined,
- };
- }
- const existingWg = await tx.withdrawalGroups.get(
- withdrawalGroup.withdrawalGroupId,
- );
- if (existingWg) {
- return {
- withdrawalGroup: existingWg,
- exchangeNotif: undefined,
- transitionInfo: undefined,
- };
- }
- await tx.withdrawalGroups.add(withdrawalGroup);
- await tx.reserves.put({
- reservePub: withdrawalGroup.reservePub,
- reservePriv: withdrawalGroup.reservePriv,
- });
-
- const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
- if (exchange) {
- exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
- await tx.exchanges.put(exchange);
- }
-
- const oldTxState = {
- major: TransactionMajorState.None,
- minor: undefined,
- };
- const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
- const transitionInfo = {
- oldTxState,
- newTxState,
- };
-
- const exchangeUsedRes = await markExchangeUsed(
- ws,
- tx,
- prep.withdrawalGroup.exchangeBaseUrl,
- );
-
- const ctx = new WithdrawTransactionContext(
- ws,
- withdrawalGroup.withdrawalGroupId,
- );
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- withdrawalGroup,
- transitionInfo,
- exchangeNotif: exchangeUsedRes.notif,
- };
-}
-
-/**
- * Create a withdrawal group.
- *
- * If a forcedWithdrawalGroupId is given and a
- * withdrawal group with this ID already exists,
- * the existing one is returned. No conflict checking
- * of the other arguments is done in that case.
- */
-export async function internalCreateWithdrawalGroup(
- ws: InternalWalletState,
- args: {
- reserveStatus: WithdrawalGroupStatus;
- amount: AmountJson;
- exchangeBaseUrl: string;
- forcedWithdrawalGroupId?: string;
- forcedDenomSel?: ForcedDenomSel;
- reserveKeyPair?: EddsaKeypair;
- restrictAge?: number;
- wgInfo: WgInfo;
- },
-): Promise<WithdrawalGroupRecord> {
- const prep = await internalPrepareCreateWithdrawalGroup(ws, args);
- const transactionId = constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
- });
- const res = await ws.db.runReadWriteTx(
- ["withdrawalGroups", "reserves", "exchanges", "exchangeDetails"],
- async (tx) => {
- return await internalPerformCreateWithdrawalGroup(ws, tx, prep);
- },
- );
- if (res.exchangeNotif) {
- ws.notify(res.exchangeNotif);
- }
- notifyTransition(ws, transactionId, res.transitionInfo);
- return res.withdrawalGroup;
-}
-
-export async function acceptWithdrawalFromUri(
- ws: InternalWalletState,
- req: {
- talerWithdrawUri: string;
- selectedExchange: string;
- forcedDenomSel?: ForcedDenomSel;
- restrictAge?: number;
- },
-): Promise<AcceptWithdrawalResponse> {
- const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
- logger.info(
- `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
- );
- const existingWithdrawalGroup = await ws.db.runReadOnlyTx(
- ["withdrawalGroups"],
- async (tx) => {
- return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
- req.talerWithdrawUri,
- );
- },
- );
-
- if (existingWithdrawalGroup) {
- let url: string | undefined;
- if (
- existingWithdrawalGroup.wgInfo.withdrawalType ===
- WithdrawalRecordType.BankIntegrated
- ) {
- url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
- }
- return {
- reservePub: existingWithdrawalGroup.reservePub,
- confirmTransferUrl: url,
- transactionId: constructTransactionIdentifier({
- tag: TransactionType.Withdrawal,
- withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
- }),
- };
- }
-
- await fetchFreshExchange(ws, selectedExchange);
- const withdrawInfo = await getBankWithdrawalInfo(
- ws.http,
- req.talerWithdrawUri,
- );
- const exchangePaytoUri = await getExchangePaytoUri(
- ws,
- selectedExchange,
- withdrawInfo.wireTypes,
- );
-
- const exchange = await fetchFreshExchange(ws, selectedExchange);
-
- const withdrawalAccountList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount: withdrawInfo.amount,
- });
-
- const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
- amount: withdrawInfo.amount,
- exchangeBaseUrl: req.selectedExchange,
- wgInfo: {
- withdrawalType: WithdrawalRecordType.BankIntegrated,
- exchangeCreditAccounts: withdrawalAccountList,
- bankInfo: {
- exchangePaytoUri,
- talerWithdrawUri: req.talerWithdrawUri,
- confirmUrl: withdrawInfo.confirmTransferUrl,
- timestampBankConfirmed: undefined,
- timestampReserveInfoPosted: undefined,
- },
- },
- restrictAge: req.restrictAge,
- forcedDenomSel: req.forcedDenomSel,
- reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
- });
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
-
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
-
- const transactionId = ctx.transactionId;
-
- // We do this here, as the reserve should be registered before we return,
- // so that we can redirect the user to the bank's status page.
- await processReserveBankStatus(ws, withdrawalGroupId);
- const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
- withdrawalGroupId,
- });
- if (
- processedWithdrawalGroup?.status === WithdrawalGroupStatus.FailedBankAborted
- ) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
- {},
- );
- }
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- reservePub: withdrawalGroup.reservePub,
- confirmTransferUrl: withdrawInfo.confirmTransferUrl,
- transactionId,
- };
-}
-
-async function fetchAccount(
- ws: InternalWalletState,
- instructedAmount: AmountJson,
- acct: ExchangeWireAccount,
- reservePub?: string,
-): Promise<WithdrawalExchangeAccountDetails> {
- let paytoUri: string;
- let transferAmount: AmountString | undefined = undefined;
- let currencySpecification: CurrencySpecification | undefined = undefined;
- if (acct.conversion_url != null) {
- const reqUrl = new URL("cashin-rate", acct.conversion_url);
- reqUrl.searchParams.set(
- "amount_credit",
- Amounts.stringify(instructedAmount),
- );
- const httpResp = await ws.http.fetch(reqUrl.href);
- const respOrErr = await readSuccessResponseJsonOrErrorCode(
- httpResp,
- codecForCashinConversionResponse(),
- );
- if (respOrErr.isError) {
- return {
- status: "error",
- paytoUri: acct.payto_uri,
- conversionError: respOrErr.talerErrorResponse,
- };
- }
- const resp = respOrErr.response;
- paytoUri = acct.payto_uri;
- transferAmount = resp.amount_debit;
- const configUrl = new URL("config", acct.conversion_url);
- const configResp = await ws.http.fetch(configUrl.href);
- const configRespOrError = await readSuccessResponseJsonOrErrorCode(
- configResp,
- codecForConversionBankConfig(),
- );
- if (configRespOrError.isError) {
- return {
- status: "error",
- paytoUri: acct.payto_uri,
- conversionError: configRespOrError.talerErrorResponse,
- };
- }
- const configParsed = configRespOrError.response;
- currencySpecification = configParsed.fiat_currency_specification;
- } else {
- paytoUri = acct.payto_uri;
- transferAmount = Amounts.stringify(instructedAmount);
- }
- paytoUri = addPaytoQueryParams(paytoUri, {
- amount: Amounts.stringify(transferAmount),
- });
- if (reservePub != null) {
- paytoUri = addPaytoQueryParams(paytoUri, {
- message: `Taler Withdrawal ${reservePub}`,
- });
- }
- const acctInfo: WithdrawalExchangeAccountDetails = {
- status: "ok",
- paytoUri,
- transferAmount,
- currencySpecification,
- creditRestrictions: acct.credit_restrictions,
- };
- if (transferAmount != null) {
- acctInfo.transferAmount = transferAmount;
- }
- return acctInfo;
-}
-
-/**
- * Gather information about bank accounts that can be used for
- * withdrawals. This includes accounts that are in a different
- * currency and require conversion.
- */
-async function fetchWithdrawalAccountInfo(
- ws: InternalWalletState,
- req: {
- exchange: ReadyExchangeSummary;
- instructedAmount: AmountJson;
- reservePub?: string;
- },
-): Promise<WithdrawalExchangeAccountDetails[]> {
- const { exchange, instructedAmount } = req;
- const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
- for (let acct of exchange.wireInfo.accounts) {
- const acctInfo = await fetchAccount(
- ws,
- req.instructedAmount,
- acct,
- req.reservePub,
- );
- withdrawalAccounts.push(acctInfo);
- }
- return withdrawalAccounts;
-}
-
-/**
- * Create a manual withdrawal operation.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- *
- * Asynchronously starts the withdrawal.
- */
-export async function createManualWithdrawal(
- ws: InternalWalletState,
- req: {
- exchangeBaseUrl: string;
- amount: AmountLike;
- restrictAge?: number;
- forcedDenomSel?: ForcedDenomSel;
- },
-): Promise<AcceptManualWithdrawalResult> {
- const { exchangeBaseUrl } = req;
- const amount = Amounts.parseOrThrow(req.amount);
- const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
-
- if (exchange.currency != amount.currency) {
- throw Error(
- "manual withdrawal with conversion from foreign currency is not yet supported",
- );
- }
- const reserveKeyPair: EddsaKeypair = await ws.cryptoApi.createEddsaKeypair(
- {},
- );
-
- const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
- exchange,
- instructedAmount: amount,
- reservePub: reserveKeyPair.pub,
- });
-
- const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
- amount: Amounts.jsonifyAmount(req.amount),
- wgInfo: {
- withdrawalType: WithdrawalRecordType.BankManual,
- exchangeCreditAccounts: withdrawalAccountsList,
- },
- exchangeBaseUrl: req.exchangeBaseUrl,
- forcedDenomSel: req.forcedDenomSel,
- restrictAge: req.restrictAge,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair,
- });
-
- const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
- const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
-
- const transactionId = ctx.transactionId;
-
- const exchangePaytoUris = await ws.db.runReadOnlyTx(
- ["withdrawalGroups", "exchanges", "exchangeDetails"],
- async (tx) => {
- return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
- },
- );
-
- ws.taskScheduler.startShepherdTask(ctx.taskId);
-
- return {
- reservePub: withdrawalGroup.reservePub,
- exchangePaytoUris: exchangePaytoUris,
- withdrawalAccountsList: withdrawalAccountsList,
- transactionId,
- };
-}
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -0,0 +1,3232 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the payment operation, including downloading and
+ * claiming of proposals.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbortingCoin,
+ AbortRequest,
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ AmountString,
+ AsyncFlag,
+ codecForAbortResponse,
+ codecForMerchantContractTerms,
+ codecForMerchantOrderRefundPickupResponse,
+ codecForMerchantOrderStatusPaid,
+ codecForMerchantPayResponse,
+ codecForMerchantPostOrderResponse,
+ codecForProposal,
+ CoinDepositPermission,
+ CoinRefreshRequest,
+ ConfirmPayResult,
+ ConfirmPayResultType,
+ ContractTermsUtil,
+ Duration,
+ encodeCrock,
+ ForcedCoinSel,
+ getRandomBytes,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ makePendingOperationFailedError,
+ MerchantCoinRefundStatus,
+ MerchantContractTerms,
+ MerchantPayResponse,
+ MerchantUsingTemplateDetails,
+ NotificationType,
+ parsePayTemplateUri,
+ parsePayUri,
+ parseTalerUri,
+ PayCoinSelection,
+ PreparePayResult,
+ PreparePayResultType,
+ PreparePayTemplateRequest,
+ randomBytes,
+ RefreshReason,
+ SharePaymentResult,
+ StartRefundQueryForUriResponse,
+ stringifyPayUri,
+ stringifyTalerUri,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TalerUriAction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletContractData,
+} from "@gnu-taler/taler-util";
+import {
+ getHttpResponseErrorDetails,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ readUnexpectedResponseDetails,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ DenominationRecord,
+ PurchaseRecord,
+ PurchaseStatus,
+ RefundReason,
+ WalletStoresV1,
+} from "./db.js";
+import {
+ getCandidateWithdrawalDenomsTx,
+ PendingTaskType,
+ RefundGroupRecord,
+ RefundGroupStatus,
+ RefundItemRecord,
+ RefundItemStatus,
+ TaskId,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+} from "./index.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ InternalWalletState,
+} from "./internal-wallet-state.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { PreviousPayCoins, selectPayCoinsNew } from "./util/coinSelection.js";
+import { checkDbInvariant } from "./util/invariants.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
+import {
+ constructTaskIdentifier,
+ DbRetryInfo,
+ spendCoins,
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResult,
+} from "./common.js";
+import {
+ calculateRefreshOutput,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("pay-merchant.ts");
+
+export class PayMerchantTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskId;
+
+ constructor(
+ public ws: InternalWalletState,
+ public proposalId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
+ }
+
+ /**
+ * Transition a payment transition.
+ */
+ async transition(
+ f: (rec: PurchaseRecord) => Promise<TransitionResult>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ /**
+ * Transition a payment transition.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PurchaseRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["purchases", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResult>,
+ ): Promise<void> {
+ const ws = this.ws;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases", ...extraStores],
+ async (tx) => {
+ const purchaseRec = await tx.purchases.get(this.proposalId);
+ if (!purchaseRec) {
+ throw Error("purchase not found anymore");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchaseRec);
+ const res = await f(purchaseRec, tx);
+ switch (res) {
+ case TransitionResult.Transition: {
+ await tx.purchases.put(purchaseRec);
+ const newTxState = computePayMerchantTransactionState(purchaseRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(ws, this.transactionId, transitionInfo);
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { ws, proposalId } = this;
+ await ws.db.runReadWriteTx(["purchases", "tombstones"], async (tx) => {
+ let found = false;
+ const purchase = await tx.purchases.get(proposalId);
+ if (purchase) {
+ found = true;
+ await tx.purchases.delete(proposalId);
+ }
+ if (found) {
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePayment + ":" + proposalId,
+ });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, proposalId, transactionId } = this;
+ ws.taskScheduler.stopShepherdTask(this.taskId);
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionSuspend[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { ws, proposalId, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ [
+ "purchases",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ const oldStatus = purchase.purchaseStatus;
+ if (purchase.timestampFirstSuccessfulPay) {
+ // No point in aborting it. We don't even report an error.
+ logger.warn(`tried to abort successful payment`);
+ return;
+ }
+ switch (oldStatus) {
+ case PurchaseStatus.Done:
+ return;
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.SuspendedPaying: {
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+ if (purchase.payInfo) {
+ const coinSel = purchase.payInfo.payCoinSelection;
+ const currency = Amounts.currencyOf(
+ purchase.payInfo.totalPayCost,
+ );
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < coinSel.coinPubs.length; i++) {
+ refreshCoins.push({
+ amount: coinSel.coinContributions[i],
+ coinPub: coinSel.coinPubs[i],
+ });
+ }
+ await createRefreshGroup(
+ ws,
+ tx,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortPay,
+ this.transactionId,
+ );
+ }
+ break;
+ }
+ case PurchaseStatus.DialogProposed:
+ purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ break;
+ }
+ await tx.purchases.put(purchase);
+ await tx.operationRetries.delete(this.taskId);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, proposalId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionResume[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, proposalId, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ [
+ "purchases",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newState: PurchaseStatus | undefined = undefined;
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.AbortingWithRefund:
+ newState = PurchaseStatus.FailedAbort;
+ break;
+ }
+ if (newState) {
+ purchase.purchaseStatus = newState;
+ await tx.purchases.put(purchase);
+ }
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.stopShepherdTask(this.taskId);
+ }
+}
+
+export class RefundTransactionContext implements TransactionContext {
+ public transactionId: string;
+ constructor(
+ public ws: InternalWalletState,
+ public refundGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { ws, refundGroupId, transactionId } = this;
+ await ws.db.runReadWriteTx(["refundGroups", "tombstones"], async (tx) => {
+ const refundRecord = await tx.refundGroups.get(refundGroupId);
+ if (!refundRecord) {
+ return;
+ }
+ await tx.refundGroups.delete(refundGroupId);
+ await tx.tombstones.put({ id: transactionId });
+ // FIXME: Also tombstone the refund items, so that they won't reappear.
+ });
+ }
+
+ suspendTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ abortTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ resumeTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ failTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+}
+
+/**
+ * Compute the total cost of a payment to the customer.
+ *
+ * This includes the amount taken by the merchant, fees (wire/deposit) contributed
+ * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
+ * of coins that are too small to spend.
+ */
+export async function getTotalPaymentCost(
+ ws: InternalWalletState,
+ pcs: PayCoinSelection,
+): Promise<AmountJson> {
+ const currency = Amounts.currencyOf(pcs.paymentAmount);
+ return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.coinPubs.length; i++) {
+ const coin = await tx.coins.get(pcs.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't calculate payment cost, coin not found");
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const allDenoms = await getCandidateWithdrawalDenomsTx(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ currency,
+ );
+ const amountLeft = Amounts.sub(
+ denom.value,
+ pcs.coinContributions[i],
+ ).amount;
+ const refreshCost = getTotalRefreshCost(
+ allDenoms,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ ws.config.testing.denomselAllowLate,
+ );
+ costs.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
+ costs.push(refreshCost);
+ }
+ const zero = Amounts.zeroOfAmount(pcs.paymentAmount);
+ return Amounts.sum([zero, ...costs]).amount;
+ });
+}
+
+async function failProposalPermanently(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ // FIXME: We don't store the error detail here?!
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+}
+
+function getProposalRequestTimeout(retryInfo?: DbRetryInfo): Duration {
+ return Duration.clamp({
+ lower: Duration.fromSpec({ seconds: 1 }),
+ upper: Duration.fromSpec({ seconds: 60 }),
+ value: retryInfo
+ ? DbRetryInfo.getDuration(retryInfo)
+ : Duration.fromSpec({}),
+ });
+}
+
+function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
+ return Duration.multiply(
+ { d_ms: 15000 },
+ 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
+ );
+}
+
+/**
+ * Return the proposal download data for a purchase, throw if not available.
+ */
+export async function expectProposalDownload(
+ ws: InternalWalletState,
+ p: PurchaseRecord,
+ parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>,
+): Promise<{
+ contractData: WalletContractData;
+ contractTermsRaw: any;
+}> {
+ if (!p.download) {
+ throw Error("expected proposal to be downloaded");
+ }
+ const download = p.download;
+
+ async function getFromTransaction(
+ tx: Exclude<typeof parentTx, undefined>,
+ ): Promise<ReturnType<typeof expectProposalDownload>> {
+ const contractTerms = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTerms) {
+ throw Error("contract terms not found");
+ }
+ return {
+ contractData: extractContractData(
+ contractTerms.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ ),
+ contractTermsRaw: contractTerms.contractTermsRaw,
+ };
+ }
+
+ if (parentTx) {
+ return getFromTransaction(parentTx);
+ }
+ return await ws.db.runReadOnlyTx(["contractTerms"], getFromTransaction);
+}
+
+export function extractContractData(
+ parsedContractTerms: MerchantContractTerms,
+ contractTermsHash: string,
+ merchantSig: string,
+): WalletContractData {
+ const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
+ let maxWireFee: AmountJson;
+ if (parsedContractTerms.max_wire_fee) {
+ maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
+ } else {
+ maxWireFee = Amounts.zeroOfCurrency(amount.currency);
+ }
+ return {
+ amount: Amounts.stringify(amount),
+ contractTermsHash: contractTermsHash,
+ fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
+ merchantBaseUrl: parsedContractTerms.merchant_base_url,
+ merchantPub: parsedContractTerms.merchant_pub,
+ merchantSig,
+ orderId: parsedContractTerms.order_id,
+ summary: parsedContractTerms.summary,
+ autoRefund: parsedContractTerms.auto_refund,
+ maxWireFee: Amounts.stringify(maxWireFee),
+ payDeadline: parsedContractTerms.pay_deadline,
+ refundDeadline: parsedContractTerms.refund_deadline,
+ wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
+ allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
+ exchangeBaseUrl: x.url,
+ exchangePub: x.master_pub,
+ })),
+ timestamp: parsedContractTerms.timestamp,
+ wireMethod: parsedContractTerms.wire_method,
+ wireInfoHash: parsedContractTerms.h_wire,
+ maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee),
+ merchant: parsedContractTerms.merchant,
+ summaryI18n: parsedContractTerms.summary_i18n,
+ minimumAge: parsedContractTerms.minimum_age,
+ };
+}
+
+async function processDownloadProposal(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return await tx.purchases.get(proposalId);
+ });
+
+ if (!proposal) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new PayMerchantTransactionContext(ws, proposalId);
+
+ if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
+ logger.error(
+ `unexpected state ${proposal.purchaseStatus}/${
+ PurchaseStatus[proposal.purchaseStatus]
+ } for ${ctx.transactionId} in processDownloadProposal`,
+ );
+ return TaskRunResult.finished();
+ }
+
+ const transactionId = ctx.transactionId;
+
+ const orderClaimUrl = new URL(
+ `orders/${proposal.orderId}/claim`,
+ proposal.merchantBaseUrl,
+ ).href;
+ logger.trace("downloading contract from '" + orderClaimUrl + "'");
+
+ const requestBody: {
+ nonce: string;
+ token?: string;
+ } = {
+ nonce: proposal.noncePub,
+ };
+ if (proposal.claimToken) {
+ requestBody.token = proposal.claimToken;
+ }
+
+ const opId = TaskIdentifiers.forPay(proposal);
+ const retryRecord = await ws.db.runReadOnlyTx(
+ ["operationRetries"],
+ async (tx) => {
+ return tx.operationRetries.get(opId);
+ },
+ );
+
+ const httpResponse = await ws.http.fetch(orderClaimUrl, {
+ method: "POST",
+ body: requestBody,
+ timeout: getProposalRequestTimeout(retryRecord?.retryInfo),
+ });
+ const r = await readSuccessResponseJsonOrErrorCode(
+ httpResponse,
+ codecForProposal(),
+ );
+ if (r.isError) {
+ switch (r.talerErrorResponse.code) {
+ case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
+ {
+ orderId: proposal.orderId,
+ claimUrl: orderClaimUrl,
+ },
+ "order already claimed (likely by other wallet)",
+ );
+ default:
+ throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
+ }
+ }
+ const proposalResp = r.response;
+
+ // The proposalResp contains the contract terms as raw JSON,
+ // as the code to parse them doesn't necessarily round-trip.
+ // We need this raw JSON to compute the contract terms hash.
+
+ // FIXME: Do better error handling, check if the
+ // contract terms have all their forgettable information still
+ // present. The wallet should never accept contract terms
+ // with missing information from the merchant.
+
+ const isWellFormed = ContractTermsUtil.validateForgettable(
+ proposalResp.contract_terms,
+ );
+
+ if (!isWellFormed) {
+ logger.trace(
+ `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
+ );
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ "validation for well-formedness failed",
+ );
+ await failProposalPermanently(ws, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ proposalResp.contract_terms,
+ );
+
+ logger.info(`Contract terms hash: ${contractTermsHash}`);
+
+ let parsedContractTerms: MerchantContractTerms;
+
+ try {
+ parsedContractTerms = codecForMerchantContractTerms().decode(
+ proposalResp.contract_terms,
+ );
+ } catch (e) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ `schema validation failed: ${e}`,
+ );
+ await failProposalPermanently(ws, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const sigValid = await ws.cryptoApi.isValidContractTermsSignature({
+ contractTermsHash,
+ merchantPub: parsedContractTerms.merchant_pub,
+ sig: proposalResp.sig,
+ });
+
+ if (!sigValid) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
+ {
+ merchantPub: parsedContractTerms.merchant_pub,
+ orderId: parsedContractTerms.order_id,
+ },
+ "merchant's signature on contract terms is invalid",
+ );
+ await failProposalPermanently(ws, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const fulfillmentUrl = parsedContractTerms.fulfillment_url;
+
+ const baseUrlForDownload = proposal.merchantBaseUrl;
+ const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
+
+ if (baseUrlForDownload !== baseUrlFromContractTerms) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
+ {
+ baseUrlForDownload,
+ baseUrlFromContractTerms,
+ },
+ "merchant base URL mismatch",
+ );
+ await failProposalPermanently(ws, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractData = extractContractData(
+ parsedContractTerms,
+ contractTermsHash,
+ proposalResp.sig,
+ );
+
+ logger.trace(`extracted contract data: ${j2s(contractData)}`);
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases", "contractTerms"],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.download = {
+ contractTermsHash,
+ contractTermsMerchantSig: contractData.merchantSig,
+ currency: Amounts.currencyOf(contractData.amount),
+ fulfillmentUrl: contractData.fulfillmentUrl,
+ };
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: proposalResp.contract_terms,
+ });
+ const isResourceFulfillmentUrl =
+ fulfillmentUrl &&
+ (fulfillmentUrl.startsWith("http://") ||
+ fulfillmentUrl.startsWith("https://"));
+ let otherPurchase: PurchaseRecord | undefined;
+ if (isResourceFulfillmentUrl) {
+ otherPurchase =
+ await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
+ }
+ // FIXME: Adjust this to account for refunds, don't count as repurchase
+ // if original order is refunded.
+ if (otherPurchase && otherPurchase.refundAmountAwaiting === undefined) {
+ logger.warn("repurchase detected");
+ p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
+ p.repurchaseProposalId = otherPurchase.proposalId;
+ await tx.purchases.put(p);
+ } else {
+ p.purchaseStatus = p.shared
+ ? PurchaseStatus.DialogShared
+ : PurchaseStatus.DialogProposed;
+ await tx.purchases.put(p);
+ }
+ const newTxState = computePayMerchantTransactionState(p);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ notifyTransition(ws, transactionId, transitionInfo);
+
+ return TaskRunResult.progress();
+}
+
+/**
+ * Create a new purchase transaction if necessary. If a purchase
+ * record for the provided arguments already exists,
+ * return the old proposal ID.
+ */
+async function createOrReusePurchase(
+ ws: InternalWalletState,
+ merchantBaseUrl: string,
+ orderId: string,
+ sessionId: string | undefined,
+ claimToken: string | undefined,
+ noncePriv: string | undefined,
+): Promise<string> {
+ const oldProposals = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.getAll([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ });
+
+ const oldProposal = oldProposals.find((p) => {
+ return (
+ p.downloadSessionId === sessionId &&
+ (!noncePriv || p.noncePriv === noncePriv) &&
+ p.claimToken === claimToken
+ );
+ });
+ // If we have already claimed this proposal with the same sessionId
+ // nonce and claim token, reuse it. */
+ if (
+ oldProposal &&
+ oldProposal.downloadSessionId === sessionId &&
+ (!noncePriv || oldProposal.noncePriv === noncePriv) &&
+ oldProposal.claimToken === claimToken
+ ) {
+ logger.info(
+ `Found old proposal (status=${
+ PurchaseStatus[oldProposal.purchaseStatus]
+ }) for order ${orderId} at ${merchantBaseUrl}`,
+ );
+ if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) {
+ const download = await expectProposalDownload(ws, oldProposal);
+ const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
+ logger.info(`old proposal paid: ${paid}`);
+ if (paid) {
+ // if this transaction was shared and the order is paid then it
+ // means that another wallet already paid the proposal
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(oldProposal.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: oldProposal.proposalId,
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+ }
+ return oldProposal.proposalId;
+ }
+
+ let noncePair: EddsaKeypair;
+ let shared = false;
+ if (noncePriv) {
+ shared = true;
+ noncePair = {
+ priv: noncePriv,
+ pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
+ };
+ } else {
+ noncePair = await ws.cryptoApi.createEddsaKeypair({});
+ }
+
+ const { priv, pub } = noncePair;
+ const proposalId = encodeCrock(getRandomBytes(32));
+
+ const proposalRecord: PurchaseRecord = {
+ download: undefined,
+ noncePriv: priv,
+ noncePub: pub,
+ claimToken,
+ timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ merchantBaseUrl,
+ orderId,
+ proposalId: proposalId,
+ purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
+ repurchaseProposalId: undefined,
+ downloadSessionId: sessionId,
+ autoRefundDeadline: undefined,
+ lastSessionId: undefined,
+ merchantPaySig: undefined,
+ payInfo: undefined,
+ refundAmountAwaiting: undefined,
+ timestampAccept: undefined,
+ timestampFirstSuccessfulPay: undefined,
+ timestampLastRefundStatus: undefined,
+ pendingRemovedCoinPubs: undefined,
+ posConfirmation: undefined,
+ shared: shared,
+ };
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ await tx.purchases.put(proposalRecord);
+ const oldTxState: TransactionState = {
+ major: TransactionMajorState.None,
+ };
+ const newTxState = computePayMerchantTransactionState(proposalRecord);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return proposalId;
+}
+
+async function storeFirstPaySuccess(
+ ws: InternalWalletState,
+ proposalId: string,
+ sessionId: string | undefined,
+ payResponse: MerchantPayResponse,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["contractTerms", "purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (!isFirst) {
+ logger.warn("payment success already stored");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(now);
+ purchase.lastSessionId = sessionId;
+ purchase.merchantPaySig = payResponse.sig;
+ purchase.posConfirmation = payResponse.pos_confirmation;
+ const dl = purchase.download;
+ checkDbInvariant(!!dl);
+ const contractTermsRecord = await tx.contractTerms.get(
+ dl.contractTermsHash,
+ );
+ checkDbInvariant(!!contractTermsRecord);
+ const contractData = extractContractData(
+ contractTermsRecord.contractTermsRaw,
+ dl.contractTermsHash,
+ dl.contractTermsMerchantSig,
+ );
+ const protoAr = contractData.autoRefund;
+ if (protoAr) {
+ const ar = Duration.fromTalerProtocolDuration(protoAr);
+ logger.info("auto_refund present");
+ purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
+ purchase.autoRefundDeadline = timestampProtocolToDb(
+ AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
+ ),
+ );
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+}
+
+async function storePayReplaySuccess(
+ ws: InternalWalletState,
+ proposalId: string,
+ sessionId: string | undefined,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (isFirst) {
+ throw Error("invalid payment state");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ if (
+ purchase.purchaseStatus === PurchaseStatus.PendingPaying ||
+ purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay
+ ) {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ purchase.lastSessionId = sessionId;
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+}
+
+/**
+ * Handle a 409 Conflict response from the merchant.
+ *
+ * We do this by going through the coin history provided by the exchange and
+ * (1) verifying the signatures from the exchange
+ * (2) adjusting the remaining coin value and refreshing it
+ * (3) re-do coin selection with the bad coin removed
+ */
+async function handleInsufficientFunds(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ logger.trace("handling insufficient funds, trying to re-select coins");
+
+ const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!proposal) {
+ return;
+ }
+
+ logger.trace(`got error details: ${j2s(err)}`);
+
+ const exchangeReply = (err as any).exchange_reply;
+ if (
+ exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
+ ) {
+ // FIXME: set as failed
+ if (logger.shouldLogTrace()) {
+ logger.trace("got exchange error reply (see below)");
+ logger.trace(j2s(exchangeReply));
+ }
+ throw Error(`unable to handle /pay error response (${exchangeReply.code})`);
+ }
+
+ const brokenCoinPub = (exchangeReply as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ throw new TalerProtocolViolationError();
+ }
+
+ const { contractData } = await expectProposalDownload(ws, proposal);
+
+ const prevPayCoins: PreviousPayCoins = [];
+
+ const payInfo = proposal.payInfo;
+ if (!payInfo) {
+ return;
+ }
+
+ const payCoinSelection = payInfo.payCoinSelection;
+
+ await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ if (coinPub === brokenCoinPub) {
+ continue;
+ }
+ const contrib = payCoinSelection.coinContributions[i];
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ continue;
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ continue;
+ }
+ prevPayCoins.push({
+ coinPub,
+ contribution: Amounts.parseOrThrow(contrib),
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ });
+ }
+ });
+
+ const res = await selectPayCoinsNew(ws, {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ wireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
+ wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
+ prevPayCoins,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+
+ if (res.type !== "success") {
+ logger.trace("insufficient funds for coin re-selection");
+ return;
+ }
+
+ logger.trace("re-selected coins");
+
+ await ws.db.runReadWriteTx(
+ [
+ "purchases",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ ],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const payInfo = p.payInfo;
+ if (!payInfo) {
+ return;
+ }
+ payInfo.payCoinSelection = res.coinSel;
+ payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ await tx.purchases.put(p);
+ await spendCoins(ws, tx, {
+ // allocationId: `txn:proposal:${p.proposalId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: payInfo.payCoinSelection.coinPubs,
+ contributions: payInfo.payCoinSelection.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ },
+ );
+
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ }),
+ });
+}
+
+// FIXME: Should take a transaction ID instead of a proposal ID
+// FIXME: Does way more than checking the payment
+// FIXME: Should return immediately.
+async function checkPaymentByProposalId(
+ ws: InternalWalletState,
+ proposalId: string,
+ sessionId?: string,
+): Promise<PreparePayResult> {
+ let proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!proposal) {
+ throw Error(`could not get proposal ${proposalId}`);
+ }
+ if (proposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) {
+ const existingProposalId = proposal.repurchaseProposalId;
+ if (existingProposalId) {
+ logger.trace("using existing purchase for same product");
+ const oldProposal = await ws.db.runReadOnlyTx(
+ ["purchases"],
+ async (tx) => {
+ return tx.purchases.get(existingProposalId);
+ },
+ );
+ if (oldProposal) {
+ proposal = oldProposal;
+ }
+ }
+ }
+ const d = await expectProposalDownload(ws, proposal);
+ const contractData = d.contractData;
+ const merchantSig = d.contractData.merchantSig;
+ if (!merchantSig) {
+ throw Error("BUG: proposal is in invalid state");
+ }
+
+ proposalId = proposal.proposalId;
+
+ const ctx = new PayMerchantTransactionContext(ws, proposalId);
+
+ const transactionId = ctx.transactionId;
+
+ const talerUri = stringifyTalerUri({
+ type: TalerUriAction.Pay,
+ merchantBaseUrl: proposal.merchantBaseUrl,
+ orderId: proposal.orderId,
+ sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
+ claimToken: proposal.claimToken,
+ });
+
+ // First check if we already paid for it.
+ const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (
+ !purchase ||
+ purchase.purchaseStatus === PurchaseStatus.DialogProposed ||
+ purchase.purchaseStatus === PurchaseStatus.DialogShared
+ ) {
+ // If not already paid, check if we could pay for it.
+ const res = await selectPayCoinsNew(ws, {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
+ wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ wireMethod: contractData.wireMethod,
+ });
+
+ if (res.type !== "success") {
+ logger.info("not allowing payment, insufficient coins");
+ logger.info(
+ `insufficient balance details: ${j2s(res.insufficientBalanceDetails)}`,
+ );
+ return {
+ status: PreparePayResultType.InsufficientBalance,
+ contractTerms: d.contractTermsRaw,
+ proposalId: proposal.proposalId,
+ transactionId,
+ amountRaw: Amounts.stringify(d.contractData.amount),
+ talerUri,
+ balanceDetails: res.insufficientBalanceDetails,
+ };
+ }
+
+ const totalCost = await getTotalPaymentCost(ws, res.coinSel);
+ logger.trace("costInfo", totalCost);
+ logger.trace("coinsForPayment", res);
+
+ return {
+ status: PreparePayResultType.PaymentPossible,
+ contractTerms: d.contractTermsRaw,
+ transactionId,
+ proposalId: proposal.proposalId,
+ amountEffective: Amounts.stringify(totalCost),
+ amountRaw: Amounts.stringify(res.coinSel.paymentAmount),
+ contractTermsHash: d.contractData.contractTermsHash,
+ talerUri,
+ };
+ }
+
+ if (
+ purchase.purchaseStatus === PurchaseStatus.Done &&
+ purchase.lastSessionId !== sessionId
+ ) {
+ logger.trace(
+ "automatically re-submitting payment with different session ID",
+ );
+ logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.lastSessionId = sessionId;
+ p.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ await tx.purchases.put(p);
+ const newTxState = computePayMerchantTransactionState(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Consider changing the API here so that we don't have to
+ // wait inline for the repurchase.
+
+ await waitPaymentResult(ws, proposalId, sessionId);
+ const download = await expectProposalDownload(ws, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: true,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ } else if (!purchase.timestampFirstSuccessfulPay) {
+ const download = await expectProposalDownload(ws, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: false,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ } else {
+ const paid =
+ purchase.purchaseStatus === PurchaseStatus.Done ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
+ const download = await expectProposalDownload(ws, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ ...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ }
+}
+
+export async function getContractTermsDetails(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<WalletContractData> {
+ const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(ws, proposal);
+
+ return d.contractData;
+}
+
+/**
+ * Check if a payment for the given taler://pay/ URI is possible.
+ *
+ * If the payment is possible, the signature are already generated but not
+ * yet send to the merchant.
+ */
+export async function preparePayForUri(
+ ws: InternalWalletState,
+ talerPayUri: string,
+): Promise<PreparePayResult> {
+ const uriResult = parsePayUri(talerPayUri);
+
+ if (!uriResult) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
+ {
+ talerPayUri,
+ },
+ `invalid taler://pay URI (${talerPayUri})`,
+ );
+ }
+
+ const proposalId = await createOrReusePurchase(
+ ws,
+ uriResult.merchantBaseUrl,
+ uriResult.orderId,
+ uriResult.sessionId,
+ uriResult.claimToken,
+ uriResult.noncePriv,
+ );
+
+ await waitProposalDownloaded(ws, proposalId);
+
+ return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
+}
+
+/**
+ * Wait until a proposal is at least downloaded.
+ */
+async function waitProposalDownloaded(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ const ctx = new PayMerchantTransactionContext(ws, proposalId);
+
+ logger.info(`waiting for ${ctx.transactionId} to be downloaded`);
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const payNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ payNotifFlag.raise();
+ }
+ });
+
+ try {
+ await internalWaitProposalDownloaded(ctx, payNotifFlag);
+ logger.info(`done waiting for ${ctx.transactionId} to be downloaded`);
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function internalWaitProposalDownloaded(
+ ctx: PayMerchantTransactionContext,
+ payNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { purchase, retryInfo } = await ctx.ws.db.runReadOnlyTx(
+ ["purchases", "operationRetries"],
+ async (tx) => {
+ return {
+ purchase: await tx.purchases.get(ctx.proposalId),
+ retryInfo: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+ if (!purchase) {
+ throw Error("purchase does not exist anymore");
+ }
+ if (purchase.download) {
+ return;
+ }
+ if (retryInfo) {
+ if (retryInfo.lastError) {
+ throw TalerError.fromUncheckedDetail(retryInfo.lastError);
+ } else {
+ throw Error("transient error while waiting for proposal download");
+ }
+ }
+ await payNotifFlag.wait();
+ payNotifFlag.reset();
+ }
+}
+
+export async function preparePayForTemplate(
+ ws: InternalWalletState,
+ req: PreparePayTemplateRequest,
+): Promise<PreparePayResult> {
+ const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
+ const templateDetails: MerchantUsingTemplateDetails = {};
+ if (!parsedUri) {
+ throw Error("invalid taler-template URI");
+ }
+ logger.trace(`parsed URI: ${j2s(parsedUri)}`);
+
+ const amountFromUri = parsedUri.templateParams.amount;
+ if (amountFromUri != null) {
+ const templateParamsAmount = req.templateParams?.amount;
+ if (templateParamsAmount != null) {
+ templateDetails.amount = templateParamsAmount as AmountString;
+ } else {
+ if (Amounts.isCurrency(amountFromUri)) {
+ throw Error(
+ "Amount from template URI only has a currency without value. The value must be provided in the templateParams.",
+ );
+ } else {
+ templateDetails.amount = amountFromUri as AmountString;
+ }
+ }
+ }
+ if (
+ parsedUri.templateParams.summary !== undefined &&
+ typeof parsedUri.templateParams.summary === "string"
+ ) {
+ templateDetails.summary =
+ req.templateParams?.summary ?? parsedUri.templateParams.summary;
+ }
+ const reqUrl = new URL(
+ `templates/${parsedUri.templateId}`,
+ parsedUri.merchantBaseUrl,
+ );
+ const httpReq = await ws.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: templateDetails,
+ });
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpReq,
+ codecForMerchantPostOrderResponse(),
+ );
+
+ const payUri = stringifyPayUri({
+ merchantBaseUrl: parsedUri.merchantBaseUrl,
+ orderId: resp.order_id,
+ sessionId: "",
+ claimToken: resp.token,
+ });
+
+ return await preparePayForUri(ws, payUri);
+}
+
+/**
+ * Generate deposit permissions for a purchase.
+ *
+ * Accesses the database and the crypto worker.
+ */
+export async function generateDepositPermissions(
+ ws: InternalWalletState,
+ payCoinSel: PayCoinSelection,
+ contractData: WalletContractData,
+): Promise<CoinDepositPermission[]> {
+ const depositPermissions: CoinDepositPermission[] = [];
+ const coinWithDenom: Array<{
+ coin: CoinRecord;
+ denom: DenominationRecord;
+ }> = [];
+ await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
+ const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't pay, allocated coin not found anymore");
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't pay, denomination of allocated coin not found anymore",
+ );
+ }
+ coinWithDenom.push({ coin, denom });
+ }
+ });
+
+ for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
+ const { coin, denom } = coinWithDenom[i];
+ let wireInfoHash: string;
+ wireInfoHash = contractData.wireInfoHash;
+ logger.trace(
+ `signing deposit permission for coin with ageRestriction=${j2s(
+ coin.ageCommitmentProof,
+ )}`,
+ );
+ const dp = await ws.cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash: contractData.contractTermsHash,
+ denomPubHash: coin.denomPubHash,
+ denomKeyType: denom.denomPub.cipher,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ merchantPub: contractData.merchantPub,
+ refundDeadline: contractData.refundDeadline,
+ spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
+ timestamp: contractData.timestamp,
+ wireInfoHash,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ depositPermissions.push(dp);
+ }
+ return depositPermissions;
+}
+
+async function internalWaitPaymentResult(
+ ctx: PayMerchantTransactionContext,
+ purchaseNotifFlag: AsyncFlag,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ while (true) {
+ const txRes = await ctx.ws.db.runReadOnlyTx(
+ ["purchases", "operationRetries"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(ctx.proposalId);
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+ return { purchase, retryRecord };
+ },
+ );
+
+ if (!txRes.purchase) {
+ throw Error("purchase gone");
+ }
+
+ const purchase = txRes.purchase;
+
+ logger.info(
+ `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
+ );
+
+ const d = await expectProposalDownload(ctx.ws, purchase);
+
+ if (txRes.purchase.timestampFirstSuccessfulPay) {
+ if (
+ waitSessionId == null ||
+ txRes.purchase.lastSessionId === waitSessionId
+ ) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+ }
+
+ if (txRes.retryRecord) {
+ return {
+ type: ConfirmPayResultType.Pending,
+ lastError: txRes.retryRecord.lastError,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ if (txRes.purchase.purchaseStatus > PurchaseStatus.Done) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ await purchaseNotifFlag.wait();
+ purchaseNotifFlag.reset();
+ }
+}
+
+/**
+ * Wait until either:
+ * a) the payment succeeded (if provided under the {@param waitSessionId}), or
+ * b) the attempt to pay failed (merchant unavailable, etc.)
+ */
+async function waitPaymentResult(
+ ws: InternalWalletState,
+ proposalId: string,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ const ctx = new PayMerchantTransactionContext(ws, proposalId);
+
+ ws.ensureTaskLoopRunning();
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const purchaseNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our purchase.
+ const cancelNotif = ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ purchaseNotifFlag.raise();
+ }
+ });
+
+ try {
+ logger.info(`waiting for first payment success on ${ctx.transactionId}`);
+ const res = await internalWaitPaymentResult(
+ ctx,
+ purchaseNotifFlag,
+ waitSessionId,
+ );
+ logger.info(
+ `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`,
+ );
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+/**
+ * Confirm payment for a proposal previously claimed by the wallet.
+ */
+export async function confirmPay(
+ ws: InternalWalletState,
+ transactionId: string,
+ sessionIdOverride?: string,
+ forcedCoinSel?: ForcedCoinSel,
+): Promise<ConfirmPayResult> {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+ if (parsedTx?.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ const proposalId = parsedTx.proposalId;
+ logger.trace(
+ `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
+ );
+ const proposal = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(ws, proposal);
+ if (!d) {
+ throw Error("proposal is in invalid state");
+ }
+
+ const existingPurchase = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (
+ purchase &&
+ sessionIdOverride !== undefined &&
+ sessionIdOverride != purchase.lastSessionId
+ ) {
+ logger.trace(`changing session ID to ${sessionIdOverride}`);
+ purchase.lastSessionId = sessionIdOverride;
+ if (purchase.purchaseStatus === PurchaseStatus.Done) {
+ purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ }
+ await tx.purchases.put(purchase);
+ }
+ return purchase;
+ },
+ );
+
+ if (existingPurchase && existingPurchase.payInfo) {
+ logger.trace("confirmPay: submitting payment for existing purchase");
+ const ctx = new PayMerchantTransactionContext(
+ ws,
+ existingPurchase.proposalId,
+ );
+ await ws.taskScheduler.resetTaskRetries(ctx.taskId);
+ return waitPaymentResult(ws, proposalId);
+ }
+
+ logger.trace("confirmPay: purchase record does not exist yet");
+
+ const contractData = d.contractData;
+
+ const selectCoinsResult = await selectPayCoinsNew(ws, {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ wireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
+ wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ forcedSelection: forcedCoinSel,
+ });
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ if (selectCoinsResult.type === "failure") {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+
+ const coinSelection = selectCoinsResult.coinSel;
+ const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
+
+ let sessionId: string | undefined;
+ if (sessionIdOverride) {
+ sessionId = sessionIdOverride;
+ } else {
+ sessionId = proposal.downloadSessionId;
+ }
+
+ logger.trace(
+ `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
+ );
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const p = await tx.purchases.get(proposal.proposalId);
+ if (!p) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ payCoinSelection: coinSelection,
+ payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
+ totalPayCost: Amounts.stringify(payCostInfo),
+ };
+ p.lastSessionId = sessionId;
+ p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+ await spendCoins(ws, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: coinSelection.coinPubs,
+ contributions: coinSelection.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ break;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ const newTxState = computePayMerchantTransactionState(p);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ // Wait until we have completed the first attempt to pay.
+ return waitPaymentResult(ws, proposalId);
+}
+
+export async function processPurchase(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ when: AbsoluteTime.now(),
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.PendingDownloadingProposal:
+ return processDownloadProposal(ws, proposalId);
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ return processPurchasePay(ws, proposalId);
+ case PurchaseStatus.PendingQueryingRefund:
+ return processPurchaseQueryRefund(ws, purchase);
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ return processPurchaseAutoRefund(ws, purchase);
+ case PurchaseStatus.AbortingWithRefund:
+ return processPurchaseAbortingRefund(ws, purchase);
+ case PurchaseStatus.PendingAcceptRefund:
+ return processPurchaseAcceptRefund(ws, purchase);
+ case PurchaseStatus.DialogShared:
+ return processPurchaseDialogShared(ws, purchase);
+ case PurchaseStatus.FailedClaim:
+ case PurchaseStatus.Done:
+ case PurchaseStatus.DoneRepurchaseDetected:
+ case PurchaseStatus.DialogProposed:
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedIncompletePayment:
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ case PurchaseStatus.SuspendedPaying:
+ case PurchaseStatus.SuspendedPayingReplay:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ case PurchaseStatus.FailedAbort:
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(purchase.purchaseStatus);
+ // throw Error(`unexpected purchase status (${purchase.purchaseStatus})`);
+ }
+}
+
+async function processPurchasePay(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ when: AbsoluteTime.now(),
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ break;
+ default:
+ return TaskRunResult.finished();
+ }
+ logger.trace(`processing purchase pay ${proposalId}`);
+
+ const sessionId = purchase.lastSessionId;
+
+ logger.trace(`paying with session ID ${sessionId}`);
+ const payInfo = purchase.payInfo;
+ checkDbInvariant(!!payInfo, "payInfo");
+
+ const download = await expectProposalDownload(ws, purchase);
+
+ if (purchase.shared) {
+ const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
+
+ if (paid) {
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ notifyTransition(ws, transactionId, transitionInfo);
+
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, {
+ orderId: purchase.orderId,
+ fulfillmentUrl: download.contractData.fulfillmentUrl,
+ }),
+ };
+ }
+ }
+
+ if (!purchase.merchantPaySig) {
+ const payUrl = new URL(
+ `orders/${download.contractData.orderId}/pay`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+
+ let depositPermissions: CoinDepositPermission[];
+ // FIXME: Cache!
+ depositPermissions = await generateDepositPermissions(
+ ws,
+ payInfo.payCoinSelection,
+ download.contractData,
+ );
+
+ const reqBody = {
+ coins: depositPermissions,
+ session_id: purchase.lastSessionId,
+ };
+
+ logger.trace(
+ "making pay request ... ",
+ JSON.stringify(reqBody, undefined, 2),
+ );
+
+ const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ ws.http.fetch(payUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getPayRequestTimeout(purchase),
+ }),
+ );
+
+ logger.trace(`got resp ${JSON.stringify(resp)}`);
+
+ if (resp.status >= 500 && resp.status <= 599) {
+ const errDetails = await readUnexpectedResponseDetails(resp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
+ {
+ requestError: errDetails,
+ },
+ ),
+ };
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ const err = await readTalerErrorResponse(resp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
+ ) {
+ // Do this in the background, as it might take some time
+ handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
+ logger.error("handling insufficient funds failed");
+ logger.error(`${e.toString()}`);
+ });
+
+ // FIXME: Should we really consider this to be pending?
+
+ return TaskRunResult.backoff();
+ }
+ }
+
+ if (resp.status >= 400 && resp.status <= 499) {
+ logger.trace("got generic 4xx from merchant");
+ const err = await readTalerErrorResponse(resp);
+ throwUnexpectedRequestError(resp, err);
+ }
+
+ const merchantResp = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantPayResponse(),
+ );
+
+ logger.trace("got success from pay URL", merchantResp);
+
+ const merchantPub = download.contractData.merchantPub;
+ const { valid } = await ws.cryptoApi.isValidPaymentSignature({
+ contractHash: download.contractData.contractTermsHash,
+ merchantPub,
+ sig: merchantResp.sig,
+ });
+
+ if (!valid) {
+ logger.error("merchant payment signature invalid");
+ // FIXME: properly display error
+ throw Error("merchant payment signature invalid");
+ }
+
+ await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp);
+ } else {
+ const payAgainUrl = new URL(
+ `orders/${download.contractData.orderId}/paid`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+ const reqBody = {
+ sig: purchase.merchantPaySig,
+ h_contract: download.contractData.contractTermsHash,
+ session_id: sessionId ?? "",
+ };
+ logger.trace(`/paid request body: ${j2s(reqBody)}`);
+ const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ ws.http.fetch(payAgainUrl, { method: "POST", body: reqBody }),
+ );
+ logger.trace(`/paid response status: ${resp.status}`);
+ if (
+ resp.status !== HttpStatusCode.NoContent &&
+ resp.status != HttpStatusCode.Ok
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ getHttpResponseErrorDetails(resp),
+ "/paid failed",
+ );
+ }
+ await storePayReplaySuccess(ws, proposalId, sessionId);
+ }
+
+ return TaskRunResult.progress();
+}
+
+export async function refuseProposal(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const proposal = await tx.purchases.get(proposalId);
+ if (!proposal) {
+ logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
+ return undefined;
+ }
+ if (
+ proposal.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ proposal.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(proposal);
+ proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ const newTxState = computePayMerchantTransactionState(proposal);
+ await tx.purchases.put(proposal);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(ws, transactionId, transitionInfo);
+}
+
+const transitionSuspend: {
+ [x in PurchaseStatus]?: {
+ next: PurchaseStatus | undefined;
+ };
+} = {
+ [PurchaseStatus.PendingDownloadingProposal]: {
+ next: PurchaseStatus.SuspendedDownloadingProposal,
+ },
+ [PurchaseStatus.AbortingWithRefund]: {
+ next: PurchaseStatus.SuspendedAbortingWithRefund,
+ },
+ [PurchaseStatus.PendingPaying]: {
+ next: PurchaseStatus.SuspendedPaying,
+ },
+ [PurchaseStatus.PendingPayingReplay]: {
+ next: PurchaseStatus.SuspendedPayingReplay,
+ },
+ [PurchaseStatus.PendingQueryingAutoRefund]: {
+ next: PurchaseStatus.SuspendedQueryingAutoRefund,
+ },
+};
+
+const transitionResume: {
+ [x in PurchaseStatus]?: {
+ next: PurchaseStatus | undefined;
+ };
+} = {
+ [PurchaseStatus.SuspendedDownloadingProposal]: {
+ next: PurchaseStatus.PendingDownloadingProposal,
+ },
+ [PurchaseStatus.SuspendedAbortingWithRefund]: {
+ next: PurchaseStatus.AbortingWithRefund,
+ },
+ [PurchaseStatus.SuspendedPaying]: {
+ next: PurchaseStatus.PendingPaying,
+ },
+ [PurchaseStatus.SuspendedPayingReplay]: {
+ next: PurchaseStatus.PendingPayingReplay,
+ },
+ [PurchaseStatus.SuspendedQueryingAutoRefund]: {
+ next: PurchaseStatus.PendingQueryingAutoRefund,
+ },
+};
+
+export function computePayMerchantTransactionState(
+ purchaseRecord: PurchaseRecord,
+): TransactionState {
+ switch (purchaseRecord.purchaseStatus) {
+ // Pending States
+ case PurchaseStatus.PendingDownloadingProposal:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.PendingPaying:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.SubmitPayment,
+ };
+ case PurchaseStatus.PendingPayingReplay:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.PendingQueryingRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CheckRefund,
+ };
+ case PurchaseStatus.PendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ // Suspended Pending States
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.SuspendedPaying:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.SubmitPayment,
+ };
+ case PurchaseStatus.SuspendedPayingReplay:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.SuspendedQueryingRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CheckRefund,
+ };
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ // Aborting States
+ case PurchaseStatus.AbortingWithRefund:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ // Suspended Aborting States
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ };
+ // Dialog States
+ case PurchaseStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ case PurchaseStatus.DialogShared:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ // Final States
+ case PurchaseStatus.AbortedProposalRefused:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Refused,
+ };
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PurchaseStatus.DoneRepurchaseDetected:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Repurchase,
+ };
+ case PurchaseStatus.AbortedIncompletePayment:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.FailedClaim:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.FailedAbort:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.AbortingBank,
+ };
+ }
+}
+
+export function computePayMerchantTransactionActions(
+ purchaseRecord: PurchaseRecord,
+): TransactionAction[] {
+ switch (purchaseRecord.purchaseStatus) {
+ // Pending States
+ case PurchaseStatus.PendingDownloadingProposal:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingPaying:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingPayingReplay:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingQueryingRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingAcceptRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ // Suspended Pending States
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPaying:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPayingReplay:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedQueryingRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ // Aborting States
+ case PurchaseStatus.AbortingWithRefund:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ return [TransactionAction.Fail, TransactionAction.Resume];
+ // Dialog States
+ case PurchaseStatus.DialogProposed:
+ return [];
+ case PurchaseStatus.DialogShared:
+ return [];
+ // Final States
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.Done:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.DoneRepurchaseDetected:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.AbortedIncompletePayment:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedClaim:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedAbort:
+ return [TransactionAction.Delete];
+ }
+}
+
+export async function sharePayment(
+ ws: InternalWalletState,
+ merchantBaseUrl: string,
+ orderId: string,
+): Promise<SharePaymentResult> {
+ const result = await ws.db.runReadWriteTx(["purchases"], async (tx) => {
+ const p = await tx.purchases.indexes.byUrlAndOrderId.get([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (
+ p.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ p.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ // FIXME: purchase can be shared before being paid
+ return undefined;
+ }
+ if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
+ p.purchaseStatus = PurchaseStatus.DialogShared;
+ p.shared = true;
+ tx.purchases.put(p);
+ }
+
+ return {
+ nonce: p.noncePriv,
+ session: p.lastSessionId ?? p.downloadSessionId,
+ token: p.claimToken,
+ };
+ });
+
+ if (result === undefined) {
+ throw Error("This purchase can't be shared");
+ }
+ const privatePayUri = stringifyPayUri({
+ merchantBaseUrl,
+ orderId,
+ sessionId: result.session ?? "",
+ noncePriv: result.nonce,
+ claimToken: result.token,
+ });
+ return { privatePayUri };
+}
+
+async function checkIfOrderIsAlreadyPaid(
+ ws: InternalWalletState,
+ contract: WalletContractData,
+) {
+ const requestUrl = new URL(
+ `orders/${contract.orderId}`,
+ contract.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
+
+ requestUrl.searchParams.set("timeout_ms", "1000");
+
+ const resp = await ws.http.fetch(requestUrl.href);
+ if (
+ resp.status === HttpStatusCode.Ok ||
+ resp.status === HttpStatusCode.Accepted ||
+ resp.status === HttpStatusCode.Found
+ ) {
+ return true;
+ } else if (resp.status === HttpStatusCode.PaymentRequired) {
+ return false;
+ }
+ //forbidden, not found, not acceptable
+ throw Error(`this order cant be paid: ${resp.status}`);
+}
+
+async function processPurchaseDialogShared(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing dialog-shared for proposal ${proposalId}`);
+ const download = await expectProposalDownload(ws, purchase);
+
+ if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) {
+ return TaskRunResult.finished();
+ }
+
+ const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData);
+ if (paid) {
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processPurchaseAutoRefund(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing auto-refund for proposal ${proposalId}`);
+
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ const download = await expectProposalDownload(ws, purchase);
+
+ if (
+ !purchase.autoRefundDeadline ||
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(purchase.autoRefundDeadline),
+ ),
+ )
+ ) {
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ }
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+
+ requestUrl.searchParams.set("timeout_ms", "1000");
+ requestUrl.searchParams.set("await_refund_obtained", "yes");
+
+ const resp = await ws.http.fetch(requestUrl.href);
+
+ // FIXME: Check other status codes!
+
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ if (orderStatus.refund_pending) {
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processPurchaseAbortingRefund(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ const download = await expectProposalDownload(ws, purchase);
+ logger.trace(`processing aborting-refund for proposal ${proposalId}`);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/abort`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ const abortingCoins: AbortingCoin[] = [];
+
+ const payCoinSelection = purchase.payInfo?.payCoinSelection;
+ if (!payCoinSelection) {
+ throw Error("can't abort, no coins selected");
+ }
+
+ await ws.db.runReadOnlyTx(["coins"], async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
+ });
+
+ const abortReq: AbortRequest = {
+ h_contract: download.contractData.contractTermsHash,
+ coins: abortingCoins,
+ };
+
+ logger.trace(`making order abort request to ${requestUrl.href}`);
+
+ const abortHttpResp = await ws.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: abortReq,
+ });
+
+ if (abortHttpResp.status === HttpStatusCode.NotFound) {
+ const err = await readTalerErrorResponse(abortHttpResp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
+ ) {
+ const ctx = new PayMerchantTransactionContext(ws, proposalId);
+ await ctx.transition(async (rec) => {
+ if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
+ rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+ return TransitionResult.Transition;
+ }
+ return TransitionResult.Stay;
+ });
+ }
+ }
+
+ const abortResp = await readSuccessResponseJsonOrThrow(
+ abortHttpResp,
+ codecForAbortResponse(),
+ );
+
+ const refunds: MerchantCoinRefundStatus[] = [];
+
+ if (abortResp.refunds.length != abortingCoins.length) {
+ // FIXME: define error code!
+ throw Error("invalid order abort response");
+ }
+
+ for (let i = 0; i < abortResp.refunds.length; i++) {
+ const r = abortResp.refunds[i];
+ refunds.push({
+ ...r,
+ coin_pub: payCoinSelection.coinPubs[i],
+ refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ rtransaction_id: 0,
+ execution_time: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.fromProtocolTimestamp(download.contractData.timestamp),
+ Duration.fromSpec({ seconds: 1 }),
+ ),
+ ),
+ });
+ }
+ return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund);
+}
+
+async function processPurchaseQueryRefund(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing query-refund for proposal ${proposalId}`);
+
+ const download = await expectProposalDownload(ws, purchase);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+
+ const resp = await ws.http.fetch(requestUrl.href);
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ if (!orderStatus.refund_pending) {
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else {
+ const refundAwaiting = Amounts.sub(
+ Amounts.parseOrThrow(orderStatus.refund_amount),
+ Amounts.parseOrThrow(orderStatus.refund_taken),
+ ).amount;
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+}
+
+async function processPurchaseAcceptRefund(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const download = await expectProposalDownload(ws, purchase);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/refund`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ logger.trace(`making refund request to ${requestUrl.href}`);
+
+ const request = await ws.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: {
+ h_contract: download.contractData.contractTermsHash,
+ },
+ });
+
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForMerchantOrderRefundPickupResponse(),
+ );
+ return await storeRefunds(
+ ws,
+ purchase,
+ refundResponse.refunds,
+ RefundReason.AbortRefund,
+ );
+}
+
+export async function startRefundQueryForUri(
+ ws: InternalWalletState,
+ talerUri: string,
+): Promise<StartRefundQueryForUriResponse> {
+ const parsedUri = parseTalerUri(talerUri);
+ if (!parsedUri) {
+ throw Error("invalid taler:// URI");
+ }
+ if (parsedUri.type !== TalerUriAction.Refund) {
+ throw Error("expected taler://refund URI");
+ }
+ const purchaseRecord = await ws.db.runReadOnlyTx(
+ ["purchases"],
+ async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.get([
+ parsedUri.merchantBaseUrl,
+ parsedUri.orderId,
+ ]);
+ },
+ );
+ if (!purchaseRecord) {
+ logger.error(
+ `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`,
+ );
+ throw Error("no purchase found, can't refund");
+ }
+ const proposalId = purchaseRecord.proposalId;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ await startQueryRefund(ws, proposalId);
+ return {
+ transactionId,
+ };
+}
+
+export async function startQueryRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ const ctx = new PayMerchantTransactionContext(ws, proposalId);
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.warn(`purchase ${proposalId} does not exist anymore`);
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.Done) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingQueryingRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, ctx.transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+async function computeRefreshRequest(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<["coins", "denominations"]>,
+ items: RefundItemRecord[],
+): Promise<CoinRefreshRequest[]> {
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const item of items) {
+ const coin = await tx.coins.get(item.coinPub);
+ if (!coin) {
+ throw Error("coin not found");
+ }
+ const denomInfo = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error("denom not found");
+ }
+ if (item.status === RefundItemStatus.Done) {
+ const refundedAmount = Amounts.sub(
+ item.refundAmount,
+ denomInfo.feeRefund,
+ ).amount;
+ refreshCoins.push({
+ amount: Amounts.stringify(refundedAmount),
+ coinPub: item.coinPub,
+ });
+ }
+ }
+ return refreshCoins;
+}
+
+/**
+ * Compute the refund item status based on the merchant's response.
+ */
+function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
+ if (rf.type === "success") {
+ return RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ return RefundItemStatus.Pending;
+ } else {
+ return RefundItemStatus.Failed;
+ }
+ }
+}
+
+/**
+ * Store refunds, possibly creating a new refund group.
+ */
+async function storeRefunds(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<TaskRunResult> {
+ logger.info(`storing refunds: ${j2s(refunds)}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchase.proposalId,
+ });
+
+ const newRefundGroupId = encodeCrock(randomBytes(32));
+ const now = TalerPreciseTimestamp.now();
+
+ const download = await expectProposalDownload(ws, purchase);
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ const result = await ws.db.runReadWriteTx(
+ [
+ "coins",
+ "denominations",
+ "purchases",
+ "refundItems",
+ "refundGroups",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ ],
+ async (tx) => {
+ const myPurchase = await tx.purchases.get(purchase.proposalId);
+ if (!myPurchase) {
+ logger.warn("purchase group not found anymore");
+ return;
+ }
+ let isAborting: boolean;
+ switch (myPurchase.purchaseStatus) {
+ case PurchaseStatus.PendingAcceptRefund:
+ isAborting = false;
+ break;
+ case PurchaseStatus.AbortingWithRefund:
+ isAborting = true;
+ break;
+ default:
+ logger.warn("wrong state, not accepting refund");
+ return;
+ }
+
+ let newGroup: RefundGroupRecord | undefined = undefined;
+ // Pending, but not part of an aborted refund group.
+ let numPendingItemsTotal = 0;
+ const newGroupRefunds: RefundItemRecord[] = [];
+
+ for (const rf of refunds) {
+ const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
+ rf.coin_pub,
+ rf.rtransaction_id,
+ ]);
+ if (oldItem) {
+ logger.info("already have refund in database");
+ if (oldItem.status === RefundItemStatus.Done) {
+ continue;
+ }
+ if (rf.type === "success") {
+ oldItem.status = RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ oldItem.status = RefundItemStatus.Pending;
+ numPendingItemsTotal += 1;
+ } else {
+ oldItem.status = RefundItemStatus.Failed;
+ }
+ }
+ await tx.refundItems.put(oldItem);
+ } else {
+ // Put refund item into a new group!
+ if (!newGroup) {
+ newGroup = {
+ proposalId: purchase.proposalId,
+ refundGroupId: newRefundGroupId,
+ status: RefundGroupStatus.Pending,
+ timestampCreated: timestampPreciseToDb(now),
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfCurrency(currency),
+ ),
+ amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
+ };
+ }
+ const status: RefundItemStatus = getItemStatus(rf);
+ const newItem: RefundItemRecord = {
+ coinPub: rf.coin_pub,
+ executionTime: timestampProtocolToDb(rf.execution_time),
+ obtainedTime: timestampPreciseToDb(now),
+ refundAmount: rf.refund_amount,
+ refundGroupId: newGroup.refundGroupId,
+ rtxid: rf.rtransaction_id,
+ status,
+ };
+ if (status === RefundItemStatus.Pending) {
+ numPendingItemsTotal += 1;
+ }
+ newGroupRefunds.push(newItem);
+ await tx.refundItems.put(newItem);
+ }
+ }
+
+ // Now that we know all the refunds for the new refund group,
+ // we can compute the raw/effective amounts.
+ if (newGroup) {
+ const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
+ const refreshCoins = await computeRefreshRequest(
+ ws,
+ tx,
+ newGroupRefunds,
+ );
+ const outInfo = await calculateRefreshOutput(
+ ws,
+ tx,
+ currency,
+ refreshCoins,
+ );
+ newGroup.amountEffective = Amounts.stringify(
+ Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
+ );
+ newGroup.amountRaw = Amounts.stringify(
+ Amounts.sumOrZero(currency, amountsRaw).amount,
+ );
+ await tx.refundGroups.put(newGroup);
+ }
+
+ const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
+ myPurchase.proposalId,
+ );
+
+ for (const refundGroup of refundGroups) {
+ switch (refundGroup.status) {
+ case RefundGroupStatus.Aborted:
+ case RefundGroupStatus.Expired:
+ case RefundGroupStatus.Failed:
+ case RefundGroupStatus.Done:
+ continue;
+ case RefundGroupStatus.Pending:
+ break;
+ default:
+ assertUnreachable(refundGroup.status);
+ }
+ const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
+ refundGroup.refundGroupId,
+ ]);
+ let numPending = 0;
+ let numFailed = 0;
+ for (const item of items) {
+ if (item.status === RefundItemStatus.Pending) {
+ numPending++;
+ }
+ if (item.status === RefundItemStatus.Failed) {
+ numFailed++;
+ }
+ }
+ if (numPending === 0) {
+ // We're done for this refund group!
+ if (numFailed === 0) {
+ refundGroup.status = RefundGroupStatus.Done;
+ } else {
+ refundGroup.status = RefundGroupStatus.Failed;
+ }
+ await tx.refundGroups.put(refundGroup);
+ const refreshCoins = await computeRefreshRequest(ws, tx, items);
+ await createRefreshGroup(
+ ws,
+ tx,
+ Amounts.currencyOf(download.contractData.amount),
+ refreshCoins,
+ RefreshReason.Refund,
+ // Since refunds are really just pseudo-transactions,
+ // the originating transaction for the refresh is the payment transaction.
+ constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: myPurchase.proposalId,
+ }),
+ );
+ }
+ }
+
+ const oldTxState = computePayMerchantTransactionState(myPurchase);
+ if (numPendingItemsTotal === 0) {
+ if (isAborting) {
+ myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
+ } else {
+ myPurchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ myPurchase.refundAmountAwaiting = undefined;
+ }
+ await tx.purchases.put(myPurchase);
+ const newTxState = computePayMerchantTransactionState(myPurchase);
+
+ return {
+ numPendingItemsTotal,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ },
+ );
+
+ if (!result) {
+ return TaskRunResult.finished();
+ }
+
+ notifyTransition(ws, transactionId, result.transitionInfo);
+
+ if (result.numPendingItemsTotal > 0) {
+ return TaskRunResult.backoff();
+ } else {
+ return TaskRunResult.progress();
+ }
+}
+
+export function computeRefundTransactionState(
+ refundGroupRecord: RefundGroupRecord,
+): TransactionState {
+ switch (refundGroupRecord.status) {
+ case RefundGroupStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case RefundGroupStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RefundGroupStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case RefundGroupStatus.Pending:
+ return {
+ major: TransactionMajorState.Pending,
+ };
+ case RefundGroupStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -0,0 +1,172 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountJson,
+ AmountString,
+ Amounts,
+ Codec,
+ Logger,
+ TalerProtocolTimestamp,
+ buildCodecForObject,
+ codecForAmountString,
+ codecForTimestamp,
+ codecOptional,
+} from "@gnu-taler/taler-util";
+import { SpendCoinDetails } from "./crypto/cryptoImplementation.js";
+import { PeerPushPaymentCoinSelection, ReserveRecord } from "./db.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import type { SelectedPeerCoin } from "./util/coinSelection.js";
+import { checkDbInvariant } from "./util/invariants.js";
+import { getTotalRefreshCost } from "./refresh.js";
+import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
+
+const logger = new Logger("operations/peer-to-peer.ts");
+
+/**
+ * Get information about the coin selected for signatures.
+ */
+export async function queryCoinInfosForSelection(
+ ws: InternalWalletState,
+ csel: PeerPushPaymentCoinSelection,
+): Promise<SpendCoinDetails[]> {
+ let infos: SpendCoinDetails[] = [];
+ await ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ for (let i = 0; i < csel.coinPubs.length; i++) {
+ const coin = await tx.coins.get(csel.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin not found anymore");
+ }
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("denom for coin not found anymore");
+ }
+ infos.push({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ contribution: csel.contributions[i],
+ });
+ }
+ });
+ return infos;
+}
+
+export async function getTotalPeerPaymentCost(
+ ws: InternalWalletState,
+ pcs: SelectedPeerCoin[],
+): Promise<AmountJson> {
+ const currency = Amounts.currencyOf(pcs[0].contribution);
+ return ws.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const coin = await tx.coins.get(pcs[i].coinPub);
+ if (!coin) {
+ throw Error("can't calculate payment cost, coin not found");
+ }
+ const denomInfo = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const allDenoms = await getCandidateWithdrawalDenomsTx(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ currency,
+ );
+ const amountLeft = Amounts.sub(
+ denomInfo.value,
+ pcs[i].contribution,
+ ).amount;
+ const refreshCost = getTotalRefreshCost(
+ allDenoms,
+ denomInfo,
+ amountLeft,
+ ws.config.testing.denomselAllowLate,
+ );
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
+ }
+ const zero = Amounts.zeroOfAmount(pcs[0].contribution);
+ return Amounts.sum([zero, ...costs]).amount;
+ });
+}
+
+interface ExchangePurseStatus {
+ balance: AmountString;
+ deposit_timestamp?: TalerProtocolTimestamp;
+ merge_timestamp?: TalerProtocolTimestamp;
+}
+
+export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
+ buildCodecForObject<ExchangePurseStatus>()
+ .property("balance", codecForAmountString())
+ .property("deposit_timestamp", codecOptional(codecForTimestamp))
+ .property("merge_timestamp", codecOptional(codecForTimestamp))
+ .build("ExchangePurseStatus");
+
+export async function getMergeReserveInfo(
+ ws: InternalWalletState,
+ req: {
+ exchangeBaseUrl: string;
+ },
+): Promise<ReserveRecord> {
+ // We have to eagerly create the key pair outside of the transaction,
+ // due to the async crypto API.
+ const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
+
+ const mergeReserveRecord: ReserveRecord = await ws.db.runReadWriteTx(
+ ["exchanges", "reserves"],
+ async (tx) => {
+ const ex = await tx.exchanges.get(req.exchangeBaseUrl);
+ checkDbInvariant(!!ex);
+ if (ex.currentMergeReserveRowId != null) {
+ const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
+ checkDbInvariant(!!reserve);
+ return reserve;
+ }
+ const reserve: ReserveRecord = {
+ reservePriv: newReservePair.priv,
+ reservePub: newReservePair.pub,
+ };
+ const insertResp = await tx.reserves.put(reserve);
+ checkDbInvariant(typeof insertResp.key === "number");
+ reserve.rowId = insertResp.key;
+ ex.currentMergeReserveRowId = reserve.rowId;
+ await tx.exchanges.put(ex);
+ return reserve;
+ },
+ );
+
+ return mergeReserveRecord;
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -0,0 +1,1204 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ CancellationToken,
+ CheckPeerPullCreditRequest,
+ CheckPeerPullCreditResponse,
+ ContractTermsUtil,
+ ExchangeReservePurseRequest,
+ HttpStatusCode,
+ InitiatePeerPullCreditRequest,
+ InitiatePeerPullCreditResponse,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TalerUriAction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ WalletAccountMergeFlags,
+ WalletKycUuid,
+ codecForAny,
+ codecForWalletKycUuid,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ stringifyTalerUri,
+ talerPaytoFromExchangeReserve,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ KycPendingInfo,
+ KycUserType,
+ PeerPullCreditRecord,
+ PeerPullPaymentCreditStatus,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ fetchFreshExchange,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+} from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { PendingTaskType, TaskId } from "./pending-types.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { checkDbInvariant } from "./util/invariants.js";
+import {
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ codecForExchangePurseStatus,
+ getMergeReserveInfo,
+} from "./pay-peer-common.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import {
+ getExchangeWithdrawalInfo,
+ internalCreateWithdrawalGroup,
+} from "./withdraw.js";
+
+const logger = new Logger("pay-peer-pull-credit.ts");
+
+export class PeerPullCreditTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly retryTag: TaskId;
+
+ constructor(
+ public ws: InternalWalletState,
+ public pursePub: string,
+ ) {
+ this.retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub,
+ });
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { ws, pursePub } = this;
+ await ws.db.runReadWriteTx(
+ ["withdrawalGroups", "peerPullCredit", "tombstones"],
+ async (tx) => {
+ const pullIni = await tx.peerPullCredit.get(pursePub);
+ if (!pullIni) {
+ return;
+ }
+ if (pullIni.withdrawalGroupId) {
+ const withdrawalGroupId = pullIni.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPullCredit.delete(pursePub);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
+ });
+ },
+ );
+
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, pursePub, retryTag, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse;
+ break;
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPullPaymentCreditStatus.PendingReady:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedReady;
+ break;
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ newStatus =
+ PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, pursePub, retryTag, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ break;
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPullPaymentCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, pursePub, retryTag, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.Aborted:
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ newStatus = PeerPullPaymentCreditStatus.PendingReady;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { ws, pursePub, retryTag, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ throw Error("can't abort anymore");
+ case PeerPullPaymentCreditStatus.PendingReady:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+async function queryPurseForPeerPullCredit(
+ ws: InternalWalletState,
+ pullIni: PeerPullCreditRecord,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const purseDepositUrl = new URL(
+ `purses/${pullIni.pursePub}/deposit`,
+ pullIni.exchangeBaseUrl,
+ );
+ purseDepositUrl.searchParams.set("timeout_ms", "30000");
+ logger.info(`querying purse status via ${purseDepositUrl.href}`);
+ const resp = await ws.http.fetch(purseDepositUrl.href, {
+ timeout: { d_ms: 60000 },
+ cancellationToken,
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+
+ logger.info(`purse status code: HTTP ${resp.status}`);
+
+ switch (resp.status) {
+ case HttpStatusCode.Gone: {
+ // Exchange says that purse doesn't exist anymore => expired!
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!finPi) {
+ logger.warn("peerPullCredit not found anymore");
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(finPi);
+ if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
+ finPi.status = PeerPullPaymentCreditStatus.Expired;
+ }
+ await tx.peerPullCredit.put(finPi);
+ const newTxState = computePeerPullCreditTransactionState(finPi);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.NotFound:
+ return TaskRunResult.backoff();
+ }
+
+ const result = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangePurseStatus(),
+ );
+
+ logger.trace(`purse status: ${j2s(result)}`);
+
+ const depositTimestamp = result.deposit_timestamp;
+
+ if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) {
+ logger.info("purse not ready yet (no deposit)");
+ return TaskRunResult.backoff();
+ }
+
+ const reserve = await ws.db.runReadOnlyTx(["reserves"], async (tx) => {
+ return await tx.reserves.get(pullIni.mergeReserveRowId);
+ });
+
+ if (!reserve) {
+ throw Error("reserve for peer pull credit not found in wallet DB");
+ }
+
+ await internalCreateWithdrawalGroup(ws, {
+ amount: Amounts.parseOrThrow(pullIni.amount),
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.PeerPullCredit,
+ contractPriv: pullIni.contractPriv,
+ },
+ forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
+ exchangeBaseUrl: pullIni.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ priv: reserve.reservePriv,
+ pub: reserve.reservePub,
+ },
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!finPi) {
+ logger.warn("peerPullCredit not found anymore");
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(finPi);
+ if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
+ finPi.status = PeerPullPaymentCreditStatus.PendingWithdrawing;
+ }
+ await tx.peerPullCredit.put(finPi);
+ const newTxState = computePeerPullCreditTransactionState(finPi);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+}
+
+async function longpollKycStatus(
+ ws: InternalWalletState,
+ pursePub: string,
+ exchangeUrl: string,
+ kycInfo: KycPendingInfo,
+ userType: KycUserType,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+ const retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub,
+ });
+
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await ws.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const peerIni = await tx.peerPullCredit.get(pursePub);
+ if (!peerIni) {
+ return;
+ }
+ if (
+ peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired
+ ) {
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(peerIni);
+ peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse;
+ const newTxState = computePeerPullCreditTransactionState(peerIni);
+ await tx.peerPullCredit.put(peerIni);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPullCreditAbortingDeletePurse(
+ ws: InternalWalletState,
+ peerPullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ const { pursePub, pursePriv } = peerPullIni;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+
+ const sigResp = await ws.cryptoApi.signDeletePurse({
+ pursePriv,
+ });
+ const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl);
+ const resp = await ws.http.fetch(purseUrl.href, {
+ method: "DELETE",
+ headers: {
+ "taler-purse-signature": sigResp.sig,
+ },
+ });
+ logger.info(`deleted purse with response status ${resp.status}`);
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ [
+ "peerPullCredit",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ async (tx) => {
+ const ppiRec = await tx.peerPullCredit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) {
+ return undefined;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(ppiRec);
+ ppiRec.status = PeerPullPaymentCreditStatus.Aborted;
+ await tx.peerPullCredit.put(ppiRec);
+ const newTxState = computePeerPullCreditTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+
+ return TaskRunResult.backoff();
+}
+
+async function handlePeerPullCreditWithdrawing(
+ ws: InternalWalletState,
+ pullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ if (!pullIni.withdrawalGroupId) {
+ throw Error("invalid db state (withdrawing, but no withdrawal group ID");
+ }
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+ const wgId = pullIni.withdrawalGroupId;
+ let finished: boolean = false;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit", "withdrawalGroups"],
+ async (tx) => {
+ const ppi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!ppi) {
+ finished = true;
+ return;
+ }
+ if (ppi.status !== PeerPullPaymentCreditStatus.PendingWithdrawing) {
+ finished = true;
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(ppi);
+ const wg = await tx.withdrawalGroups.get(wgId);
+ if (!wg) {
+ // FIXME: Fail the operation instead?
+ return undefined;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.Done:
+ finished = true;
+ ppi.status = PeerPullPaymentCreditStatus.Done;
+ break;
+ // FIXME: Also handle other final states!
+ }
+ await tx.peerPullCredit.put(ppi);
+ const newTxState = computePeerPullCreditTransactionState(ppi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ if (finished) {
+ return TaskRunResult.finished();
+ } else {
+ // FIXME: Return indicator that we depend on the other operation!
+ return TaskRunResult.backoff();
+ }
+}
+
+async function handlePeerPullCreditCreatePurse(
+ ws: InternalWalletState,
+ pullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
+ const pursePub = pullIni.pursePub;
+ const mergeReserve = await ws.db.runReadOnlyTx(["reserves"], async (tx) => {
+ return tx.reserves.get(pullIni.mergeReserveRowId);
+ });
+
+ if (!mergeReserve) {
+ throw Error("merge reserve for peer pull payment not found in database");
+ }
+
+ const contractTermsRecord = await ws.db.runReadOnlyTx(
+ ["contractTerms"],
+ async (tx) => {
+ return tx.contractTerms.get(pullIni.contractTermsHash);
+ },
+ );
+
+ if (!contractTermsRecord) {
+ throw Error("contract terms for peer pull payment not found in database");
+ }
+
+ const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw;
+
+ const reservePayto = talerPaytoFromExchangeReserve(
+ pullIni.exchangeBaseUrl,
+ mergeReserve.reservePub,
+ );
+
+ const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
+ contractPriv: pullIni.contractPriv,
+ contractPub: pullIni.contractPub,
+ contractTerms: contractTermsRecord.contractTermsRaw,
+ pursePriv: pullIni.pursePriv,
+ pursePub: pullIni.pursePub,
+ nonce: pullIni.contractEncNonce,
+ });
+
+ const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp);
+
+ const purseExpiration = contractTerms.purse_expiration;
+ const sigRes = await ws.cryptoApi.signReservePurseCreate({
+ contractTermsHash: pullIni.contractTermsHash,
+ flags: WalletAccountMergeFlags.CreateWithPurseFee,
+ mergePriv: pullIni.mergePriv,
+ mergeTimestamp: TalerPreciseTimestamp.round(mergeTimestamp),
+ purseAmount: pullIni.amount,
+ purseExpiration: purseExpiration,
+ purseFee: purseFee,
+ pursePriv: pullIni.pursePriv,
+ pursePub: pullIni.pursePub,
+ reservePayto,
+ reservePriv: mergeReserve.reservePriv,
+ });
+
+ const reservePurseReqBody: ExchangeReservePurseRequest = {
+ merge_sig: sigRes.mergeSig,
+ merge_timestamp: TalerPreciseTimestamp.round(mergeTimestamp),
+ h_contract_terms: pullIni.contractTermsHash,
+ merge_pub: pullIni.mergePub,
+ min_age: 0,
+ purse_expiration: purseExpiration,
+ purse_fee: purseFee,
+ purse_pub: pullIni.pursePub,
+ purse_sig: sigRes.purseSig,
+ purse_value: pullIni.amount,
+ reserve_sig: sigRes.accountSig,
+ econtract: econtractResp.econtract,
+ };
+
+ logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
+
+ const reservePurseMergeUrl = new URL(
+ `reserves/${mergeReserve.reservePub}/purse`,
+ pullIni.exchangeBaseUrl,
+ );
+
+ const httpResp = await ws.http.fetch(reservePurseMergeUrl.href, {
+ method: "POST",
+ body: reservePurseReqBody,
+ });
+
+ if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ const respJson = await httpResp.json();
+ const kycPending = codecForWalletKycUuid().decode(respJson);
+ logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+ return processPeerPullCreditKycRequired(ws, pullIni, kycPending);
+ }
+
+ const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
+
+ logger.info(`reserve merge response: ${j2s(resp)}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pi2 = await tx.peerPullCredit.get(pursePub);
+ if (!pi2) {
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(pi2);
+ pi2.status = PeerPullPaymentCreditStatus.PendingReady;
+ await tx.peerPullCredit.put(pi2);
+ const newTxState = computePeerPullCreditTransactionState(pi2);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+}
+
+export async function processPeerPullCredit(
+ ws: InternalWalletState,
+ pursePub: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const pullIni = await ws.db.runReadOnlyTx(["peerPullCredit"], async (tx) => {
+ return tx.peerPullCredit.get(pursePub);
+ });
+ if (!pullIni) {
+ throw Error("peer pull payment initiation not found in database");
+ }
+
+ const retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub,
+ });
+
+ logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
+
+ switch (pullIni.status) {
+ case PeerPullPaymentCreditStatus.Done: {
+ return TaskRunResult.finished();
+ }
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return queryPurseForPeerPullCredit(ws, pullIni, cancellationToken);
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired: {
+ if (!pullIni.kycInfo) {
+ throw Error("invalid state, kycInfo required");
+ }
+ return await longpollKycStatus(
+ ws,
+ pursePub,
+ pullIni.exchangeBaseUrl,
+ pullIni.kycInfo,
+ "individual",
+ cancellationToken,
+ );
+ }
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return handlePeerPullCreditCreatePurse(ws, pullIni);
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return await processPeerPullCreditAbortingDeletePurse(ws, pullIni);
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return handlePeerPullCreditWithdrawing(ws, pullIni);
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ break;
+ default:
+ assertUnreachable(pullIni.status);
+ }
+
+ return TaskRunResult.finished();
+}
+
+async function processPeerPullCreditKycRequired(
+ ws: InternalWalletState,
+ peerIni: PeerPullCreditRecord,
+ kycPending: WalletKycUuid,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: peerIni.pursePub,
+ });
+ const { pursePub } = peerIni;
+
+ const userType = "individual";
+ const url = new URL(
+ `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
+ peerIni.exchangeBaseUrl,
+ );
+
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await ws.http.fetch(url.href, {
+ method: "GET",
+ });
+
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.backoff();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const { transitionInfo, result } = await ws.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const peerInc = await tx.peerPullCredit.get(pursePub);
+ if (!peerInc) {
+ return {
+ transitionInfo: undefined,
+ result: TaskRunResult.finished(),
+ };
+ }
+ const oldTxState = computePeerPullCreditTransactionState(peerInc);
+ peerInc.kycInfo = {
+ paytoHash: kycPending.h_payto,
+ requirementRow: kycPending.requirement_row,
+ };
+ peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
+ const newTxState = computePeerPullCreditTransactionState(peerInc);
+ await tx.peerPullCredit.put(peerInc);
+ // We'll remove this eventually! New clients should rely on the
+ // kycUrl field of the transaction, not the error code.
+ const res: TaskRunResult = {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
+ {
+ kycUrl: kycStatus.kyc_url,
+ },
+ ),
+ };
+ return {
+ transitionInfo: { oldTxState, newTxState },
+ result: res,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+/**
+ * Check fees and available exchanges for a peer push payment initiation.
+ */
+export async function checkPeerPullPaymentInitiation(
+ ws: InternalWalletState,
+ req: CheckPeerPullCreditRequest,
+): Promise<CheckPeerPullCreditResponse> {
+ // FIXME: We don't support exchanges with purse fees yet.
+ // Select an exchange where we have money in the specified currency
+ // FIXME: How do we handle regional currency scopes here? Is it an additional input?
+
+ logger.trace("checking peer-pull-credit fees");
+
+ const currency = Amounts.currencyOf(req.amount);
+ let exchangeUrl;
+ if (req.exchangeBaseUrl) {
+ exchangeUrl = req.exchangeBaseUrl;
+ } else {
+ exchangeUrl = await getPreferredExchangeForCurrency(ws, currency);
+ }
+
+ if (!exchangeUrl) {
+ throw Error("no exchange found for initiating a peer pull payment");
+ }
+
+ logger.trace(`found ${exchangeUrl} as preferred exchange`);
+
+ const wi = await getExchangeWithdrawalInfo(
+ ws,
+ exchangeUrl,
+ Amounts.parseOrThrow(req.amount),
+ undefined,
+ );
+
+ logger.trace(`got withdrawal info`);
+
+ let numCoins = 0;
+ for (let i = 0; i < wi.selectedDenoms.selectedDenoms.length; i++) {
+ numCoins += wi.selectedDenoms.selectedDenoms[i].count;
+ }
+
+ return {
+ exchangeBaseUrl: exchangeUrl,
+ amountEffective: wi.withdrawalAmountEffective,
+ amountRaw: req.amount,
+ numCoins,
+ };
+}
+
+/**
+ * Find a preferred exchange based on when we withdrew last from this exchange.
+ */
+async function getPreferredExchangeForCurrency(
+ ws: InternalWalletState,
+ currency: string,
+): Promise<string | undefined> {
+ // Find an exchange with the matching currency.
+ // Prefer exchanges with the most recent withdrawal.
+ const url = await ws.db.runReadOnlyTx(["exchanges"], async (tx) => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ let candidate = undefined;
+ for (const e of exchanges) {
+ if (e.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ if (!candidate) {
+ candidate = e;
+ continue;
+ }
+ if (candidate.lastWithdrawal && !e.lastWithdrawal) {
+ continue;
+ }
+ const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
+ e.lastWithdrawal,
+ );
+ const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
+ candidate.lastWithdrawal,
+ );
+ if (exchangeLastWithdrawal && candidateLastWithdrawal) {
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
+ AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
+ ) > 0
+ ) {
+ candidate = e;
+ }
+ }
+ }
+ if (candidate) {
+ return candidate.baseUrl;
+ }
+ return undefined;
+ });
+ return url;
+}
+
+/**
+ * Initiate a peer pull payment.
+ */
+export async function initiatePeerPullPayment(
+ ws: InternalWalletState,
+ req: InitiatePeerPullCreditRequest,
+): Promise<InitiatePeerPullCreditResponse> {
+ const currency = Amounts.currencyOf(req.partialContractTerms.amount);
+ let maybeExchangeBaseUrl: string | undefined;
+ if (req.exchangeBaseUrl) {
+ maybeExchangeBaseUrl = req.exchangeBaseUrl;
+ } else {
+ maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency);
+ }
+
+ if (!maybeExchangeBaseUrl) {
+ throw Error("no exchange found for initiating a peer pull payment");
+ }
+
+ const exchangeBaseUrl = maybeExchangeBaseUrl;
+
+ await fetchFreshExchange(ws, exchangeBaseUrl);
+
+ const mergeReserveInfo = await getMergeReserveInfo(ws, {
+ exchangeBaseUrl: exchangeBaseUrl,
+ });
+
+ const pursePair = await ws.cryptoApi.createEddsaKeypair({});
+ const mergePair = await ws.cryptoApi.createEddsaKeypair({});
+
+ const contractTerms = req.partialContractTerms;
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
+
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+ const mergeReserveRowId = mergeReserveInfo.rowId;
+ checkDbInvariant(!!mergeReserveRowId);
+
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const wi = await getExchangeWithdrawalInfo(
+ ws,
+ exchangeBaseUrl,
+ Amounts.parseOrThrow(req.partialContractTerms.amount),
+ undefined,
+ );
+
+ const mergeTimestamp = TalerPreciseTimestamp.now();
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullCredit", "contractTerms"],
+ async (tx) => {
+ const ppi: PeerPullCreditRecord = {
+ amount: req.partialContractTerms.amount,
+ contractTermsHash: hContractTerms,
+ exchangeBaseUrl: exchangeBaseUrl,
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ mergePriv: mergePair.priv,
+ mergePub: mergePair.pub,
+ status: PeerPullPaymentCreditStatus.PendingCreatePurse,
+ mergeTimestamp: timestampPreciseToDb(mergeTimestamp),
+ contractEncNonce,
+ mergeReserveRowId: mergeReserveRowId,
+ contractPriv: contractKeyPair.priv,
+ contractPub: contractKeyPair.pub,
+ withdrawalGroupId,
+ estimatedAmountEffective: wi.withdrawalAmountEffective,
+ };
+ await tx.peerPullCredit.put(ppi);
+ const oldTxState: TransactionState = {
+ major: TransactionMajorState.None,
+ };
+ const newTxState = computePeerPullCreditTransactionState(ppi);
+ await tx.contractTerms.put({
+ contractTermsRaw: contractTerms,
+ h: hContractTerms,
+ });
+ return { oldTxState, newTxState };
+ },
+ );
+
+ const ctx = new PeerPullCreditTransactionContext(ws, pursePair.pub);
+
+ // The pending-incoming balance has changed.
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ notifyTransition(ws, ctx.transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(ctx.retryTag);
+
+ return {
+ talerUri: stringifyTalerUri({
+ type: TalerUriAction.PayPull,
+ exchangeBaseUrl: exchangeBaseUrl,
+ contractPriv: contractKeyPair.priv,
+ }),
+ transactionId: ctx.transactionId,
+ };
+}
+
+export function computePeerPullCreditTransactionState(
+ pullCreditRecord: PeerPullCreditRecord,
+): TransactionState {
+ switch (pullCreditRecord.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPullPaymentCreditStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPullPaymentCreditStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPullPaymentCreditStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPullPaymentCreditStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ }
+}
+
+export function computePeerPullCreditTransactionActions(
+ pullCreditRecord: PeerPullCreditRecord,
+): TransactionAction[] {
+ switch (pullCreditRecord.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ return [TransactionAction.Abort, TransactionAction.Resume];
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.Failed:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.Expired:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -0,0 +1,883 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of the peer-pull-debit transaction, i.e.
+ * paying for an invoice the wallet received from another wallet.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AcceptPeerPullPaymentResponse,
+ Amounts,
+ CoinRefreshRequest,
+ ConfirmPeerPullDebitRequest,
+ ContractTermsUtil,
+ ExchangePurseDeposits,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ PreparePeerPullDebitRequest,
+ PreparePeerPullDebitResponse,
+ RefreshReason,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ parsePayPullUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import {
+ DbReadWriteTransaction,
+ InternalWalletState,
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentIncomingRecord,
+ PendingTaskType,
+ RefreshOperationStatus,
+ StoreNames,
+ TaskId,
+ WalletStoresV1,
+ createRefreshGroup,
+ timestampPreciseToDb,
+} from "./index.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { PeerCoinRepair, selectPeerCoins } from "./util/coinSelection.js";
+import { checkLogicInvariant } from "./util/invariants.js";
+import {
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ TransitionResult,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+
+const logger = new Logger("pay-peer-pull-debit.ts");
+
+/**
+ * Common context for a peer-pull-debit transaction.
+ */
+export class PeerPullDebitTransactionContext implements TransactionContext {
+ ws: InternalWalletState;
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskId;
+ peerPullDebitId: string;
+
+ constructor(ws: InternalWalletState, peerPullDebitId: string) {
+ this.ws = ws;
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.peerPullDebitId = peerPullDebitId;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const transactionId = this.transactionId;
+ const ws = this.ws;
+ const peerPullDebitId = this.peerPullDebitId;
+ await ws.db.runReadWriteTx(["peerPullDebit", "tombstones"], async (tx) => {
+ const debit = await tx.peerPullDebit.get(peerPullDebitId);
+ if (debit) {
+ await tx.peerPullDebit.delete(peerPullDebitId);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const taskId = this.taskId;
+ const transactionId = this.transactionId;
+ const ws = this.ws;
+ const peerPullDebitId = this.peerPullDebitId;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullDebit"],
+ async (tx) => {
+ const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pullDebitRec) {
+ logger.warn(`peer pull debit ${peerPullDebitId} not found`);
+ return;
+ }
+ let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+ switch (pullDebitRec.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ break;
+ case PeerPullDebitRecordStatus.Done:
+ break;
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
+ break;
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ break;
+ case PeerPullDebitRecordStatus.Aborted:
+ break;
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
+ break;
+ case PeerPullDebitRecordStatus.Failed:
+ break;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ break;
+ default:
+ assertUnreachable(pullDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
+ pullDebitRec.status = newStatus;
+ const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
+ await tx.peerPullDebit.put(pullDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.stopShepherdTask(taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ return TransitionResult.Transition;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ return TransitionResult.Transition;
+ case PeerPullDebitRecordStatus.Aborted:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.Failed:
+ case PeerPullDebitRecordStatus.DialogProposed:
+ case PeerPullDebitRecordStatus.Done:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return TransitionResult.Stay;
+ }
+ });
+ this.ws.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ // FIXME: Should we also abort the corresponding refresh session?!
+ pi.status = PeerPullDebitRecordStatus.Failed;
+ return TransitionResult.Transition;
+ default:
+ return TransitionResult.Stay;
+ }
+ });
+ this.ws.taskScheduler.stopShepherdTask(this.taskId);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transitionExtra(
+ {
+ extraStores: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "coins",
+ "coinAvailability",
+ ],
+ },
+ async (pi, tx) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ break;
+ default:
+ return TransitionResult.Stay;
+ }
+ const currency = Amounts.currencyOf(pi.totalCostEstimated);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (!pi.coinSel) {
+ throw Error("invalid db state");
+ }
+
+ for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: pi.coinSel.contributions[i],
+ coinPub: pi.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ ctx.ws,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPullDebit,
+ this.transactionId,
+ );
+
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ pi.abortRefreshGroupId = refresh.refreshGroupId;
+ return TransitionResult.Transition;
+ },
+ );
+ }
+
+ async transition(
+ f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResult>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PeerPullPaymentIncomingRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["peerPullDebit", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResult>,
+ ): Promise<void> {
+ const ws = this.ws;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullDebit", ...extraStores],
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
+ if (!pi) {
+ throw Error("peer pull payment not found anymore");
+ }
+ const oldTxState = computePeerPullDebitTransactionState(pi);
+ const res = await f(pi, tx);
+ switch (res) {
+ case TransitionResult.Transition: {
+ await tx.peerPullDebit.put(pi);
+ const newTxState = computePeerPullDebitTransactionState(pi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(ws, this.transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(this.taskId);
+ }
+}
+
+async function handlePurseCreationConflict(
+ ctx: PeerPullDebitTransactionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const ws = ctx.ws;
+ const errResp = await readTalerErrorResponse(resp);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const sel = peerPullInc.coinSel;
+ if (!sel) {
+ throw Error("invalid state (coin selection expected)");
+ }
+
+ const repair: PeerCoinRepair = {
+ coinPubs: [],
+ contribs: [],
+ exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
+ };
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.coinPubs.push(sel.coinPubs[i]);
+ repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
+
+ if (coinSelRes.type == "failure") {
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+
+ await ws.db.runReadWriteTx(["peerPullDebit"], async (tx) => {
+ const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.SuspendedDeposit: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPullDebit.put(myPpi);
+ });
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPullDebitPendingDeposit(
+ ws: InternalWalletState,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPullInc.pursePub;
+
+ const coinSel = peerPullInc.coinSel;
+ if (!coinSel) {
+ throw Error("invalid state, no coins selected");
+ }
+
+ const coins = await queryCoinInfosForSelection(ws, coinSel);
+
+ const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
+ pursePub: peerPullInc.pursePub,
+ coins,
+ });
+
+ const purseDepositUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPullInc.exchangeBaseUrl,
+ );
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
+ }
+
+ const httpResp = await ws.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ });
+
+ const ctx = new PeerPullDebitTransactionContext(
+ ws,
+ peerPullInc.peerPullDebitId,
+ );
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok: {
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForAny(),
+ );
+ logger.trace(`purse deposit response: ${j2s(resp)}`);
+
+ await ctx.transition(async (r) => {
+ if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return TransitionResult.Stay;
+ }
+ r.status = PeerPullDebitRecordStatus.Done;
+ return TransitionResult.Transition;
+ });
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Gone: {
+ await ctx.abortTransaction();
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.Conflict: {
+ return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+}
+
+async function processPeerPullDebitAbortingRefresh(
+ ws: InternalWalletState,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPullDebit", "refreshGroups"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPullDebitRecordStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPullDebitRecordStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPullDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPullDebitTransactionState(newDg);
+ await tx.peerPullDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+export async function processPeerPullDebit(
+ ws: InternalWalletState,
+ peerPullDebitId: string,
+): Promise<TaskRunResult> {
+ const peerPullInc = await ws.db.runReadOnlyTx(
+ ["peerPullDebit"],
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+ if (!peerPullInc) {
+ throw Error("peer pull debit not found");
+ }
+
+ switch (peerPullInc.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return await processPeerPullDebitPendingDeposit(ws, peerPullInc);
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return await processPeerPullDebitAbortingRefresh(ws, peerPullInc);
+ }
+ return TaskRunResult.finished();
+}
+
+export async function confirmPeerPullDebit(
+ ws: InternalWalletState,
+ req: ConfirmPeerPullDebitRequest,
+): Promise<AcceptPeerPullPaymentResponse> {
+ let peerPullDebitId: string;
+
+ if (req.transactionId) {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
+ throw Error("invalid peer-pull-debit transaction identifier");
+ }
+ peerPullDebitId = parsedTx.peerPullDebitId;
+ } else if (req.peerPullDebitId) {
+ peerPullDebitId = req.peerPullDebitId;
+ } else {
+ throw Error("invalid request, transactionId or peerPullDebitId required");
+ }
+
+ const peerPullInc = await ws.db.runReadOnlyTx(
+ ["peerPullDebit"],
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+
+ if (!peerPullInc) {
+ throw Error(
+ `can't accept unknown incoming p2p pull payment (${req.peerPullDebitId})`,
+ );
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ if (coinSelRes.type !== "success") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ }
+
+ const sel = coinSelRes.result;
+
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+
+ await ws.db.runReadWriteTx(
+ [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ await spendCoins(ws, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ throw Error();
+ }
+ if (pi.status === PeerPullDebitRecordStatus.DialogProposed) {
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ pi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ }
+ await tx.peerPullDebit.put(pi);
+ },
+ );
+
+ const ctx = new PeerPullDebitTransactionContext(ws, peerPullDebitId);
+
+ const transactionId = ctx.transactionId;
+
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId,
+ };
+}
+
+/**
+ * Look up information about an incoming peer pull payment.
+ * Store the results in the wallet DB.
+ */
+export async function preparePeerPullDebit(
+ ws: InternalWalletState,
+ req: PreparePeerPullDebitRequest,
+): Promise<PreparePeerPullDebitResponse> {
+ const uri = parsePayPullUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-pull URI");
+ }
+
+ const existing = await ws.db.runReadOnlyTx(
+ ["peerPullDebit", "contractTerms"],
+ async (tx) => {
+ const peerPullDebitRecord =
+ await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!peerPullDebitRecord) {
+ return;
+ }
+ const contractTerms = await tx.contractTerms.get(
+ peerPullDebitRecord.contractTermsHash,
+ );
+ if (!contractTerms) {
+ return;
+ }
+ return { peerPullDebitRecord, contractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.peerPullDebitRecord.amount,
+ amountRaw: existing.peerPullDebitRecord.amount,
+ amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
+ contractTerms: existing.contractTerms.contractTermsRaw,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ }),
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await ws.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await ws.cryptoApi.decryptContractForDeposit({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
+
+ const purseHttpResp = await ws.http.fetch(getPurseUrl.href);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ const peerPullDebitId = encodeCrock(getRandomBytes(32));
+
+ let contractTerms: PeerContractTerms;
+
+ if (dec.contractTerms) {
+ contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+ // FIXME: Check that the purseStatus balance matches contract terms amount
+ } else {
+ // FIXME: In this case, where do we get the purse expiration from?!
+ // https://bugs.gnunet.org/view.php?id=7706
+ throw Error("pull payments without contract terms not supported yet");
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ // FIXME: Why don't we compute the totalCost here?!
+
+ const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ if (coinSelRes.type !== "success") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+
+ await ws.db.runReadWriteTx(["peerPullDebit", "contractTerms"], async (tx) => {
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: contractTerms,
+ }),
+ await tx.peerPullDebit.add({
+ peerPullDebitId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ pursePub: pursePub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ amount: contractTerms.amount,
+ status: PeerPullDebitRecordStatus.DialogProposed,
+ totalCostEstimated: Amounts.stringify(totalAmount),
+ });
+ });
+
+ return {
+ amount: contractTerms.amount,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: contractTerms.amount,
+ contractTerms: contractTerms,
+ peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: peerPullDebitId,
+ }),
+ };
+}
+
+export function computePeerPullDebitTransactionState(
+ pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionState {
+ switch (pullDebitRecord.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Deposit,
+ };
+ case PeerPullDebitRecordStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Deposit,
+ };
+ case PeerPullDebitRecordStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPullDebitRecordStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ }
+}
+
+export function computePeerPullDebitTransactionActions(
+ pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionAction[] {
+ switch (pullDebitRecord.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return [];
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPullDebitRecordStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.Failed:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -0,0 +1,1037 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AcceptPeerPushPaymentResponse,
+ Amounts,
+ CancellationToken,
+ ConfirmPeerPushCreditRequest,
+ ContractTermsUtil,
+ ExchangePurseMergeRequest,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ PreparePeerPushCreditRequest,
+ PreparePeerPushCreditResponse,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TransactionAction,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ WalletAccountMergeFlags,
+ WalletKycUuid,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ codecForWalletKycUuid,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parsePayPushUri,
+ talerPaytoFromExchangeReserve,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ InternalWalletState,
+ KycPendingInfo,
+ KycUserType,
+ PeerPushCreditStatus,
+ PeerPushPaymentIncomingRecord,
+ PendingTaskType,
+ TaskId,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampPreciseToDb,
+} from "./index.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { checkDbInvariant } from "./util/invariants.js";
+import {
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ codecForExchangePurseStatus,
+ getMergeReserveInfo,
+} from "./pay-peer-common.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ PerformCreateWithdrawalGroupResult,
+ getExchangeWithdrawalInfo,
+ internalPerformCreateWithdrawalGroup,
+ internalPrepareCreateWithdrawalGroup,
+} from "./withdraw.js";
+
+const logger = new Logger("pay-peer-push-credit.ts");
+
+export class PeerPushCreditTransactionContext implements TransactionContext {
+ readonly transactionId: string;
+ readonly retryTag: TaskId;
+
+ constructor(
+ public ws: InternalWalletState,
+ public peerPushCreditId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ this.retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { ws, peerPushCreditId } = this;
+ await ws.db.runReadWriteTx(
+ ["withdrawalGroups", "peerPushCredit", "tombstones"],
+ async (tx) => {
+ const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushInc) {
+ return;
+ }
+ if (pushInc.withdrawalGroupId) {
+ const withdrawalGroupId = pushInc.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPushCredit.delete(peerPushCreditId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
+ });
+ },
+ );
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, peerPushCreditId, retryTag, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.SuspendedMerge;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ // FIXME: Suspend internal withdrawal transaction!
+ newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { ws, peerPushCreditId, retryTag, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Done:
+ break;
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, peerPushCreditId, retryTag, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ newStatus = PeerPushCreditStatus.PendingMerge;
+ break;
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ // FIXME: resume underlying "internal-withdrawal" transaction.
+ newStatus = PeerPushCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, peerPushCreditId, retryTag, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.Aborted:
+ case PeerPushCreditStatus.Failed:
+ // Already in a final state.
+ return;
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+export async function preparePeerPushCredit(
+ ws: InternalWalletState,
+ req: PreparePeerPushCreditRequest,
+): Promise<PreparePeerPushCreditResponse> {
+ const uri = parsePayPushUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-push URI");
+ }
+
+ const existing = await ws.db.runReadOnlyTx(
+ ["contractTerms", "peerPushCredit"],
+ async (tx) => {
+ const existingPushInc =
+ await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!existingPushInc) {
+ return;
+ }
+ const existingContractTermsRec = await tx.contractTerms.get(
+ existingPushInc.contractTermsHash,
+ );
+ if (!existingContractTermsRec) {
+ throw Error(
+ "contract terms for peer push payment credit not found in database",
+ );
+ }
+ const existingContractTerms = codecForPeerContractTerms().decode(
+ existingContractTermsRec.contractTermsRaw,
+ );
+ return { existingPushInc, existingContractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.existingContractTerms.amount,
+ amountEffective: existing.existingPushInc.estimatedAmountEffective,
+ amountRaw: existing.existingContractTerms.amount,
+ contractTerms: existing.existingContractTerms,
+ peerPushCreditId: existing.existingPushInc.peerPushCreditId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: existing.existingPushInc.peerPushCreditId,
+ }),
+ exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl,
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+
+ await fetchFreshExchange(ws, exchangeBaseUrl);
+
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await ws.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await ws.cryptoApi.decryptContractForMerge({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
+
+ const purseHttpResp = await ws.http.fetch(getPurseUrl.href);
+
+ const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ logger.info(
+ `peer push credit, purse balance ${purseStatus.balance}, contract amount ${contractTerms.amount}`,
+ );
+
+ const peerPushCreditId = encodeCrock(getRandomBytes(32));
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ dec.contractTerms,
+ );
+
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+ const wi = await getExchangeWithdrawalInfo(
+ ws,
+ exchangeBaseUrl,
+ Amounts.parseOrThrow(purseStatus.balance),
+ undefined,
+ );
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["contractTerms", "peerPushCredit"],
+ async (tx) => {
+ const rec: PeerPushPaymentIncomingRecord = {
+ peerPushCreditId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ mergePriv: dec.mergePriv,
+ pursePub: pursePub,
+ timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ status: PeerPushCreditStatus.DialogProposed,
+ withdrawalGroupId,
+ currency: Amounts.currencyOf(purseStatus.balance),
+ estimatedAmountEffective: Amounts.stringify(
+ wi.withdrawalAmountEffective,
+ ),
+ };
+ await tx.peerPushCredit.add(rec);
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: dec.contractTerms,
+ });
+
+ const newTxState = computePeerPushCreditTransactionState(rec);
+
+ return {
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ } satisfies TransitionInfo;
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ notifyTransition(ws, transactionId, transitionInfo);
+
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ return {
+ amount: purseStatus.balance,
+ amountEffective: wi.withdrawalAmountEffective,
+ amountRaw: purseStatus.balance,
+ contractTerms: dec.contractTerms,
+ peerPushCreditId,
+ transactionId,
+ exchangeBaseUrl,
+ };
+}
+
+async function longpollKycStatus(
+ ws: InternalWalletState,
+ peerPushCreditId: string,
+ exchangeUrl: string,
+ kycInfo: KycPendingInfo,
+ userType: KycUserType,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await ws.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushCreditKycRequired(
+ ws: InternalWalletState,
+ peerInc: PeerPushPaymentIncomingRecord,
+ kycPending: WalletKycUuid,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: peerInc.peerPushCreditId,
+ });
+ const { peerPushCreditId } = peerInc;
+
+ const userType = "individual";
+ const url = new URL(
+ `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
+ peerInc.exchangeBaseUrl,
+ );
+
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await ws.http.fetch(url.href, {
+ method: "GET",
+ });
+
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.finished();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const { transitionInfo, result } = await ws.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return {
+ transitionInfo: undefined,
+ result: TaskRunResult.finished(),
+ };
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.kycInfo = {
+ paytoHash: kycPending.h_payto,
+ requirementRow: kycPending.requirement_row,
+ };
+ peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ // We'll remove this eventually! New clients should rely on the
+ // kycUrl field of the transaction, not the error code.
+ const res: TaskRunResult = {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
+ {
+ kycUrl: kycStatus.kyc_url,
+ },
+ ),
+ };
+ return {
+ transitionInfo: { oldTxState, newTxState },
+ result: res,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return result;
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function handlePendingMerge(
+ ws: InternalWalletState,
+ peerInc: PeerPushPaymentIncomingRecord,
+ contractTerms: PeerContractTerms,
+): Promise<TaskRunResult> {
+ const { peerPushCreditId } = peerInc;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ const amount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const mergeReserveInfo = await getMergeReserveInfo(ws, {
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ });
+
+ const mergeTimestamp = TalerProtocolTimestamp.now();
+
+ const reservePayto = talerPaytoFromExchangeReserve(
+ peerInc.exchangeBaseUrl,
+ mergeReserveInfo.reservePub,
+ );
+
+ const sigRes = await ws.cryptoApi.signPurseMerge({
+ contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
+ flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
+ mergePriv: peerInc.mergePriv,
+ mergeTimestamp: mergeTimestamp,
+ purseAmount: Amounts.stringify(amount),
+ purseExpiration: contractTerms.purse_expiration,
+ purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
+ pursePub: peerInc.pursePub,
+ reservePayto,
+ reservePriv: mergeReserveInfo.reservePriv,
+ });
+
+ const mergePurseUrl = new URL(
+ `purses/${peerInc.pursePub}/merge`,
+ peerInc.exchangeBaseUrl,
+ );
+
+ const mergeReq: ExchangePurseMergeRequest = {
+ payto_uri: reservePayto,
+ merge_timestamp: mergeTimestamp,
+ merge_sig: sigRes.mergeSig,
+ reserve_sig: sigRes.accountSig,
+ };
+
+ const mergeHttpResp = await ws.http.fetch(mergePurseUrl.href, {
+ method: "POST",
+ body: mergeReq,
+ });
+
+ if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ const respJson = await mergeHttpResp.json();
+ const kycPending = codecForWalletKycUuid().decode(respJson);
+ logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+ return processPeerPushCreditKycRequired(ws, peerInc, kycPending);
+ }
+
+ logger.trace(`merge request: ${j2s(mergeReq)}`);
+ const res = await readSuccessResponseJsonOrThrow(
+ mergeHttpResp,
+ codecForAny(),
+ );
+ logger.trace(`merge response: ${j2s(res)}`);
+
+ const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(ws, {
+ amount,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.PeerPushCredit,
+ },
+ forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ priv: mergeReserveInfo.reservePriv,
+ pub: mergeReserveInfo.reservePub,
+ },
+ });
+
+ const txRes = await ws.db.runReadWriteTx(
+ [
+ "contractTerms",
+ "peerPushCredit",
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ ],
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return undefined;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined =
+ undefined;
+ switch (peerInc.status) {
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingMergeKycRequired: {
+ peerInc.status = PeerPushCreditStatus.PendingWithdrawing;
+ wgCreateRes = await internalPerformCreateWithdrawalGroup(
+ ws,
+ tx,
+ withdrawalGroupPrep,
+ );
+ peerInc.withdrawalGroupId =
+ wgCreateRes.withdrawalGroup.withdrawalGroupId;
+ break;
+ }
+ }
+ await tx.peerPushCredit.put(peerInc);
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ return {
+ peerPushCreditTransition: { oldTxState, newTxState },
+ wgCreateRes,
+ };
+ },
+ );
+ // Transaction was committed, now we can emit notifications.
+ if (txRes?.wgCreateRes?.exchangeNotif) {
+ ws.notify(txRes.wgCreateRes.exchangeNotif);
+ }
+ notifyTransition(
+ ws,
+ withdrawalGroupPrep.transactionId,
+ txRes?.wgCreateRes?.transitionInfo,
+ );
+ notifyTransition(ws, transactionId, txRes?.peerPushCreditTransition);
+
+ return TaskRunResult.backoff();
+}
+
+async function handlePendingWithdrawing(
+ ws: InternalWalletState,
+ peerInc: PeerPushPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ if (!peerInc.withdrawalGroupId) {
+ throw Error("invalid db state (withdrawing, but no withdrawal group ID");
+ }
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: peerInc.peerPushCreditId,
+ });
+ const wgId = peerInc.withdrawalGroupId;
+ let finished: boolean = false;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushCredit", "withdrawalGroups"],
+ async (tx) => {
+ const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
+ if (!ppi) {
+ finished = true;
+ return;
+ }
+ if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) {
+ finished = true;
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(ppi);
+ const wg = await tx.withdrawalGroups.get(wgId);
+ if (!wg) {
+ // FIXME: Fail the operation instead?
+ return undefined;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.Done:
+ finished = true;
+ ppi.status = PeerPushCreditStatus.Done;
+ break;
+ // FIXME: Also handle other final states!
+ }
+ await tx.peerPushCredit.put(ppi);
+ const newTxState = computePeerPushCreditTransactionState(ppi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ if (finished) {
+ return TaskRunResult.finished();
+ } else {
+ // FIXME: Return indicator that we depend on the other operation!
+ return TaskRunResult.backoff();
+ }
+}
+
+export async function processPeerPushCredit(
+ ws: InternalWalletState,
+ peerPushCreditId: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ let peerInc: PeerPushPaymentIncomingRecord | undefined;
+ let contractTerms: PeerContractTerms | undefined;
+ await ws.db.runReadWriteTx(
+ ["contractTerms", "peerPushCredit"],
+ async (tx) => {
+ peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
+ if (ctRec) {
+ contractTerms = ctRec.contractTermsRaw;
+ }
+ await tx.peerPushCredit.put(peerInc);
+ },
+ );
+
+ if (!peerInc) {
+ throw Error(
+ `can't accept unknown incoming p2p push payment (${peerPushCreditId})`,
+ );
+ }
+
+ logger.info(
+ `processing peerPushCredit in state ${peerInc.status.toString(16)}`,
+ );
+
+ checkDbInvariant(!!contractTerms);
+
+ switch (peerInc.status) {
+ case PeerPushCreditStatus.PendingMergeKycRequired: {
+ if (!peerInc.kycInfo) {
+ throw Error("invalid state, kycInfo required");
+ }
+ return await longpollKycStatus(
+ ws,
+ peerPushCreditId,
+ peerInc.exchangeBaseUrl,
+ peerInc.kycInfo,
+ "individual",
+ cancellationToken,
+ );
+ }
+
+ case PeerPushCreditStatus.PendingMerge:
+ return handlePendingMerge(ws, peerInc, contractTerms);
+
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return handlePendingWithdrawing(ws, peerInc);
+
+ default:
+ return TaskRunResult.finished();
+ }
+}
+
+export async function confirmPeerPushCredit(
+ ws: InternalWalletState,
+ req: ConfirmPeerPushCreditRequest,
+): Promise<AcceptPeerPushPaymentResponse> {
+ let peerInc: PeerPushPaymentIncomingRecord | undefined;
+ let peerPushCreditId: string;
+ if (req.peerPushCreditId) {
+ peerPushCreditId = req.peerPushCreditId;
+ } else if (req.transactionId) {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx) {
+ throw Error("invalid transaction ID");
+ }
+ if (parsedTx.tag !== TransactionType.PeerPushCredit) {
+ throw Error("invalid transaction ID type");
+ }
+ peerPushCreditId = parsedTx.peerPushCreditId;
+ } else {
+ throw Error("no transaction ID (or deprecated peerPushCreditId) provided");
+ }
+
+ await ws.db.runReadWriteTx(
+ ["contractTerms", "peerPushCredit"],
+ async (tx) => {
+ peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status === PeerPushCreditStatus.DialogProposed) {
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ }
+ await tx.peerPushCredit.put(peerInc);
+ },
+ );
+
+ if (!peerInc) {
+ throw Error(
+ `can't accept unknown incoming p2p push payment (${req.peerPushCreditId})`,
+ );
+ }
+
+ const ctx = new PeerPushCreditTransactionContext(ws, peerPushCreditId);
+
+ ws.taskScheduler.startShepherdTask(ctx.retryTag);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ return {
+ transactionId,
+ };
+}
+
+export function computePeerPushCreditTransactionState(
+ pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionState {
+ switch (pushCreditRecord.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case PeerPushCreditStatus.PendingMerge:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Merge,
+ };
+ case PeerPushCreditStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPushCreditStatus.SuspendedMerge:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Merge,
+ };
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPushCreditStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPushCreditStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ default:
+ assertUnreachable(pushCreditRecord.status);
+ }
+}
+
+export function computePeerPushCreditTransactionActions(
+ pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionAction[] {
+ switch (pushCreditRecord.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.PendingMerge:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushCreditStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushCreditStatus.SuspendedMerge:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushCreditStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.Failed:
+ return [TransactionAction.Delete];
+ default:
+ assertUnreachable(pushCreditRecord.status);
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -0,0 +1,1150 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ CancellationToken,
+ CheckPeerPushDebitRequest,
+ CheckPeerPushDebitResponse,
+ CoinRefreshRequest,
+ ContractTermsUtil,
+ HttpStatusCode,
+ InitiatePeerPushDebitRequest,
+ InitiatePeerPushDebitResponse,
+ Logger,
+ NotificationType,
+ RefreshReason,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { EncryptContractRequest } from "./crypto/cryptoTypes.js";
+import {
+ PeerPushDebitRecord,
+ PeerPushDebitStatus,
+ RefreshOperationStatus,
+ createRefreshGroup,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { PendingTaskType, TaskId } from "./pending-types.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { PeerCoinRepair, selectPeerCoins } from "./util/coinSelection.js";
+import { checkLogicInvariant } from "./util/invariants.js";
+import {
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+
+const logger = new Logger("pay-peer-push-debit.ts");
+
+export class PeerPushDebitTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly retryTag: TaskId;
+
+ constructor(
+ public ws: InternalWalletState,
+ public pursePub: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+ this.retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { ws, pursePub, transactionId } = this;
+ await ws.db.runReadWriteTx(["peerPushDebit", "tombstones"], async (tx) => {
+ const debit = await tx.peerPushDebit.get(pursePub);
+ if (debit) {
+ await tx.peerPushDebit.delete(pursePub);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, pursePub, transactionId, retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ newStatus = PeerPushDebitStatus.SuspendedCreatePurse;
+ break;
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshDeleted;
+ break;
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshExpired;
+ break;
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.PendingReady:
+ newStatus = PeerPushDebitStatus.SuspendedReady;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { ws, pursePub, transactionId, retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ // Network request might already be in-flight!
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Expired:
+ case PeerPushDebitStatus.Failed:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, pursePub, transactionId, retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ newStatus = PeerPushDebitStatus.AbortingRefreshDeleted;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ newStatus = PeerPushDebitStatus.AbortingRefreshExpired;
+ break;
+ case PeerPushDebitStatus.SuspendedReady:
+ newStatus = PeerPushDebitStatus.PendingReady;
+ break;
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ newStatus = PeerPushDebitStatus.PendingCreatePurse;
+ break;
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.startShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, pursePub, transactionId, retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ // FIXME: What to do about the refresh group?
+ newStatus = PeerPushDebitStatus.Failed;
+ break;
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ newStatus = PeerPushDebitStatus.Failed;
+ break;
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+export async function checkPeerPushDebit(
+ ws: InternalWalletState,
+ req: CheckPeerPushDebitRequest,
+): Promise<CheckPeerPushDebitResponse> {
+ const instructedAmount = Amounts.parseOrThrow(req.amount);
+ logger.trace(
+ `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
+ );
+ const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
+ if (coinSelRes.type === "failure") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ }
+ logger.trace(`selected peer coins (len=${coinSelRes.result.coins.length})`);
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+ logger.trace("computed total peer payment cost");
+ return {
+ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: req.amount,
+ maxExpirationDate: coinSelRes.result.maxExpirationDate,
+ };
+}
+
+async function handlePurseCreationConflict(
+ ws: InternalWalletState,
+ peerPushInitiation: PeerPushDebitRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const errResp = await readTalerErrorResponse(resp);
+ const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
+ const sel = peerPushInitiation.coinSel;
+
+ const repair: PeerCoinRepair = {
+ coinPubs: [],
+ contribs: [],
+ exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
+ };
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.coinPubs.push(sel.coinPubs[i]);
+ repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
+
+ if (coinSelRes.type == "failure") {
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ }
+
+ await ws.db.runReadWriteTx(["peerPushDebit"], async (tx) => {
+ const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.SuspendedCreatePurse: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPushDebit.put(myPpi);
+ });
+ return TaskRunResult.progress();
+}
+
+async function processPeerPushDebitCreateReserve(
+ ws: InternalWalletState,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const purseExpiration = peerPushInitiation.purseExpiration;
+ const hContractTerms = peerPushInitiation.contractTermsHash;
+ const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
+ const transactionId = ctx.transactionId;
+
+ logger.trace(`processing ${transactionId} pending(create-reserve)`);
+
+ const contractTermsRecord = await ws.db.runReadOnlyTx(
+ ["contractTerms"],
+ async (tx) => {
+ return tx.contractTerms.get(hContractTerms);
+ },
+ );
+
+ if (!contractTermsRecord) {
+ throw Error(
+ `db invariant failed, contract terms for ${transactionId} missing`,
+ );
+ }
+
+ const purseSigResp = await ws.cryptoApi.signPurseCreation({
+ hContractTerms,
+ mergePub: peerPushInitiation.mergePub,
+ minAge: 0,
+ purseAmount: peerPushInitiation.amount,
+ purseExpiration: timestampProtocolFromDb(purseExpiration),
+ pursePriv: peerPushInitiation.pursePriv,
+ });
+
+ const coins = await queryCoinInfosForSelection(
+ ws,
+ peerPushInitiation.coinSel,
+ );
+
+ const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
+ pursePub: peerPushInitiation.pursePub,
+ coins,
+ });
+
+ const encryptContractRequest: EncryptContractRequest = {
+ contractTerms: contractTermsRecord.contractTermsRaw,
+ mergePriv: peerPushInitiation.mergePriv,
+ pursePriv: peerPushInitiation.pursePriv,
+ pursePub: peerPushInitiation.pursePub,
+ contractPriv: peerPushInitiation.contractPriv,
+ contractPub: peerPushInitiation.contractPub,
+ nonce: peerPushInitiation.contractEncNonce,
+ };
+
+ logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
+
+ const econtractResp = await ws.cryptoApi.encryptContractForMerge(
+ encryptContractRequest,
+ );
+
+ const createPurseUrl = new URL(
+ `purses/${peerPushInitiation.pursePub}/create`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const reqBody = {
+ amount: peerPushInitiation.amount,
+ merge_pub: peerPushInitiation.mergePub,
+ purse_sig: purseSigResp.sig,
+ h_contract_terms: hContractTerms,
+ purse_expiration: timestampProtocolFromDb(purseExpiration),
+ deposits: depositSigsResp.deposits,
+ min_age: 0,
+ econtract: econtractResp.econtract,
+ };
+
+ logger.trace(`request body: ${j2s(reqBody)}`);
+
+ const httpResp = await ws.http.fetch(createPurseUrl.href, {
+ method: "POST",
+ body: reqBody,
+ });
+
+ {
+ const resp = await httpResp.json();
+ logger.info(`resp: ${j2s(resp)}`);
+ }
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok:
+ break;
+ case HttpStatusCode.Forbidden: {
+ // FIXME: Store this error!
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Conflict: {
+ // Handle double-spending
+ return handlePurseCreationConflict(ws, peerPushInitiation, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+
+ if (httpResp.status !== HttpStatusCode.Ok) {
+ // FIXME: do proper error reporting
+ throw Error("got error response from exchange");
+ }
+
+ await transitionPeerPushDebitTransaction(ws, pursePub, {
+ stFrom: PeerPushDebitStatus.PendingCreatePurse,
+ stTo: PeerPushDebitStatus.PendingReady,
+ });
+
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushDebitAbortingDeletePurse(
+ ws: InternalWalletState,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const { pursePub, pursePriv } = peerPushInitiation;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+
+ const sigResp = await ws.cryptoApi.signDeletePurse({
+ pursePriv,
+ });
+ const purseUrl = new URL(
+ `purses/${pursePub}`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+ const resp = await ws.http.fetch(purseUrl.href, {
+ method: "DELETE",
+ headers: {
+ "taler-purse-signature": sigResp.sig,
+ },
+ });
+ logger.info(`deleted purse with response status ${resp.status}`);
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ [
+ "peerPushDebit",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(ppiRec.amount);
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: ppiRec.coinSel.contributions[i],
+ coinPub: ppiRec.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ ws,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPushDebit,
+ transactionId,
+ );
+ ppiRec.status = PeerPushDebitStatus.AbortingRefreshDeleted;
+ ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+
+ return TaskRunResult.backoff();
+}
+
+interface SimpleTransition {
+ stFrom: PeerPushDebitStatus;
+ stTo: PeerPushDebitStatus;
+}
+
+async function transitionPeerPushDebitTransaction(
+ ws: InternalWalletState,
+ pursePub: string,
+ transitionSpec: SimpleTransition,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== transitionSpec.stFrom) {
+ return undefined;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ ppiRec.status = transitionSpec.stTo;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+}
+
+async function processPeerPushDebitAbortingRefreshDeleted(
+ ws: InternalWalletState,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: peerPushInitiation.pursePub,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["refreshGroups", "peerPushDebit"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPushDebitStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPushDebitStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPushDebitStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPushDebitStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPushDebit.get(pursePub);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPushDebitTransactionState(newDg);
+ await tx.peerPushDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushDebitAbortingRefreshExpired(
+ ws: InternalWalletState,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: peerPushInitiation.pursePub,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["peerPushDebit", "refreshGroups"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPushDebitStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPushDebitStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPushDebitStatus.Expired;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPushDebitStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPushDebit.get(pursePub);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPushDebitTransactionState(newDg);
+ await tx.peerPushDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Process the "pending(ready)" state of a peer-push-debit transaction.
+ */
+async function processPeerPushDebitReady(
+ ws: InternalWalletState,
+ peerPushInitiation: PeerPushDebitRecord,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.trace("processing peer-push-debit pending(ready)");
+ const pursePub = peerPushInitiation.pursePub;
+ const transactionId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub,
+ });
+ const mergeUrl = new URL(
+ `purses/${pursePub}/merge`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+ mergeUrl.searchParams.set("timeout_ms", "30000");
+ logger.info(`long-polling on purse status at ${mergeUrl.href}`);
+ const resp = await ws.http.fetch(mergeUrl.href, {
+ // timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken,
+ });
+ if (resp.status === HttpStatusCode.Ok) {
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangePurseStatus(),
+ );
+ const mergeTimestamp = purseStatus.merge_timestamp;
+ logger.info(`got purse status ${j2s(purseStatus)}`);
+ if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) {
+ return TaskRunResult.backoff();
+ } else {
+ await transitionPeerPushDebitTransaction(
+ ws,
+ peerPushInitiation.pursePub,
+ {
+ stFrom: PeerPushDebitStatus.PendingReady,
+ stTo: PeerPushDebitStatus.Done,
+ },
+ );
+ return TaskRunResult.finished();
+ }
+ } else if (resp.status === HttpStatusCode.Gone) {
+ logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
+ const transitionInfo = await ws.db.runReadWriteTx(
+ [
+ "peerPushDebit",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPushDebitStatus.PendingReady) {
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(ppiRec.amount);
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: ppiRec.coinSel.contributions[i],
+ coinPub: ppiRec.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ ws,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPushDebit,
+ transactionId,
+ );
+ ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired;
+ ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ } else {
+ logger.warn(`unexpected HTTP status for purse: ${resp.status}`);
+ return TaskRunResult.backoff();
+ }
+}
+
+export async function processPeerPushDebit(
+ ws: InternalWalletState,
+ pursePub: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const peerPushInitiation = await ws.db.runReadOnlyTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ return tx.peerPushDebit.get(pursePub);
+ },
+ );
+ if (!peerPushInitiation) {
+ throw Error("peer push payment not found");
+ }
+
+ switch (peerPushInitiation.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return processPeerPushDebitCreateReserve(ws, peerPushInitiation);
+ case PeerPushDebitStatus.PendingReady:
+ return processPeerPushDebitReady(
+ ws,
+ peerPushInitiation,
+ cancellationToken,
+ );
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return processPeerPushDebitAbortingDeletePurse(ws, peerPushInitiation);
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return processPeerPushDebitAbortingRefreshDeleted(ws, peerPushInitiation);
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return processPeerPushDebitAbortingRefreshExpired(ws, peerPushInitiation);
+ default: {
+ const txState = computePeerPushDebitTransactionState(peerPushInitiation);
+ logger.warn(
+ `not processing peer-push-debit transaction in state ${j2s(txState)}`,
+ );
+ }
+ }
+
+ return TaskRunResult.finished();
+}
+
+/**
+ * Initiate sending a peer-to-peer push payment.
+ */
+export async function initiatePeerPushDebit(
+ ws: InternalWalletState,
+ req: InitiatePeerPushDebitRequest,
+): Promise<InitiatePeerPushDebitResponse> {
+ const instructedAmount = Amounts.parseOrThrow(
+ req.partialContractTerms.amount,
+ );
+ const purseExpiration = req.partialContractTerms.purse_expiration;
+ const contractTerms = req.partialContractTerms;
+
+ const pursePair = await ws.cryptoApi.createEddsaKeypair({});
+ const mergePair = await ws.cryptoApi.createEddsaKeypair({});
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
+
+ const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
+
+ if (coinSelRes.type !== "success") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ }
+
+ const sel = coinSelRes.result;
+
+ logger.info(`selected p2p coins (push):`);
+ logger.trace(`${j2s(coinSelRes)}`);
+
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+
+ logger.info(`computed total peer payment cost`);
+
+ const pursePub = pursePair.pub;
+
+ const ctx = new PeerPushDebitTransactionContext(ws, pursePub);
+
+ const transactionId = ctx.transactionId;
+
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "peerPushDebit",
+ ],
+ async (tx) => {
+ // FIXME: Instead of directly doing a spendCoin here,
+ // we might want to mark the coins as used and spend them
+ // after we've been able to create the purse.
+ await spendCoins(ws, tx, {
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pursePair.pub,
+ }),
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPush,
+ });
+
+ const ppi: PeerPushDebitRecord = {
+ amount: Amounts.stringify(instructedAmount),
+ contractPriv: contractKeyPair.priv,
+ contractPub: contractKeyPair.pub,
+ contractTermsHash: hContractTerms,
+ exchangeBaseUrl: sel.exchangeBaseUrl,
+ mergePriv: mergePair.priv,
+ mergePub: mergePair.pub,
+ purseExpiration: timestampProtocolToDb(purseExpiration),
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ status: PeerPushDebitStatus.PendingCreatePurse,
+ contractEncNonce,
+ coinSel: {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ },
+ totalCost: Amounts.stringify(totalAmount),
+ };
+
+ await tx.peerPushDebit.add(ppi);
+
+ await tx.contractTerms.put({
+ h: hContractTerms,
+ contractTermsRaw: contractTerms,
+ });
+
+ const newTxState = computePeerPushDebitTransactionState(ppi);
+ return {
+ oldTxState: { major: TransactionMajorState.None },
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ ws.taskScheduler.startShepherdTask(ctx.retryTag);
+
+ return {
+ contractPriv: contractKeyPair.priv,
+ mergePriv: mergePair.priv,
+ pursePub: pursePair.pub,
+ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pursePair.pub,
+ }),
+ };
+}
+
+export function computePeerPushDebitTransactionActions(
+ ppiRecord: PeerPushDebitRecord,
+): TransactionAction[] {
+ switch (ppiRecord.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushDebitStatus.PendingReady:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushDebitStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushDebitStatus.SuspendedReady:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushDebitStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.Expired:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.Failed:
+ return [TransactionAction.Delete];
+ }
+}
+
+export function computePeerPushDebitTransactionState(
+ ppiRecord: PeerPushDebitRecord,
+): TransactionState {
+ switch (ppiRecord.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPushDebitStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPushDebitStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.RefreshExpired,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.RefreshExpired,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPushDebitStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPushDebitStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPushDebitStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPushDebitStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ }
+}
diff --git a/packages/taler-wallet-core/src/pending-types.ts b/packages/taler-wallet-core/src/pending-types.ts
@@ -25,7 +25,7 @@
* Imports.
*/
import { TalerErrorDetail, AbsoluteTime } from "@gnu-taler/taler-util";
-import { DbRetryInfo } from "./operations/common.js";
+import { DbRetryInfo } from "./common.js";
export enum PendingTaskType {
ExchangeUpdate = "exchange-update",
diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/query.ts
diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts
@@ -0,0 +1,535 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the recoup operation, which allows to recover the
+ * value of coins held in a revoked denomination.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Amounts,
+ CoinStatus,
+ Logger,
+ RefreshReason,
+ TalerPreciseTimestamp,
+ TransactionType,
+ URL,
+ codecForRecoupConfirmation,
+ codecForReserveStatus,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ CoinRecord,
+ CoinSourceType,
+ RecoupGroupRecord,
+ RecoupOperationStatus,
+ RefreshCoinSource,
+ WalletDbReadWriteTransaction,
+ WithdrawCoinSource,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampPreciseToDb,
+} from "./db.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { PendingTaskType } from "./pending-types.js";
+import { checkDbInvariant } from "./util/invariants.js";
+import {
+ TaskRunResult,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import { createRefreshGroup } from "./refresh.js";
+import { constructTransactionIdentifier } from "./transactions.js";
+import { internalCreateWithdrawalGroup } from "./withdraw.js";
+
+const logger = new Logger("operations/recoup.ts");
+
+/**
+ * Store a recoup group record in the database after marking
+ * a coin in the group as finished.
+ */
+async function putGroupAsFinished(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["recoupGroups", "denominations", "refreshGroups", "coins"]
+ >,
+ recoupGroup: RecoupGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ logger.trace(
+ `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
+ );
+ if (recoupGroup.timestampFinished) {
+ return;
+ }
+ recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
+ await tx.recoupGroups.put(recoupGroup);
+}
+
+async function recoupRewardCoin(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+): Promise<void> {
+ // We can't really recoup a coin we got via tipping.
+ // Thus we just put the coin to sleep.
+ // FIXME: somehow report this to the user
+ await ws.db.runReadWriteTx(
+ ["recoupGroups", "denominations", "refreshGroups", "coins"],
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+async function recoupWithdrawCoin(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: WithdrawCoinSource,
+): Promise<void> {
+ const reservePub = cs.reservePub;
+ const denomInfo = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
+ const denomInfo = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ return denomInfo;
+ });
+ if (!denomInfo) {
+ // FIXME: We should at least emit some pending operation / warning for this?
+ return;
+ }
+
+ const recoupRequest = await ws.cryptoApi.createRecoupRequest({
+ blindingKey: coin.blindingKey,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPub: denomInfo.denomPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ });
+ const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
+ logger.trace(`requesting recoup via ${reqUrl.href}`);
+ const resp = await ws.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: recoupRequest,
+ });
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`);
+
+ if (recoupConfirmation.reserve_pub !== reservePub) {
+ throw Error(`Coin's reserve doesn't match reserve on recoup`);
+ }
+
+ // FIXME: verify that our expectations about the amount match
+
+ await ws.db.runReadWriteTx(
+ ["coins", "denominations", "recoupGroups", "refreshGroups"],
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const updatedCoin = await tx.coins.get(coin.coinPub);
+ if (!updatedCoin) {
+ return;
+ }
+ updatedCoin.status = CoinStatus.Dormant;
+ await tx.coins.put(updatedCoin);
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+async function recoupRefreshCoin(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: RefreshCoinSource,
+): Promise<void> {
+ const d = await ws.db.runReadOnlyTx(
+ ["coins", "denominations"],
+ async (tx) => {
+ const denomInfo = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ return;
+ }
+ return { denomInfo };
+ },
+ );
+ if (!d) {
+ // FIXME: We should at least emit some pending operation / warning for this?
+ return;
+ }
+
+ const recoupRequest = await ws.cryptoApi.createRecoupRefreshRequest({
+ blindingKey: coin.blindingKey,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPub: d.denomInfo.denomPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ });
+ const reqUrl = new URL(
+ `/coins/${coin.coinPub}/recoup-refresh`,
+ coin.exchangeBaseUrl,
+ );
+ logger.trace(`making recoup request for ${coin.coinPub}`);
+
+ const resp = await ws.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: recoupRequest,
+ });
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
+ throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
+ }
+
+ await ws.db.runReadWriteTx(
+ ["coins", "denominations", "recoupGroups", "refreshGroups"],
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const oldCoin = await tx.coins.get(cs.oldCoinPub);
+ const revokedCoin = await tx.coins.get(coin.coinPub);
+ if (!revokedCoin) {
+ logger.warn("revoked coin for recoup not found");
+ return;
+ }
+ if (!oldCoin) {
+ logger.warn("refresh old coin for recoup not found");
+ return;
+ }
+ const oldCoinDenom = await ws.getDenomInfo(
+ ws,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ const revokedCoinDenom = await ws.getDenomInfo(
+ ws,
+ tx,
+ revokedCoin.exchangeBaseUrl,
+ revokedCoin.denomPubHash,
+ );
+ checkDbInvariant(!!oldCoinDenom);
+ checkDbInvariant(!!revokedCoinDenom);
+ revokedCoin.status = CoinStatus.Dormant;
+ if (!revokedCoin.spendAllocation) {
+ // We don't know what happened to this coin
+ logger.error(
+ `can't refresh-recoup coin ${revokedCoin.coinPub}, no spendAllocation known`,
+ );
+ } else {
+ let residualAmount = Amounts.sub(
+ revokedCoinDenom.value,
+ revokedCoin.spendAllocation.amount,
+ ).amount;
+ recoupGroup.scheduleRefreshCoins.push({
+ coinPub: oldCoin.coinPub,
+ amount: Amounts.stringify(residualAmount),
+ });
+ }
+ await tx.coins.put(revokedCoin);
+ await tx.coins.put(oldCoin);
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+export async function processRecoupGroup(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+): Promise<TaskRunResult> {
+ let recoupGroup = await ws.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
+ return tx.recoupGroups.get(recoupGroupId);
+ });
+ if (!recoupGroup) {
+ return TaskRunResult.finished();
+ }
+ if (recoupGroup.timestampFinished) {
+ logger.trace("recoup group finished");
+ return TaskRunResult.finished();
+ }
+ const ps = recoupGroup.coinPubs.map(async (x, i) => {
+ try {
+ await processRecoupForCoin(ws, recoupGroupId, i);
+ } catch (e) {
+ logger.warn(`processRecoup failed: ${e}`);
+ throw e;
+ }
+ });
+ await Promise.all(ps);
+
+ recoupGroup = await ws.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
+ return tx.recoupGroups.get(recoupGroupId);
+ });
+ if (!recoupGroup) {
+ return TaskRunResult.finished();
+ }
+
+ for (const b of recoupGroup.recoupFinishedPerCoin) {
+ if (!b) {
+ return TaskRunResult.finished();
+ }
+ }
+
+ logger.info("all recoups of recoup group are finished");
+
+ const reserveSet = new Set<string>();
+ const reservePrivMap: Record<string, string> = {};
+ for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
+ const coinPub = recoupGroup.coinPubs[i];
+ await ws.db.runReadOnlyTx(["coins", "reserves"], async (tx) => {
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request recoup`);
+ }
+ if (coin.coinSource.type === CoinSourceType.Withdraw) {
+ const reserve = await tx.reserves.indexes.byReservePub.get(
+ coin.coinSource.reservePub,
+ );
+ if (!reserve) {
+ return;
+ }
+ reserveSet.add(coin.coinSource.reservePub);
+ reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
+ }
+ });
+ }
+
+ for (const reservePub of reserveSet) {
+ const reserveUrl = new URL(
+ `reserves/${reservePub}`,
+ recoupGroup.exchangeBaseUrl,
+ );
+ logger.info(`querying reserve status for recoup via ${reserveUrl}`);
+
+ const resp = await ws.http.fetch(reserveUrl.href);
+
+ const result = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForReserveStatus(),
+ );
+ await internalCreateWithdrawalGroup(ws, {
+ amount: Amounts.parseOrThrow(result.balance),
+ exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ pub: reservePub,
+ priv: reservePrivMap[reservePub],
+ },
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.Recoup,
+ },
+ });
+ }
+
+ await ws.db.runReadWriteTx(
+ [
+ "recoupGroups",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "coins",
+ ],
+ async (tx) => {
+ const rg2 = await tx.recoupGroups.get(recoupGroupId);
+ if (!rg2) {
+ return;
+ }
+ rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ rg2.operationStatus = RecoupOperationStatus.Finished;
+ if (rg2.scheduleRefreshCoins.length > 0) {
+ await createRefreshGroup(
+ ws,
+ tx,
+ Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
+ rg2.scheduleRefreshCoins,
+ RefreshReason.Recoup,
+ constructTransactionIdentifier({
+ tag: TransactionType.Recoup,
+ recoupGroupId: rg2.recoupGroupId,
+ }),
+ );
+ }
+ await tx.recoupGroups.put(rg2);
+ },
+ );
+ return TaskRunResult.finished();
+}
+
+export class RewardTransactionContext implements TransactionContext {
+ abortTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ deleteTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ public transactionId: string;
+ public retryTag: string;
+
+ constructor(
+ public ws: InternalWalletState,
+ private recoupGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Recoup,
+ recoupGroupId,
+ });
+ this.retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId,
+ });
+ }
+}
+
+export async function createRecoupGroup(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["recoupGroups", "denominations", "refreshGroups", "coins"]
+ >,
+ exchangeBaseUrl: string,
+ coinPubs: string[],
+): Promise<string> {
+ const recoupGroupId = encodeCrock(getRandomBytes(32));
+
+ const recoupGroup: RecoupGroupRecord = {
+ recoupGroupId,
+ exchangeBaseUrl: exchangeBaseUrl,
+ coinPubs: coinPubs,
+ timestampFinished: undefined,
+ timestampStarted: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ recoupFinishedPerCoin: coinPubs.map(() => false),
+ scheduleRefreshCoins: [],
+ operationStatus: RecoupOperationStatus.Pending,
+ };
+
+ for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
+ const coinPub = coinPubs[coinIdx];
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
+ continue;
+ }
+ await tx.coins.put(coin);
+ }
+
+ await tx.recoupGroups.put(recoupGroup);
+
+ return recoupGroupId;
+}
+
+/**
+ * Run the recoup protocol for a single coin in a recoup group.
+ */
+async function processRecoupForCoin(
+ ws: InternalWalletState,
+ recoupGroupId: string,
+ coinIdx: number,
+): Promise<void> {
+ const coin = await ws.db.runReadOnlyTx(
+ ["coins", "recoupGroups"],
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.timestampFinished) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+
+ const coinPub = recoupGroup.coinPubs[coinIdx];
+
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request recoup`);
+ }
+ return coin;
+ },
+ );
+
+ if (!coin) {
+ return;
+ }
+
+ const cs = coin.coinSource;
+
+ switch (cs.type) {
+ case CoinSourceType.Reward:
+ return recoupRewardCoin(ws, recoupGroupId, coinIdx, coin);
+ case CoinSourceType.Refresh:
+ return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
+ case CoinSourceType.Withdraw:
+ return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
+ default:
+ throw Error("unknown coin source type");
+ }
+}
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -0,0 +1,1430 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AgeCommitment,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ amountToPretty,
+ CancellationToken,
+ codecForExchangeMeltResponse,
+ codecForExchangeRevealResponse,
+ CoinPublicKeyString,
+ CoinRefreshRequest,
+ CoinStatus,
+ DenominationInfo,
+ DenomKeyType,
+ Duration,
+ encodeCrock,
+ ExchangeMeltRequest,
+ ExchangeProtocolVersion,
+ ExchangeRefreshRevealRequest,
+ fnutil,
+ ForceRefreshRequest,
+ getErrorDetailFromException,
+ getRandomBytes,
+ HashCodeString,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ NotificationType,
+ RefreshGroupId,
+ RefreshReason,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TransactionAction,
+ TransactionMajorState,
+ TransactionState,
+ TransactionType,
+ URL,
+} from "@gnu-taler/taler-util";
+import {
+ readSuccessResponseJsonOrThrow,
+ readUnexpectedResponseDetails,
+} from "@gnu-taler/taler-util/http";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
+import {
+ DerivedRefreshSession,
+ RefreshNewDenomInfo,
+} from "./crypto/cryptoTypes.js";
+import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ RefreshCoinStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+} from "./db.js";
+import {
+ getCandidateWithdrawalDenomsTx,
+ PendingTaskType,
+ RefreshGroupPerExchangeInfo,
+ RefreshSessionRecord,
+ TaskId,
+ timestampPreciseToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+} from "./index.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ InternalWalletState,
+} from "./internal-wallet-state.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { selectWithdrawalDenominations } from "./util/coinSelection.js";
+import { checkDbInvariant } from "./util/invariants.js";
+import {
+ constructTaskIdentifier,
+ makeCoinAvailable,
+ makeCoinsVisible,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+} from "./common.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+
+const logger = new Logger("refresh.ts");
+
+export class RefreshTransactionContext implements TransactionContext {
+ public transactionId: string;
+ readonly taskId: TaskId;
+
+ constructor(
+ public ws: InternalWalletState,
+ public refreshGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const refreshGroupId = this.refreshGroupId;
+ const ws = this.ws;
+ await ws.db.runReadWriteTx(["refreshGroups", "tombstones"], async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (rg) {
+ await tx.refreshGroups.delete(refreshGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
+ });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, refreshGroupId, transactionId } = this;
+ let res = await ws.db.runReadWriteTx(["refreshGroups"], async (tx) => {
+ const dg = await tx.refreshGroups.get(refreshGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend refresh group, refreshGroupId=${refreshGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeRefreshTransactionState(dg);
+ switch (dg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return undefined;
+ case RefreshOperationStatus.Pending: {
+ dg.operationStatus = RefreshOperationStatus.Suspended;
+ await tx.refreshGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeRefreshTransactionState(dg),
+ };
+ }
+ case RefreshOperationStatus.Suspended:
+ return undefined;
+ }
+ return undefined;
+ });
+ if (res) {
+ ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: res.oldTxState,
+ newTxState: res.newTxState,
+ });
+ }
+ }
+
+ async abortTransaction(): Promise<void> {
+ // Refresh transactions only support fail, not abort.
+ throw new Error("refresh transactions cannot be aborted");
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, refreshGroupId, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["refreshGroups"],
+ async (tx) => {
+ const dg = await tx.refreshGroups.get(refreshGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
+ );
+ return;
+ }
+ const oldState = computeRefreshTransactionState(dg);
+ switch (dg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return;
+ case RefreshOperationStatus.Pending: {
+ return;
+ }
+ case RefreshOperationStatus.Suspended:
+ dg.operationStatus = RefreshOperationStatus.Pending;
+ await tx.refreshGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeRefreshTransactionState(dg),
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, refreshGroupId, transactionId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["refreshGroups"],
+ async (tx) => {
+ const dg = await tx.refreshGroups.get(refreshGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't resume refresh group, refreshGroupId=${refreshGroupId} not found`,
+ );
+ return;
+ }
+ const oldState = computeRefreshTransactionState(dg);
+ let newStatus: RefreshOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ break;
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended:
+ newStatus = RefreshOperationStatus.Failed;
+ break;
+ case RefreshOperationStatus.Failed:
+ break;
+ default:
+ assertUnreachable(dg.operationStatus);
+ }
+ if (newStatus) {
+ dg.operationStatus = newStatus;
+ await tx.refreshGroups.put(dg);
+ }
+ return {
+ oldTxState: oldState,
+ newTxState: computeRefreshTransactionState(dg),
+ };
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(this.taskId);
+ }
+}
+
+/**
+ * Get the amount that we lose when refreshing a coin of the given denomination
+ * with a certain amount left.
+ *
+ * If the amount left is zero, then the refresh cost
+ * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
+ * the right denominations), then the cost is the full amount left.
+ *
+ * Considers refresh fees, withdrawal fees after refresh and amounts too small
+ * to refresh.
+ */
+export function getTotalRefreshCost(
+ denoms: DenominationRecord[],
+ refreshedDenom: DenominationInfo,
+ amountLeft: AmountJson,
+ denomselAllowLate: boolean,
+): AmountJson {
+ const withdrawAmount = Amounts.sub(
+ amountLeft,
+ refreshedDenom.feeRefresh,
+ ).amount;
+ const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
+ const withdrawDenoms = selectWithdrawalDenominations(
+ withdrawAmount,
+ denoms,
+ denomselAllowLate,
+ );
+ const resultingAmount = Amounts.add(
+ Amounts.zeroOfCurrency(withdrawAmount.currency),
+ ...withdrawDenoms.selectedDenoms.map(
+ (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
+ ),
+ ).amount;
+ const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
+ logger.trace(
+ `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
+ totalCost,
+ )}`,
+ );
+ return totalCost;
+}
+
+function updateGroupStatus(rg: RefreshGroupRecord): { final: boolean } {
+ const allFinal = fnutil.all(
+ rg.statusPerCoin,
+ (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
+ );
+ const anyFailed = fnutil.any(
+ rg.statusPerCoin,
+ (x) => x === RefreshCoinStatus.Failed,
+ );
+ if (allFinal) {
+ if (anyFailed) {
+ rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ rg.operationStatus = RefreshOperationStatus.Failed;
+ } else {
+ rg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ rg.operationStatus = RefreshOperationStatus.Finished;
+ }
+ return { final: true };
+ }
+ return { final: false };
+}
+
+/**
+ * Create a refresh session for one particular coin inside a refresh group.
+ *
+ * If the session already exists, return the existing one.
+ *
+ * If the session doesn't need to be created (refresh group gone or session already
+ * finished), return undefined.
+ */
+async function provideRefreshSession(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<RefreshSessionRecord | undefined> {
+ logger.trace(
+ `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
+ );
+
+ const d = await ws.db.runReadWriteTx(
+ ["coins", "refreshGroups", "refreshSessions"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ if (
+ refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished
+ ) {
+ return;
+ }
+ const existingRefreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
+ const coin = await tx.coins.get(oldCoinPub);
+ if (!coin) {
+ throw Error("Can't refresh, coin not found");
+ }
+ return { refreshGroup, coin, existingRefreshSession };
+ },
+ );
+
+ if (!d) {
+ return undefined;
+ }
+
+ if (d.existingRefreshSession) {
+ return d.existingRefreshSession;
+ }
+
+ const { refreshGroup, coin } = d;
+
+ const exch = await fetchFreshExchange(ws, coin.exchangeBaseUrl);
+
+ // FIXME: use helper functions from withdraw.ts
+ // to update and filter withdrawable denoms.
+
+ const { availableAmount, availableDenoms } = await ws.db.runReadOnlyTx(
+ ["denominations"],
+ async (tx) => {
+ const oldDenom = await ws.getDenomInfo(
+ ws,
+ tx,
+ exch.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+
+ if (!oldDenom) {
+ throw Error("db inconsistent: denomination for coin not found");
+ }
+
+ // FIXME: Use denom groups instead of querying all denominations!
+ const availableDenoms: DenominationRecord[] =
+ await tx.denominations.indexes.byExchangeBaseUrl
+ .iter(exch.exchangeBaseUrl)
+ .toArray();
+
+ const availableAmount = Amounts.sub(
+ refreshGroup.inputPerCoin[coinIndex],
+ oldDenom.feeRefresh,
+ ).amount;
+ return { availableAmount, availableDenoms };
+ },
+ );
+
+ const newCoinDenoms = selectWithdrawalDenominations(
+ availableAmount,
+ availableDenoms,
+ ws.config.testing.denomselAllowLate,
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+
+ if (newCoinDenoms.selectedDenoms.length === 0) {
+ logger.trace(
+ `not refreshing, available amount ${amountToPretty(
+ availableAmount,
+ )} too small`,
+ );
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["refreshGroups", "coins", "coinAvailability"],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ const oldTxState = computeRefreshTransactionState(rg);
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ const updateRes = updateGroupStatus(rg);
+ if (updateRes.final) {
+ await makeCoinsVisible(ws, tx, transactionId);
+ }
+ await tx.refreshGroups.put(rg);
+ const newTxState = computeRefreshTransactionState(rg);
+ return { oldTxState, newTxState };
+ },
+ );
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return;
+ }
+
+ const sessionSecretSeed = encodeCrock(getRandomBytes(64));
+
+ // Store refresh session for this coin in the database.
+ const mySession = await ws.db.runReadWriteTx(
+ ["refreshGroups", "refreshSessions"],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ const existingSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (existingSession) {
+ return existingSession;
+ }
+ const newSession: RefreshSessionRecord = {
+ coinIndex,
+ refreshGroupId,
+ norevealIndex: undefined,
+ sessionSecretSeed: sessionSecretSeed,
+ newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
+ count: x.count,
+ denomPubHash: x.denomPubHash,
+ })),
+ amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue),
+ };
+ await tx.refreshSessions.put(newSession);
+ return newSession;
+ },
+ );
+ logger.trace(
+ `found/created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
+ );
+ return mySession;
+}
+
+function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
+ return Duration.fromSpec({
+ seconds: 5,
+ });
+}
+
+async function refreshMelt(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ const d = await ws.db.runReadWriteTx(
+ ["refreshGroups", "refreshSessions", "coins", "denominations"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ return;
+ }
+ if (refreshSession.norevealIndex !== undefined) {
+ return;
+ }
+
+ const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
+ checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
+ const oldDenom = await ws.getDenomInfo(
+ ws,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!oldDenom,
+ "denomination for melted coin doesn't exist",
+ );
+
+ const newCoinDenoms: RefreshNewDenomInfo[] = [];
+
+ for (const dh of refreshSession.newDenoms) {
+ const newDenom = await ws.getDenomInfo(
+ ws,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ dh.denomPubHash,
+ );
+ checkDbInvariant(
+ !!newDenom,
+ "new denomination for refresh not in database",
+ );
+ newCoinDenoms.push({
+ count: dh.count,
+ denomPub: newDenom.denomPub,
+ denomPubHash: newDenom.denomPubHash,
+ feeWithdraw: newDenom.feeWithdraw,
+ value: Amounts.stringify(newDenom.value),
+ });
+ }
+ return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
+
+ let exchangeProtocolVersion: ExchangeProtocolVersion;
+ switch (d.oldDenom.denomPub.cipher) {
+ case DenomKeyType.Rsa: {
+ exchangeProtocolVersion = ExchangeProtocolVersion.V12;
+ break;
+ }
+ default:
+ throw Error("unsupported key type");
+ }
+
+ const derived = await ws.cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion,
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ newCoinDenoms,
+ sessionSecretSeed: refreshSession.sessionSecretSeed,
+ });
+
+ const reqUrl = new URL(
+ `coins/${oldCoin.coinPub}/melt`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ let maybeAch: HashCodeString | undefined;
+ if (oldCoin.ageCommitmentProof) {
+ maybeAch = AgeRestriction.hashCommitment(
+ oldCoin.ageCommitmentProof.commitment,
+ );
+ }
+
+ const meltReqBody: ExchangeMeltRequest = {
+ coin_pub: oldCoin.coinPub,
+ confirm_sig: derived.confirmSig,
+ denom_pub_hash: oldCoin.denomPubHash,
+ denom_sig: oldCoin.denomSig,
+ rc: derived.hash,
+ value_with_fee: Amounts.stringify(derived.meltValueWithFee),
+ age_commitment_hash: maybeAch,
+ };
+
+ const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
+ return await ws.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: meltReqBody,
+ timeout: getRefreshRequestTimeout(refreshGroup),
+ });
+ });
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+
+ if (resp.status === HttpStatusCode.NotFound) {
+ const errDetails = await readUnexpectedResponseDetails(resp);
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["refreshGroups", "refreshSessions", "coins", "coinAvailability"],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ const oldTxState = computeRefreshTransactionState(rg);
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error(
+ "db invariant failed: missing refresh session in database",
+ );
+ }
+ refreshSession.lastError = errDetails;
+ const updateRes = updateGroupStatus(rg);
+ if (updateRes.final) {
+ await makeCoinsVisible(ws, tx, transactionId);
+ }
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ const newTxState = computeRefreshTransactionState(rg);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return;
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ // Just log for better diagnostics here, error status
+ // will be handled later.
+ logger.error(
+ `melt request for ${Amounts.stringify(
+ derived.meltValueWithFee,
+ )} failed in refresh group ${refreshGroupId} due to conflict`,
+ );
+
+ const historySig = await ws.cryptoApi.signCoinHistoryRequest({
+ coinPriv: oldCoin.coinPriv,
+ coinPub: oldCoin.coinPub,
+ startOffset: 0,
+ });
+
+ const historyUrl = new URL(
+ `coins/${oldCoin.coinPub}/history`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const historyResp = await ws.http.fetch(historyUrl.href, {
+ method: "GET",
+ headers: {
+ "Taler-Coin-History-Signature": historySig.sig,
+ },
+ });
+
+ const historyJson = await historyResp.json();
+ logger.info(`coin history: ${j2s(historyJson)}`);
+
+ // FIXME: Before failing and re-trying, analyse response and adjust amount
+ }
+
+ const meltResponse = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeMeltResponse(),
+ );
+
+ const norevealIndex = meltResponse.noreveal_index;
+
+ refreshSession.norevealIndex = norevealIndex;
+
+ await ws.db.runReadWriteTx(
+ ["refreshGroups", "refreshSessions"],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ if (!rs) {
+ return;
+ }
+ if (rs.norevealIndex !== undefined) {
+ return;
+ }
+ rs.norevealIndex = norevealIndex;
+ await tx.refreshSessions.put(rs);
+ },
+ );
+}
+
+export async function assembleRefreshRevealRequest(args: {
+ cryptoApi: TalerCryptoInterface;
+ derived: DerivedRefreshSession;
+ norevealIndex: number;
+ oldCoinPub: CoinPublicKeyString;
+ oldCoinPriv: string;
+ newDenoms: {
+ denomPubHash: string;
+ count: number;
+ }[];
+ oldAgeCommitment?: AgeCommitment;
+}): Promise<ExchangeRefreshRevealRequest> {
+ const {
+ derived,
+ norevealIndex,
+ cryptoApi,
+ oldCoinPriv,
+ oldCoinPub,
+ newDenoms,
+ } = args;
+ const privs = Array.from(derived.transferPrivs);
+ privs.splice(norevealIndex, 1);
+
+ const planchets = derived.planchetsForGammas[norevealIndex];
+ if (!planchets) {
+ throw Error("refresh index error");
+ }
+
+ const newDenomsFlat: string[] = [];
+ const linkSigs: string[] = [];
+
+ for (let i = 0; i < newDenoms.length; i++) {
+ const dsel = newDenoms[i];
+ for (let j = 0; j < dsel.count; j++) {
+ const newCoinIndex = linkSigs.length;
+ const linkSig = await cryptoApi.signCoinLink({
+ coinEv: planchets[newCoinIndex].coinEv,
+ newDenomHash: dsel.denomPubHash,
+ oldCoinPriv: oldCoinPriv,
+ oldCoinPub: oldCoinPub,
+ transferPub: derived.transferPubs[norevealIndex],
+ });
+ linkSigs.push(linkSig.sig);
+ newDenomsFlat.push(dsel.denomPubHash);
+ }
+ }
+
+ const req: ExchangeRefreshRevealRequest = {
+ coin_evs: planchets.map((x) => x.coinEv),
+ new_denoms_h: newDenomsFlat,
+ transfer_privs: privs,
+ transfer_pub: derived.transferPubs[norevealIndex],
+ link_sigs: linkSigs,
+ old_age_commitment: args.oldAgeCommitment?.publicKeys,
+ };
+ return req;
+}
+
+async function refreshReveal(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`,
+ );
+ const d = await ws.db.runReadOnlyTx(
+ ["refreshGroups", "refreshSessions", "coins", "denominations"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ return;
+ }
+ const norevealIndex = refreshSession.norevealIndex;
+ if (norevealIndex === undefined) {
+ throw Error("can't reveal without melting first");
+ }
+
+ const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
+ checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
+ const oldDenom = await ws.getDenomInfo(
+ ws,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!oldDenom,
+ "denomination for melted coin doesn't exist",
+ );
+
+ const newCoinDenoms: RefreshNewDenomInfo[] = [];
+
+ for (const dh of refreshSession.newDenoms) {
+ const newDenom = await ws.getDenomInfo(
+ ws,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ dh.denomPubHash,
+ );
+ checkDbInvariant(
+ !!newDenom,
+ "new denomination for refresh not in database",
+ );
+ newCoinDenoms.push({
+ count: dh.count,
+ denomPub: newDenom.denomPub,
+ denomPubHash: newDenom.denomPubHash,
+ feeWithdraw: newDenom.feeWithdraw,
+ value: Amounts.stringify(newDenom.value),
+ });
+ }
+ return {
+ oldCoin,
+ oldDenom,
+ newCoinDenoms,
+ refreshSession,
+ refreshGroup,
+ norevealIndex,
+ };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const {
+ oldCoin,
+ oldDenom,
+ newCoinDenoms,
+ refreshSession,
+ refreshGroup,
+ norevealIndex,
+ } = d;
+
+ let exchangeProtocolVersion: ExchangeProtocolVersion;
+ switch (d.oldDenom.denomPub.cipher) {
+ case DenomKeyType.Rsa: {
+ exchangeProtocolVersion = ExchangeProtocolVersion.V12;
+ break;
+ }
+ default:
+ throw Error("unsupported key type");
+ }
+
+ const derived = await ws.cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion,
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ newCoinDenoms,
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ sessionSecretSeed: refreshSession.sessionSecretSeed,
+ });
+
+ const reqUrl = new URL(
+ `refreshes/${derived.hash}/reveal`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const req = await assembleRefreshRevealRequest({
+ cryptoApi: ws.cryptoApi,
+ derived,
+ newDenoms: newCoinDenoms,
+ norevealIndex: norevealIndex,
+ oldCoinPriv: oldCoin.coinPriv,
+ oldCoinPub: oldCoin.coinPub,
+ oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment,
+ });
+
+ const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
+ return await ws.http.fetch(reqUrl.href, {
+ body: req,
+ method: "POST",
+ timeout: getRefreshRequestTimeout(refreshGroup),
+ });
+ });
+
+ const reveal = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeRevealResponse(),
+ );
+
+ const coins: CoinRecord[] = [];
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+
+ for (let i = 0; i < refreshSession.newDenoms.length; i++) {
+ const ncd = newCoinDenoms[i];
+ for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
+ const newCoinIndex = coins.length;
+ const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
+ if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error("cipher unsupported");
+ }
+ const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
+ const denomSig = await ws.cryptoApi.unblindDenominationSignature({
+ planchet: {
+ blindingKey: pc.blindingKey,
+ denomPub: ncd.denomPub,
+ },
+ evSig,
+ });
+ const coin: CoinRecord = {
+ blindingKey: pc.blindingKey,
+ coinPriv: pc.coinPriv,
+ coinPub: pc.coinPub,
+ denomPubHash: ncd.denomPubHash,
+ denomSig,
+ exchangeBaseUrl: oldCoin.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Refresh,
+ refreshGroupId,
+ oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
+ },
+ sourceTransactionId: transactionId,
+ coinEvHash: pc.coinEvHash,
+ maxAge: pc.maxAge,
+ ageCommitmentProof: pc.ageCommitmentProof,
+ spendAllocation: undefined,
+ };
+
+ coins.push(coin);
+ }
+ }
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ logger.warn("no refresh session found");
+ return;
+ }
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ if (!rs) {
+ return;
+ }
+ const oldTxState = computeRefreshTransactionState(rg);
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ updateGroupStatus(rg);
+ for (const coin of coins) {
+ await makeCoinAvailable(ws, tx, coin);
+ }
+ await makeCoinsVisible(ws, tx, transactionId);
+ await tx.refreshGroups.put(rg);
+ const newTxState = computeRefreshTransactionState(rg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ logger.trace("refresh finished (end of reveal)");
+}
+
+export async function processRefreshGroup(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.trace(`processing refresh group ${refreshGroupId}`);
+
+ const refreshGroup = await ws.db.runReadOnlyTx(
+ ["refreshGroups"],
+ async (tx) => tx.refreshGroups.get(refreshGroupId),
+ );
+ if (!refreshGroup) {
+ return TaskRunResult.finished();
+ }
+ if (refreshGroup.timestampFinished) {
+ return TaskRunResult.finished();
+ }
+ // Process refresh sessions of the group in parallel.
+ logger.trace(
+ `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`,
+ );
+ let errors: TalerErrorDetail[] = [];
+ let inShutdown = false;
+ const ps = refreshGroup.oldCoinPubs.map((x, i) =>
+ processRefreshSession(ws, refreshGroupId, i).catch((x) => {
+ if (x instanceof CryptoApiStoppedError) {
+ inShutdown = true;
+ logger.info(
+ "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
+ );
+ return;
+ }
+ if (x instanceof TalerError) {
+ logger.warn("process refresh session got exception (TalerError)");
+ logger.warn(`exc ${x}`);
+ logger.warn(`exc stack ${x.stack}`);
+ logger.warn(`error detail: ${j2s(x.errorDetail)}`);
+ } else {
+ logger.warn("process refresh session got exception");
+ logger.warn(`exc ${x}`);
+ logger.warn(`exc stack ${x.stack}`);
+ }
+ errors.push(getErrorDetailFromException(x));
+ }),
+ );
+ try {
+ logger.info("waiting for refreshes");
+ await Promise.all(ps);
+ logger.info("refresh group finished");
+ } catch (e) {
+ logger.warn("process refresh sessions got exception");
+ logger.warn(`exception: ${e}`);
+ }
+ if (inShutdown) {
+ return TaskRunResult.backoff();
+ }
+ if (errors.length > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE,
+ {
+ numErrors: errors.length,
+ errors: errors.slice(0, 5),
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processRefreshSession(
+ ws: InternalWalletState,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
+ );
+ let { refreshGroup, refreshSession } = await ws.db.runReadOnlyTx(
+ ["refreshGroups", "refreshSessions"],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ return {
+ refreshGroup: rg,
+ refreshSession: rs,
+ };
+ },
+ );
+ if (!refreshGroup) {
+ return;
+ }
+ if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
+ return;
+ }
+ if (!refreshSession) {
+ refreshSession = await provideRefreshSession(ws, refreshGroupId, coinIndex);
+ }
+ if (!refreshSession) {
+ // We tried to create the refresh session, but didn't get a result back.
+ // This means that either the session is finished, or that creating
+ // one isn't necessary.
+ return;
+ }
+ if (refreshSession.norevealIndex === undefined) {
+ await refreshMelt(ws, refreshGroupId, coinIndex);
+ }
+ await refreshReveal(ws, refreshGroupId, coinIndex);
+}
+
+export interface RefreshOutputInfo {
+ outputPerCoin: AmountJson[];
+ perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>;
+}
+
+export async function calculateRefreshOutput(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+): Promise<RefreshOutputInfo> {
+ const estimatedOutputPerCoin: AmountJson[] = [];
+
+ const denomsPerExchange: Record<string, DenominationRecord[]> = {};
+
+ const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {};
+
+ // FIXME: Use denom groups instead of querying all denominations!
+ const getDenoms = async (
+ exchangeBaseUrl: string,
+ ): Promise<DenominationRecord[]> => {
+ if (denomsPerExchange[exchangeBaseUrl]) {
+ return denomsPerExchange[exchangeBaseUrl];
+ }
+ const allDenoms = await getCandidateWithdrawalDenomsTx(
+ ws,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+ denomsPerExchange[exchangeBaseUrl] = allDenoms;
+ return allDenoms;
+ };
+
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
+ const refreshAmount = ocp.amount;
+ const denoms = await getDenoms(coin.exchangeBaseUrl);
+ const cost = getTotalRefreshCost(
+ denoms,
+ denom,
+ Amounts.parseOrThrow(refreshAmount),
+ ws.config.testing.denomselAllowLate,
+ );
+ const output = Amounts.sub(refreshAmount, cost).amount;
+ let exchInfo = infoPerExchange[coin.exchangeBaseUrl];
+ if (!exchInfo) {
+ infoPerExchange[coin.exchangeBaseUrl] = exchInfo = {
+ outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)),
+ };
+ }
+ exchInfo.outputEffective = Amounts.stringify(
+ Amounts.add(exchInfo.outputEffective, output).amount,
+ );
+ estimatedOutputPerCoin.push(output);
+ }
+
+ return {
+ outputPerCoin: estimatedOutputPerCoin,
+ perExchangeInfo: infoPerExchange,
+ };
+}
+
+async function applyRefresh(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshGroupId: string,
+): Promise<void> {
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
+ switch (coin.status) {
+ case CoinStatus.Dormant:
+ break;
+ case CoinStatus.Fresh: {
+ coin.status = CoinStatus.Dormant;
+ const coinAv = await tx.coinAvailability.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ coin.maxAge,
+ ]);
+ checkDbInvariant(!!coinAv);
+ checkDbInvariant(coinAv.freshCoinCount > 0);
+ coinAv.freshCoinCount--;
+ await tx.coinAvailability.put(coinAv);
+ break;
+ }
+ case CoinStatus.FreshSuspended: {
+ // For suspended coins, we don't have to adjust coin
+ // availability, as they are not counted as available.
+ coin.status = CoinStatus.Dormant;
+ break;
+ }
+ default:
+ assertUnreachable(coin.status);
+ }
+ if (!coin.spendAllocation) {
+ coin.spendAllocation = {
+ amount: Amounts.stringify(ocp.amount),
+ // id: `txn:refresh:${refreshGroupId}`,
+ id: constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ }),
+ };
+ }
+ await tx.coins.put(coin);
+ }
+}
+
+export interface CreateRefreshGroupResult {
+ refreshGroupId: string;
+}
+
+/**
+ * Create a refresh group for a list of coins.
+ *
+ * Refreshes the remaining amount on the coin, effectively capturing the remaining
+ * value in the refresh group.
+ *
+ * The caller must also ensure that the coins that should be refreshed exist
+ * in the current database transaction.
+ */
+export async function createRefreshGroup(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshReason: RefreshReason,
+ originatingTransactionId: string | undefined,
+): Promise<CreateRefreshGroupResult> {
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+
+ const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs);
+
+ const estimatedOutputPerCoin = outInfo.outputPerCoin;
+
+ await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId);
+
+ const refreshGroup: RefreshGroupRecord = {
+ operationStatus: RefreshOperationStatus.Pending,
+ currency,
+ timestampFinished: undefined,
+ statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
+ oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
+ originatingTransactionId,
+ reason: refreshReason,
+ refreshGroupId,
+ inputPerCoin: oldCoinPubs.map((x) => x.amount),
+ expectedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
+ Amounts.stringify(x),
+ ),
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+
+ if (oldCoinPubs.length == 0) {
+ logger.warn("created refresh group with zero coins");
+ refreshGroup.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ refreshGroup.operationStatus = RefreshOperationStatus.Finished;
+ }
+
+ await tx.refreshGroups.put(refreshGroup);
+
+ logger.trace(`created refresh group ${refreshGroupId}`);
+
+ const ctx = new RefreshTransactionContext(ws, refreshGroupId);
+
+ // Shepherd the task.
+ // If the current transaction fails to commit the refresh
+ // group to the DB, the shepherd will give up.
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ refreshGroupId,
+ };
+}
+
+export function computeRefreshTransactionState(
+ rg: RefreshGroupRecord,
+): TransactionState {
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RefreshOperationStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case RefreshOperationStatus.Pending:
+ return {
+ major: TransactionMajorState.Pending,
+ };
+ case RefreshOperationStatus.Suspended:
+ return {
+ major: TransactionMajorState.Suspended,
+ };
+ }
+}
+
+export function computeRefreshTransactionActions(
+ rg: RefreshGroupRecord,
+): TransactionAction[] {
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return [TransactionAction.Delete];
+ case RefreshOperationStatus.Failed:
+ return [TransactionAction.Delete];
+ case RefreshOperationStatus.Pending:
+ return [
+ TransactionAction.Retry,
+ TransactionAction.Suspend,
+ TransactionAction.Fail,
+ ];
+ case RefreshOperationStatus.Suspended:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
+
+export function getRefreshesForTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<string[]> {
+ return ws.db.runReadOnlyTx(["refreshGroups"], async (tx) => {
+ const groups =
+ await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll(
+ transactionId,
+ );
+ return groups.map((x) =>
+ constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: x.refreshGroupId,
+ }),
+ );
+ });
+}
+
+export async function forceRefresh(
+ ws: InternalWalletState,
+ req: ForceRefreshRequest,
+): Promise<{ refreshGroupId: RefreshGroupId }> {
+ if (req.coinPubList.length == 0) {
+ throw Error("refusing to create empty refresh group");
+ }
+ const refreshGroupId = await ws.db.runReadWriteTx(
+ ["refreshGroups", "coinAvailability", "denominations", "coins"],
+ async (tx) => {
+ let coinPubs: CoinRefreshRequest[] = [];
+ for (const c of req.coinPubList) {
+ const coin = await tx.coins.get(c);
+ if (!coin) {
+ throw Error(`coin (pubkey ${c}) not found`);
+ }
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ coinPubs.push({
+ coinPub: c,
+ amount: denom?.value,
+ });
+ }
+ return await createRefreshGroup(
+ ws,
+ tx,
+ Amounts.currencyOf(coinPubs[0].amount),
+ coinPubs,
+ RefreshReason.Manual,
+ undefined,
+ );
+ },
+ );
+
+ return {
+ refreshGroupId,
+ };
+}
diff --git a/packages/taler-wallet-core/src/reward.ts b/packages/taler-wallet-core/src/reward.ts
@@ -0,0 +1,321 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AcceptTipResponse,
+ Logger,
+ PrepareTipResult,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { RewardRecord, RewardRecordStatus } from "./db.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { PendingTaskType } from "./pending-types.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import {
+ TaskRunResult,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+
+const logger = new Logger("operations/tip.ts");
+
+export class RewardTransactionContext implements TransactionContext {
+ public transactionId: string;
+ public retryTag: string;
+
+ constructor(
+ public ws: InternalWalletState,
+ public walletRewardId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Reward,
+ walletRewardId,
+ });
+ this.retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.RewardPickup,
+ walletRewardId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { ws, walletRewardId } = this;
+ await ws.db.runReadWriteTx(["rewards", "tombstones"], async (tx) => {
+ const tipRecord = await tx.rewards.get(walletRewardId);
+ if (tipRecord) {
+ await tx.rewards.delete(walletRewardId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteReward + ":" + walletRewardId,
+ });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, walletRewardId, transactionId, retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["rewards"],
+ async (tx) => {
+ const tipRec = await tx.rewards.get(walletRewardId);
+ if (!tipRec) {
+ logger.warn(`transaction tip ${walletRewardId} not found`);
+ return;
+ }
+ let newStatus: RewardRecordStatus | undefined = undefined;
+ switch (tipRec.status) {
+ case RewardRecordStatus.Done:
+ case RewardRecordStatus.SuspendedPickup:
+ case RewardRecordStatus.Aborted:
+ case RewardRecordStatus.DialogAccept:
+ case RewardRecordStatus.Failed:
+ break;
+ case RewardRecordStatus.PendingPickup:
+ newStatus = RewardRecordStatus.SuspendedPickup;
+ break;
+
+ default:
+ assertUnreachable(tipRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computeRewardTransactionStatus(tipRec);
+ tipRec.status = newStatus;
+ const newTxState = computeRewardTransactionStatus(tipRec);
+ await tx.rewards.put(tipRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { ws, walletRewardId, transactionId, retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["rewards"],
+ async (tx) => {
+ const tipRec = await tx.rewards.get(walletRewardId);
+ if (!tipRec) {
+ logger.warn(`transaction tip ${walletRewardId} not found`);
+ return;
+ }
+ let newStatus: RewardRecordStatus | undefined = undefined;
+ switch (tipRec.status) {
+ case RewardRecordStatus.Done:
+ case RewardRecordStatus.Aborted:
+ case RewardRecordStatus.PendingPickup:
+ case RewardRecordStatus.DialogAccept:
+ case RewardRecordStatus.Failed:
+ break;
+ case RewardRecordStatus.SuspendedPickup:
+ newStatus = RewardRecordStatus.Aborted;
+ break;
+ default:
+ assertUnreachable(tipRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computeRewardTransactionStatus(tipRec);
+ tipRec.status = newStatus;
+ const newTxState = computeRewardTransactionStatus(tipRec);
+ await tx.rewards.put(tipRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, walletRewardId, transactionId, retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["rewards"],
+ async (tx) => {
+ const rewardRec = await tx.rewards.get(walletRewardId);
+ if (!rewardRec) {
+ logger.warn(`transaction reward ${walletRewardId} not found`);
+ return;
+ }
+ let newStatus: RewardRecordStatus | undefined = undefined;
+ switch (rewardRec.status) {
+ case RewardRecordStatus.Done:
+ case RewardRecordStatus.PendingPickup:
+ case RewardRecordStatus.Aborted:
+ case RewardRecordStatus.DialogAccept:
+ case RewardRecordStatus.Failed:
+ break;
+ case RewardRecordStatus.SuspendedPickup:
+ newStatus = RewardRecordStatus.PendingPickup;
+ break;
+ default:
+ assertUnreachable(rewardRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computeRewardTransactionStatus(rewardRec);
+ rewardRec.status = newStatus;
+ const newTxState = computeRewardTransactionStatus(rewardRec);
+ await tx.rewards.put(rewardRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, walletRewardId, transactionId, retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["rewards"],
+ async (tx) => {
+ const tipRec = await tx.rewards.get(walletRewardId);
+ if (!tipRec) {
+ logger.warn(`transaction tip ${walletRewardId} not found`);
+ return;
+ }
+ let newStatus: RewardRecordStatus | undefined = undefined;
+ switch (tipRec.status) {
+ case RewardRecordStatus.Done:
+ case RewardRecordStatus.Aborted:
+ case RewardRecordStatus.Failed:
+ break;
+ case RewardRecordStatus.PendingPickup:
+ case RewardRecordStatus.DialogAccept:
+ case RewardRecordStatus.SuspendedPickup:
+ newStatus = RewardRecordStatus.Failed;
+ break;
+ default:
+ assertUnreachable(tipRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computeRewardTransactionStatus(tipRec);
+ tipRec.status = newStatus;
+ const newTxState = computeRewardTransactionStatus(tipRec);
+ await tx.rewards.put(tipRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+}
+
+/**
+ * Get the (DD37-style) transaction status based on the
+ * database record of a reward.
+ */
+export function computeRewardTransactionStatus(
+ tipRecord: RewardRecord,
+): TransactionState {
+ switch (tipRecord.status) {
+ case RewardRecordStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RewardRecordStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case RewardRecordStatus.PendingPickup:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Pickup,
+ };
+ case RewardRecordStatus.DialogAccept:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case RewardRecordStatus.SuspendedPickup:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Pickup,
+ };
+ case RewardRecordStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ default:
+ assertUnreachable(tipRecord.status);
+ }
+}
+
+export function computeTipTransactionActions(
+ tipRecord: RewardRecord,
+): TransactionAction[] {
+ switch (tipRecord.status) {
+ case RewardRecordStatus.Done:
+ return [TransactionAction.Delete];
+ case RewardRecordStatus.Failed:
+ return [TransactionAction.Delete];
+ case RewardRecordStatus.Aborted:
+ return [TransactionAction.Delete];
+ case RewardRecordStatus.PendingPickup:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case RewardRecordStatus.SuspendedPickup:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case RewardRecordStatus.DialogAccept:
+ return [TransactionAction.Abort];
+ default:
+ assertUnreachable(tipRecord.status);
+ }
+}
+
+export async function prepareReward(
+ ws: InternalWalletState,
+ talerTipUri: string,
+): Promise<PrepareTipResult> {
+ throw Error("the rewards feature is not supported anymore");
+}
+
+export async function processTip(
+ ws: InternalWalletState,
+ walletTipId: string,
+): Promise<TaskRunResult> {
+ return TaskRunResult.finished();
+}
+
+export async function acceptTip(
+ ws: InternalWalletState,
+ transactionId: TransactionIdStr,
+): Promise<AcceptTipResponse> {
+ throw Error("the rewards feature is not supported anymore");
+}
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
@@ -45,7 +45,7 @@ import {
timestampAbsoluteFromDb,
} from "./index.js";
import { InternalWalletState } from "./internal-wallet-state.js";
-import { processBackupForProvider } from "./operations/backup/index.js";
+import { processBackupForProvider } from "./backup/index.js";
import {
DbRetryInfo,
TaskRunResult,
@@ -53,18 +53,18 @@ import {
constructTaskIdentifier,
getExchangeState,
parseTaskIdentifier,
-} from "./operations/common.js";
-import { processDepositGroup } from "./operations/deposits.js";
-import { updateExchangeFromUrlHandler } from "./operations/exchanges.js";
-import { processPurchase } from "./operations/pay-merchant.js";
-import { processPeerPullCredit } from "./operations/pay-peer-pull-credit.js";
-import { processPeerPullDebit } from "./operations/pay-peer-pull-debit.js";
-import { processPeerPushCredit } from "./operations/pay-peer-push-credit.js";
-import { processPeerPushDebit } from "./operations/pay-peer-push-debit.js";
-import { processRecoupGroup } from "./operations/recoup.js";
-import { processRefreshGroup } from "./operations/refresh.js";
-import { constructTransactionIdentifier } from "./operations/transactions.js";
-import { processWithdrawalGroup } from "./operations/withdraw.js";
+} from "./common.js";
+import { processDepositGroup } from "./deposits.js";
+import { updateExchangeFromUrlHandler } from "./exchanges.js";
+import { processPurchase } from "./pay-merchant.js";
+import { processPeerPullCredit } from "./pay-peer-pull-credit.js";
+import { processPeerPullDebit } from "./pay-peer-pull-debit.js";
+import { processPeerPushCredit } from "./pay-peer-push-credit.js";
+import { processPeerPushDebit } from "./pay-peer-push-debit.js";
+import { processRecoupGroup } from "./recoup.js";
+import { processRefreshGroup } from "./refresh.js";
+import { constructTransactionIdentifier } from "./transactions.js";
+import { processWithdrawalGroup } from "./withdraw.js";
import { PendingTaskType, TaskId } from "./pending-types.js";
const logger = new Logger("shepherd.ts");
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
@@ -0,0 +1,913 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @file
+ * Implementation of wallet-core operations that are used for testing,
+ * but typically not in the production wallet.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ addPaytoQueryParams,
+ Amounts,
+ AmountString,
+ CheckPaymentResponse,
+ codecForAny,
+ codecForCheckPaymentResponse,
+ ConfirmPayResultType,
+ Duration,
+ IntegrationTestArgs,
+ IntegrationTestV2Args,
+ j2s,
+ Logger,
+ NotificationType,
+ OpenedPromise,
+ openPromise,
+ parsePaytoUri,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+ TestPayArgs,
+ TestPayResult,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WithdrawTestBalanceRequest,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
+import { getRefreshesForTransaction } from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { checkLogicInvariant } from "./util/invariants.js";
+import { getBalances } from "./balance.js";
+import { createDepositGroup } from "./deposits.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ confirmPay,
+ preparePayForUri,
+ startRefundQueryForUri,
+} from "./pay-merchant.js";
+import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
+import {
+ confirmPeerPullDebit,
+ preparePeerPullDebit,
+} from "./pay-peer-pull-debit.js";
+import {
+ confirmPeerPushCredit,
+ preparePeerPushCredit,
+} from "./pay-peer-push-credit.js";
+import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
+import { getTransactionById, getTransactions } from "./transactions.js";
+import { acceptWithdrawalFromUri } from "./withdraw.js";
+
+const logger = new Logger("operations/testing.ts");
+
+interface MerchantBackendInfo {
+ baseUrl: string;
+ authToken?: string;
+}
+
+export interface WithdrawTestBalanceResult {
+ /**
+ * Transaction ID of the newly created withdrawal transaction.
+ */
+ transactionId: string;
+
+ /**
+ * Account of the user registered for the withdrawal.
+ */
+ accountPaytoUri: string;
+}
+
+export async function withdrawTestBalance(
+ ws: InternalWalletState,
+ req: WithdrawTestBalanceRequest,
+): Promise<WithdrawTestBalanceResult> {
+ const amount = req.amount;
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ const corebankApiBaseUrl = req.corebankApiBaseUrl;
+
+ logger.trace(
+ `Registering bank user, bank access base url ${corebankApiBaseUrl}`,
+ );
+
+ const corebankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
+
+ const bankUser = await corebankClient.createRandomBankUser();
+ logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
+
+ corebankClient.setAuth(bankUser);
+
+ const wresp = await corebankClient.createWithdrawalOperation(
+ bankUser.username,
+ amount,
+ );
+
+ const acceptResp = await acceptWithdrawalFromUri(ws, {
+ talerWithdrawUri: wresp.taler_withdraw_uri,
+ selectedExchange: exchangeBaseUrl,
+ forcedDenomSel: req.forcedDenomSel,
+ });
+
+ await corebankClient.confirmWithdrawalOperation(bankUser.username, {
+ withdrawalOperationId: wresp.withdrawal_id,
+ });
+
+ return {
+ transactionId: acceptResp.transactionId,
+ accountPaytoUri: bankUser.accountPaytoUri,
+ };
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+function getMerchantAuthHeader(m: MerchantBackendInfo): Record<string, string> {
+ if (m.authToken) {
+ return {
+ Authorization: `Bearer ${m.authToken}`,
+ };
+ }
+ return {};
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function refund(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ orderId: string,
+ reason: string,
+ refundAmount: string,
+): Promise<string> {
+ const reqUrl = new URL(
+ `private/orders/${orderId}/refund`,
+ merchantBackend.baseUrl,
+ );
+ const refundReq = {
+ order_id: orderId,
+ reason,
+ refund: refundAmount,
+ };
+ const resp = await http.fetch(reqUrl.href, {
+ method: "POST",
+ body: refundReq,
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ const refundUri = r.taler_refund_uri;
+ if (!refundUri) {
+ throw Error("no refund URI in response");
+ }
+ return refundUri;
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function createOrder(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ amount: string,
+ summary: string,
+ fulfillmentUrl: string,
+): Promise<{ orderId: string }> {
+ const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
+ const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
+ const orderReq = {
+ order: {
+ amount,
+ summary,
+ fulfillment_url: fulfillmentUrl,
+ refund_deadline: { t_s: t },
+ wire_transfer_deadline: { t_s: t },
+ },
+ };
+ const resp = await http.fetch(reqUrl, {
+ method: "POST",
+ body: orderReq,
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ const orderId = r.order_id;
+ if (!orderId) {
+ throw Error("no order id in response");
+ }
+ return { orderId };
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function checkPayment(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ orderId: string,
+): Promise<CheckPaymentResponse> {
+ const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
+ reqUrl.searchParams.set("order_id", orderId);
+ const resp = await http.fetch(reqUrl.href, {
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
+}
+
+interface MakePaymentResult {
+ orderId: string;
+ paymentTransactionId: string;
+}
+
+async function makePayment(
+ ws: InternalWalletState,
+ merchant: MerchantBackendInfo,
+ amount: string,
+ summary: string,
+): Promise<MakePaymentResult> {
+ const orderResp = await createOrder(
+ ws.http,
+ merchant,
+ amount,
+ summary,
+ "taler://fulfillment-success/thx",
+ );
+
+ logger.trace("created order with orderId", orderResp.orderId);
+
+ let paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
+
+ logger.trace("payment status", paymentStatus);
+
+ const talerPayUri = paymentStatus.taler_pay_uri;
+ if (!talerPayUri) {
+ throw Error("no taler://pay/ URI in payment response");
+ }
+
+ const preparePayResult = await preparePayForUri(ws, talerPayUri);
+
+ logger.trace("prepare pay result", preparePayResult);
+
+ if (preparePayResult.status != "payment-possible") {
+ throw Error("payment not possible");
+ }
+
+ const confirmPayResult = await confirmPay(
+ ws,
+ preparePayResult.transactionId,
+ undefined,
+ );
+
+ logger.trace("confirmPayResult", confirmPayResult);
+
+ paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
+
+ logger.trace("payment status after wallet payment:", paymentStatus);
+
+ if (paymentStatus.order_status !== "paid") {
+ throw Error("payment did not succeed");
+ }
+
+ return {
+ orderId: orderResp.orderId,
+ paymentTransactionId: preparePayResult.transactionId,
+ };
+}
+
+export async function runIntegrationTest(
+ ws: InternalWalletState,
+ args: IntegrationTestArgs,
+): Promise<void> {
+ logger.info("running test with arguments", args);
+
+ const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
+ const currency = parsedSpendAmount.currency;
+
+ logger.info("withdrawing test balance");
+ const withdrawRes1 = await withdrawTestBalance(ws, {
+ amount: args.amountToWithdraw,
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+ await waitUntilGivenTransactionsFinal(ws, [withdrawRes1.transactionId]);
+ logger.info("done withdrawing test balance");
+
+ const balance = await getBalances(ws);
+
+ logger.trace(JSON.stringify(balance, null, 2));
+
+ const myMerchant: MerchantBackendInfo = {
+ baseUrl: args.merchantBaseUrl,
+ authToken: args.merchantAuthToken,
+ };
+
+ const makePaymentRes = await makePayment(
+ ws,
+ myMerchant,
+ args.amountToSpend,
+ "hello world",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ makePaymentRes.paymentTransactionId,
+ );
+
+ logger.trace("withdrawing test balance for refund");
+ const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
+ const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
+ const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
+ const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
+
+ const withdrawRes2 = await withdrawTestBalance(ws, {
+ amount: Amounts.stringify(withdrawAmountTwo),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+
+ await waitUntilGivenTransactionsFinal(ws, [withdrawRes2.transactionId]);
+
+ const { orderId: refundOrderId } = await makePayment(
+ ws,
+ myMerchant,
+ Amounts.stringify(spendAmountTwo),
+ "order that will be refunded",
+ );
+
+ const refundUri = await refund(
+ ws.http,
+ myMerchant,
+ refundOrderId,
+ "test refund",
+ Amounts.stringify(refundAmount),
+ );
+
+ logger.trace("refund URI", refundUri);
+
+ const refundResp = await startRefundQueryForUri(ws, refundUri);
+
+ logger.trace("integration test: applied refund");
+
+ // Wait until the refund is done
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ refundResp.transactionId,
+ );
+
+ logger.trace("integration test: making payment after refund");
+
+ const paymentResp2 = await makePayment(
+ ws,
+ myMerchant,
+ Amounts.stringify(spendAmountThree),
+ "payment after refund",
+ );
+
+ logger.trace("integration test: make payment done");
+
+ await waitUntilGivenTransactionsFinal(ws, [
+ paymentResp2.paymentTransactionId,
+ ]);
+ await waitUntilGivenTransactionsFinal(
+ ws,
+ await getRefreshesForTransaction(ws, paymentResp2.paymentTransactionId),
+ );
+
+ logger.trace("integration test: all done!");
+}
+
+/**
+ * Wait until all transactions are in a final state.
+ */
+export async function waitUntilAllTransactionsFinal(
+ ws: InternalWalletState,
+): Promise<void> {
+ logger.info("waiting until all transactions are in a final state");
+ ws.ensureTaskLoopRunning();
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ break;
+ default:
+ p.resolve();
+ }
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const txs = await getTransactions(ws, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ let finished = true;
+ for (const tx of txs.transactions) {
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ finished = false;
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ break;
+ }
+ }
+ if (finished) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ cancelNotifs();
+ logger.info("done waiting until all transactions are in a final state");
+}
+
+/**
+ * Wait until all chosen transactions are in a final state.
+ */
+export async function waitUntilGivenTransactionsFinal(
+ ws: InternalWalletState,
+ transactionIds: string[],
+): Promise<void> {
+ logger.info(
+ `waiting until given ${transactionIds.length} transactions are in a final state`,
+ );
+ logger.info(`transaction IDs are: ${j2s(transactionIds)}`);
+ if (transactionIds.length === 0) {
+ return;
+ }
+ ws.ensureTaskLoopRunning();
+ const txIdSet = new Set(transactionIds);
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ if (!txIdSet.has(notif.transactionId)) {
+ return;
+ }
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ break;
+ default:
+ p.resolve();
+ }
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const txs = await getTransactions(ws, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ let finished = true;
+ for (const tx of txs.transactions) {
+ if (!txIdSet.has(tx.transactionId)) {
+ // Don't look at this transaction, we're not interested in it.
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ finished = false;
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ break;
+ }
+ }
+ if (finished) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ cancelNotifs();
+ logger.info("done waiting until given transactions are in a final state");
+}
+
+export async function waitUntilRefreshesDone(
+ ws: InternalWalletState,
+): Promise<void> {
+ logger.info("waiting until all refresh transactions are in a final state");
+ ws.ensureTaskLoopRunning();
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ break;
+ default:
+ p.resolve();
+ }
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const txs = await getTransactions(ws, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ let finished = true;
+ for (const tx of txs.transactions) {
+ if (tx.type !== TransactionType.Refresh) {
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ finished = false;
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ break;
+ }
+ }
+ if (finished) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ cancelNotifs();
+ logger.info("done waiting until all refreshes are in a final state");
+}
+
+async function waitUntilTransactionPendingReady(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ logger.info(`starting waiting for ${transactionId} to be in pending(ready)`);
+ ws.ensureTaskLoopRunning();
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ p.resolve();
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const tx = await getTransactionById(ws, {
+ transactionId,
+ });
+ if (
+ tx.txState.major == TransactionMajorState.Pending &&
+ tx.txState.minor === TransactionMinorState.Ready
+ ) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ logger.info(`done waiting for ${transactionId} to be in pending(ready)`);
+ cancelNotifs();
+}
+
+/**
+ * Wait until a transaction is in a particular state.
+ */
+export async function waitTransactionState(
+ ws: InternalWalletState,
+ transactionId: string,
+ txState: TransactionState,
+): Promise<void> {
+ logger.info(
+ `starting waiting for ${transactionId} to be in ${JSON.stringify(
+ txState,
+ )})`,
+ );
+ ws.ensureTaskLoopRunning();
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ p.resolve();
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const tx = await getTransactionById(ws, {
+ transactionId,
+ });
+ if (
+ tx.txState.major === txState.major &&
+ tx.txState.minor === txState.minor
+ ) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ logger.info(
+ `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
+ );
+ cancelNotifs();
+}
+
+export async function waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ await waitUntilGivenTransactionsFinal(ws, [transactionId]);
+ await waitUntilGivenTransactionsFinal(
+ ws,
+ await getRefreshesForTransaction(ws, transactionId),
+ );
+}
+
+export async function waitUntilTransactionFinal(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ await waitUntilGivenTransactionsFinal(ws, [transactionId]);
+}
+
+export async function runIntegrationTest2(
+ ws: InternalWalletState,
+ args: IntegrationTestV2Args,
+): Promise<void> {
+ ws.ensureTaskLoopRunning();
+ logger.info("running test with arguments", args);
+
+ const exchangeInfo = await fetchFreshExchange(ws, args.exchangeBaseUrl);
+
+ const currency = exchangeInfo.currency;
+
+ const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`);
+ const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);
+
+ logger.info("withdrawing test balance");
+ const withdrawalRes = await withdrawTestBalance(ws, {
+ amount: Amounts.stringify(amountToWithdraw),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+ await waitUntilTransactionFinal(ws, withdrawalRes.transactionId);
+ logger.info("done withdrawing test balance");
+
+ const balance = await getBalances(ws);
+
+ logger.trace(JSON.stringify(balance, null, 2));
+
+ const myMerchant: MerchantBackendInfo = {
+ baseUrl: args.merchantBaseUrl,
+ authToken: args.merchantAuthToken,
+ };
+
+ const makePaymentRes = await makePayment(
+ ws,
+ myMerchant,
+ Amounts.stringify(amountToSpend),
+ "hello world",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ makePaymentRes.paymentTransactionId,
+ );
+
+ logger.trace("withdrawing test balance for refund");
+ const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
+ const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
+ const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
+ const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
+
+ const withdrawalRes2 = await withdrawTestBalance(ws, {
+ amount: Amounts.stringify(withdrawAmountTwo),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+
+ // Wait until the withdraw is done
+ await waitUntilTransactionFinal(ws, withdrawalRes2.transactionId);
+
+ const { orderId: refundOrderId } = await makePayment(
+ ws,
+ myMerchant,
+ Amounts.stringify(spendAmountTwo),
+ "order that will be refunded",
+ );
+
+ const refundUri = await refund(
+ ws.http,
+ myMerchant,
+ refundOrderId,
+ "test refund",
+ Amounts.stringify(refundAmount),
+ );
+
+ logger.trace("refund URI", refundUri);
+
+ const refundResp = await startRefundQueryForUri(ws, refundUri);
+
+ logger.trace("integration test: applied refund");
+
+ // Wait until the refund is done
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ refundResp.transactionId,
+ );
+
+ logger.trace("integration test: making payment after refund");
+
+ const makePaymentRes2 = await makePayment(
+ ws,
+ myMerchant,
+ Amounts.stringify(spendAmountThree),
+ "payment after refund",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ makePaymentRes2.paymentTransactionId,
+ );
+
+ logger.trace("integration test: make payment done");
+
+ const peerPushInit = await initiatePeerPushDebit(ws, {
+ partialContractTerms: {
+ amount: `${currency}:1` as AmountString,
+ summary: "Payment Peer Push Test",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ await waitUntilTransactionPendingReady(ws, peerPushInit.transactionId);
+ const txDetails = await getTransactionById(ws, {
+ transactionId: peerPushInit.transactionId,
+ });
+
+ if (txDetails.type !== TransactionType.PeerPushDebit) {
+ throw Error("internal invariant failed");
+ }
+
+ if (!txDetails.talerUri) {
+ throw Error("internal invariant failed");
+ }
+
+ const peerPushCredit = await preparePeerPushCredit(ws, {
+ talerUri: txDetails.talerUri,
+ });
+
+ await confirmPeerPushCredit(ws, {
+ transactionId: peerPushCredit.transactionId,
+ });
+
+ const peerPullInit = await initiatePeerPullPayment(ws, {
+ partialContractTerms: {
+ amount: `${currency}:1` as AmountString,
+ summary: "Payment Peer Pull Test",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ await waitUntilTransactionPendingReady(ws, peerPullInit.transactionId);
+
+ const peerPullInc = await preparePeerPullDebit(ws, {
+ talerUri: peerPullInit.talerUri,
+ });
+
+ await confirmPeerPullDebit(ws, {
+ peerPullDebitId: peerPullInc.peerPullDebitId,
+ });
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ peerPullInc.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ peerPullInit.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ peerPushCredit.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ ws,
+ peerPushInit.transactionId,
+ );
+
+ let depositPayto = withdrawalRes.accountPaytoUri;
+
+ const parsedPayto = parsePaytoUri(depositPayto);
+ if (!parsedPayto) {
+ throw Error("invalid payto");
+ }
+
+ // Work around libeufin-bank bug where receiver-name is missing
+ if (!parsedPayto.params["receiver-name"]) {
+ depositPayto = addPaytoQueryParams(depositPayto, {
+ "receiver-name": "Test",
+ });
+ }
+
+ await createDepositGroup(ws, {
+ amount: `${currency}:5` as AmountString,
+ depositPaytoUri: depositPayto,
+ });
+
+ logger.trace("integration test: all done!");
+}
+
+export async function testPay(
+ ws: InternalWalletState,
+ args: TestPayArgs,
+): Promise<TestPayResult> {
+ logger.trace("creating order");
+ const merchant = {
+ authToken: args.merchantAuthToken,
+ baseUrl: args.merchantBaseUrl,
+ };
+ const orderResp = await createOrder(
+ ws.http,
+ merchant,
+ args.amount,
+ args.summary,
+ "taler://fulfillment-success/thank+you",
+ );
+ logger.trace("created new order with order ID", orderResp.orderId);
+ const checkPayResp = await checkPayment(ws.http, merchant, orderResp.orderId);
+ const talerPayUri = checkPayResp.taler_pay_uri;
+ if (!talerPayUri) {
+ console.error("fatal: no taler pay URI received from backend");
+ process.exit(1);
+ }
+ logger.trace("taler pay URI:", talerPayUri);
+ const result = await preparePayForUri(ws, talerPayUri);
+ if (result.status !== PreparePayResultType.PaymentPossible) {
+ throw Error(`unexpected prepare pay status: ${result.status}`);
+ }
+ const r = await confirmPay(
+ ws,
+ result.transactionId,
+ undefined,
+ args.forcedCoinSel,
+ );
+ if (r.type != ConfirmPayResultType.Done) {
+ throw Error("payment not done");
+ }
+ const purchase = await ws.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(result.proposalId);
+ });
+ checkLogicInvariant(!!purchase);
+ return {
+ payCoinSelection: purchase.payInfo?.payCoinSelection!,
+ };
+}
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
@@ -0,0 +1,2007 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ Amounts,
+ DepositTransactionTrackingState,
+ j2s,
+ Logger,
+ NotificationType,
+ OrderShortInfo,
+ PeerContractTerms,
+ RefundInfoShort,
+ RefundPaymentInfo,
+ ScopeType,
+ stringifyPayPullUri,
+ stringifyPayPushUri,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ Transaction,
+ TransactionByIdRequest,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionRecordFilter,
+ TransactionsRequest,
+ TransactionsResponse,
+ TransactionState,
+ TransactionType,
+ TransactionWithdrawal,
+ WalletContractData,
+ WithdrawalTransactionByURIRequest,
+ WithdrawalType,
+} from "@gnu-taler/taler-util";
+import {
+ DepositElementStatus,
+ DepositGroupRecord,
+ OperationRetryRecord,
+ PeerPullCreditRecord,
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentIncomingRecord,
+ PeerPushCreditStatus,
+ PeerPushDebitRecord,
+ PeerPushPaymentIncomingRecord,
+ PurchaseRecord,
+ PurchaseStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ RefundGroupRecord,
+ RewardRecord,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+} from "./db.js";
+import {
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ PeerPushDebitStatus,
+ timestampPreciseFromDb,
+ timestampProtocolFromDb,
+ WalletDbReadOnlyTransaction,
+} from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import { PendingTaskType, TaskId } from "./pending-types.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js";
+import {
+ constructTaskIdentifier,
+ TaskIdentifiers,
+ TransactionContext,
+} from "./common.js";
+import {
+ computeDepositTransactionActions,
+ computeDepositTransactionStatus,
+ DepositTransactionContext,
+} from "./deposits.js";
+import {
+ ExchangeWireDetails,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import {
+ computePayMerchantTransactionActions,
+ computePayMerchantTransactionState,
+ computeRefundTransactionState,
+ expectProposalDownload,
+ extractContractData,
+ PayMerchantTransactionContext,
+ RefundTransactionContext,
+} from "./pay-merchant.js";
+import {
+ computePeerPullCreditTransactionActions,
+ computePeerPullCreditTransactionState,
+ PeerPullCreditTransactionContext,
+} from "./pay-peer-pull-credit.js";
+import {
+ computePeerPullDebitTransactionActions,
+ computePeerPullDebitTransactionState,
+ PeerPullDebitTransactionContext,
+} from "./pay-peer-pull-debit.js";
+import {
+ computePeerPushCreditTransactionActions,
+ computePeerPushCreditTransactionState,
+ PeerPushCreditTransactionContext,
+} from "./pay-peer-push-credit.js";
+import {
+ computePeerPushDebitTransactionActions,
+ computePeerPushDebitTransactionState,
+ PeerPushDebitTransactionContext,
+} from "./pay-peer-push-debit.js";
+import {
+ computeRefreshTransactionActions,
+ computeRefreshTransactionState,
+ RefreshTransactionContext,
+} from "./refresh.js";
+import {
+ computeRewardTransactionStatus,
+ computeTipTransactionActions,
+ RewardTransactionContext,
+} from "./reward.js";
+import {
+ augmentPaytoUrisForWithdrawal,
+ computeWithdrawalTransactionActions,
+ computeWithdrawalTransactionStatus,
+ WithdrawTransactionContext,
+} from "./withdraw.js";
+
+const logger = new Logger("taler-wallet-core:transactions.ts");
+
+function shouldSkipCurrency(
+ transactionsRequest: TransactionsRequest | undefined,
+ currency: string,
+ exchangesInTransaction: string[],
+): boolean {
+ if (transactionsRequest?.scopeInfo) {
+ const sameCurrency = Amounts.isSameCurrency(
+ currency,
+ transactionsRequest.scopeInfo.currency,
+ );
+ switch (transactionsRequest.scopeInfo.type) {
+ case ScopeType.Global: {
+ return !sameCurrency;
+ }
+ case ScopeType.Exchange: {
+ return (
+ !sameCurrency ||
+ (exchangesInTransaction.length > 0 &&
+ !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url))
+ );
+ }
+ case ScopeType.Auditor: {
+ // same currency and same auditor
+ throw Error("filering balance in auditor scope is not implemented");
+ }
+ default:
+ assertUnreachable(transactionsRequest.scopeInfo);
+ }
+ }
+ // FIXME: remove next release
+ if (transactionsRequest?.currency) {
+ return (
+ transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
+ );
+ }
+ return false;
+}
+
+function shouldSkipSearch(
+ transactionsRequest: TransactionsRequest | undefined,
+ fields: string[],
+): boolean {
+ if (!transactionsRequest?.search) {
+ return false;
+ }
+ const needle = transactionsRequest.search.trim();
+ for (const f of fields) {
+ if (f.indexOf(needle) >= 0) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Fallback order of transactions that have the same timestamp.
+ */
+const txOrder: { [t in TransactionType]: number } = {
+ [TransactionType.Withdrawal]: 1,
+ [TransactionType.Reward]: 2,
+ [TransactionType.Payment]: 3,
+ [TransactionType.PeerPullCredit]: 4,
+ [TransactionType.PeerPullDebit]: 5,
+ [TransactionType.PeerPushCredit]: 6,
+ [TransactionType.PeerPushDebit]: 7,
+ [TransactionType.Refund]: 8,
+ [TransactionType.Deposit]: 9,
+ [TransactionType.Refresh]: 10,
+ [TransactionType.Recoup]: 11,
+ [TransactionType.InternalWithdrawal]: 12,
+};
+
+export async function getTransactionById(
+ ws: InternalWalletState,
+ req: TransactionByIdRequest,
+): Promise<Transaction> {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+
+ if (!parsedTx) {
+ throw Error("invalid transaction ID");
+ }
+
+ switch (parsedTx.tag) {
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal: {
+ const withdrawalGroupId = parsedTx.withdrawalGroupId;
+ return await ws.db.runReadWriteTx(
+ [
+ "withdrawalGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+
+ if (!withdrawalGroupRecord) throw Error("not found");
+
+ const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
+ const ort = await tx.operationRetries.get(opId);
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ ort,
+ );
+ }
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ return buildTransactionForManualWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ },
+ );
+ }
+
+ case TransactionType.Recoup:
+ throw new Error("not yet supported");
+
+ case TransactionType.Payment: {
+ const proposalId = parsedTx.proposalId;
+ return await ws.db.runReadWriteTx(
+ [
+ "purchases",
+ "tombstones",
+ "operationRetries",
+ "contractTerms",
+ "refundGroups",
+ ],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) throw Error("not found");
+ const download = await expectProposalDownload(ws, purchase, tx);
+ const contractData = download.contractData;
+ const payOpId = TaskIdentifiers.forPay(purchase);
+ const payRetryRecord = await tx.operationRetries.get(payOpId);
+
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+
+ return buildTransactionForPurchase(
+ purchase,
+ contractData,
+ refunds,
+ payRetryRecord,
+ );
+ },
+ );
+ }
+
+ case TransactionType.Refresh: {
+ // FIXME: We should return info about the refresh here!;
+ const refreshGroupId = parsedTx.refreshGroupId;
+ return await ws.db.runReadOnlyTx(
+ ["refreshGroups", "operationRetries"],
+ async (tx) => {
+ const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroupRec) {
+ throw Error("not found");
+ }
+ const retries = await tx.operationRetries.get(
+ TaskIdentifiers.forRefresh(refreshGroupRec),
+ );
+ return buildTransactionForRefresh(refreshGroupRec, retries);
+ },
+ );
+ }
+
+ case TransactionType.Reward: {
+ const tipId = parsedTx.walletRewardId;
+ return await ws.db.runReadWriteTx(
+ ["rewards", "operationRetries"],
+ async (tx) => {
+ const tipRecord = await tx.rewards.get(tipId);
+ if (!tipRecord) throw Error("not found");
+
+ const retries = await tx.operationRetries.get(
+ TaskIdentifiers.forTipPickup(tipRecord),
+ );
+ return buildTransactionForTip(tipRecord, retries);
+ },
+ );
+ }
+
+ case TransactionType.Deposit: {
+ const depositGroupId = parsedTx.depositGroupId;
+ return await ws.db.runReadWriteTx(
+ ["depositGroups", "operationRetries"],
+ async (tx) => {
+ const depositRecord = await tx.depositGroups.get(depositGroupId);
+ if (!depositRecord) throw Error("not found");
+
+ const retries = await tx.operationRetries.get(
+ TaskIdentifiers.forDeposit(depositRecord),
+ );
+ return buildTransactionForDeposit(depositRecord, retries);
+ },
+ );
+ }
+
+ case TransactionType.Refund: {
+ return await ws.db.runReadOnlyTx(
+ ["refundGroups", "purchases", "operationRetries", "contractTerms"],
+ async (tx) => {
+ const refundRecord = await tx.refundGroups.get(
+ parsedTx.refundGroupId,
+ );
+ if (!refundRecord) {
+ throw Error("not found");
+ }
+ const contractData = await lookupMaybeContractData(
+ tx,
+ refundRecord?.proposalId,
+ );
+ return buildTransactionForRefund(refundRecord, contractData);
+ },
+ );
+ }
+ case TransactionType.PeerPullDebit: {
+ return await ws.db.runReadWriteTx(
+ ["peerPullDebit", "contractTerms"],
+ async (tx) => {
+ const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
+ if (!debit) throw Error("not found");
+ const contractTermsRec = await tx.contractTerms.get(
+ debit.contractTermsHash,
+ );
+ if (!contractTermsRec)
+ throw Error("contract terms for peer-pull-debit not found");
+ return buildTransactionForPullPaymentDebit(
+ debit,
+ contractTermsRec.contractTermsRaw,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPushDebit: {
+ return await ws.db.runReadWriteTx(
+ ["peerPushDebit", "contractTerms"],
+ async (tx) => {
+ const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
+ if (!debit) throw Error("not found");
+ const ct = await tx.contractTerms.get(debit.contractTermsHash);
+ checkDbInvariant(!!ct);
+ return buildTransactionForPushPaymentDebit(
+ debit,
+ ct.contractTermsRaw,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPushCredit: {
+ const peerPushCreditId = parsedTx.peerPushCreditId;
+ return await ws.db.runReadWriteTx(
+ [
+ "peerPushCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushInc) throw Error("not found");
+ const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
+ checkDbInvariant(!!ct);
+
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pushInc.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ return buildTransactionForPeerPushCredit(
+ pushInc,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPullCredit: {
+ const pursePub = parsedTx.pursePub;
+ return await ws.db.runReadWriteTx(
+ [
+ "peerPullCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const pushInc = await tx.peerPullCredit.get(pursePub);
+ if (!pushInc) throw Error("not found");
+ const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
+ checkDbInvariant(!!ct);
+
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pushInc.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId =
+ TaskIdentifiers.forPeerPullPaymentInitiation(pushInc);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ return buildTransactionForPeerPullCredit(
+ pushInc,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ );
+ },
+ );
+ }
+ }
+}
+
+function buildTransactionForPushPaymentDebit(
+ pi: PeerPushDebitRecord,
+ contractTerms: PeerContractTerms,
+ ort?: OperationRetryRecord,
+): Transaction {
+ let talerUri: string | undefined = undefined;
+ switch (pi.status) {
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ talerUri = stringifyPayPushUri({
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ contractPriv: pi.contractPriv,
+ });
+ }
+ const txState = computePeerPushDebitTransactionState(pi);
+ return {
+ type: TransactionType.PeerPushDebit,
+ txState,
+ txActions: computePeerPushDebitTransactionActions(pi),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost))
+ : pi.totalCost,
+ amountRaw: pi.amount,
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ info: {
+ expiration: contractTerms.purse_expiration,
+ summary: contractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(pi.timestampCreated),
+ talerUri,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pi.pursePub,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForPullPaymentDebit(
+ pi: PeerPullPaymentIncomingRecord,
+ contractTerms: PeerContractTerms,
+ ort?: OperationRetryRecord,
+): Transaction {
+ const txState = computePeerPullDebitTransactionState(pi);
+ return {
+ type: TransactionType.PeerPullDebit,
+ txState,
+ txActions: computePeerPullDebitTransactionActions(pi),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(pi.amount))
+ : pi.coinSel?.totalCost
+ ? pi.coinSel?.totalCost
+ : Amounts.stringify(pi.amount),
+ amountRaw: Amounts.stringify(pi.amount),
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ info: {
+ expiration: contractTerms.purse_expiration,
+ summary: contractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(pi.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: pi.peerPullDebitId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForPeerPullCredit(
+ pullCredit: PeerPullCreditRecord,
+ pullCreditOrt: OperationRetryRecord | undefined,
+ peerContractTerms: PeerContractTerms,
+ wsr: WithdrawalGroupRecord | undefined,
+ wsrOrt: OperationRetryRecord | undefined,
+): Transaction {
+ if (wsr) {
+ if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) {
+ throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`);
+ }
+ /**
+ * FIXME: this should be handled in the withdrawal process.
+ * PeerPull withdrawal fails until reserve have funds but it is not
+ * an error from the user perspective.
+ */
+ const silentWithdrawalErrorForInvoice =
+ wsrOrt?.lastError &&
+ wsrOrt.lastError.code ===
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
+ Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => {
+ return (
+ e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
+ e.httpStatusCode === 409
+ );
+ });
+ const txState = computePeerPullCreditTransactionState(pullCredit);
+ return {
+ type: TransactionType.PeerPullCredit,
+ txState,
+ txActions: computePeerPullCreditTransactionActions(pullCredit),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
+ : Amounts.stringify(wsr.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wsr.instructedAmount),
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ talerUri: stringifyPayPullUri({
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ contractPriv: wsr.wgInfo.contractPriv,
+ }),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullCredit.pursePub,
+ }),
+ kycUrl: pullCredit.kycUrl,
+ ...(wsrOrt?.lastError
+ ? {
+ error: silentWithdrawalErrorForInvoice
+ ? undefined
+ : wsrOrt.lastError,
+ }
+ : {}),
+ };
+ }
+
+ const txState = computePeerPullCreditTransactionState(pullCredit);
+ return {
+ type: TransactionType.PeerPullCredit,
+ txState,
+ txActions: computePeerPullCreditTransactionActions(pullCredit),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
+ : Amounts.stringify(pullCredit.estimatedAmountEffective),
+ amountRaw: Amounts.stringify(peerContractTerms.amount),
+ exchangeBaseUrl: pullCredit.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ talerUri: stringifyPayPullUri({
+ exchangeBaseUrl: pullCredit.exchangeBaseUrl,
+ contractPriv: pullCredit.contractPriv,
+ }),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullCredit.pursePub,
+ }),
+ kycUrl: pullCredit.kycUrl,
+ ...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
+ };
+}
+
+function buildTransactionForPeerPushCredit(
+ pushInc: PeerPushPaymentIncomingRecord,
+ pushOrt: OperationRetryRecord | undefined,
+ peerContractTerms: PeerContractTerms,
+ wsr: WithdrawalGroupRecord | undefined,
+ wsrOrt: OperationRetryRecord | undefined,
+): Transaction {
+ if (wsr) {
+ if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
+ throw Error("invalid withdrawal group type for push payment credit");
+ }
+
+ const txState = computePeerPushCreditTransactionState(pushInc);
+ return {
+ type: TransactionType.PeerPushCredit,
+ txState,
+ txActions: computePeerPushCreditTransactionActions(pushInc),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
+ : Amounts.stringify(wsr.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wsr.instructedAmount),
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(wsr.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: pushInc.peerPushCreditId,
+ }),
+ kycUrl: pushInc.kycUrl,
+ ...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
+ };
+ }
+
+ const txState = computePeerPushCreditTransactionState(pushInc);
+ return {
+ type: TransactionType.PeerPushCredit,
+ txState,
+ txActions: computePeerPushCreditTransactionActions(pushInc),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
+ : // FIXME: This is wrong, needs to consider fees!
+ Amounts.stringify(peerContractTerms.amount),
+ amountRaw: Amounts.stringify(peerContractTerms.amount),
+ exchangeBaseUrl: pushInc.exchangeBaseUrl,
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ kycUrl: pushInc.kycUrl,
+ timestamp: timestampPreciseFromDb(pushInc.timestamp),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: pushInc.peerPushCreditId,
+ }),
+ ...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}),
+ };
+}
+
+function buildTransactionForBankIntegratedWithdraw(
+ wgRecord: WithdrawalGroupRecord,
+ ort?: OperationRetryRecord,
+): TransactionWithdrawal {
+ if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
+ throw Error("");
+
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+ return {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reservePub: wgRecord.reservePub,
+ bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: wgRecord.withdrawalGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function isUnsuccessfulTransaction(state: TransactionState): boolean {
+ return (
+ state.major === TransactionMajorState.Aborted ||
+ state.major === TransactionMajorState.Expired ||
+ state.major === TransactionMajorState.Aborting ||
+ state.major === TransactionMajorState.Deleted ||
+ state.major === TransactionMajorState.Failed
+ );
+}
+
+function buildTransactionForManualWithdraw(
+ withdrawalGroup: WithdrawalGroupRecord,
+ exchangeDetails: ExchangeWireDetails,
+ ort?: OperationRetryRecord,
+): TransactionWithdrawal {
+ if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
+ throw Error("");
+
+ const plainPaytoUris =
+ exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+
+ const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ withdrawalGroup.reservePub,
+ withdrawalGroup.instructedAmount,
+ );
+
+ const txState = computeWithdrawalTransactionStatus(withdrawalGroup);
+
+ return {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(withdrawalGroup),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(
+ Amounts.zeroOfAmount(withdrawalGroup.instructedAmount),
+ )
+ : Amounts.stringify(withdrawalGroup.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris,
+ exchangeCreditAccountDetails:
+ withdrawalGroup.wgInfo.exchangeCreditAccounts,
+ reserveIsReady:
+ withdrawalGroup.status === WithdrawalGroupStatus.Done ||
+ withdrawalGroup.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: withdrawalGroup.kycUrl,
+ exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForRefund(
+ refundRecord: RefundGroupRecord,
+ maybeContractData: WalletContractData | undefined,
+): Transaction {
+ let paymentInfo: RefundPaymentInfo | undefined = undefined;
+
+ if (maybeContractData) {
+ paymentInfo = {
+ merchant: maybeContractData.merchant,
+ summary: maybeContractData.summary,
+ summary_i18n: maybeContractData.summaryI18n,
+ };
+ }
+
+ const txState = computeRefundTransactionState(refundRecord);
+ return {
+ type: TransactionType.Refund,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
+ : refundRecord.amountEffective,
+ amountRaw: refundRecord.amountRaw,
+ refundedTransactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: refundRecord.proposalId,
+ }),
+ timestamp: timestampPreciseFromDb(refundRecord.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId: refundRecord.refundGroupId,
+ }),
+ txState,
+ txActions: [],
+ paymentInfo,
+ };
+}
+
+function buildTransactionForRefresh(
+ refreshGroupRecord: RefreshGroupRecord,
+ ort?: OperationRetryRecord,
+): Transaction {
+ const inputAmount = Amounts.sumOrZero(
+ refreshGroupRecord.currency,
+ refreshGroupRecord.inputPerCoin,
+ ).amount;
+ const outputAmount = Amounts.sumOrZero(
+ refreshGroupRecord.currency,
+ refreshGroupRecord.expectedOutputPerCoin,
+ ).amount;
+ const txState = computeRefreshTransactionState(refreshGroupRecord);
+ return {
+ type: TransactionType.Refresh,
+ txState,
+ txActions: computeRefreshTransactionActions(refreshGroupRecord),
+ refreshReason: refreshGroupRecord.reason,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(inputAmount))
+ : Amounts.stringify(Amounts.sub(outputAmount, inputAmount).amount),
+ amountRaw: Amounts.stringify(
+ Amounts.zeroOfCurrency(refreshGroupRecord.currency),
+ ),
+ refreshInputAmount: Amounts.stringify(inputAmount),
+ refreshOutputAmount: Amounts.stringify(outputAmount),
+ originatingTransactionId: refreshGroupRecord.originatingTransactionId,
+ timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: refreshGroupRecord.refreshGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForDeposit(
+ dg: DepositGroupRecord,
+ ort?: OperationRetryRecord,
+): Transaction {
+ let deposited = true;
+ for (const d of dg.statusPerCoin) {
+ if (d == DepositElementStatus.DepositPending) {
+ deposited = false;
+ }
+ }
+
+ const trackingState: DepositTransactionTrackingState[] = [];
+
+ for (const ts of Object.values(dg.trackingState ?? {})) {
+ trackingState.push({
+ amountRaw: ts.amountRaw,
+ timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
+ wireFee: ts.wireFee,
+ wireTransferId: ts.wireTransferId,
+ });
+ }
+
+ const txState = computeDepositTransactionStatus(dg);
+ return {
+ type: TransactionType.Deposit,
+ txState,
+ txActions: computeDepositTransactionActions(dg),
+ amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
+ : Amounts.stringify(dg.totalPayCost),
+ timestamp: timestampPreciseFromDb(dg.timestampCreated),
+ targetPaytoUri: dg.wire.payto_uri,
+ wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: dg.depositGroupId,
+ }),
+ wireTransferProgress:
+ (100 *
+ dg.statusPerCoin.reduce(
+ (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
+ 0,
+ )) /
+ dg.statusPerCoin.length,
+ depositGroupId: dg.depositGroupId,
+ trackingState,
+ deposited,
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForTip(
+ tipRecord: RewardRecord,
+ ort?: OperationRetryRecord,
+): Transaction {
+ checkLogicInvariant(!!tipRecord.acceptedTimestamp);
+
+ const txState = computeRewardTransactionStatus(tipRecord);
+ return {
+ type: TransactionType.Reward,
+ txState,
+ txActions: computeTipTransactionActions(tipRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(tipRecord.rewardAmountEffective))
+ : Amounts.stringify(tipRecord.rewardAmountEffective),
+ amountRaw: Amounts.stringify(tipRecord.rewardAmountRaw),
+ timestamp: timestampPreciseFromDb(tipRecord.acceptedTimestamp),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Reward,
+ walletRewardId: tipRecord.walletRewardId,
+ }),
+ merchantBaseUrl: tipRecord.merchantBaseUrl,
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+async function lookupMaybeContractData(
+ tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>,
+ proposalId: string,
+): Promise<WalletContractData | undefined> {
+ let contractData: WalletContractData | undefined = undefined;
+ const purchaseTx = await tx.purchases.get(proposalId);
+ if (purchaseTx && purchaseTx.download) {
+ const download = purchaseTx.download;
+ const contractTermsRecord = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTermsRecord) {
+ return;
+ }
+ contractData = extractContractData(
+ contractTermsRecord?.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ );
+ }
+
+ return contractData;
+}
+
+async function buildTransactionForPurchase(
+ purchaseRecord: PurchaseRecord,
+ contractData: WalletContractData,
+ refundsInfo: RefundGroupRecord[],
+ ort?: OperationRetryRecord,
+): Promise<Transaction> {
+ const zero = Amounts.zeroOfAmount(contractData.amount);
+
+ const info: OrderShortInfo = {
+ merchant: contractData.merchant,
+ orderId: contractData.orderId,
+ summary: contractData.summary,
+ summary_i18n: contractData.summaryI18n,
+ contractTermsHash: contractData.contractTermsHash,
+ };
+
+ if (contractData.fulfillmentUrl !== "") {
+ info.fulfillmentUrl = contractData.fulfillmentUrl;
+ }
+
+ const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
+ amountEffective: r.amountEffective,
+ amountRaw: r.amountRaw,
+ timestamp: TalerPreciseTimestamp.round(
+ timestampPreciseFromDb(r.timestampCreated),
+ ),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId: r.refundGroupId,
+ }),
+ }));
+
+ const timestamp = purchaseRecord.timestampAccept;
+ checkDbInvariant(!!timestamp);
+ checkDbInvariant(!!purchaseRecord.payInfo);
+
+ const txState = computePayMerchantTransactionState(purchaseRecord);
+ return {
+ type: TransactionType.Payment,
+ txState,
+ txActions: computePayMerchantTransactionActions(purchaseRecord),
+ amountRaw: Amounts.stringify(contractData.amount),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(zero)
+ : Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
+ totalRefundRaw: Amounts.stringify(zero), // FIXME!
+ totalRefundEffective: Amounts.stringify(zero), // FIXME!
+ refundPending:
+ purchaseRecord.refundAmountAwaiting === undefined
+ ? undefined
+ : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
+ refunds,
+ posConfirmation: purchaseRecord.posConfirmation,
+ timestamp: timestampPreciseFromDb(timestamp),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchaseRecord.proposalId,
+ }),
+ proposalId: purchaseRecord.proposalId,
+ info,
+ refundQueryActive:
+ purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+export async function getWithdrawalTransactionByUri(
+ ws: InternalWalletState,
+ request: WithdrawalTransactionByURIRequest,
+): Promise<TransactionWithdrawal | undefined> {
+ return await ws.db.runReadWriteTx(
+ ["withdrawalGroups", "exchangeDetails", "exchanges", "operationRetries"],
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ request.talerWithdrawUri,
+ );
+
+ if (!withdrawalGroupRecord) {
+ return undefined;
+ }
+
+ const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
+ const ort = await tx.operationRetries.get(opId);
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ ort,
+ );
+ }
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ return buildTransactionForManualWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ },
+ );
+}
+
+/**
+ * Retrieve the full event history for this wallet.
+ */
+export async function getTransactions(
+ ws: InternalWalletState,
+ transactionsRequest?: TransactionsRequest,
+): Promise<TransactionsResponse> {
+ const transactions: Transaction[] = [];
+
+ const filter: TransactionRecordFilter = {};
+ if (transactionsRequest?.filterByState) {
+ filter.onlyState = transactionsRequest.filterByState;
+ }
+
+ await ws.db.runReadOnlyTx(
+ [
+ "coins",
+ "denominations",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ "peerPullDebit",
+ "peerPushDebit",
+ "peerPushCredit",
+ "peerPullCredit",
+ "planchets",
+ "purchases",
+ "contractTerms",
+ "recoupGroups",
+ "rewards",
+ "tombstones",
+ "withdrawalGroups",
+ "refreshGroups",
+ "refundGroups",
+ ],
+ async (tx) => {
+ await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
+ const amount = Amounts.parseOrThrow(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw),
+ );
+ });
+
+ await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
+ const amount = Amounts.parseOrThrow(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ if (
+ pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
+ pi.status !== PeerPullDebitRecordStatus.Done
+ ) {
+ // FIXME: Why?!
+ return;
+ }
+
+ const contractTermsRec = await tx.contractTerms.get(
+ pi.contractTermsHash,
+ );
+ if (!contractTermsRec) {
+ return;
+ }
+
+ transactions.push(
+ buildTransactionForPullPaymentDebit(
+ pi,
+ contractTermsRec.contractTermsRaw,
+ ),
+ );
+ });
+
+ await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
+ if (!pi.currency) {
+ // Legacy transaction
+ return;
+ }
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx)
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ if (pi.status === PeerPushCreditStatus.DialogProposed) {
+ // We don't report proposed push credit transactions, user needs
+ // to scan URI again and confirm to see it.
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pi.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPeerPushCredit(
+ pi,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ ),
+ );
+ });
+
+ await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
+ const currency = Amounts.currencyOf(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pi.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPeerPullCredit(
+ pi,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ ),
+ );
+ });
+
+ await iterRecordsForRefund(tx, filter, async (refundGroup) => {
+ const currency = Amounts.currencyOf(refundGroup.amountRaw);
+
+ const exchangesInTx: string[] = [];
+ const p = await tx.purchases.get(refundGroup.proposalId);
+ if (!p || !p.payInfo) return; //refund with no payment
+
+ // FIXME: This is very slow, should become obsolete with materialized transactions.
+ for (const cp of p.payInfo.payCoinSelection.coinPubs) {
+ const c = await tx.coins.get(cp);
+ if (c?.exchangeBaseUrl) {
+ exchangesInTx.push(c.exchangeBaseUrl);
+ }
+ }
+
+ if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
+ return;
+ }
+ const contractData = await lookupMaybeContractData(
+ tx,
+ refundGroup.proposalId,
+ );
+ transactions.push(buildTransactionForRefund(refundGroup, contractData));
+ });
+
+ await iterRecordsForRefresh(tx, filter, async (rg) => {
+ const exchangesInTx = rg.infoPerExchange
+ ? Object.keys(rg.infoPerExchange)
+ : [];
+ if (
+ shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx)
+ ) {
+ return;
+ }
+ let required = false;
+ const opId = TaskIdentifiers.forRefresh(rg);
+ if (transactionsRequest?.includeRefreshes) {
+ required = true;
+ } else if (rg.operationStatus !== RefreshOperationStatus.Finished) {
+ const ort = await tx.operationRetries.get(opId);
+ if (ort) {
+ required = true;
+ }
+ }
+ if (required) {
+ const ort = await tx.operationRetries.get(opId);
+ transactions.push(buildTransactionForRefresh(rg, ort));
+ }
+ });
+
+ await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
+ const exchangesInTx = [wsr.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ Amounts.currencyOf(wsr.rawWithdrawalAmount),
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+
+ const opId = TaskIdentifiers.forWithdrawal(wsr);
+ const ort = await tx.operationRetries.get(opId);
+
+ switch (wsr.wgInfo.withdrawalType) {
+ case WithdrawalRecordType.PeerPullCredit:
+ // Will be reported by the corresponding p2p transaction.
+ // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
+ // FIXME: Still report if requested with verbose option?
+ return;
+ case WithdrawalRecordType.PeerPushCredit:
+ // Will be reported by the corresponding p2p transaction.
+ // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
+ // FIXME: Still report if requested with verbose option?
+ return;
+ case WithdrawalRecordType.BankIntegrated:
+ transactions.push(
+ buildTransactionForBankIntegratedWithdraw(wsr, ort),
+ );
+ return;
+ case WithdrawalRecordType.BankManual: {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ wsr.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ // FIXME: report somehow
+ return;
+ }
+
+ transactions.push(
+ buildTransactionForManualWithdraw(wsr, exchangeDetails, ort),
+ );
+ return;
+ }
+ case WithdrawalRecordType.Recoup:
+ // FIXME: Do we also report a transaction here?
+ return;
+ }
+ });
+
+ await iterRecordsForDeposit(tx, filter, async (dg) => {
+ const amount = Amounts.parseOrThrow(dg.amount);
+ const exchangesInTx = dg.infoPerExchange
+ ? Object.keys(dg.infoPerExchange)
+ : [];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ const opId = TaskIdentifiers.forDeposit(dg);
+ const retryRecord = await tx.operationRetries.get(opId);
+
+ transactions.push(buildTransactionForDeposit(dg, retryRecord));
+ });
+
+ await iterRecordsForPurchase(tx, filter, async (purchase) => {
+ const download = purchase.download;
+ if (!download) {
+ return;
+ }
+ if (!purchase.payInfo) {
+ return;
+ }
+
+ const exchangesInTx: string[] = [];
+ for (const cp of purchase.payInfo.payCoinSelection.coinPubs) {
+ const c = await tx.coins.get(cp);
+ if (c?.exchangeBaseUrl) {
+ exchangesInTx.push(c.exchangeBaseUrl);
+ }
+ }
+
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ download.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ const contractTermsRecord = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTermsRecord) {
+ return;
+ }
+ if (
+ shouldSkipSearch(transactionsRequest, [
+ contractTermsRecord?.contractTermsRaw?.summary || "",
+ ])
+ ) {
+ return;
+ }
+
+ const contractData = extractContractData(
+ contractTermsRecord?.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ );
+
+ const payOpId = TaskIdentifiers.forPay(purchase);
+ const payRetryRecord = await tx.operationRetries.get(payOpId);
+
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+
+ transactions.push(
+ await buildTransactionForPurchase(
+ purchase,
+ contractData,
+ refunds,
+ payRetryRecord,
+ ),
+ );
+ });
+
+ //FIXME: remove rewards
+ await iterRecordsForReward(tx, filter, async (tipRecord) => {
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ Amounts.parseOrThrow(tipRecord.rewardAmountRaw).currency,
+ [tipRecord.exchangeBaseUrl],
+ )
+ ) {
+ return;
+ }
+ if (!tipRecord.acceptedTimestamp) {
+ return;
+ }
+ const opId = TaskIdentifiers.forTipPickup(tipRecord);
+ const retryRecord = await tx.operationRetries.get(opId);
+ transactions.push(buildTransactionForTip(tipRecord, retryRecord));
+ });
+ //ends REMOVE REWARDS
+ },
+ );
+
+ // One-off checks, because of a bug where the wallet previously
+ // did not migrate the DB correctly and caused these amounts
+ // to be missing sometimes.
+ for (let tx of transactions) {
+ if (!tx.amountEffective) {
+ logger.warn(`missing amountEffective in ${j2s(tx)}`);
+ }
+ if (!tx.amountRaw) {
+ logger.warn(`missing amountRaw in ${j2s(tx)}`);
+ }
+ if (!tx.timestamp) {
+ logger.warn(`missing timestamp in ${j2s(tx)}`);
+ }
+ }
+
+ const isPending = (x: Transaction) =>
+ x.txState.major === TransactionMajorState.Pending ||
+ x.txState.major === TransactionMajorState.Aborting ||
+ x.txState.major === TransactionMajorState.Dialog;
+
+ const txPending = transactions.filter((x) => isPending(x));
+ const txNotPending = transactions.filter((x) => !isPending(x));
+
+ let sortSign: number;
+ if (transactionsRequest?.sort == "descending") {
+ sortSign = -1;
+ } else {
+ sortSign = 1;
+ }
+
+ const txCmp = (h1: Transaction, h2: Transaction) => {
+ // Order transactions by timestamp. Newest transactions come first.
+ const tsCmp = AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
+ AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
+ );
+ // If the timestamp is exactly the same, order by transaction type.
+ if (tsCmp === 0) {
+ return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
+ }
+ return sortSign * tsCmp;
+ };
+
+ txPending.sort(txCmp);
+ txNotPending.sort(txCmp);
+
+ return { transactions: [...txNotPending, ...txPending] };
+}
+
+export type ParsedTransactionIdentifier =
+ | { tag: TransactionType.Deposit; depositGroupId: string }
+ | { tag: TransactionType.Payment; proposalId: string }
+ | { tag: TransactionType.PeerPullDebit; peerPullDebitId: string }
+ | { tag: TransactionType.PeerPullCredit; pursePub: string }
+ | { tag: TransactionType.PeerPushCredit; peerPushCreditId: string }
+ | { tag: TransactionType.PeerPushDebit; pursePub: string }
+ | { tag: TransactionType.Refresh; refreshGroupId: string }
+ | { tag: TransactionType.Refund; refundGroupId: string }
+ | { tag: TransactionType.Reward; walletRewardId: string }
+ | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
+ | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string }
+ | { tag: TransactionType.Recoup; recoupGroupId: string };
+
+export function constructTransactionIdentifier(
+ pTxId: ParsedTransactionIdentifier,
+): TransactionIdStr {
+ switch (pTxId.tag) {
+ case TransactionType.Deposit:
+ return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr;
+ case TransactionType.Payment:
+ return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr;
+ case TransactionType.PeerPullCredit:
+ return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
+ case TransactionType.PeerPullDebit:
+ return `txn:${pTxId.tag}:${pTxId.peerPullDebitId}` as TransactionIdStr;
+ case TransactionType.PeerPushCredit:
+ return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` as TransactionIdStr;
+ case TransactionType.PeerPushDebit:
+ return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
+ case TransactionType.Refresh:
+ return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
+ case TransactionType.Refund:
+ return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
+ case TransactionType.Reward:
+ return `txn:${pTxId.tag}:${pTxId.walletRewardId}` as TransactionIdStr;
+ case TransactionType.Withdrawal:
+ return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
+ case TransactionType.InternalWithdrawal:
+ return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
+ case TransactionType.Recoup:
+ return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr;
+ default:
+ assertUnreachable(pTxId);
+ }
+}
+
+/**
+ * Parse a transaction identifier string into a typed, structured representation.
+ */
+export function parseTransactionIdentifier(
+ transactionId: string,
+): ParsedTransactionIdentifier | undefined {
+ const txnParts = transactionId.split(":");
+
+ if (txnParts.length < 3) {
+ throw Error("id should have al least 3 parts separated by ':'");
+ }
+
+ const [prefix, type, ...rest] = txnParts;
+
+ if (prefix != "txn") {
+ throw Error("invalid transaction identifier");
+ }
+
+ switch (type) {
+ case TransactionType.Deposit:
+ return { tag: TransactionType.Deposit, depositGroupId: rest[0] };
+ case TransactionType.Payment:
+ return { tag: TransactionType.Payment, proposalId: rest[0] };
+ case TransactionType.PeerPullCredit:
+ return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] };
+ case TransactionType.PeerPullDebit:
+ return {
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: rest[0],
+ };
+ case TransactionType.PeerPushCredit:
+ return {
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: rest[0],
+ };
+ case TransactionType.PeerPushDebit:
+ return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] };
+ case TransactionType.Refresh:
+ return { tag: TransactionType.Refresh, refreshGroupId: rest[0] };
+ case TransactionType.Refund:
+ return {
+ tag: TransactionType.Refund,
+ refundGroupId: rest[0],
+ };
+ case TransactionType.Reward:
+ return {
+ tag: TransactionType.Reward,
+ walletRewardId: rest[0],
+ };
+ case TransactionType.Withdrawal:
+ return {
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: rest[0],
+ };
+ default:
+ return undefined;
+ }
+}
+
+function maybeTaskFromTransaction(transactionId: string): TaskId | undefined {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+
+ if (!parsedTx) {
+ throw Error("invalid transaction identifier");
+ }
+
+ // FIXME: We currently don't cancel active long-polling tasks here.
+
+ switch (parsedTx.tag) {
+ case TransactionType.PeerPullCredit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: parsedTx.pursePub,
+ });
+ case TransactionType.Deposit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: parsedTx.depositGroupId,
+ });
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: parsedTx.withdrawalGroupId,
+ });
+ case TransactionType.Payment:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: parsedTx.proposalId,
+ });
+ case TransactionType.Reward:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.RewardPickup,
+ walletRewardId: parsedTx.walletRewardId,
+ });
+ case TransactionType.Refresh:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: parsedTx.refreshGroupId,
+ });
+ case TransactionType.PeerPullDebit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: parsedTx.peerPullDebitId,
+ });
+ case TransactionType.PeerPushCredit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId: parsedTx.peerPushCreditId,
+ });
+ case TransactionType.PeerPushDebit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub: parsedTx.pursePub,
+ });
+ case TransactionType.Refund:
+ // Nothing to do for a refund transaction.
+ return undefined;
+ case TransactionType.Recoup:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: parsedTx.recoupGroupId,
+ });
+ default:
+ assertUnreachable(parsedTx);
+ }
+}
+
+/**
+ * Immediately retry the underlying operation
+ * of a transaction.
+ */
+export async function retryTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ logger.info(`resetting retry timeout for ${transactionId}`);
+ const taskId = maybeTaskFromTransaction(transactionId);
+ if (taskId) {
+ ws.taskScheduler.resetTaskRetries(taskId);
+ }
+}
+
+async function getContextForTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<TransactionContext> {
+ const tx = parseTransactionIdentifier(transactionId);
+ if (!tx) {
+ throw Error("invalid transaction ID");
+ }
+ switch (tx.tag) {
+ case TransactionType.Deposit:
+ return new DepositTransactionContext(ws, tx.depositGroupId);
+ case TransactionType.Refresh:
+ return new RefreshTransactionContext(ws, tx.refreshGroupId);
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal:
+ return new WithdrawTransactionContext(ws, tx.withdrawalGroupId);
+ case TransactionType.Payment:
+ return new PayMerchantTransactionContext(ws, tx.proposalId);
+ case TransactionType.PeerPullCredit:
+ return new PeerPullCreditTransactionContext(ws, tx.pursePub);
+ case TransactionType.PeerPushDebit:
+ return new PeerPushDebitTransactionContext(ws, tx.pursePub);
+ case TransactionType.PeerPullDebit:
+ return new PeerPullDebitTransactionContext(ws, tx.peerPullDebitId);
+ case TransactionType.PeerPushCredit:
+ return new PeerPushCreditTransactionContext(ws, tx.peerPushCreditId);
+ case TransactionType.Refund:
+ return new RefundTransactionContext(ws, tx.refundGroupId);
+ case TransactionType.Reward:
+ return new RewardTransactionContext(ws, tx.walletRewardId);
+ case TransactionType.Recoup:
+ throw new Error("not yet supported");
+ //return new RecoupTransactionContext(ws, tx.recoupGroupId);
+ default:
+ assertUnreachable(tx);
+ }
+}
+
+/**
+ * Suspends a pending transaction, stopping any associated network activities,
+ * but with a chance of trying again at a later time. This could be useful if
+ * a user needs to save battery power or bandwidth and an operation is expected
+ * to take longer (such as a backup, recovery or very large withdrawal operation).
+ */
+export async function suspendTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(ws, transactionId);
+ await ctx.suspendTransaction();
+}
+
+export async function failTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(ws, transactionId);
+ await ctx.failTransaction();
+}
+
+/**
+ * Resume a suspended transaction.
+ */
+export async function resumeTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(ws, transactionId);
+ await ctx.resumeTransaction();
+}
+
+/**
+ * Permanently delete a transaction based on the transaction ID.
+ */
+export async function deleteTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(ws, transactionId);
+ await ctx.deleteTransaction();
+}
+
+export async function abortTransaction(
+ ws: InternalWalletState,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(ws, transactionId);
+ await ctx.abortTransaction();
+}
+
+export interface TransitionInfo {
+ oldTxState: TransactionState;
+ newTxState: TransactionState;
+}
+
+/**
+ * Notify of a state transition if necessary.
+ */
+export function notifyTransition(
+ ws: InternalWalletState,
+ transactionId: string,
+ transitionInfo: TransitionInfo | undefined,
+ experimentalUserData: any = undefined,
+): void {
+ if (
+ transitionInfo &&
+ !(
+ transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
+ transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
+ )
+ ) {
+ ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ oldTxState: transitionInfo.oldTxState,
+ newTxState: transitionInfo.newTxState,
+ transactionId,
+ experimentalUserData,
+ });
+ }
+}
+
+/**
+ * Iterate refresh records based on a filter.
+ */
+async function iterRecordsForRefresh(
+ tx: WalletDbReadOnlyTransaction<["refreshGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RefreshGroupRecord) => Promise<void>,
+): Promise<void> {
+ let refreshGroups: RefreshGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ RefreshOperationStatus.Pending,
+ RefreshOperationStatus.Suspended,
+ );
+ refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
+ }
+
+ for (const r of refreshGroups) {
+ await f(r);
+ }
+}
+
+async function iterRecordsForWithdrawal(
+ tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: WithdrawalGroupRecord) => Promise<void>,
+): Promise<void> {
+ let withdrawalGroupRecords: WithdrawalGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ withdrawalGroupRecords =
+ await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ withdrawalGroupRecords =
+ await tx.withdrawalGroups.indexes.byStatus.getAll();
+ }
+ for (const wgr of withdrawalGroupRecords) {
+ await f(wgr);
+ }
+}
+
+async function iterRecordsForDeposit(
+ tx: WalletDbReadOnlyTransaction<["depositGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: DepositGroupRecord) => Promise<void>,
+): Promise<void> {
+ let dgs: DepositGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ dgs = await tx.depositGroups.indexes.byStatus.getAll();
+ }
+
+ for (const dg of dgs) {
+ await f(dg);
+ }
+}
+
+async function iterRecordsForReward(
+ tx: WalletDbReadOnlyTransaction<["rewards"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RewardRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.rewards.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.rewards.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForRefund(
+ tx: WalletDbReadOnlyTransaction<["refundGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RefundGroupRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.refundGroups.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPurchase(
+ tx: WalletDbReadOnlyTransaction<["purchases"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PurchaseRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPullCredit(
+ tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPullCreditRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPullDebit(
+ tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPullPaymentIncomingRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPushDebit(
+ tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPushDebitRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPushCredit(
+ tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPushPaymentIncomingRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts
@@ -64,8 +64,8 @@ import { InternalWalletState } from "../internal-wallet-state.js";
import {
getMerchantPaymentBalanceDetails,
getPeerPaymentBalanceDetailsInTx,
-} from "../operations/balance.js";
-import { getAutoRefreshExecuteThreshold } from "../operations/common.js";
+} from "../balance.js";
+import { getAutoRefreshExecuteThreshold } from "../common.js";
import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
const logger = new Logger("coinSelection.ts");
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -144,8 +144,8 @@ import {
BackupInfo,
RemoveBackupProviderRequest,
RunBackupCycleRequest,
-} from "./operations/backup/index.js";
-import { MerchantPaymentBalanceDetails } from "./operations/balance.js";
+} from "./backup/index.js";
+import { MerchantPaymentBalanceDetails } from "./balance.js";
import { PendingOperationsResponse as PendingTasksResponse } from "./pending-types.js";
export enum WalletApiOperation {
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -162,7 +162,7 @@ import {
getUserAttentions,
getUserAttentionsUnreadCount,
markAttentionRequestAsRead,
-} from "./operations/attention.js";
+} from "./attention.js";
import {
addBackupProvider,
codecForAddBackupProviderRequest,
@@ -174,14 +174,14 @@ import {
removeBackupProvider,
runBackupCycle,
setWalletDeviceId,
-} from "./operations/backup/index.js";
-import { getBalanceDetail, getBalances } from "./operations/balance.js";
+} from "./backup/index.js";
+import { getBalanceDetail, getBalances } from "./balance.js";
import {
computeDepositTransactionStatus,
createDepositGroup,
generateDepositGroupTxId,
prepareDepositGroup,
-} from "./operations/deposits.js";
+} from "./deposits.js";
import {
acceptExchangeTermsOfService,
addPresetExchangeEntry,
@@ -192,7 +192,7 @@ import {
getExchangeTos,
listExchanges,
lookupExchangeByUri,
-} from "./operations/exchanges.js";
+} from "./exchanges.js";
import {
computePayMerchantTransactionState,
computeRefundTransactionState,
@@ -203,33 +203,33 @@ import {
sharePayment,
startQueryRefund,
startRefundQueryForUri,
-} from "./operations/pay-merchant.js";
+} from "./pay-merchant.js";
import {
checkPeerPullPaymentInitiation,
computePeerPullCreditTransactionState,
initiatePeerPullPayment,
-} from "./operations/pay-peer-pull-credit.js";
+} from "./pay-peer-pull-credit.js";
import {
computePeerPullDebitTransactionState,
confirmPeerPullDebit,
preparePeerPullDebit,
-} from "./operations/pay-peer-pull-debit.js";
+} from "./pay-peer-pull-debit.js";
import {
computePeerPushCreditTransactionState,
confirmPeerPushCredit,
preparePeerPushCredit,
-} from "./operations/pay-peer-push-credit.js";
+} from "./pay-peer-push-credit.js";
import {
checkPeerPushDebit,
computePeerPushDebitTransactionState,
initiatePeerPushDebit,
-} from "./operations/pay-peer-push-debit.js";
-import { createRecoupGroup } from "./operations/recoup.js";
+} from "./pay-peer-push-debit.js";
+import { createRecoupGroup } from "./recoup.js";
import {
computeRefreshTransactionState,
forceRefresh,
-} from "./operations/refresh.js";
-import { computeRewardTransactionStatus } from "./operations/reward.js";
+} from "./refresh.js";
+import { computeRewardTransactionStatus } from "./reward.js";
import {
runIntegrationTest,
runIntegrationTest2,
@@ -238,7 +238,7 @@ import {
waitUntilAllTransactionsFinal,
waitUntilRefreshesDone,
withdrawTestBalance,
-} from "./operations/testing.js";
+} from "./testing.js";
import {
abortTransaction,
constructTransactionIdentifier,
@@ -251,14 +251,14 @@ import {
resumeTransaction,
retryTransaction,
suspendTransaction,
-} from "./operations/transactions.js";
+} from "./transactions.js";
import {
acceptWithdrawalFromUri,
computeWithdrawalTransactionStatus,
createManualWithdrawal,
getExchangeWithdrawalInfo,
getWithdrawalDetailsForUri,
-} from "./operations/withdraw.js";
+} from "./withdraw.js";
import { PendingOperationsResponse } from "./pending-types.js";
import { TaskScheduler } from "./shepherd.js";
import { assertUnreachable } from "./util/assertUnreachable.js";
@@ -270,7 +270,7 @@ import {
getMaxPeerPushAmount,
} from "./util/instructedAmountConversion.js";
import { checkDbInvariant } from "./util/invariants.js";
-import { DbAccess } from "./util/query.js";
+import { DbAccess } from "./query.js";
import { TimerAPI, TimerGroup } from "./util/timer.js";
import {
WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts
@@ -0,0 +1,370 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountString, Amounts, DenomKeyType } from "@gnu-taler/taler-util";
+import test from "ava";
+import {
+ DenominationRecord,
+ DenominationVerificationStatus,
+ timestampProtocolToDb,
+} from "./db.js";
+import { selectWithdrawalDenominations } from "./util/coinSelection.js";
+
+test("withdrawal selection bug repro", (t) => {
+ const amount = {
+ currency: "KUDOS",
+ fraction: 43000000,
+ value: 23,
+ };
+
+ const denoms: DenominationRecord[] = [
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ currency: "KUDOS",
+ value: "KUDOS:1000" as AmountString,
+ listIssueDate: timestampProtocolToDb({ t_s: 0 }),
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
+ age_mask: 0,
+ },
+
+ denomPubHash:
+ "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:10" as AmountString,
+ currency: "KUDOS",
+ listIssueDate: timestampProtocolToDb({ t_s: 0 }),
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:5" as AmountString,
+ currency: "KUDOS",
+ listIssueDate: timestampProtocolToDb({ t_s: 0 }),
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
+ age_mask: 0,
+ },
+
+ denomPubHash:
+ "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:1" as AmountString,
+ currency: "KUDOS",
+ listIssueDate: timestampProtocolToDb({ t_s: 0 }),
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 10000000,
+ value: 0,
+ }),
+ currency: "KUDOS",
+ listIssueDate: timestampProtocolToDb({ t_s: 0 }),
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:2" as AmountString,
+ currency: "KUDOS",
+ listIssueDate: timestampProtocolToDb({ t_s: 0 }),
+ },
+ ];
+
+ const res = selectWithdrawalDenominations(amount, denoms);
+
+ t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
+ t.pass();
+});
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -0,0 +1,2754 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2024 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AcceptManualWithdrawalResult,
+ AcceptWithdrawalResponse,
+ AgeRestriction,
+ AmountJson,
+ AmountLike,
+ AmountString,
+ Amounts,
+ BankWithdrawDetails,
+ CancellationToken,
+ CoinStatus,
+ CurrencySpecification,
+ DenomKeyType,
+ DenomSelectionState,
+ Duration,
+ ExchangeBatchWithdrawRequest,
+ ExchangeUpdateStatus,
+ ExchangeWireAccount,
+ ExchangeWithdrawBatchResponse,
+ ExchangeWithdrawRequest,
+ ExchangeWithdrawResponse,
+ ExchangeWithdrawalDetails,
+ ForcedDenomSel,
+ HttpStatusCode,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ TalerBankIntegrationHttpClient,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ UnblindedSignature,
+ WalletNotification,
+ WithdrawUriInfoResponse,
+ WithdrawalExchangeAccountDetails,
+ addPaytoQueryParams,
+ canonicalizeBaseUrl,
+ codecForAny,
+ codecForCashinConversionResponse,
+ codecForConversionBankConfig,
+ codecForExchangeWithdrawBatchResponse,
+ codecForReserveStatus,
+ codecForWalletKycUuid,
+ codecForWithdrawOperationStatusResponse,
+ encodeCrock,
+ getErrorDetailFromException,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parseWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpResponse,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ KycPendingInfo,
+ PlanchetRecord,
+ PlanchetStatus,
+ WalletStoresV1,
+ WgInfo,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+} from "./db.js";
+import {
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ isWithdrawableDenom,
+ timestampPreciseToDb,
+} from "./index.js";
+import { InternalWalletState } from "./internal-wallet-state.js";
+import {
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+ makeCoinAvailable,
+ makeCoinsVisible,
+} from "./common.js";
+import { PendingTaskType, TaskId } from "./pending-types.js";
+import { assertUnreachable } from "./util/assertUnreachable.js";
+import {
+ selectForcedWithdrawalDenominations,
+ selectWithdrawalDenominations,
+} from "./util/coinSelection.js";
+import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js";
+import { DbAccess } from "./query.js";
+import {
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+} from "./versions.js";
+import {
+ ReadyExchangeSummary,
+ fetchFreshExchange,
+ getExchangePaytoUri,
+ getExchangeWireDetailsInTx,
+ listExchanges,
+ markExchangeUsed,
+} from "./exchanges.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+
+/**
+ * Logger for this file.
+ */
+const logger = new Logger("operations/withdraw.ts");
+
+export class WithdrawTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskId;
+
+ constructor(
+ public ws: InternalWalletState,
+ public withdrawalGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId } = this;
+ await ws.db.runReadWriteTx(
+ ["withdrawalGroups", "tombstones"],
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ return;
+ }
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId, transactionId, taskId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return;
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.PendingReady:
+ newStatus = WithdrawalGroupStatus.SuspendedReady;
+ break;
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
+ break;
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
+ break;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
+ break;
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
+ break;
+ case WithdrawalGroupStatus.PendingKyc:
+ newStatus = WithdrawalGroupStatus.SuspendedKyc;
+ break;
+ case WithdrawalGroupStatus.PendingAml:
+ newStatus = WithdrawalGroupStatus.SuspendedAml;
+ break;
+ default:
+ logger.warn(
+ `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
+ );
+ }
+ if (newStatus != null) {
+ const oldTxState = computeWithdrawalTransactionStatus(wg);
+ wg.status = newStatus;
+ const newTxState = computeWithdrawalTransactionStatus(wg);
+ await tx.withdrawalGroups.put(wg);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(taskId);
+ notifyTransition(ws, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId, transactionId, taskId } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return;
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ newStatus = WithdrawalGroupStatus.AbortingBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedAml:
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ newStatus = WithdrawalGroupStatus.AbortedExchange;
+ break;
+ case WithdrawalGroupStatus.PendingReady:
+ newStatus = WithdrawalGroupStatus.SuspendedReady;
+ break;
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ // No transition needed, but not an error
+ break;
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ // Not allowed
+ throw Error("abort not allowed in current state");
+ default:
+ assertUnreachable(wg.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computeWithdrawalTransactionStatus(wg);
+ wg.status = newStatus;
+ const newTxState = computeWithdrawalTransactionStatus(wg);
+ await tx.withdrawalGroups.put(wg);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(taskId);
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return;
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedReady:
+ newStatus = WithdrawalGroupStatus.PendingReady;
+ break;
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ newStatus = WithdrawalGroupStatus.AbortingBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
+ break;
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedAml:
+ newStatus = WithdrawalGroupStatus.PendingAml;
+ break;
+ case WithdrawalGroupStatus.SuspendedKyc:
+ newStatus = WithdrawalGroupStatus.PendingKyc;
+ break;
+ default:
+ logger.warn(
+ `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
+ );
+ }
+ if (newStatus != null) {
+ const oldTxState = computeWithdrawalTransactionStatus(wg);
+ wg.status = newStatus;
+ const newTxState = computeWithdrawalTransactionStatus(wg);
+ await tx.withdrawalGroups.put(wg);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { ws, withdrawalGroupId, transactionId, taskId: retryTag } = this;
+ const stateUpdate = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return;
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.FailedAbortingBank;
+ break;
+ default:
+ break;
+ }
+ if (newStatus != null) {
+ const oldTxState = computeWithdrawalTransactionStatus(wg);
+ wg.status = newStatus;
+ const newTxState = computeWithdrawalTransactionStatus(wg);
+ await tx.withdrawalGroups.put(wg);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ ws.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(ws, transactionId, stateUpdate);
+ ws.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+/**
+ * Compute the DD37 transaction state of a withdrawal transaction
+ * from the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionStatus(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionState {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case WithdrawalGroupStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankRegisterReserve,
+ };
+ case WithdrawalGroupStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.WithdrawCoins,
+ };
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ };
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ };
+ case WithdrawalGroupStatus.AbortingBank:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ };
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BankRegisterReserve,
+ };
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ };
+ case WithdrawalGroupStatus.SuspendedReady: {
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.WithdrawCoins,
+ };
+ }
+ case WithdrawalGroupStatus.PendingAml: {
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AmlRequired,
+ };
+ }
+ case WithdrawalGroupStatus.PendingKyc: {
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ }
+ case WithdrawalGroupStatus.SuspendedAml: {
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AmlRequired,
+ };
+ }
+ case WithdrawalGroupStatus.SuspendedKyc: {
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.KycRequired,
+ };
+ }
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.AbortingBank,
+ };
+ case WithdrawalGroupStatus.AbortedExchange:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Exchange,
+ };
+
+ case WithdrawalGroupStatus.AbortedBank:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Bank,
+ };
+ }
+}
+
+/**
+ * Compute DD37 transaction actions for a withdrawal transaction
+ * based on the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionActions(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionAction[] {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.Done:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingReady:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.AbortingBank:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedReady:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingAml:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedAml:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.AbortedExchange:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.AbortedBank:
+ return [TransactionAction.Delete];
+ }
+}
+
+/**
+ * Get information about a withdrawal from
+ * a taler://withdraw URI by asking the bank.
+ *
+ * FIXME: Move into bank client.
+ */
+export async function getBankWithdrawalInfo(
+ http: HttpRequestLibrary,
+ talerWithdrawUri: string,
+): Promise<BankWithdrawDetails> {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse URL ${talerWithdrawUri}`);
+ }
+
+ const bankApi = new TalerBankIntegrationHttpClient(
+ uriResult.bankIntegrationApiBaseUrl,
+ http,
+ );
+
+ const { body: config } = await bankApi.getConfig();
+
+ if (!bankApi.isCompatible(config.version)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ bankProtocolVersion: config.version,
+ walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ },
+ "bank integration protocol version not compatible with wallet",
+ );
+ }
+
+ const resp = await bankApi.getWithdrawalOperationById(
+ uriResult.withdrawalOperationId,
+ );
+
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ const { body: status } = resp;
+
+ logger.info(`bank withdrawal operation status: ${j2s(status)}`);
+
+ return {
+ operationId: uriResult.withdrawalOperationId,
+ apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
+ amount: Amounts.parseOrThrow(status.amount),
+ confirmTransferUrl: status.confirm_transfer_url,
+ senderWire: status.sender_wire,
+ suggestedExchange: status.suggested_exchange,
+ wireTypes: status.wire_types,
+ status: status.status,
+ };
+}
+
+/**
+ * Return denominations that can potentially used for a withdrawal.
+ */
+async function getCandidateWithdrawalDenoms(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ return await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
+ return getCandidateWithdrawalDenomsTx(ws, tx, exchangeBaseUrl, currency);
+ });
+}
+
+export async function getCandidateWithdrawalDenomsTx(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ // FIXME: Use denom groups instead of querying all denominations!
+ const allDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ return allDenoms
+ .filter((d) => d.currency === currency)
+ .filter((d) => isWithdrawableDenom(d, ws.config.testing.denomselAllowLate));
+}
+
+/**
+ * Generate a planchet for a coin index in a withdrawal group.
+ * Does not actually withdraw the coin yet.
+ *
+ * Split up so that we can parallelize the crypto, but serialize
+ * the exchange requests per reserve.
+ */
+async function processPlanchetGenerate(
+ ws: InternalWalletState,
+ withdrawalGroup: WithdrawalGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ let planchet = await ws.db.runReadOnlyTx(["planchets"], async (tx) => {
+ return tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ });
+ if (planchet) {
+ return;
+ }
+ let ci = 0;
+ let maybeDenomPubHash: string | undefined;
+ for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
+ const d = withdrawalGroup.denomsSel.selectedDenoms[di];
+ if (coinIdx >= ci && coinIdx < ci + d.count) {
+ maybeDenomPubHash = d.denomPubHash;
+ break;
+ }
+ ci += d.count;
+ }
+ if (!maybeDenomPubHash) {
+ throw Error("invariant violated");
+ }
+ const denomPubHash = maybeDenomPubHash;
+
+ const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
+ return ws.getDenomInfo(
+ ws,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ denomPubHash,
+ );
+ });
+ checkDbInvariant(!!denom);
+ const r = await ws.cryptoApi.createPlanchet({
+ denomPub: denom.denomPub,
+ feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
+ reservePriv: withdrawalGroup.reservePriv,
+ reservePub: withdrawalGroup.reservePub,
+ value: Amounts.parseOrThrow(denom.value),
+ coinIndex: coinIdx,
+ secretSeed: withdrawalGroup.secretSeed,
+ restrictAge: withdrawalGroup.restrictAge,
+ });
+ const newPlanchet: PlanchetRecord = {
+ blindingKey: r.blindingKey,
+ coinEv: r.coinEv,
+ coinEvHash: r.coinEvHash,
+ coinIdx,
+ coinPriv: r.coinPriv,
+ coinPub: r.coinPub,
+ denomPubHash: r.denomPubHash,
+ planchetStatus: PlanchetStatus.Pending,
+ withdrawSig: r.withdrawSig,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ ageCommitmentProof: r.ageCommitmentProof,
+ lastError: undefined,
+ };
+ await ws.db.runReadWriteTx(["planchets"], async (tx) => {
+ const p = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (p) {
+ planchet = p;
+ return;
+ }
+ await tx.planchets.put(newPlanchet);
+ planchet = newPlanchet;
+ });
+}
+
+interface WithdrawalRequestBatchArgs {
+ coinStartIndex: number;
+
+ batchSize: number;
+}
+
+interface WithdrawalBatchResult {
+ coinIdxs: number[];
+ batchResp: ExchangeWithdrawBatchResponse;
+}
+
+enum AmlStatus {
+ normal = 0,
+ pending = 1,
+ fronzen = 2,
+}
+
+/**
+ * Transition a withdrawal transaction with a (new) KYC URL.
+ *
+ * Emit a notification for the (self-)transition.
+ */
+async function transitionKycUrlUpdate(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ kycUrl: string,
+): Promise<void> {
+ let notificationKycUrl: string | undefined = undefined;
+ const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
+ const transactionId = ctx.transactionId;
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg2) {
+ return;
+ }
+ const oldTxState = computeWithdrawalTransactionStatus(wg2);
+ switch (wg2.status) {
+ case WithdrawalGroupStatus.PendingReady: {
+ wg2.kycUrl = kycUrl;
+ notificationKycUrl = kycUrl;
+ await tx.withdrawalGroups.put(wg2);
+ const newTxState = computeWithdrawalTransactionStatus(wg2);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ if (transitionInfo) {
+ // Always notify, even on self-transition, as the KYC URL might have changed.
+ ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ oldTxState: transitionInfo.oldTxState,
+ newTxState: transitionInfo.newTxState,
+ transactionId,
+ experimentalUserData: notificationKycUrl,
+ });
+ }
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+async function handleKycRequired(
+ ws: InternalWalletState,
+ withdrawalGroup: WithdrawalGroupRecord,
+ resp: HttpResponse,
+ startIdx: number,
+ requestCoinIdxs: number[],
+): Promise<void> {
+ logger.info("withdrawal requires KYC");
+ const respJson = await resp.json();
+ const uuidResp = codecForWalletKycUuid().decode(respJson);
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
+ const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
+ const userType = "individual";
+ const kycInfo: KycPendingInfo = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await ws.http.fetch(url.href, {
+ method: "GET",
+ });
+ let kycUrl: string;
+ let amlStatus: AmlStatus | undefined;
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return;
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ kycUrl = kycStatus.kyc_url;
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ amlStatus = kycStatus.aml_status;
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+
+ let notificationKycUrl: string | undefined = undefined;
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["planchets", "withdrawalGroups"],
+ async (tx) => {
+ for (let i = startIdx; i < requestCoinIdxs.length; i++) {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ requestCoinIdxs[i],
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ planchet.planchetStatus = PlanchetStatus.KycRequired;
+ await tx.planchets.put(planchet);
+ }
+ const wg2 = await tx.withdrawalGroups.get(
+ withdrawalGroup.withdrawalGroupId,
+ );
+ if (!wg2) {
+ return;
+ }
+ const oldTxState = computeWithdrawalTransactionStatus(wg2);
+ switch (wg2.status) {
+ case WithdrawalGroupStatus.PendingReady: {
+ wg2.kycPending = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ wg2.kycUrl = kycUrl;
+ wg2.status =
+ amlStatus === AmlStatus.normal || amlStatus === undefined
+ ? WithdrawalGroupStatus.PendingKyc
+ : amlStatus === AmlStatus.pending
+ ? WithdrawalGroupStatus.PendingAml
+ : amlStatus === AmlStatus.fronzen
+ ? WithdrawalGroupStatus.SuspendedAml
+ : assertUnreachable(amlStatus);
+
+ notificationKycUrl = kycUrl;
+
+ await tx.withdrawalGroups.put(wg2);
+ const newTxState = computeWithdrawalTransactionStatus(wg2);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo, notificationKycUrl);
+}
+
+/**
+ * Send the withdrawal request for a generated planchet to the exchange.
+ *
+ * The verification of the response is done asynchronously to enable parallelism.
+ */
+async function processPlanchetExchangeBatchRequest(
+ ws: InternalWalletState,
+ wgContext: WithdrawalGroupContext,
+ args: WithdrawalRequestBatchArgs,
+): Promise<WithdrawalBatchResult> {
+ const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
+ logger.info(
+ `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
+ );
+
+ const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
+ // Indices of coins that are included in the batch request
+ const requestCoinIdxs: number[] = [];
+
+ await ws.db.runReadOnlyTx(["planchets", "denominations"], async (tx) => {
+ for (
+ let coinIdx = args.coinStartIndex;
+ coinIdx < args.coinStartIndex + args.batchSize &&
+ coinIdx < wgContext.numPlanchets;
+ coinIdx++
+ ) {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ continue;
+ }
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
+
+ if (!denom) {
+ logger.error("db inconsistent: denom for planchet not found");
+ continue;
+ }
+
+ const planchetReq: ExchangeWithdrawRequest = {
+ denom_pub_hash: planchet.denomPubHash,
+ reserve_sig: planchet.withdrawSig,
+ coin_ev: planchet.coinEv,
+ };
+ batchReq.planchets.push(planchetReq);
+ requestCoinIdxs.push(coinIdx);
+ }
+ });
+
+ if (batchReq.planchets.length == 0) {
+ logger.warn("empty withdrawal batch");
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+
+ async function storeCoinError(e: any, coinIdx: number): Promise<void> {
+ const errDetail = getErrorDetailFromException(e);
+ logger.trace("withdrawal request failed", e);
+ logger.trace(String(e));
+ await ws.db.runReadWriteTx(["planchets"], async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ planchet.lastError = errDetail;
+ await tx.planchets.put(planchet);
+ });
+ }
+
+ // FIXME: handle individual error codes better!
+
+ const reqUrl = new URL(
+ `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
+ withdrawalGroup.exchangeBaseUrl,
+ ).href;
+
+ try {
+ const resp = await ws.http.fetch(reqUrl, {
+ method: "POST",
+ body: batchReq,
+ });
+ if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ await handleKycRequired(ws, withdrawalGroup, resp, 0, requestCoinIdxs);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ const r = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWithdrawBatchResponse(),
+ );
+ return {
+ coinIdxs: requestCoinIdxs,
+ batchResp: r,
+ };
+ } catch (e) {
+ await storeCoinError(e, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+}
+
+async function processPlanchetVerifyAndStoreCoin(
+ ws: InternalWalletState,
+ wgContext: WithdrawalGroupContext,
+ coinIdx: number,
+ resp: ExchangeWithdrawResponse,
+): Promise<void> {
+ const withdrawalGroup = wgContext.wgRecord;
+ logger.trace(`checking and storing planchet idx=${coinIdx}`);
+ const d = await ws.db.runReadOnlyTx(
+ ["planchets", "denominations"],
+ async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ return;
+ }
+ const denomInfo = await ws.getDenomInfo(
+ ws,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
+ if (!denomInfo) {
+ return;
+ }
+ return {
+ planchet,
+ denomInfo,
+ exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
+ };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
+ });
+
+ const { planchet, denomInfo } = d;
+
+ const planchetDenomPub = denomInfo.denomPub;
+ if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
+ }
+
+ let evSig = resp.ev_sig;
+ if (!(evSig.cipher === DenomKeyType.Rsa)) {
+ throw Error("unsupported cipher");
+ }
+
+ const denomSigRsa = await ws.cryptoApi.rsaUnblind({
+ bk: planchet.blindingKey,
+ blindedSig: evSig.blinded_rsa_signature,
+ pk: planchetDenomPub.rsa_public_key,
+ });
+
+ const isValid = await ws.cryptoApi.rsaVerify({
+ hm: planchet.coinPub,
+ pk: planchetDenomPub.rsa_public_key,
+ sig: denomSigRsa.sig,
+ });
+
+ if (!isValid) {
+ await ws.db.runReadWriteTx(["planchets"], async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ planchet.lastError = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
+ {},
+ "invalid signature from the exchange after unblinding",
+ );
+ await tx.planchets.put(planchet);
+ });
+ return;
+ }
+
+ let denomSig: UnblindedSignature;
+ if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
+ denomSig = {
+ cipher: planchetDenomPub.cipher,
+ rsa_signature: denomSigRsa.sig,
+ };
+ } else {
+ throw Error("unsupported cipher");
+ }
+
+ const coin: CoinRecord = {
+ blindingKey: planchet.blindingKey,
+ coinPriv: planchet.coinPriv,
+ coinPub: planchet.coinPub,
+ denomPubHash: planchet.denomPubHash,
+ denomSig,
+ coinEvHash: planchet.coinEvHash,
+ exchangeBaseUrl: d.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Withdraw,
+ coinIndex: coinIdx,
+ reservePub: withdrawalGroup.reservePub,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ },
+ sourceTransactionId: transactionId,
+ maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
+ ageCommitmentProof: planchet.ageCommitmentProof,
+ spendAllocation: undefined,
+ };
+
+ const planchetCoinPub = planchet.coinPub;
+
+ wgContext.planchetsFinished.add(planchet.coinPub);
+
+ await ws.db.runReadWriteTx(
+ ["planchets", "coins", "coinAvailability", "denominations"],
+ async (tx) => {
+ const p = await tx.planchets.get(planchetCoinPub);
+ if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ return;
+ }
+ p.planchetStatus = PlanchetStatus.WithdrawalDone;
+ p.lastError = undefined;
+ await tx.planchets.put(p);
+ await makeCoinAvailable(ws, tx, coin);
+ },
+ );
+}
+
+/**
+ * Make sure that denominations that currently can be used for withdrawal
+ * are validated, and the result of validation is stored in the database.
+ */
+async function updateWithdrawalDenoms(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ logger.trace(
+ `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
+ );
+ const exchangeDetails = await ws.db.runReadOnlyTx(
+ ["exchanges", "exchangeDetails"],
+ async (tx) => {
+ return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
+ },
+ );
+ if (!exchangeDetails) {
+ logger.error("exchange details not available");
+ throw Error(`exchange ${exchangeBaseUrl} details not available`);
+ }
+ // First do a pass where the validity of candidate denominations
+ // is checked and the result is stored in the database.
+ logger.trace("getting candidate denominations");
+ const denominations = await getCandidateWithdrawalDenoms(
+ ws,
+ exchangeBaseUrl,
+ exchangeDetails.currency,
+ );
+ logger.trace(`got ${denominations.length} candidate denominations`);
+ const batchSize = 500;
+ let current = 0;
+
+ while (current < denominations.length) {
+ const updatedDenominations: DenominationRecord[] = [];
+ // Do a batch of batchSize
+ for (
+ let batchIdx = 0;
+ batchIdx < batchSize && current < denominations.length;
+ batchIdx++, current++
+ ) {
+ const denom = denominations[current];
+ if (
+ denom.verificationStatus === DenominationVerificationStatus.Unverified
+ ) {
+ logger.trace(
+ `Validating denomination (${current + 1}/${
+ denominations.length
+ }) signature of ${denom.denomPubHash}`,
+ );
+ let valid = false;
+ if (ws.config.testing.insecureTrustExchange) {
+ valid = true;
+ } else {
+ const res = await ws.cryptoApi.isValidDenom({
+ denom,
+ masterPub: exchangeDetails.masterPublicKey,
+ });
+ valid = res.valid;
+ }
+ logger.trace(`Done validating ${denom.denomPubHash}`);
+ if (!valid) {
+ logger.warn(
+ `Signature check for denomination h=${denom.denomPubHash} failed`,
+ );
+ denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
+ } else {
+ denom.verificationStatus =
+ DenominationVerificationStatus.VerifiedGood;
+ }
+ updatedDenominations.push(denom);
+ }
+ }
+ if (updatedDenominations.length > 0) {
+ logger.trace("writing denomination batch to db");
+ await ws.db.runReadWriteTx(["denominations"], async (tx) => {
+ for (let i = 0; i < updatedDenominations.length; i++) {
+ const denom = updatedDenominations[i];
+ await tx.denominations.put(denom);
+ }
+ });
+ logger.trace("done with DB write");
+ }
+ }
+}
+
+/**
+ * Update the information about a reserve that is stored in the wallet
+ * by querying the reserve's exchange.
+ *
+ * If the reserve have funds that are not allocated in a withdrawal group yet
+ * and are big enough to withdraw with available denominations,
+ * create a new withdrawal group for the remaining amount.
+ */
+async function queryReserve(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+ withdrawalGroupId,
+ });
+ checkDbInvariant(!!withdrawalGroup);
+ if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
+ return TaskRunResult.backoff();
+ }
+ const reservePub = withdrawalGroup.reservePub;
+
+ const reserveUrl = new URL(
+ `reserves/${reservePub}`,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+
+ logger.trace(`querying reserve status via ${reserveUrl.href}`);
+
+ const resp = await ws.http.fetch(reserveUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken,
+ });
+
+ logger.trace(`reserve status code: HTTP ${resp.status}`);
+
+ const result = await readSuccessResponseJsonOrErrorCode(
+ resp,
+ codecForReserveStatus(),
+ );
+
+ if (result.isError) {
+ logger.trace(
+ `got reserve status error, EC=${result.talerErrorResponse.code}`,
+ );
+ if (resp.status === HttpStatusCode.NotFound) {
+ return TaskRunResult.backoff();
+ } else {
+ throwUnexpectedRequestError(resp, result.talerErrorResponse);
+ }
+ }
+
+ logger.trace(`got reserve status ${j2s(result.response)}`);
+
+ const transitionResult = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return undefined;
+ }
+ const txStateOld = computeWithdrawalTransactionStatus(wg);
+ wg.status = WithdrawalGroupStatus.PendingReady;
+ const txStateNew = computeWithdrawalTransactionStatus(wg);
+ wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
+ await tx.withdrawalGroups.put(wg);
+ return {
+ oldTxState: txStateOld,
+ newTxState: txStateNew,
+ };
+ },
+ );
+
+ notifyTransition(ws, transactionId, transitionResult);
+
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Withdrawal context that is kept in-memory.
+ *
+ * Used to store some cached info during a withdrawal operation.
+ */
+export interface WithdrawalGroupContext {
+ numPlanchets: number;
+ planchetsFinished: Set<string>;
+
+ /**
+ * Cached withdrawal group record from the database.
+ */
+ wgRecord: WithdrawalGroupRecord;
+}
+
+async function processWithdrawalGroupAbortingBank(
+ ws: InternalWalletState,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+
+ const wgInfo = withdrawalGroup.wgInfo;
+ if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
+ throw Error("invalid state (aborting(bank) without bank info");
+ }
+ const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
+ logger.info(`aborting withdrawal at ${abortUrl}`);
+ const abortResp = await ws.http.fetch(abortUrl, {
+ method: "POST",
+ body: {},
+ });
+ logger.info(`abort response status: ${abortResp.status}`);
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return undefined;
+ }
+ const txStatusOld = computeWithdrawalTransactionStatus(wg);
+ wg.status = WithdrawalGroupStatus.AbortedBank;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ const txStatusNew = computeWithdrawalTransactionStatus(wg);
+ await tx.withdrawalGroups.put(wg);
+ return {
+ oldTxState: txStatusOld,
+ newTxState: txStatusNew,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+}
+
+/**
+ * Store in the database that the KYC for a withdrawal is now
+ * satisfied.
+ */
+async function transitionKycSatisfied(
+ ws: InternalWalletState,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ });
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg2 = await tx.withdrawalGroups.get(
+ withdrawalGroup.withdrawalGroupId,
+ );
+ if (!wg2) {
+ return;
+ }
+ const oldTxState = computeWithdrawalTransactionStatus(wg2);
+ switch (wg2.status) {
+ case WithdrawalGroupStatus.PendingKyc: {
+ delete wg2.kycPending;
+ delete wg2.kycUrl;
+ wg2.status = WithdrawalGroupStatus.PendingReady;
+ await tx.withdrawalGroups.put(wg2);
+ const newTxState = computeWithdrawalTransactionStatus(wg2);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+}
+
+async function processWithdrawalGroupPendingKyc(
+ ws: InternalWalletState,
+ withdrawalGroup: WithdrawalGroupRecord,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ const userType = "individual";
+ const kycInfo = withdrawalGroup.kycPending;
+ if (!kycInfo) {
+ throw Error("no kyc info available in pending(kyc)");
+ }
+ const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "30000");
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
+ const kycStatusRes = await ws.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken,
+ });
+ logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`);
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ await transitionKycSatisfied(ws, withdrawalGroup);
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const kycUrl = kycStatus.kyc_url;
+ if (typeof kycUrl === "string") {
+ await transitionKycUrlUpdate(ws, withdrawalGroupId, kycUrl);
+ }
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+async function processWithdrawalGroupPendingReady(
+ ws: InternalWalletState,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+
+ await fetchFreshExchange(ws, withdrawalGroup.exchangeBaseUrl);
+
+ if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
+ logger.warn("Finishing empty withdrawal group (no denoms)");
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return undefined;
+ }
+ const txStatusOld = computeWithdrawalTransactionStatus(wg);
+ wg.status = WithdrawalGroupStatus.Done;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ const txStatusNew = computeWithdrawalTransactionStatus(wg);
+ await tx.withdrawalGroups.put(wg);
+ return {
+ oldTxState: txStatusOld,
+ newTxState: txStatusNew,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ }
+
+ const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
+ .map((x) => x.count)
+ .reduce((a, b) => a + b);
+
+ const wgContext: WithdrawalGroupContext = {
+ numPlanchets: numTotalCoins,
+ planchetsFinished: new Set<string>(),
+ wgRecord: withdrawalGroup,
+ };
+
+ await ws.db.runReadOnlyTx(["planchets"], async (tx) => {
+ const planchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const p of planchets) {
+ if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ wgContext.planchetsFinished.add(p.coinPub);
+ }
+ }
+ });
+
+ // We sequentially generate planchets, so that
+ // large withdrawal groups don't make the wallet unresponsive.
+ for (let i = 0; i < numTotalCoins; i++) {
+ await processPlanchetGenerate(ws, withdrawalGroup, i);
+ }
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
+ const resp = await processPlanchetExchangeBatchRequest(ws, wgContext, {
+ batchSize: maxBatchSize,
+ coinStartIndex: i,
+ });
+ let work: Promise<void>[] = [];
+ work = [];
+ for (let j = 0; j < resp.coinIdxs.length; j++) {
+ if (!resp.batchResp.ev_sigs[j]) {
+ // response may not be available when there is kyc needed
+ continue;
+ }
+ work.push(
+ processPlanchetVerifyAndStoreCoin(
+ ws,
+ wgContext,
+ resp.coinIdxs[j],
+ resp.batchResp.ev_sigs[j],
+ ),
+ );
+ }
+ await Promise.all(work);
+ }
+
+ let numFinished = 0;
+ const errorsPerCoin: Record<number, TalerErrorDetail> = {};
+ let numPlanchetErrors = 0;
+ const maxReportedErrors = 5;
+
+ const res = await ws.db.runReadWriteTx(
+ ["coins", "coinAvailability", "withdrawalGroups", "planchets"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return;
+ }
+
+ await tx.planchets.indexes.byGroup
+ .iter(withdrawalGroupId)
+ .forEach((x) => {
+ if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ numFinished++;
+ }
+ if (x.lastError) {
+ numPlanchetErrors++;
+ if (numPlanchetErrors < maxReportedErrors) {
+ errorsPerCoin[x.coinIdx] = x.lastError;
+ }
+ }
+ });
+ const oldTxState = computeWithdrawalTransactionStatus(wg);
+ logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
+ if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ wg.status = WithdrawalGroupStatus.Done;
+ await makeCoinsVisible(ws, tx, transactionId);
+ }
+
+ const newTxState = computeWithdrawalTransactionStatus(wg);
+ await tx.withdrawalGroups.put(wg);
+
+ return {
+ kycInfo: wg.kycPending,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ },
+ );
+
+ if (!res) {
+ throw Error("withdrawal group does not exist anymore");
+ }
+
+ notifyTransition(ws, transactionId, res.transitionInfo);
+ ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ if (numPlanchetErrors > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
+ {
+ errorsPerCoin,
+ numErrors: numPlanchetErrors,
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+export async function processWithdrawalGroup(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+ cancellationToken: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.trace("processing withdrawal group", withdrawalGroupId);
+ const withdrawalGroup = await ws.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error(`withdrawal group ${withdrawalGroupId} not found`);
+ }
+
+ switch (withdrawalGroup.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ await processReserveBankStatus(ws, withdrawalGroupId);
+ // FIXME: This will get called by the main task loop, why call it here?!
+ return await processWithdrawalGroup(
+ ws,
+ withdrawalGroupId,
+ cancellationToken,
+ );
+ case WithdrawalGroupStatus.PendingQueryingStatus: {
+ return queryReserve(ws, withdrawalGroupId, cancellationToken);
+ }
+ case WithdrawalGroupStatus.PendingWaitConfirmBank: {
+ return await processReserveBankStatus(ws, withdrawalGroupId);
+ }
+ case WithdrawalGroupStatus.PendingAml:
+ // FIXME: Handle this case, withdrawal doesn't support AML yet.
+ return TaskRunResult.backoff();
+ case WithdrawalGroupStatus.PendingKyc:
+ return processWithdrawalGroupPendingKyc(
+ ws,
+ withdrawalGroup,
+ cancellationToken,
+ );
+ case WithdrawalGroupStatus.PendingReady:
+ // Continue with the actual withdrawal!
+ return await processWithdrawalGroupPendingReady(ws, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortingBank:
+ return await processWithdrawalGroupAbortingBank(ws, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedAml:
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ // Nothing to do.
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(withdrawalGroup.status);
+ }
+}
+
+const AGE_MASK_GROUPS = "8:10:12:14:16:18"
+ .split(":")
+ .map((n) => parseInt(n, 10));
+
+export async function getExchangeWithdrawalInfo(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+ instructedAmount: AmountJson,
+ ageRestricted: number | undefined,
+): Promise<ExchangeWithdrawalDetails> {
+ logger.trace("updating exchange");
+ const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
+
+ if (exchange.currency != instructedAmount.currency) {
+ // Specifying the amount in the conversion input currency is not yet supported.
+ // We might add support for it later.
+ throw new Error(
+ `withdrawal only supported when specifying target currency ${exchange.currency}`,
+ );
+ }
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
+ exchange,
+ instructedAmount,
+ });
+
+ logger.trace("updating withdrawal denoms");
+ await updateWithdrawalDenoms(ws, exchangeBaseUrl);
+
+ logger.trace("getting candidate denoms");
+ const denoms = await getCandidateWithdrawalDenoms(
+ ws,
+ exchangeBaseUrl,
+ instructedAmount.currency,
+ );
+ logger.trace("selecting withdrawal denoms");
+ const selectedDenoms = selectWithdrawalDenominations(
+ instructedAmount,
+ denoms,
+ ws.config.testing.denomselAllowLate,
+ );
+
+ logger.trace("selection done");
+
+ if (selectedDenoms.selectedDenoms.length === 0) {
+ throw Error(
+ `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
+ instructedAmount,
+ )}`,
+ );
+ }
+
+ const exchangeWireAccounts: string[] = [];
+
+ for (const account of exchange.wireInfo.accounts) {
+ exchangeWireAccounts.push(account.payto_uri);
+ }
+
+ let hasDenomWithAgeRestriction = false;
+
+ logger.trace("computing earliest deposit expiration");
+
+ let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
+ for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
+ const ds = selectedDenoms.selectedDenoms[i];
+ // FIXME: Do in one transaction!
+ const denom = await ws.db.runReadOnlyTx(["denominations"], async (tx) => {
+ return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
+ });
+ checkDbInvariant(!!denom);
+ hasDenomWithAgeRestriction =
+ hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
+ const expireDeposit = denom.stampExpireDeposit;
+ if (!earliestDepositExpiration) {
+ earliestDepositExpiration = expireDeposit;
+ continue;
+ }
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromProtocolTimestamp(expireDeposit),
+ AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration),
+ ) < 0
+ ) {
+ earliestDepositExpiration = expireDeposit;
+ }
+ }
+
+ checkLogicInvariant(!!earliestDepositExpiration);
+
+ const possibleDenoms = await getCandidateWithdrawalDenoms(
+ ws,
+ exchangeBaseUrl,
+ instructedAmount.currency,
+ );
+
+ let versionMatch;
+ if (exchange.protocolVersionRange) {
+ versionMatch = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ exchange.protocolVersionRange,
+ );
+
+ if (
+ versionMatch &&
+ !versionMatch.compatible &&
+ versionMatch.currentCmp === -1
+ ) {
+ logger.warn(
+ `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
+ `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
+ );
+ }
+ }
+
+ let tosAccepted = false;
+ if (exchange.tosAcceptedTimestamp) {
+ if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
+ tosAccepted = true;
+ }
+ }
+
+ const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
+ if (!paytoUris) {
+ throw Error("exchange is in invalid state");
+ }
+
+ const ret: ExchangeWithdrawalDetails = {
+ earliestDepositExpiration,
+ exchangePaytoUris: paytoUris,
+ exchangeWireAccounts,
+ exchangeCreditAccountDetails: withdrawalAccountsList,
+ exchangeVersion: exchange.protocolVersionRange || "unknown",
+ numOfferedDenoms: possibleDenoms.length,
+ selectedDenoms,
+ // FIXME: delete this field / replace by something we can display to the user
+ trustedAuditorPubs: [],
+ versionMatch,
+ walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ termsOfServiceAccepted: tosAccepted,
+ withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
+ withdrawalAmountRaw: Amounts.stringify(instructedAmount),
+ // TODO: remove hardcoding, this should be calculated from the denominations info
+ // force enabled for testing
+ ageRestrictionOptions: hasDenomWithAgeRestriction
+ ? AGE_MASK_GROUPS
+ : undefined,
+ scopeInfo: exchange.scopeInfo,
+ };
+ return ret;
+}
+
+export interface GetWithdrawalDetailsForUriOpts {
+ restrictAge?: number;
+ notifyChangeFromPendingTimeoutMs?: number;
+}
+
+type WithdrawalOperationMemoryMap = {
+ [uri: string]: boolean | undefined;
+};
+const ongoingChecks: WithdrawalOperationMemoryMap = {};
+/**
+ * Get more information about a taler://withdraw URI.
+ *
+ * As side effects, the bank (via the bank integration API) is queried
+ * and the exchange suggested by the bank is ephemerally added
+ * to the wallet's list of known exchanges.
+ */
+export async function getWithdrawalDetailsForUri(
+ ws: InternalWalletState,
+ talerWithdrawUri: string,
+ opts: GetWithdrawalDetailsForUriOpts = {},
+): Promise<WithdrawUriInfoResponse> {
+ logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
+ const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
+ logger.trace(`got bank info`);
+ if (info.suggestedExchange) {
+ try {
+ // If the exchange entry doesn't exist yet,
+ // it'll be created as an ephemeral entry.
+ await fetchFreshExchange(ws, info.suggestedExchange);
+ } catch (e) {
+ // We still continued if it failed, as other exchanges might be available.
+ // We don't want to fail if the bank-suggested exchange is broken/offline.
+ logger.trace(
+ `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
+ );
+ }
+ }
+
+ const currency = Amounts.currencyOf(info.amount);
+
+ const listExchangesResp = await listExchanges(ws);
+ const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
+ return (
+ x.currency === currency &&
+ (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
+ x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
+ );
+ });
+
+ // FIXME: this should be removed after the extended version of
+ // withdrawal state machine. issue #8099
+ if (
+ info.status === "pending" &&
+ opts.notifyChangeFromPendingTimeoutMs !== undefined &&
+ !ongoingChecks[talerWithdrawUri]
+ ) {
+ ongoingChecks[talerWithdrawUri] = true;
+ const bankApi = new TalerBankIntegrationHttpClient(
+ info.apiBaseUrl,
+ ws.http,
+ );
+ console.log(
+ `waiting operation (${info.operationId}) to change from pending`,
+ );
+ bankApi
+ .getWithdrawalOperationById(info.operationId, {
+ old_state: "pending",
+ timeoutMs: opts.notifyChangeFromPendingTimeoutMs,
+ })
+ .then((resp) => {
+ console.log(
+ `operation (${info.operationId}) to change to ${JSON.stringify(
+ resp,
+ undefined,
+ 2,
+ )}`,
+ );
+ ws.notify({
+ type: NotificationType.WithdrawalOperationTransition,
+ operationId: info.operationId,
+ state: resp.type === "fail" ? info.status : resp.body.status,
+ });
+ ongoingChecks[talerWithdrawUri] = false;
+ });
+ }
+
+ return {
+ operationId: info.operationId,
+ confirmTransferUrl: info.confirmTransferUrl,
+ status: info.status,
+ amount: Amounts.stringify(info.amount),
+ defaultExchangeBaseUrl: info.suggestedExchange,
+ possibleExchanges,
+ };
+}
+
+export function augmentPaytoUrisForWithdrawal(
+ plainPaytoUris: string[],
+ reservePub: string,
+ instructedAmount: AmountLike,
+): string[] {
+ return plainPaytoUris.map((x) =>
+ addPaytoQueryParams(x, {
+ amount: Amounts.stringify(instructedAmount),
+ message: `Taler Withdrawal ${reservePub}`,
+ }),
+ );
+}
+
+/**
+ * Get payto URIs that can be used to fund a withdrawal operation.
+ */
+export async function getFundingPaytoUris(
+ tx: WalletDbReadOnlyTransaction<
+ ["withdrawalGroups", "exchanges", "exchangeDetails"]
+ >,
+ withdrawalGroupId: string,
+): Promise<string[]> {
+ const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
+ checkDbInvariant(!!withdrawalGroup);
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
+ return [];
+ }
+ const plainPaytoUris =
+ exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+ if (!plainPaytoUris) {
+ logger.error(
+ `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
+ );
+ return [];
+ }
+ return augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ withdrawalGroup.reservePub,
+ withdrawalGroup.instructedAmount,
+ );
+}
+
+async function getWithdrawalGroupRecordTx(
+ db: DbAccess<typeof WalletStoresV1>,
+ req: {
+ withdrawalGroupId: string;
+ },
+): Promise<WithdrawalGroupRecord | undefined> {
+ return await db.runReadOnlyTx(["withdrawalGroups"], async (tx) => {
+ return tx.withdrawalGroups.get(req.withdrawalGroupId);
+ });
+}
+
+export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
+ return { d_ms: 60000 };
+}
+
+export function getBankStatusUrl(talerWithdrawUri: string): string {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ return url.href;
+}
+
+export function getBankAbortUrl(talerWithdrawUri: string): string {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ return url.href;
+}
+
+async function registerReserveWithBank(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const withdrawalGroup = await ws.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return await tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ switch (withdrawalGroup?.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default:
+ return;
+ }
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("expecting withdrarwal type = bank integrated");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ return;
+ }
+ const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
+ const reqBody = {
+ reserve_pub: withdrawalGroup.reservePub,
+ selected_exchange: bankInfo.exchangePaytoUri,
+ };
+ logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
+ const httpResp = await ws.http.fetch(bankStatusUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ });
+ // FIXME: libeufin-bank currently doesn't return a response in the right format, so we don't validate at all.
+ await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!r) {
+ return undefined;
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return;
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
+ );
+ const oldTxState = computeWithdrawalTransactionStatus(r);
+ r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ const newTxState = computeWithdrawalTransactionStatus(r);
+ await tx.withdrawalGroups.put(r);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ notifyTransition(ws, transactionId, transitionInfo);
+}
+
+async function processReserveBankStatus(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+ withdrawalGroupId,
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ switch (withdrawalGroup?.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default:
+ return TaskRunResult.backoff();
+ }
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ return TaskRunResult.backoff();
+ }
+
+ const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
+
+ const statusResp = await ws.http.fetch(bankStatusUrl, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ });
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.aborted) {
+ logger.info("bank aborted the withdrawal");
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!r) {
+ return;
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return;
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const oldTxState = computeWithdrawalTransactionStatus(r);
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.FailedBankAborted;
+ const newTxState = computeWithdrawalTransactionStatus(r);
+ await tx.withdrawalGroups.put(r);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(ws, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ }
+
+ // Bank still needs to know our reserve info
+ if (!status.selection_done) {
+ await registerReserveWithBank(ws, withdrawalGroupId);
+ return await processReserveBankStatus(ws, withdrawalGroupId);
+ }
+
+ // FIXME: Why do we do this?!
+ if (withdrawalGroup.status === WithdrawalGroupStatus.PendingRegisteringBank) {
+ await registerReserveWithBank(ws, withdrawalGroupId);
+ return await processReserveBankStatus(ws, withdrawalGroupId);
+ }
+
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!r) {
+ return undefined;
+ }
+ // Re-check reserve status within transaction
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return undefined;
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ const oldTxState = computeWithdrawalTransactionStatus(r);
+ if (status.transfer_done) {
+ logger.info("withdrawal: transfer confirmed by bank.");
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.PendingQueryingStatus;
+ } else {
+ logger.trace("withdrawal: transfer not yet confirmed by bank");
+ r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
+ r.senderWire = status.sender_wire;
+ }
+ const newTxState = computeWithdrawalTransactionStatus(r);
+ await tx.withdrawalGroups.put(r);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ notifyTransition(ws, transactionId, transitionInfo);
+
+ if (transitionInfo) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+export interface PrepareCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transactionId: string;
+ creationInfo?: {
+ amount: AmountJson;
+ canonExchange: string;
+ };
+}
+
+export async function internalPrepareCreateWithdrawalGroup(
+ ws: InternalWalletState,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<PrepareCreateWithdrawalGroupResult> {
+ const reserveKeyPair =
+ args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const secretSeed = encodeCrock(getRandomBytes(32));
+ const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
+ const amount = args.amount;
+ const currency = Amounts.currencyOf(amount);
+
+ let withdrawalGroupId;
+
+ if (args.forcedWithdrawalGroupId) {
+ withdrawalGroupId = args.forcedWithdrawalGroupId;
+ const wgId = withdrawalGroupId;
+ const existingWg = await ws.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return tx.withdrawalGroups.get(wgId);
+ },
+ );
+
+ if (existingWg) {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWg.withdrawalGroupId,
+ });
+ return { withdrawalGroup: existingWg, transactionId };
+ }
+ } else {
+ withdrawalGroupId = encodeCrock(getRandomBytes(32));
+ }
+
+ await updateWithdrawalDenoms(ws, canonExchange);
+ const denoms = await getCandidateWithdrawalDenoms(
+ ws,
+ canonExchange,
+ currency,
+ );
+
+ let initialDenomSel: DenomSelectionState;
+ const denomSelUid = encodeCrock(getRandomBytes(16));
+ if (args.forcedDenomSel) {
+ logger.warn("using forced denom selection");
+ initialDenomSel = selectForcedWithdrawalDenominations(
+ amount,
+ denoms,
+ args.forcedDenomSel,
+ ws.config.testing.denomselAllowLate,
+ );
+ } else {
+ initialDenomSel = selectWithdrawalDenominations(
+ amount,
+ denoms,
+ ws.config.testing.denomselAllowLate,
+ );
+ }
+
+ const withdrawalGroup: WithdrawalGroupRecord = {
+ denomSelUid,
+ denomsSel: initialDenomSel,
+ exchangeBaseUrl: canonExchange,
+ instructedAmount: Amounts.stringify(amount),
+ timestampStart: timestampPreciseToDb(now),
+ rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
+ effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
+ secretSeed,
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ status: args.reserveStatus,
+ withdrawalGroupId,
+ restrictAge: args.restrictAge,
+ senderWire: undefined,
+ timestampFinish: undefined,
+ wgInfo: args.wgInfo,
+ };
+
+ await fetchFreshExchange(ws, canonExchange);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ });
+
+ return {
+ withdrawalGroup,
+ transactionId,
+ creationInfo: {
+ canonExchange,
+ amount,
+ },
+ };
+}
+
+export interface PerformCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transitionInfo: TransitionInfo | undefined;
+
+ /**
+ * Notification for the exchange state transition.
+ *
+ * Should be emitted after the transaction has succeeded.
+ */
+ exchangeNotif: WalletNotification | undefined;
+}
+
+export async function internalPerformCreateWithdrawalGroup(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<
+ ["withdrawalGroups", "reserves", "exchanges"]
+ >,
+ prep: PrepareCreateWithdrawalGroupResult,
+): Promise<PerformCreateWithdrawalGroupResult> {
+ const { withdrawalGroup } = prep;
+ if (!prep.creationInfo) {
+ return {
+ withdrawalGroup,
+ transitionInfo: undefined,
+ exchangeNotif: undefined,
+ };
+ }
+ const existingWg = await tx.withdrawalGroups.get(
+ withdrawalGroup.withdrawalGroupId,
+ );
+ if (existingWg) {
+ return {
+ withdrawalGroup: existingWg,
+ exchangeNotif: undefined,
+ transitionInfo: undefined,
+ };
+ }
+ await tx.withdrawalGroups.add(withdrawalGroup);
+ await tx.reserves.put({
+ reservePub: withdrawalGroup.reservePub,
+ reservePriv: withdrawalGroup.reservePriv,
+ });
+
+ const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
+ if (exchange) {
+ exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ await tx.exchanges.put(exchange);
+ }
+
+ const oldTxState = {
+ major: TransactionMajorState.None,
+ minor: undefined,
+ };
+ const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
+ const transitionInfo = {
+ oldTxState,
+ newTxState,
+ };
+
+ const exchangeUsedRes = await markExchangeUsed(
+ ws,
+ tx,
+ prep.withdrawalGroup.exchangeBaseUrl,
+ );
+
+ const ctx = new WithdrawTransactionContext(
+ ws,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ withdrawalGroup,
+ transitionInfo,
+ exchangeNotif: exchangeUsedRes.notif,
+ };
+}
+
+/**
+ * Create a withdrawal group.
+ *
+ * If a forcedWithdrawalGroupId is given and a
+ * withdrawal group with this ID already exists,
+ * the existing one is returned. No conflict checking
+ * of the other arguments is done in that case.
+ */
+export async function internalCreateWithdrawalGroup(
+ ws: InternalWalletState,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<WithdrawalGroupRecord> {
+ const prep = await internalPrepareCreateWithdrawalGroup(ws, args);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
+ });
+ const res = await ws.db.runReadWriteTx(
+ ["withdrawalGroups", "reserves", "exchanges", "exchangeDetails"],
+ async (tx) => {
+ return await internalPerformCreateWithdrawalGroup(ws, tx, prep);
+ },
+ );
+ if (res.exchangeNotif) {
+ ws.notify(res.exchangeNotif);
+ }
+ notifyTransition(ws, transactionId, res.transitionInfo);
+ return res.withdrawalGroup;
+}
+
+export async function acceptWithdrawalFromUri(
+ ws: InternalWalletState,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+ },
+): Promise<AcceptWithdrawalResponse> {
+ const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
+ logger.info(
+ `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
+ );
+ const existingWithdrawalGroup = await ws.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ req.talerWithdrawUri,
+ );
+ },
+ );
+
+ if (existingWithdrawalGroup) {
+ let url: string | undefined;
+ if (
+ existingWithdrawalGroup.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
+ }
+ return {
+ reservePub: existingWithdrawalGroup.reservePub,
+ confirmTransferUrl: url,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
+ }),
+ };
+ }
+
+ await fetchFreshExchange(ws, selectedExchange);
+ const withdrawInfo = await getBankWithdrawalInfo(
+ ws.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ ws,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const exchange = await fetchFreshExchange(ws, selectedExchange);
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(ws, {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ });
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
+ amount: withdrawInfo.amount,
+ exchangeBaseUrl: req.selectedExchange,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ exchangeCreditAccounts: withdrawalAccountList,
+ bankInfo: {
+ exchangePaytoUri,
+ talerWithdrawUri: req.talerWithdrawUri,
+ confirmUrl: withdrawInfo.confirmTransferUrl,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ },
+ },
+ restrictAge: req.restrictAge,
+ forcedDenomSel: req.forcedDenomSel,
+ reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
+
+ const transactionId = ctx.transactionId;
+
+ // We do this here, as the reserve should be registered before we return,
+ // so that we can redirect the user to the bank's status page.
+ await processReserveBankStatus(ws, withdrawalGroupId);
+ const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+ withdrawalGroupId,
+ });
+ if (
+ processedWithdrawalGroup?.status === WithdrawalGroupStatus.FailedBankAborted
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+ {},
+ );
+ }
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ transactionId,
+ };
+}
+
+async function fetchAccount(
+ ws: InternalWalletState,
+ instructedAmount: AmountJson,
+ acct: ExchangeWireAccount,
+ reservePub?: string,
+): Promise<WithdrawalExchangeAccountDetails> {
+ let paytoUri: string;
+ let transferAmount: AmountString | undefined = undefined;
+ let currencySpecification: CurrencySpecification | undefined = undefined;
+ if (acct.conversion_url != null) {
+ const reqUrl = new URL("cashin-rate", acct.conversion_url);
+ reqUrl.searchParams.set(
+ "amount_credit",
+ Amounts.stringify(instructedAmount),
+ );
+ const httpResp = await ws.http.fetch(reqUrl.href);
+ const respOrErr = await readSuccessResponseJsonOrErrorCode(
+ httpResp,
+ codecForCashinConversionResponse(),
+ );
+ if (respOrErr.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: respOrErr.talerErrorResponse,
+ };
+ }
+ const resp = respOrErr.response;
+ paytoUri = acct.payto_uri;
+ transferAmount = resp.amount_debit;
+ const configUrl = new URL("config", acct.conversion_url);
+ const configResp = await ws.http.fetch(configUrl.href);
+ const configRespOrError = await readSuccessResponseJsonOrErrorCode(
+ configResp,
+ codecForConversionBankConfig(),
+ );
+ if (configRespOrError.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: configRespOrError.talerErrorResponse,
+ };
+ }
+ const configParsed = configRespOrError.response;
+ currencySpecification = configParsed.fiat_currency_specification;
+ } else {
+ paytoUri = acct.payto_uri;
+ transferAmount = Amounts.stringify(instructedAmount);
+ }
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ amount: Amounts.stringify(transferAmount),
+ });
+ if (reservePub != null) {
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ message: `Taler Withdrawal ${reservePub}`,
+ });
+ }
+ const acctInfo: WithdrawalExchangeAccountDetails = {
+ status: "ok",
+ paytoUri,
+ transferAmount,
+ currencySpecification,
+ creditRestrictions: acct.credit_restrictions,
+ };
+ if (transferAmount != null) {
+ acctInfo.transferAmount = transferAmount;
+ }
+ return acctInfo;
+}
+
+/**
+ * Gather information about bank accounts that can be used for
+ * withdrawals. This includes accounts that are in a different
+ * currency and require conversion.
+ */
+async function fetchWithdrawalAccountInfo(
+ ws: InternalWalletState,
+ req: {
+ exchange: ReadyExchangeSummary;
+ instructedAmount: AmountJson;
+ reservePub?: string;
+ },
+): Promise<WithdrawalExchangeAccountDetails[]> {
+ const { exchange, instructedAmount } = req;
+ const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
+ for (let acct of exchange.wireInfo.accounts) {
+ const acctInfo = await fetchAccount(
+ ws,
+ req.instructedAmount,
+ acct,
+ req.reservePub,
+ );
+ withdrawalAccounts.push(acctInfo);
+ }
+ return withdrawalAccounts;
+}
+
+/**
+ * Create a manual withdrawal operation.
+ *
+ * Adds the corresponding exchange as a trusted exchange if it is neither
+ * audited nor trusted already.
+ *
+ * Asynchronously starts the withdrawal.
+ */
+export async function createManualWithdrawal(
+ ws: InternalWalletState,
+ req: {
+ exchangeBaseUrl: string;
+ amount: AmountLike;
+ restrictAge?: number;
+ forcedDenomSel?: ForcedDenomSel;
+ },
+): Promise<AcceptManualWithdrawalResult> {
+ const { exchangeBaseUrl } = req;
+ const amount = Amounts.parseOrThrow(req.amount);
+ const exchange = await fetchFreshExchange(ws, exchangeBaseUrl);
+
+ if (exchange.currency != amount.currency) {
+ throw Error(
+ "manual withdrawal with conversion from foreign currency is not yet supported",
+ );
+ }
+ const reserveKeyPair: EddsaKeypair = await ws.cryptoApi.createEddsaKeypair(
+ {},
+ );
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(ws, {
+ exchange,
+ instructedAmount: amount,
+ reservePub: reserveKeyPair.pub,
+ });
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
+ amount: Amounts.jsonifyAmount(req.amount),
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankManual,
+ exchangeCreditAccounts: withdrawalAccountsList,
+ },
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+ const ctx = new WithdrawTransactionContext(ws, withdrawalGroupId);
+
+ const transactionId = ctx.transactionId;
+
+ const exchangePaytoUris = await ws.db.runReadOnlyTx(
+ ["withdrawalGroups", "exchanges", "exchangeDetails"],
+ async (tx) => {
+ return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
+ },
+ );
+
+ ws.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris: exchangePaytoUris,
+ withdrawalAccountsList: withdrawalAccountsList,
+ transactionId,
+ };
+}
diff --git a/packages/taler-wallet-core/tsconfig.json b/packages/taler-wallet-core/tsconfig.json
@@ -33,5 +33,5 @@
"path": "../taler-util/"
}
],
- "include": ["src/**/*", "src/*.json", "../taler-util/src/bank-api-client.ts"]
+ "include": ["src/**/*", "src/*.json"]
}