/* 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, CreateDepositGroupRequest, CreateDepositGroupResponse, DepositGroupFees, durationFromSpec, encodeCrock, ExchangeDepositRequest, GetFeeForDepositRequest, getRandomBytes, hashTruncate32, hashWire, HttpStatusCode, j2s, Logger, MerchantContractTerms, NotificationType, parsePaytoUri, PayCoinSelection, PrepareDepositRequest, PrepareDepositResponse, RefreshReason, stringToBytes, TalerErrorCode, TalerProtocolTimestamp, TrackDepositGroupRequest, TrackDepositGroupResponse, TrackTransaction, TrackTransactionWired, TransactionType, URL, WireFee, } from "@gnu-taler/taler-util"; import { DenominationRecord, DepositGroupRecord, ExchangeDetailsRecord, OperationStatus, TransactionStatus, } from "../db.js"; import { TalerError } from "@gnu-taler/taler-util"; import { KycPendingInfo, KycUserType } 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 { makeTransactionId, spendCoins } from "./common.js"; import { getExchangeDetails } from "./exchanges.js"; import { extractContractData, generateDepositPermissions, getTotalPaymentCost, selectPayCoinsNew, } from "./pay-merchant.js"; import { getTotalRefreshCost } from "./refresh.js"; /** * Logger. */ const logger = new Logger("deposits.ts"); export 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})`); } } /** * @see {processDepositGroup} */ 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 contractData = extractContractData( depositGroup.contractTermsRaw, depositGroup.contractTermsHash, "", ); // Check for cancellation before expensive operations. options.cancellationToken?.throwIfCancelled(); const depositPermissions = await generateDepositPermissions( ws, depositGroup.payCoinSelection, contractData, ); for (let i = 0; i < depositPermissions.length; i++) { const perm = depositPermissions[i]; let updatedDeposit: boolean | undefined = undefined; 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.postJson(url.href, requestBody, { cancellationToken: options.cancellationToken, }); await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess()); updatedDeposit = true; } let updatedTxStatus: TransactionStatus | undefined = undefined; type ValueOf = T[keyof T]; let newWiredTransaction: | { id: string; value: ValueOf>; } | undefined; let signature: string | undefined; if (depositGroup.transactionPerCoin[i] !== TransactionStatus.Wired) { const track = await trackDepositPermission(ws, depositGroup, perm); if (track.type === "accepted") { if (!track.kyc_ok && track.requirement_row !== undefined) { updatedTxStatus = TransactionStatus.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 = TransactionStatus.Accepted; } } else if (track.type === "wired") { updatedTxStatus = TransactionStatus.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); const effective = Amounts.sub(raw, wireFee).amount; newWiredTransaction = { value: { amountRaw: Amounts.stringify(raw), amountEffective: Amounts.stringify(effective), exchangePub: track.exchange_pub, timestampExecuted: track.execution_time, wireTransferId: track.wtid, }, id: track.exchange_sig, }; } else { updatedTxStatus = TransactionStatus.Unknown; } } if (updatedTxStatus !== undefined || updatedDeposit !== undefined) { await ws.db .mktx((x) => [x.depositGroups]) .runReadWrite(async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return; } if (updatedDeposit !== undefined) { dg.depositedPerCoin[i] = updatedDeposit; } if (updatedTxStatus !== undefined) { dg.transactionPerCoin[i] = updatedTxStatus; } if (newWiredTransaction) { if (!dg.trackingState) { dg.trackingState = {}; } dg.trackingState[newWiredTransaction.id] = newWiredTransaction.value; } await tx.depositGroups.put(dg); }); } } await ws.db .mktx((x) => [x.depositGroups]) .runReadWrite(async (tx) => { const dg = await tx.depositGroups.get(depositGroupId); if (!dg) { return; } let allDepositedAndWired = true; for (let i = 0; i < depositGroup.depositedPerCoin.length; i++) { if ( !depositGroup.depositedPerCoin[i] || depositGroup.transactionPerCoin[i] !== TransactionStatus.Wired ) { allDepositedAndWired = false; break; } } if (allDepositedAndWired) { dg.timestampFinished = TalerProtocolTimestamp.now(); dg.operationStatus = OperationStatus.Finished; await tx.depositGroups.put(dg); } }); 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; } export async function trackDepositGroup( ws: InternalWalletState, req: TrackDepositGroupRequest, ): Promise { const responses: TrackTransaction[] = []; const depositGroup = await ws.db .mktx((x) => [x.depositGroups]) .runReadOnly(async (tx) => { return tx.depositGroups.get(req.depositGroupId); }); if (!depositGroup) { throw Error("deposit group not found"); } const contractData = extractContractData( depositGroup.contractTermsRaw, depositGroup.contractTermsHash, "", ); const depositPermissions = await generateDepositPermissions( ws, depositGroup.payCoinSelection, contractData, ); for (const dp of depositPermissions) { const track = await trackDepositPermission(ws, depositGroup, dp); responses.push(track); } return { responses }; } async function trackDepositPermission( 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.get(url.href); 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})`, ); } } } export async function getFeeForDeposit( ws: InternalWalletState, req: GetFeeForDepositRequest, ): 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 payCoinSel = await selectPayCoinsNew(ws, { auditors: [], exchanges: Object.values(exchangeInfos).map((v) => ({ exchangeBaseUrl: v.url, exchangePub: v.master_pub, })), wireMethod: p.targetType, contractTermsAmount: Amounts.parseOrThrow(req.amount), depositFeeLimit: Amounts.parseOrThrow(req.amount), wireFeeAmortization: 1, wireFeeLimit: Amounts.parseOrThrow(req.amount), prevPayCoins: [], }); if (payCoinSel.type !== "success") { throw TalerError.fromDetail( TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, { insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, }, ); } return await getTotalFeesForDepositAmount( ws, p.targetType, amount, payCoinSel.coinSel, ); } 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 = { auditors: [], 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: contractData.allowedAuditors, 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 getEffectiveDepositAmount( ws, p.targetType, payCoinSel.coinSel, ); return { totalDepositCost: Amounts.stringify(totalDepositCost), effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount), }; } 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 = { auditors: [], 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: contractData.allowedAuditors, 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 depositGroupId = encodeCrock(getRandomBytes(32)); const effectiveDepositAmount = await getEffectiveDepositAmount( 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( () => TransactionStatus.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(effectiveDepositAmount), wire: { payto_uri: req.depositPaytoUri, salt: wireSalt, }, operationStatus: OperationStatus.Pending, }; 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: `txn:deposit:${depositGroup.depositGroupId}`, coinPubs: payCoinSel.coinSel.coinPubs, contributions: payCoinSel.coinSel.coinContributions.map((x) => Amounts.parseOrThrow(x), ), refreshReason: RefreshReason.PayDeposit, }); await tx.depositGroups.put(depositGroup); }); return { depositGroupId: depositGroupId, transactionId: makeTransactionId(TransactionType.Deposit, depositGroupId), }; } /** * Get the amount that will be deposited on the merchant's bank * account, not considering aggregation. */ export async function getEffectiveDepositAmount( 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); 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, ), }; }