/* 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 */ /** * Imports. */ import { AbsoluteTime, AmountJson, Amounts, CancellationToken, canonicalJson, codecForDepositSuccess, codecForTackTransactionAccepted, codecForTackTransactionWired, CoinDepositPermission, CoinRefreshRequest, CreateDepositGroupRequest, CreateDepositGroupResponse, DepositGroupFees, durationFromSpec, encodeCrock, ExchangeDepositRequest, ExchangeRefundRequest, getRandomBytes, hashTruncate32, hashWire, HttpStatusCode, j2s, Logger, MerchantContractTerms, NotificationType, parsePaytoUri, PayCoinSelection, PrepareDepositRequest, PrepareDepositResponse, RefreshReason, stringToBytes, TalerErrorCode, TalerProtocolTimestamp, TrackTransaction, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, URL, WireFee, } from "@gnu-taler/taler-util"; import { DenominationRecord, DepositGroupRecord, OperationStatus, DepositElementStatus, } from "../db.js"; import { TalerError } from "@gnu-taler/taler-util"; import { createRefreshGroup, DepositOperationStatus, DepositTrackingInfo, getTotalRefreshCost, KycPendingInfo, KycUserType, PendingTaskType, RefreshOperationStatus, } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { OperationAttemptResult } from "../util/retries.js"; import { spendCoins, TombstoneTag } from "./common.js"; import { getExchangeDetails } from "./exchanges.js"; import { extractContractData, generateDepositPermissions, getTotalPaymentCost, } from "./pay-merchant.js"; import { selectPayCoinsNew } from "../util/coinSelection.js"; import { constructTransactionIdentifier, notifyTransition, parseTransactionIdentifier, stopLongpolling, } from "./transactions.js"; import { constructTaskIdentifier } from "../util/retries.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; /** * Logger. */ const logger = new Logger("deposits.ts"); /** * 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.Pending: { const numTotal = dg.payCoinSelection.coinPubs.length; let numDeposited = 0; let numKycRequired = 0; let numWired = 0; for (let i = 0; i < numTotal; i++) { if (dg.depositedPerCoin[i]) { numDeposited++; } switch (dg.transactionPerCoin[i]) { case DepositElementStatus.KycRequired: numKycRequired++; break; case DepositElementStatus.Wired: numWired++; break; } } logger.info(`num total ${numTotal}`); logger.info(`num deposited ${numDeposited}`); if (numKycRequired > 0) { return { major: TransactionMajorState.Pending, minor: TransactionMinorState.KycRequired, }; } if (numDeposited == numTotal) { return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Track, }; } return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Deposit, }; } case DepositOperationStatus.Suspended: 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: throw Error(`unexpected deposit group state (${dg.operationStatus})`); } } export async function suspendDepositGroup( ws: InternalWalletState, depositGroupId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId, }); const retryTag = constructTaskIdentifier({ tag: PendingTaskType.Deposit, depositGroupId, }); const transitionInfo = await ws.db .mktx((x) => [x.depositGroups]) .runReadWrite(async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { logger.warn( `can't suspend deposit group, depositGroupId=${depositGroupId} not found`, ); return undefined; } const oldState = computeDepositTransactionStatus(dg); switch (dg.operationStatus) { case DepositOperationStatus.Finished: return undefined; case DepositOperationStatus.Pending: { dg.operationStatus = DepositOperationStatus.Suspended; await tx.depositGroups.put(dg); return { oldTxState: oldState, newTxState: computeDepositTransactionStatus(dg), }; } case DepositOperationStatus.Suspended: return undefined; } return undefined; }); stopLongpolling(ws, retryTag); notifyTransition(ws, transactionId, transitionInfo); } export async function resumeDepositGroup( ws: InternalWalletState, depositGroupId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId, }); const transitionInfo = await ws.db .mktx((x) => [x.depositGroups]) .runReadWrite(async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { logger.warn( `can't resume deposit group, depositGroupId=${depositGroupId} not found`, ); return; } const oldState = computeDepositTransactionStatus(dg); switch (dg.operationStatus) { case DepositOperationStatus.Finished: return; case DepositOperationStatus.Pending: { return; } case DepositOperationStatus.Suspended: dg.operationStatus = DepositOperationStatus.Pending; await tx.depositGroups.put(dg); return { oldTxState: oldState, newTxState: computeDepositTransactionStatus(dg), }; } return undefined; }); ws.workAvailable.trigger(); notifyTransition(ws, transactionId, transitionInfo); } export async function abortDepositGroup( ws: InternalWalletState, depositGroupId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId, }); const retryTag = constructTaskIdentifier({ tag: PendingTaskType.Deposit, depositGroupId, }); const transitionInfo = await ws.db .mktx((x) => [x.depositGroups]) .runReadWrite(async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { logger.warn( `can't suspend deposit group, depositGroupId=${depositGroupId} not found`, ); return undefined; } const oldState = computeDepositTransactionStatus(dg); switch (dg.operationStatus) { case DepositOperationStatus.Finished: return undefined; case DepositOperationStatus.Pending: { dg.operationStatus = DepositOperationStatus.Aborting; await tx.depositGroups.put(dg); return { oldTxState: oldState, newTxState: computeDepositTransactionStatus(dg), }; } case DepositOperationStatus.Suspended: // FIXME: Can we abort a suspended transaction?! return undefined; } return undefined; }); stopLongpolling(ws, retryTag); // Need to process the operation again. ws.workAvailable.trigger(); notifyTransition(ws, transactionId, transitionInfo); } export async function cancelAbortingDepositGroup( ws: InternalWalletState, depositGroupId: string, ): Promise { const transactionId = constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId, }); const retryTag = constructTaskIdentifier({ tag: PendingTaskType.Deposit, depositGroupId, }); const transitionInfo = await ws.db .mktx((x) => [x.depositGroups]) .runReadWrite(async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { logger.warn( `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`, ); return undefined; } const oldState = computeDepositTransactionStatus(dg); switch (dg.operationStatus) { case DepositOperationStatus.SuspendedAborting: case DepositOperationStatus.Aborting: { dg.operationStatus = DepositOperationStatus.Failed; await tx.depositGroups.put(dg); return { oldTxState: oldState, newTxState: computeDepositTransactionStatus(dg), }; } } return undefined; }); // FIXME: Also cancel ongoing work (via cancellation token, once implemented) stopLongpolling(ws, retryTag); notifyTransition(ws, transactionId, transitionInfo); } export async function deleteDepositGroup( ws: InternalWalletState, depositGroupId: string, ) { // FIXME: We should check first if we are in a final state // where deletion is allowed. await ws.db .mktx((x) => [x.depositGroups, x.tombstones]) .runReadWrite(async (tx) => { const tipRecord = await tx.depositGroups.get(depositGroupId); if (tipRecord) { await tx.depositGroups.delete(depositGroupId); await tx.tombstones.put({ id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId, }); } }); } /** * Check KYC status with the exchange, throw an appropriate exception when KYC * is required. * * FIXME: Why does this throw an exception when KYC is required? * Should we not return some proper result record here? */ async function checkDepositKycStatus( ws: InternalWalletState, exchangeUrl: string, kycInfo: KycPendingInfo, userType: KycUserType, ): Promise { 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; } else if (kycStatusReq.status === HttpStatusCode.Accepted) { const kycStatus = await kycStatusReq.json(); logger.info(`kyc status: ${j2s(kycStatus)}`); // FIXME: This error code is totally wrong throw TalerError.fromDetail( TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, { kycUrl: kycStatus.kyc_url, }, `KYC check required for deposit`, ); } else { throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`); } } /** * 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 { const abortRefreshGroupId = depositGroup.abortRefreshGroupId; checkLogicInvariant(!!abortRefreshGroupId); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId: depositGroup.depositGroupId, }); const transitionInfo = await ws.db .mktx((x) => [x.refreshGroups, x.depositGroups]) .runReadWrite(async (tx) => { const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId); let newOpState: DepositOperationStatus | undefined; if (!refreshGroup) { // Maybe it got manually deleted? Means that we should // just go into aborted. logger.warn("no aborting refresh group found for deposit group"); newOpState = DepositOperationStatus.Aborted; } else { if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) { newOpState = DepositOperationStatus.Aborted; } else if ( refreshGroup.operationStatus === RefreshOperationStatus.FinishedWithError ) { 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 OperationAttemptResult.pendingEmpty(); } async function refundDepositGroup( ws: InternalWalletState, depositGroup: DepositGroupRecord, ): Promise { const newTxPerCoin = [...depositGroup.transactionPerCoin]; logger.info(`status per coin: ${j2s(depositGroup.transactionPerCoin)}`); for (let i = 0; i < depositGroup.transactionPerCoin.length; i++) { const st = depositGroup.transactionPerCoin[i]; switch (st) { case DepositElementStatus.RefundFailed: case DepositElementStatus.RefundSuccess: break; default: { const coinPub = depositGroup.payCoinSelection.coinPubs[i]; const coinExchange = await ws.db .mktx((x) => [x.coins]) .runReadOnly(async (tx) => { const 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 .mktx((x) => [ x.depositGroups, x.refreshGroups, x.coins, x.denominations, x.coinAvailability, ]) .runReadWrite(async (tx) => { const newDg = await tx.depositGroups.get(depositGroup.depositGroupId); if (!newDg) { return; } newDg.transactionPerCoin = 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, ); newDg.abortRefreshGroupId = rgid.refreshGroupId; } await tx.depositGroups.put(newDg); }); return OperationAttemptResult.pendingEmpty(); } /** * Process a deposit group that is not in its final state yet. */ export async function processDepositGroup( ws: InternalWalletState, depositGroupId: string, options: { cancellationToken?: CancellationToken; } = {}, ): Promise { const depositGroup = await ws.db .mktx((x) => [x.depositGroups]) .runReadOnly(async (tx) => { return tx.depositGroups.get(depositGroupId); }); if (!depositGroup) { logger.warn(`deposit group ${depositGroupId} not found`); return OperationAttemptResult.finishedEmpty(); } if (depositGroup.timestampFinished) { logger.trace(`deposit group ${depositGroupId} already finished`); return OperationAttemptResult.finishedEmpty(); } const transactionId = constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId, }); const txStateOld = computeDepositTransactionStatus(depositGroup); if (depositGroup.operationStatus === DepositOperationStatus.Pending) { const contractData = extractContractData( depositGroup.contractTermsRaw, depositGroup.contractTermsHash, "", ); // Check for cancellation before expensive operations. options.cancellationToken?.throwIfCancelled(); // FIXME: Cache these! const depositPermissions = await generateDepositPermissions( ws, depositGroup.payCoinSelection, contractData, ); for (let i = 0; i < depositPermissions.length; i++) { const perm = depositPermissions[i]; let didDeposit: boolean = false; if (!depositGroup.depositedPerCoin[i]) { const requestBody: ExchangeDepositRequest = { contribution: Amounts.stringify(perm.contribution), merchant_payto_uri: depositGroup.wire.payto_uri, wire_salt: depositGroup.wire.salt, h_contract_terms: depositGroup.contractTermsHash, ub_sig: perm.ub_sig, timestamp: depositGroup.contractTermsRaw.timestamp, wire_transfer_deadline: depositGroup.contractTermsRaw.wire_transfer_deadline, refund_deadline: depositGroup.contractTermsRaw.refund_deadline, coin_sig: perm.coin_sig, denom_pub_hash: perm.h_denom, merchant_pub: depositGroup.merchantPub, h_age_commitment: perm.h_age_commitment, }; // Check for cancellation before making network request. options.cancellationToken?.throwIfCancelled(); const url = new URL( `coins/${perm.coin_pub}/deposit`, perm.exchange_url, ); logger.info(`depositing to ${url}`); const httpResp = await ws.http.fetch(url.href, { method: "POST", body: requestBody, cancellationToken: options.cancellationToken, }); await readSuccessResponseJsonOrThrow( httpResp, codecForDepositSuccess(), ); didDeposit = true; } let updatedTxStatus: DepositElementStatus | undefined = undefined; let newWiredCoin: | { id: string; value: DepositTrackingInfo; } | undefined; if (depositGroup.transactionPerCoin[i] !== DepositElementStatus.Wired) { const track = await trackDeposit(ws, depositGroup, perm); if (track.type === "accepted") { if (!track.kyc_ok && track.requirement_row !== undefined) { updatedTxStatus = DepositElementStatus.KycRequired; const { requirement_row: requirementRow } = track; const paytoHash = encodeCrock( hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")), ); await checkDepositKycStatus( ws, perm.exchange_url, { paytoHash, requirementRow }, "individual", ); } else { updatedTxStatus = DepositElementStatus.Accepted; } } 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, perm.exchange_url, 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: track.execution_time, wireTransferId: track.wtid, }, id: track.exchange_sig, }; } else { updatedTxStatus = DepositElementStatus.Unknown; } } if (updatedTxStatus !== undefined || didDeposit) { await ws.db .mktx((x) => [x.depositGroups]) .runReadWrite(async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return; } if (didDeposit) { dg.depositedPerCoin[i] = didDeposit; } if (updatedTxStatus !== undefined) { dg.transactionPerCoin[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); }); } } const txStatusNew = await ws.db .mktx((x) => [x.depositGroups]) .runReadWrite(async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return undefined; } let allDepositedAndWired = true; for (let i = 0; i < depositGroup.depositedPerCoin.length; i++) { if ( !depositGroup.depositedPerCoin[i] || depositGroup.transactionPerCoin[i] !== DepositElementStatus.Wired ) { allDepositedAndWired = false; break; } } if (allDepositedAndWired) { dg.timestampFinished = TalerProtocolTimestamp.now(); dg.operationStatus = DepositOperationStatus.Finished; await tx.depositGroups.put(dg); } return computeDepositTransactionStatus(dg); }); if (!txStatusNew) { // Doesn't exist anymore! return OperationAttemptResult.finishedEmpty(); } // Notify if state transitioned if ( txStateOld.major !== txStatusNew.major || txStateOld.minor !== txStatusNew.minor ) { ws.notify({ type: NotificationType.TransactionStateTransition, transactionId, oldTxState: txStateOld, newTxState: txStatusNew, }); } // FIXME: consider other cases like aborting, suspend, ... if ( txStatusNew.major === TransactionMajorState.Pending || txStatusNew.major === TransactionMajorState.Aborting ) { return OperationAttemptResult.pendingEmpty(); } else { return OperationAttemptResult.finishedEmpty(); } } if (depositGroup.operationStatus === DepositOperationStatus.Aborting) { 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); } return OperationAttemptResult.finishedEmpty(); } async function getExchangeWireFee( ws: InternalWalletState, wireType: string, baseUrl: string, time: TalerProtocolTimestamp, ): Promise { const exchangeDetails = await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadOnly(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.fromTimestamp(time), AbsoluteTime.fromTimestamp(x.startStamp), AbsoluteTime.fromTimestamp(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, dp: CoinDepositPermission, ): Promise { const wireHash = depositGroup.contractTermsRaw.h_wire; const url = new URL( `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${dp.coin_pub}`, dp.exchange_url, ); const sigResp = await ws.cryptoApi.signTrackTransaction({ coinPub: dp.coin_pub, 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 { 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 .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadOnly(async (tx) => { const allExchanges = await tx.exchanges.iter().toArray(); for (const e of allExchanges) { const details = await getExchangeDetails(tx, e.baseUrl); if (!details || amount.currency !== details.currency) { continue; } exchangeInfos.push({ master_pub: details.masterPublicKey, url: e.baseUrl, }); } }); const now = AbsoluteTime.now(); const nowRounded = AbsoluteTime.toTimestamp(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.toTimestamp( 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 { 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 .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadOnly(async (tx) => { const allExchanges = await tx.exchanges.iter().toArray(); for (const e of allExchanges) { const details = await getExchangeDetails(tx, e.baseUrl); if (!details || amount.currency !== details.currency) { continue; } exchangeInfos.push({ master_pub: details.masterPublicKey, url: e.baseUrl, }); } }); const now = AbsoluteTime.now(); const nowRounded = AbsoluteTime.toTimestamp(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: nowRounded, order_id: "", h_wire: wireHash, pay_deadline: AbsoluteTime.toTimestamp( 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, contractTermsRaw: contractTerms, depositGroupId, noncePriv: noncePair.priv, noncePub: noncePair.pub, timestampCreated: AbsoluteTime.toTimestamp(now), timestampFinished: undefined, transactionPerCoin: payCoinSel.coinSel.coinPubs.map( () => DepositElementStatus.Unknown, ), payCoinSelection: payCoinSel.coinSel, payCoinSelectionUid: encodeCrock(getRandomBytes(32)), depositedPerCoin: payCoinSel.coinSel.coinPubs.map(() => false), merchantPriv: merchantPair.priv, merchantPub: merchantPair.pub, totalPayCost: Amounts.stringify(totalDepositCost), effectiveDepositAmount: Amounts.stringify( counterpartyEffectiveDepositAmount, ), wire: { payto_uri: req.depositPaytoUri, salt: wireSalt, }, operationStatus: DepositOperationStatus.Pending, }; const transactionId = constructTransactionIdentifier({ tag: TransactionType.Deposit, depositGroupId, }); const newTxState = await ws.db .mktx((x) => [ x.depositGroups, x.coins, x.recoupGroups, x.denominations, x.refreshGroups, x.coinAvailability, ]) .runReadWrite(async (tx) => { await spendCoins(ws, tx, { allocationId: transactionId, coinPubs: payCoinSel.coinSel.coinPubs, contributions: payCoinSel.coinSel.coinContributions.map((x) => Amounts.parseOrThrow(x), ), refreshReason: RefreshReason.PayDeposit, }); await tx.depositGroups.put(depositGroup); return computeDepositTransactionStatus(depositGroup); }); ws.notify({ type: NotificationType.TransactionStateTransition, transactionId, oldTxState: { major: TransactionMajorState.None, }, newTxState, }); 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 { const amt: AmountJson[] = []; const fees: AmountJson[] = []; const exchangeSet: Set = new Set(); await ws.db .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails]) .runReadOnly(async (tx) => { for (let i = 0; i < pcs.coinPubs.length; i++) { const coin = await tx.coins.get(pcs.coinPubs[i]); if (!coin) { throw Error("can't calculate deposit amount, coin not found"); } const denom = await ws.getDenomInfo( ws, 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 getExchangeDetails(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.fromTimestamp(x.startStamp), AbsoluteTime.fromTimestamp(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. */ export async function getTotalFeesForDepositAmount( ws: InternalWalletState, wireType: string, total: AmountJson, pcs: PayCoinSelection, ): Promise { const wireFee: AmountJson[] = []; const coinFee: AmountJson[] = []; const refreshFee: AmountJson[] = []; const exchangeSet: Set = new Set(); await ws.db .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails]) .runReadOnly(async (tx) => { for (let i = 0; i < pcs.coinPubs.length; i++) { const coin = await tx.coins.get(pcs.coinPubs[i]); if (!coin) { throw Error("can't calculate deposit amount, coin not found"); } const denom = await ws.getDenomInfo( ws, 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 tx.denominations.indexes.byExchangeBaseUrl .iter(coin.exchangeBaseUrl) .filter((x) => Amounts.isSameCurrency( DenominationRecord.getValue(x), pcs.coinContributions[i], ), ); const amountLeft = Amounts.sub( denom.value, pcs.coinContributions[i], ).amount; const refreshCost = getTotalRefreshCost( allDenoms, denom, amountLeft, ws.config.testing.denomselAllowLate, ); refreshFee.push(refreshCost); } for (const exchangeUrl of exchangeSet.values()) { const exchangeDetails = await getExchangeDetails(tx, exchangeUrl); if (!exchangeDetails) { continue; } const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find( (x) => { return AbsoluteTime.isBetween( AbsoluteTime.now(), AbsoluteTime.fromTimestamp(x.startStamp), AbsoluteTime.fromTimestamp(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, ), }; }