/* This file is part of GNU Taler (C) 2015-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 */ /** * High-level wallet operations that should be indepentent from the underlying * browser extension interface. */ /** * Imports. */ import { AbsoluteTime, AmountJson, Amounts, BalancesResponse, codecForAbortPayWithRefundRequest, codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, codecForAcceptManualWithdrawalRequet, codecForAcceptPeerPullPaymentRequest, codecForAcceptPeerPushPaymentRequest, codecForAcceptTipRequest, codecForAddExchangeRequest, codecForAny, codecForApplyRefundFromPurchaseIdRequest, codecForApplyRefundRequest, codecForCheckPeerPullPaymentRequest, codecForCheckPeerPushPaymentRequest, codecForConfirmPayRequest, codecForCreateDepositGroupRequest, codecForDeleteTransactionRequest, codecForForceRefreshRequest, codecForGetContractTermsDetails, codecForGetExchangeTosRequest, codecForGetExchangeWithdrawalInfo, codecForGetFeeForDeposit, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForImportDbRequest, codecForInitiatePeerPullPaymentRequest, codecForInitiatePeerPushPaymentRequest, codecForIntegrationTestArgs, codecForListKnownBankAccounts, codecForPrepareDepositRequest, codecForPreparePayRequest, codecForPrepareRefundRequest, codecForPrepareTipRequest, codecForRetryTransactionRequest, codecForSetCoinSuspendedRequest, codecForSetWalletDeviceIdRequest, codecForTestPayArgs, codecForTrackDepositGroupRequest, codecForTransactionByIdRequest, codecForTransactionsRequest, codecForWithdrawFakebankRequest, codecForWithdrawTestBalance, CoinDumpJson, CoreApiResponse, DenominationInfo, Duration, durationFromSpec, durationMin, ExchangeFullDetails, ExchangeListItem, ExchangesListResponse, FeeDescription, GetExchangeTosResult, j2s, KnownBankAccounts, Logger, ManualWithdrawalDetails, NotificationType, OperationMap, parsePaytoUri, RefreshReason, TalerErrorCode, TalerErrorDetail, KnownBankAccountsInfo, codecForAddKnownBankAccounts, codecForForgetKnownBankAccounts, URL, WalletCoreVersion, WalletNotification, } from "@gnu-taler/taler-util"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { CryptoDispatcher, CryptoWorkerFactory, } from "./crypto/workers/cryptoDispatcher.js"; import { clearDatabase } from "./db-utils.js"; import { AuditorTrustRecord, CoinRecord, CoinSourceType, CoinStatus, DenominationRecord, exportDb, importDb, WalletStoresV1, } from "./db.js"; import { getErrorDetailFromException, makeErrorDetail, TalerError, } from "./errors.js"; import { ActiveLongpollInfo, ExchangeOperations, InternalWalletState, MerchantInfo, MerchantOperations, NotificationListener, RecoupOperations, } from "./internal-wallet-state.js"; import { exportBackup } from "./operations/backup/export.js"; import { addBackupProvider, codecForAddBackupProviderRequest, codecForRemoveBackupProvider, codecForRunBackupCycle, getBackupInfo, getBackupRecovery, importBackupPlain, loadBackupRecovery, processBackupForProvider, removeBackupProvider, runBackupCycle, } from "./operations/backup/index.js"; import { setWalletDeviceId } from "./operations/backup/state.js"; import { getBalances } from "./operations/balance.js"; import { createDepositGroup, getFeeForDeposit, prepareDepositGroup, processDepositGroup, trackDepositGroup, } from "./operations/deposits.js"; import { acceptExchangeTermsOfService, downloadTosFromAcceptedFormat, getExchangeDetails, getExchangeRequestTimeout, getExchangeTrust, provideExchangeRecordInTx, updateExchangeFromUrl, updateExchangeFromUrlHandler, updateExchangeTermsOfService, } from "./operations/exchanges.js"; import { getMerchantInfo } from "./operations/merchants.js"; import { confirmPay, getContractTermsDetails, preparePayForUri, processDownloadProposal, processPurchasePay, } from "./operations/pay.js"; import { acceptPeerPullPayment, acceptPeerPushPayment, checkPeerPullPayment, checkPeerPushPayment, initiatePeerRequestForPay, initiatePeerToPeerPush, } from "./operations/peer-to-peer.js"; import { getPendingOperations } from "./operations/pending.js"; import { createRecoupGroup, processRecoupGroup, processRecoupGroupHandler, } from "./operations/recoup.js"; import { autoRefresh, createRefreshGroup, processRefreshGroup, } from "./operations/refresh.js"; import { abortFailedPayWithRefund, applyRefund, applyRefundFromPurchaseId, prepareRefund, processPurchaseQueryRefund, } from "./operations/refund.js"; import { runIntegrationTest, testPay, withdrawTestBalance, } from "./operations/testing.js"; import { acceptTip, prepareTip, processTip } from "./operations/tip.js"; import { deleteTransaction, getTransactionById, getTransactions, retryTransaction, } from "./operations/transactions.js"; import { acceptWithdrawalFromUri, createManualWithdrawal, getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, processWithdrawalGroup, } from "./operations/withdraw.js"; import { PendingOperationsResponse, PendingTaskInfo, PendingTaskType, } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js"; import { createDenominationTimeline } from "./util/denominations.js"; import { HttpRequestLibrary, readSuccessResponseJsonOrThrow, } from "./util/http.js"; import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; import { AsyncCondition, OpenedPromise, openPromise, } from "./util/promiseUtils.js"; import { DbAccess, GetReadWriteAccess } from "./util/query.js"; import { OperationAttemptResult, OperationAttemptResultType, RetryInfo, } from "./util/retries.js"; import { TimerAPI, TimerGroup } from "./util/timer.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_MERCHANT_PROTOCOL_VERSION, } from "./versions.js"; import { WalletCoreApiClient } from "./wallet-api-types.js"; const builtinAuditors: AuditorTrustRecord[] = [ { currency: "KUDOS", auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0", auditorBaseUrl: "https://auditor.demo.taler.net/", uids: ["5P25XF8TVQP9AW6VYGY2KV47WT5Y3ZXFSJAA570GJPX5SVJXKBVG"], }, ]; const builtinExchanges: string[] = ["https://exchange.demo.taler.net/"]; const logger = new Logger("wallet.ts"); async function getWithdrawalDetailsForAmount( ws: InternalWalletState, exchangeBaseUrl: string, amount: AmountJson, restrictAge: number | undefined, ): Promise { const wi = await getExchangeWithdrawalInfo( ws, exchangeBaseUrl, amount, restrictAge, ); const paytoUris = wi.exchangeDetails.wireInfo.accounts.map( (x) => x.payto_uri, ); if (!paytoUris) { throw Error("exchange is in invalid state"); } return { amountRaw: Amounts.stringify(amount), amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), paytoUris, tosAccepted: wi.termsOfServiceAccepted, }; } /** * Call the right handler for a pending operation without doing * any special error handling. */ async function callOperationHandler( ws: InternalWalletState, pending: PendingTaskInfo, forceNow = false, ): Promise { switch (pending.type) { case PendingTaskType.ExchangeUpdate: return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl, { forceNow, }); case PendingTaskType.Refresh: return await processRefreshGroup(ws, pending.refreshGroupId, { forceNow, }); case PendingTaskType.Withdraw: return await processWithdrawalGroup(ws, pending.withdrawalGroupId, { forceNow, }); case PendingTaskType.ProposalDownload: return await processDownloadProposal(ws, pending.proposalId, { forceNow, }); case PendingTaskType.TipPickup: return await processTip(ws, pending.tipId, { forceNow }); case PendingTaskType.Pay: return await processPurchasePay(ws, pending.proposalId, { forceNow }); case PendingTaskType.RefundQuery: return await processPurchaseQueryRefund(ws, pending.proposalId, { forceNow, }); case PendingTaskType.Recoup: return await processRecoupGroupHandler(ws, pending.recoupGroupId, { forceNow, }); case PendingTaskType.ExchangeCheckRefresh: return await autoRefresh(ws, pending.exchangeBaseUrl); case PendingTaskType.Deposit: { return await processDepositGroup(ws, pending.depositGroupId, { forceNow, }); } case PendingTaskType.Backup: return await processBackupForProvider(ws, pending.backupProviderBaseUrl); default: return assertUnreachable(pending); } throw Error(`not reached ${pending.type}`); } export async function storeOperationError( ws: InternalWalletState, pendingTaskId: string, e: TalerErrorDetail, ): Promise { await ws.db .mktx((x) => [x.operationRetries]) .runReadWrite(async (tx) => { let retryRecord = await tx.operationRetries.get(pendingTaskId); if (!retryRecord) { retryRecord = { id: pendingTaskId, lastError: e, retryInfo: RetryInfo.reset(), }; } else { retryRecord.lastError = e; retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); } await tx.operationRetries.put(retryRecord); }); } export async function storeOperationFinished( ws: InternalWalletState, pendingTaskId: string, ): Promise { await ws.db .mktx((x) => [x.operationRetries]) .runReadWrite(async (tx) => { await tx.operationRetries.delete(pendingTaskId); }); } export async function storeOperationPending( ws: InternalWalletState, pendingTaskId: string, ): Promise { await ws.db .mktx((x) => [x.operationRetries]) .runReadWrite(async (tx) => { let retryRecord = await tx.operationRetries.get(pendingTaskId); if (!retryRecord) { retryRecord = { id: pendingTaskId, retryInfo: RetryInfo.reset(), }; } else { delete retryRecord.lastError; retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); } await tx.operationRetries.put(retryRecord); }); } export async function runOperationWithErrorReporting( ws: InternalWalletState, opId: string, f: () => Promise, ): Promise { let maybeError: TalerErrorDetail | undefined; try { const resp = await f(); switch (resp.type) { case OperationAttemptResultType.Error: return await storeOperationError(ws, opId, resp.errorDetail); case OperationAttemptResultType.Finished: return await storeOperationFinished(ws, opId); case OperationAttemptResultType.Pending: return await storeOperationPending(ws, opId); case OperationAttemptResultType.Longpoll: break; } } catch (e) { if (e instanceof TalerError) { logger.warn("operation processed resulted in error"); logger.warn(`error was: ${j2s(e.errorDetail)}`); maybeError = e.errorDetail; return await storeOperationError(ws, opId, maybeError!); } else if (e instanceof Error) { // This is a bug, as we expect pending operations to always // do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED // or return something. logger.error(`Uncaught exception: ${e.message}`); logger.error(`Stack: ${e.stack}`); maybeError = makeErrorDetail( TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, { stack: e.stack, }, `unexpected exception (message: ${e.message})`, ); return await storeOperationError(ws, opId, maybeError); } else { logger.error("Uncaught exception, value is not even an error."); maybeError = makeErrorDetail( TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, {}, `unexpected exception (not even an error)`, ); return await storeOperationError(ws, opId, maybeError); } } } /** * Process pending operations. */ export async function runPending( ws: InternalWalletState, forceNow = false, ): Promise { const pendingOpsResponse = await getPendingOperations(ws); for (const p of pendingOpsResponse.pendingOperations) { if (!forceNow && !AbsoluteTime.isExpired(p.timestampDue)) { continue; } await runOperationWithErrorReporting(ws, p.id, async () => { logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`); return await callOperationHandler(ws, p, forceNow); }); } } export interface RetryLoopOpts { /** * Stop when the number of retries is exceeded for any pending * operation. */ maxRetries?: number; /** * Stop the retry loop when all lifeness-giving pending operations * are done. * * Defaults to false. */ stopWhenDone?: boolean; } export interface TaskLoopResult { /** * Was the maximum number of retries exceeded in a task? */ retriesExceeded: boolean; } /** * Main retry loop of the wallet. * * Looks up pending operations from the wallet, runs them, repeat. */ async function runTaskLoop( ws: InternalWalletState, opts: RetryLoopOpts = {}, ): Promise { logger.info(`running task loop opts=${j2s(opts)}`); let retriesExceeded = false; for (let iteration = 0; !ws.stopped; iteration++) { const pending = await getPendingOperations(ws); logger.trace(`pending operations: ${j2s(pending)}`); let numGivingLiveness = 0; let numDue = 0; let minDue: AbsoluteTime = AbsoluteTime.never(); for (const p of pending.pendingOperations) { const maxRetries = opts.maxRetries; if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) { retriesExceeded = true; logger.warn( `skipping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`, ); continue; } minDue = AbsoluteTime.min(minDue, p.timestampDue); if (AbsoluteTime.isExpired(p.timestampDue) && !ws.activeLongpoll[p.id]) { numDue++; } if (p.givesLifeness) { numGivingLiveness++; } } if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) { logger.warn(`stopping, as no pending operations have lifeness`); return { retriesExceeded, }; } // Make sure that we run tasks that don't give lifeness at least // one time. if (iteration !== 0 && numDue === 0) { // We've executed pending, due operations at least one. // Now we don't have any more operations available, // and need to wait. // Wait for at most 5 seconds to the next check. const dt = durationMin( durationFromSpec({ seconds: 5, }), Duration.getRemaining(minDue), ); logger.trace(`waiting for at most ${dt.d_ms} ms`); const timeout = ws.timerGroup.resolveAfter(dt); ws.notify({ type: NotificationType.WaitingForRetry, numGivingLiveness, numPending: pending.pendingOperations.length, }); // Wait until either the timeout, or we are notified (via the latch) // that more work might be available. await Promise.race([timeout, ws.latch.wait()]); } else { logger.trace( `running ${pending.pendingOperations.length} pending operations`, ); for (const p of pending.pendingOperations) { if (!AbsoluteTime.isExpired(p.timestampDue)) { continue; } await runOperationWithErrorReporting(ws, p.id, async () => { logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`); return await callOperationHandler(ws, p); }); ws.notify({ type: NotificationType.PendingOperationProcessed, }); } } } logger.trace("exiting wallet retry loop"); return { retriesExceeded, }; } /** * Insert the hard-coded defaults for exchanges, coins and * auditors into the database, unless these defaults have * already been applied. */ async function fillDefaults(ws: InternalWalletState): Promise { await ws.db .mktx((x) => [x.config, x.auditorTrust, x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { const appliedRec = await tx.config.get("currencyDefaultsApplied"); let alreadyApplied = appliedRec ? !!appliedRec.value : false; if (alreadyApplied) { logger.info("defaults already applied"); return; } for (const c of builtinAuditors) { await tx.auditorTrust.put(c); } for (const baseUrl of builtinExchanges) { const now = AbsoluteTime.now(); provideExchangeRecordInTx(ws, tx, baseUrl, now); } await tx.config.put({ key: "currencyDefaultsApplied", value: true, }); }); } async function getExchangeTos( ws: InternalWalletState, exchangeBaseUrl: string, acceptedFormat?: string[], ): Promise { // FIXME: download ToS in acceptable format if passed! const { exchangeDetails } = await updateExchangeFromUrl(ws, exchangeBaseUrl); const content = exchangeDetails.termsOfServiceText; const currentEtag = exchangeDetails.termsOfServiceLastEtag; const contentType = exchangeDetails.termsOfServiceContentType; if ( content === undefined || currentEtag === undefined || contentType === undefined ) { throw Error("exchange is in invalid state"); } if ( acceptedFormat && acceptedFormat.findIndex((f) => f === contentType) !== -1 ) { return { acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag, currentEtag, content, contentType, }; } const tosDownload = await downloadTosFromAcceptedFormat( ws, exchangeBaseUrl, getExchangeRequestTimeout(), acceptedFormat, ); if (tosDownload.tosContentType === contentType) { return { acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag, currentEtag, content, contentType, }; } await updateExchangeTermsOfService(ws, exchangeBaseUrl, tosDownload); return { acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag, currentEtag: tosDownload.tosEtag, content: tosDownload.tosText, contentType: tosDownload.tosContentType, }; } /** * List bank accounts known to the wallet from * previous withdrawals. */ async function listKnownBankAccounts( ws: InternalWalletState, currency?: string, ): Promise { const accounts: KnownBankAccountsInfo[] = []; await ws.db .mktx((x) => [x.bankAccounts]) .runReadOnly(async (tx) => { const knownAccounts = await tx.bankAccounts.iter().toArray(); for (const r of knownAccounts) { if (currency && currency !== r.currency) { continue; } const payto = parsePaytoUri(r.uri); if (payto) { accounts.push({ uri: payto, alias: r.alias, kyc_completed: r.kycCompleted, currency: r.currency, }); } } }); return { accounts }; } /** */ async function addKnownBankAccounts( ws: InternalWalletState, payto: string, alias: string, currency: string, ): Promise { await ws.db .mktx((x) => [x.bankAccounts]) .runReadWrite(async (tx) => { tx.bankAccounts.put({ uri: payto, alias: alias, currency: currency, kycCompleted: false, }); }); return; } /** */ async function forgetKnownBankAccounts( ws: InternalWalletState, payto: string, ): Promise { await ws.db .mktx((x) => [x.bankAccounts]) .runReadWrite(async (tx) => { const account = await tx.bankAccounts.get(payto); if (!account) { throw Error(`account not found: ${payto}`); } tx.bankAccounts.delete(account.uri); }); return; } async function getExchanges( ws: InternalWalletState, ): Promise { const exchanges: ExchangeListItem[] = []; await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations]) .runReadOnly(async (tx) => { const exchangeRecords = await tx.exchanges.iter().toArray(); for (const r of exchangeRecords) { const dp = r.detailsPointer; if (!dp) { continue; } const { currency } = dp; const exchangeDetails = await getExchangeDetails(tx, r.baseUrl); if (!exchangeDetails) { continue; } const denominations = await tx.denominations.indexes.byExchangeBaseUrl .iter(r.baseUrl) .toArray(); if (!denominations) { continue; } exchanges.push({ exchangeBaseUrl: r.baseUrl, currency, tos: { acceptedVersion: exchangeDetails.termsOfServiceAcceptedEtag, currentVersion: exchangeDetails.termsOfServiceLastEtag, contentType: exchangeDetails.termsOfServiceContentType, content: exchangeDetails.termsOfServiceText, }, paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), }); } }); return { exchanges }; } async function getExchangeDetailedInfo( ws: InternalWalletState, exchangeBaseurl: string, ): Promise { //TODO: should we use the forceUpdate parameter? const exchange = await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations]) .runReadOnly(async (tx) => { const ex = await tx.exchanges.get(exchangeBaseurl); const dp = ex?.detailsPointer; if (!dp) { return; } const { currency } = dp; const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl); if (!exchangeDetails) { return; } const denominationRecords = await tx.denominations.indexes.byExchangeBaseUrl .iter(ex.baseUrl) .toArray(); if (!denominationRecords) { return; } const denominations: DenominationInfo[] = denominationRecords.map((x) => DenominationRecord.toDenomInfo(x), ); return { info: { exchangeBaseUrl: ex.baseUrl, currency, tos: { acceptedVersion: exchangeDetails.termsOfServiceAcceptedEtag, currentVersion: exchangeDetails.termsOfServiceLastEtag, contentType: exchangeDetails.termsOfServiceContentType, content: exchangeDetails.termsOfServiceText, }, paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), auditors: exchangeDetails.auditors, wireInfo: exchangeDetails.wireInfo, }, denominations, }; }); if (!exchange) { throw Error(`exchange with base url "${exchangeBaseurl}" not found`); } const feesDescription: OperationMap = { deposit: createDenominationTimeline( exchange.denominations, "stampExpireDeposit", "feeDeposit", ), refresh: createDenominationTimeline( exchange.denominations, "stampExpireWithdraw", "feeRefresh", ), refund: createDenominationTimeline( exchange.denominations, "stampExpireWithdraw", "feeRefund", ), withdraw: createDenominationTimeline( exchange.denominations, "stampExpireWithdraw", "feeWithdraw", ), }; return { ...exchange.info, feesDescription, }; } export async function makeCoinAvailable( ws: InternalWalletState, tx: GetReadWriteAccess<{ coins: typeof WalletStoresV1.coins; coinAvailability: typeof WalletStoresV1.coinAvailability; denominations: typeof WalletStoresV1.denominations; }>, coinRecord: CoinRecord, ): Promise { 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, amountFrac: denom.amountFrac, amountVal: denom.amountVal, currency: denom.currency, denomPubHash: denom.denomPubHash, exchangeBaseUrl: denom.exchangeBaseUrl, freshCoinCount: 0, }; } car.freshCoinCount++; await tx.coins.put(coinRecord); await tx.coinAvailability.put(car); } export interface CoinsSpendInfo { coinPubs: string[]; contributions: AmountJson[]; refreshReason: RefreshReason; /** * Identifier for what the coin has been spent for. */ allocationId: string; } export async function spendCoins( ws: InternalWalletState, tx: GetReadWriteAccess<{ coins: typeof WalletStoresV1.coins; coinAvailability: typeof WalletStoresV1.coinAvailability; refreshGroups: typeof WalletStoresV1.refreshGroups; denominations: typeof WalletStoresV1.denominations; }>, csi: CoinsSpendInfo, ): Promise { 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 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.allocation; if (!alloc) { continue; } if (alloc.id !== csi.allocationId) { // FIXME: assign error code 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.allocation = { id: csi.allocationId, amount: Amounts.stringify(contrib), }; const remaining = Amounts.sub(coin.currentAmount, contrib); if (remaining.saturated) { throw Error("not enough remaining balance on coin for payment"); } coin.currentAmount = remaining.amount; checkDbInvariant(!!coinAvailability); if (coinAvailability.freshCoinCount === 0) { throw Error( `invalid coin count ${coinAvailability.freshCoinCount} in DB`, ); } coinAvailability.freshCoinCount--; await tx.coins.put(coin); await tx.coinAvailability.put(coinAvailability); } const refreshCoinPubs = csi.coinPubs.map((x) => ({ coinPub: x, })); await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant); } async function setCoinSuspended( ws: InternalWalletState, coinPub: string, suspended: boolean, ): Promise { await ws.db .mktx((x) => [x.coins, x.coinAvailability]) .runReadWrite(async (tx) => { const c = await tx.coins.get(coinPub); if (!c) { logger.warn(`coin ${coinPub} not found, won't suspend`); return; } const coinAvailability = await tx.coinAvailability.get([ c.exchangeBaseUrl, c.denomPubHash, c.maxAge, ]); checkDbInvariant(!!coinAvailability); if (suspended) { if (c.status !== CoinStatus.Fresh) { return; } if (coinAvailability.freshCoinCount === 0) { throw Error( `invalid coin count ${coinAvailability.freshCoinCount} in DB`, ); } coinAvailability.freshCoinCount--; c.status = CoinStatus.FreshSuspended; } else { if (c.status == CoinStatus.Dormant) { return; } coinAvailability.freshCoinCount++; c.status = CoinStatus.Fresh; } await tx.coins.put(c); await tx.coinAvailability.put(coinAvailability); }); } /** * Dump the public information of coins we have in an easy-to-process format. */ async function dumpCoins(ws: InternalWalletState): Promise { const coinsJson: CoinDumpJson = { coins: [] }; logger.info("dumping coins"); await ws.db .mktx((x) => [x.coins, x.denominations, x.withdrawalGroups]) .runReadOnly(async (tx) => { const coins = await tx.coins.iter().toArray(); for (const c of coins) { const denom = await tx.denominations.get([ c.exchangeBaseUrl, c.denomPubHash, ]); if (!denom) { console.error("no denom session found for coin"); continue; } const cs = c.coinSource; let refreshParentCoinPub: string | undefined; if (cs.type == CoinSourceType.Refresh) { refreshParentCoinPub = cs.oldCoinPub; } let withdrawalReservePub: string | undefined; if (cs.type == CoinSourceType.Withdraw) { const ws = await tx.withdrawalGroups.get(cs.withdrawalGroupId); if (!ws) { console.error("no withdrawal session found for coin"); continue; } withdrawalReservePub = ws.reservePub; } const denomInfo = await ws.getDenomInfo( ws, tx, c.exchangeBaseUrl, c.denomPubHash, ); if (!denomInfo) { console.error("no denomination found for coin"); continue; } coinsJson.coins.push({ coin_pub: c.coinPub, denom_pub: denomInfo.denomPub, denom_pub_hash: c.denomPubHash, denom_value: Amounts.stringify({ value: denom.amountVal, currency: denom.currency, fraction: denom.amountFrac, }), exchange_base_url: c.exchangeBaseUrl, refresh_parent_coin_pub: refreshParentCoinPub, remaining_value: Amounts.stringify(c.currentAmount), withdrawal_reserve_pub: withdrawalReservePub, coin_suspended: c.status === CoinStatus.FreshSuspended, ageCommitmentProof: c.ageCommitmentProof, }); } }); return coinsJson; } /** * Get an API client from an internal wallet state object. */ export async function getClientFromWalletState( ws: InternalWalletState, ): Promise { let id = 0; const client: WalletCoreApiClient = { async call(op, payload): Promise { const res = await handleCoreApiRequest(ws, op, `${id++}`, payload); switch (res.type) { case "error": throw TalerError.fromUncheckedDetail(res.error); case "response": return res.result; } }, }; return client; } declare const __VERSION__: string; declare const __GIT_HASH__: string; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev"; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; /** * Implementation of the "wallet-core" API. */ async function dispatchRequestInternal( ws: InternalWalletState, operation: string, payload: unknown, ): Promise> { if (!ws.initCalled && operation !== "initWallet") { throw Error( `wallet must be initialized before running operation ${operation}`, ); } // FIXME: Can we make this more type-safe by using the request/response type // definitions we already have? switch (operation) { case "initWallet": { logger.info("initializing wallet"); ws.initCalled = true; if (typeof payload === "object" && (payload as any).skipDefaults) { logger.info("skipping defaults"); } else { logger.info("filling defaults"); await fillDefaults(ws); } return {}; } case "withdrawTestkudos": { await withdrawTestBalance(ws, { amount: "TESTKUDOS:10", bankBaseUrl: "https://bank.test.taler.net/", bankAccessApiBaseUrl: "https://bank.test.taler.net/", exchangeBaseUrl: "https://exchange.test.taler.net/", }); return {}; } case "withdrawTestBalance": { const req = codecForWithdrawTestBalance().decode(payload); await withdrawTestBalance(ws, req); return {}; } case "runIntegrationTest": { const req = codecForIntegrationTestArgs().decode(payload); await runIntegrationTest(ws, req); return {}; } case "testPay": { const req = codecForTestPayArgs().decode(payload); return await testPay(ws, req); } case "getTransactions": { const req = codecForTransactionsRequest().decode(payload); return await getTransactions(ws, req); } case "getTransactionById": { const req = codecForTransactionByIdRequest().decode(payload); return await getTransactionById(ws, req); } case "addExchange": { const req = codecForAddExchangeRequest().decode(payload); await updateExchangeFromUrl(ws, req.exchangeBaseUrl, { forceNow: req.forceUpdate, }); return {}; } case "listExchanges": { return await getExchanges(ws); } case "getExchangeDetailedInfo": { const req = codecForAddExchangeRequest().decode(payload); return await getExchangeDetailedInfo(ws, req.exchangeBaseUrl); } case "listKnownBankAccounts": { const req = codecForListKnownBankAccounts().decode(payload); return await listKnownBankAccounts(ws, req.currency); } case "addKnownBankAccounts": { const req = codecForAddKnownBankAccounts().decode(payload); await addKnownBankAccounts(ws, req.payto, req.alias, req.currency); return {}; } case "forgetKnownBankAccounts": { const req = codecForForgetKnownBankAccounts().decode(payload); await forgetKnownBankAccounts(ws, req.payto); return {}; } case "getWithdrawalDetailsForUri": { const req = codecForGetWithdrawalDetailsForUri().decode(payload); return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri); } case "getExchangeWithdrawalInfo": { const req = codecForGetExchangeWithdrawalInfo().decode(payload); return await getExchangeWithdrawalInfo( ws, req.exchangeBaseUrl, req.amount, req.ageRestricted, ); } case "acceptManualWithdrawal": { const req = codecForAcceptManualWithdrawalRequet().decode(payload); const res = await createManualWithdrawal(ws, { amount: Amounts.parseOrThrow(req.amount), exchangeBaseUrl: req.exchangeBaseUrl, restrictAge: req.restrictAge, }); return res; } case "getWithdrawalDetailsForAmount": { const req = codecForGetWithdrawalDetailsForAmountRequest().decode(payload); return await getWithdrawalDetailsForAmount( ws, req.exchangeBaseUrl, Amounts.parseOrThrow(req.amount), req.restrictAge, ); } case "getBalances": { return await getBalances(ws); } case "getPendingOperations": { return await getPendingOperations(ws); } case "setExchangeTosAccepted": { const req = codecForAcceptExchangeTosRequest().decode(payload); await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag); return {}; } case "applyRefund": { const req = codecForApplyRefundRequest().decode(payload); return await applyRefund(ws, req.talerRefundUri); } case "applyRefundFromPurchaseId": { const req = codecForApplyRefundFromPurchaseIdRequest().decode(payload); return await applyRefundFromPurchaseId(ws, req.purchaseId); } case "acceptBankIntegratedWithdrawal": { const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); return await acceptWithdrawalFromUri(ws, { selectedExchange: req.exchangeBaseUrl, talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, }); } case "getExchangeTos": { const req = codecForGetExchangeTosRequest().decode(payload); return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat); } case "getContractTermsDetails": { const req = codecForGetContractTermsDetails().decode(payload); return getContractTermsDetails(ws, req.proposalId); } case "retryPendingNow": { await runPending(ws, true); return {}; } // FIXME: Deprecate one of the aliases! case "preparePayForUri": case "preparePay": { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(ws, req.talerPayUri); } case "confirmPay": { const req = codecForConfirmPayRequest().decode(payload); return await confirmPay(ws, req.proposalId, req.sessionId); } case "abortFailedPayWithRefund": { const req = codecForAbortPayWithRefundRequest().decode(payload); await abortFailedPayWithRefund(ws, req.proposalId); return {}; } case "dumpCoins": { return await dumpCoins(ws); } case "setCoinSuspended": { const req = codecForSetCoinSuspendedRequest().decode(payload); await setCoinSuspended(ws, req.coinPub, req.suspended); return {}; } case "forceRefresh": { const req = codecForForceRefreshRequest().decode(payload); const coinPubs = req.coinPubList.map((x) => ({ coinPub: x })); const refreshGroupId = await ws.db .mktx((x) => [ x.refreshGroups, x.coinAvailability, x.denominations, x.coins, ]) .runReadWrite(async (tx) => { return await createRefreshGroup( ws, tx, coinPubs, RefreshReason.Manual, ); }); processRefreshGroup(ws, refreshGroupId.refreshGroupId, { forceNow: true, }).catch((x) => { logger.error(x); }); return { refreshGroupId, }; } case "prepareTip": { const req = codecForPrepareTipRequest().decode(payload); return await prepareTip(ws, req.talerTipUri); } case "prepareRefund": { const req = codecForPrepareRefundRequest().decode(payload); return await prepareRefund(ws, req.talerRefundUri); } case "acceptTip": { const req = codecForAcceptTipRequest().decode(payload); return await acceptTip(ws, req.walletTipId); } case "exportBackupPlain": { return exportBackup(ws); } case "addBackupProvider": { const req = codecForAddBackupProviderRequest().decode(payload); await addBackupProvider(ws, req); return {}; } case "runBackupCycle": { const req = codecForRunBackupCycle().decode(payload); await runBackupCycle(ws, req); return {}; } case "removeBackupProvider": { const req = codecForRemoveBackupProvider().decode(payload); await removeBackupProvider(ws, req); return {}; } case "exportBackupRecovery": { const resp = await getBackupRecovery(ws); return resp; } case "importBackupRecovery": { const req = codecForAny().decode(payload); await loadBackupRecovery(ws, req); return {}; } case "getBackupInfo": { const resp = await getBackupInfo(ws); return resp; } case "getFeeForDeposit": { const req = codecForGetFeeForDeposit().decode(payload); return await getFeeForDeposit(ws, req); } case "prepareDeposit": { const req = codecForPrepareDepositRequest().decode(payload); return await prepareDepositGroup(ws, req); } case "createDepositGroup": { const req = codecForCreateDepositGroupRequest().decode(payload); return await createDepositGroup(ws, req); } case "trackDepositGroup": { const req = codecForTrackDepositGroupRequest().decode(payload); return trackDepositGroup(ws, req); } case "deleteTransaction": { const req = codecForDeleteTransactionRequest().decode(payload); await deleteTransaction(ws, req.transactionId); return {}; } case "retryTransaction": { const req = codecForRetryTransactionRequest().decode(payload); await retryTransaction(ws, req.transactionId); return {}; } case "setWalletDeviceId": { const req = codecForSetWalletDeviceIdRequest().decode(payload); await setWalletDeviceId(ws, req.walletDeviceId); return {}; } case "listCurrencies": { return await ws.db .mktx((x) => [x.auditorTrust, x.exchangeTrust]) .runReadOnly(async (tx) => { const trustedAuditors = await tx.auditorTrust.iter().toArray(); const trustedExchanges = await tx.exchangeTrust.iter().toArray(); return { trustedAuditors: trustedAuditors.map((x) => ({ currency: x.currency, auditorBaseUrl: x.auditorBaseUrl, auditorPub: x.auditorPub, })), trustedExchanges: trustedExchanges.map((x) => ({ currency: x.currency, exchangeBaseUrl: x.exchangeBaseUrl, exchangeMasterPub: x.exchangeMasterPub, })), }; }); } case "withdrawFakebank": { const req = codecForWithdrawFakebankRequest().decode(payload); const amount = Amounts.parseOrThrow(req.amount); const details = await getWithdrawalDetailsForAmount( ws, req.exchange, amount, undefined, ); const wres = await createManualWithdrawal(ws, { amount: amount, exchangeBaseUrl: req.exchange, }); const paytoUri = details.paytoUris[0]; const pt = parsePaytoUri(paytoUri); if (!pt) { throw Error("failed to parse payto URI"); } const components = pt.targetPath.split("/"); const creditorAcct = components[components.length - 1]; logger.info(`making testbank transfer to '${creditorAcct}'`); const fbReq = await ws.http.postJson( new URL(`${creditorAcct}/admin/add-incoming`, req.bank).href, { amount: Amounts.stringify(amount), reserve_pub: wres.reservePub, debit_account: "payto://x-taler-bank/localhost/testdebtor", }, ); const fbResp = await readSuccessResponseJsonOrThrow(fbReq, codecForAny()); logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`); return {}; } case "testCrypto": { return await ws.cryptoApi.hashString({ str: "hello world" }); } case "clearDb": await clearDatabase(ws.db.idbHandle()); return {}; case "recycle": { const backup = await exportBackup(ws); await clearDatabase(ws.db.idbHandle()); await importBackupPlain(ws, backup); return {}; } case "exportDb": { const dbDump = await exportDb(ws.db.idbHandle()); return dbDump; } case "importDb": { const req = codecForImportDbRequest().decode(payload); await importDb(ws.db.idbHandle(), req.dump); return []; } case "initiatePeerPushPayment": { const req = codecForInitiatePeerPushPaymentRequest().decode(payload); return await initiatePeerToPeerPush(ws, req); } case "checkPeerPushPayment": { const req = codecForCheckPeerPushPaymentRequest().decode(payload); return await checkPeerPushPayment(ws, req); } case "acceptPeerPushPayment": { const req = codecForAcceptPeerPushPaymentRequest().decode(payload); return await acceptPeerPushPayment(ws, req); } case "initiatePeerPullPayment": { const req = codecForInitiatePeerPullPaymentRequest().decode(payload); return await initiatePeerRequestForPay(ws, req); } case "checkPeerPullPayment": { const req = codecForCheckPeerPullPaymentRequest().decode(payload); return await checkPeerPullPayment(ws, req); } case "acceptPeerPullPayment": { const req = codecForAcceptPeerPullPaymentRequest().decode(payload); return await acceptPeerPullPayment(ws, req); } case "getVersion": { const version: WalletCoreVersion = { hash: GIT_HASH, version: VERSION, exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, }; return version; } } throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, { operation, }, "unknown operation", ); } /** * Handle a request to the wallet-core API. */ export async function handleCoreApiRequest( ws: InternalWalletState, operation: string, id: string, payload: unknown, ): Promise { try { const result = await dispatchRequestInternal(ws, operation, payload); return { type: "response", operation, id, result, }; } catch (e: any) { const err = getErrorDetailFromException(e); logger.info(`finished wallet core request with error: ${j2s(err)}`); return { type: "error", operation, id, error: err, }; } } /** * Public handle to a running wallet. */ export class Wallet { private ws: InternalWalletState; private _client: WalletCoreApiClient | undefined; private constructor( db: DbAccess, http: HttpRequestLibrary, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ) { this.ws = new InternalWalletStateImpl(db, http, timer, cryptoWorkerFactory); } get client(): WalletCoreApiClient { if (!this._client) { throw Error(); } return this._client; } /** * Trust the exchange, do not validate signatures. * Only used to benchmark the exchange. */ setInsecureTrustExchange(): void { this.ws.insecureTrustExchange = true; } setBatchWithdrawal(enable: boolean): void { this.ws.batchWithdrawal = enable; } static async create( db: DbAccess, http: HttpRequestLibrary, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ): Promise { const w = new Wallet(db, http, timer, cryptoWorkerFactory); w._client = await getClientFromWalletState(w.ws); return w; } addNotificationListener(f: (n: WalletNotification) => void): void { return this.ws.addNotificationListener(f); } stop(): void { this.ws.stop(); } runPending(forceNow = false): Promise { return runPending(this.ws, forceNow); } runTaskLoop(opts?: RetryLoopOpts): Promise { return runTaskLoop(this.ws, opts); } handleCoreApiRequest( operation: string, id: string, payload: unknown, ): Promise { return handleCoreApiRequest(this.ws, operation, id, payload); } } /** * Internal state of the wallet. * * This ties together all the operation implementations. */ class InternalWalletStateImpl implements InternalWalletState { /** * @see {@link InternalWalletState.activeLongpoll} */ activeLongpoll: ActiveLongpollInfo = {}; cryptoApi: TalerCryptoInterface; cryptoDispatcher: CryptoDispatcher; merchantInfoCache: Record = {}; insecureTrustExchange = false; batchWithdrawal = false; readonly timerGroup: TimerGroup; latch = new AsyncCondition(); stopped = false; listeners: NotificationListener[] = []; initCalled = false; exchangeOps: ExchangeOperations = { getExchangeDetails, getExchangeTrust, updateExchangeFromUrl, }; recoupOps: RecoupOperations = { createRecoupGroup, processRecoupGroup, }; merchantOps: MerchantOperations = { getMerchantInfo, }; // FIXME: Use an LRU cache here. private denomCache: Record = {}; /** * Promises that are waiting for a particular resource. */ private resourceWaiters: Record[]> = {}; /** * Resources that are currently locked. */ private resourceLocks: Set = new Set(); constructor( // FIXME: Make this a getter and make // the actual value nullable. // Check if we are in a DB migration / garbage collection // and throw an error in that case. public db: DbAccess, public http: HttpRequestLibrary, public timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ) { this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); this.cryptoApi = this.cryptoDispatcher.cryptoApi; this.timerGroup = new TimerGroup(timer); } async getDenomInfo( ws: InternalWalletState, tx: GetReadWriteAccess<{ denominations: typeof WalletStoresV1.denominations; }>, exchangeBaseUrl: string, denomPubHash: string, ): Promise { const key = `${exchangeBaseUrl}:${denomPubHash}`; const cached = this.denomCache[key]; if (cached) { return cached; } const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); if (d) { return DenominationRecord.toDenomInfo(d); } return undefined; } notify(n: WalletNotification): void { logger.trace("Notification", n); for (const l of this.listeners) { const nc = JSON.parse(JSON.stringify(n)); setTimeout(() => { l(nc); }, 0); } } addNotificationListener(f: (n: WalletNotification) => void): void { this.listeners.push(f); } /** * Stop ongoing processing. */ stop(): void { logger.trace("stopping (at internal wallet state)"); this.stopped = true; this.timerGroup.stopCurrentAndFutureTimers(); this.cryptoDispatcher.stop(); for (const key of Object.keys(this.activeLongpoll)) { logger.trace(`cancelling active longpoll ${key}`); this.activeLongpoll[key].cancel(); } } async runUntilDone( req: { maxRetries?: number; } = {}, ): Promise { await runTaskLoop(this, { ...req, stopWhenDone: true }); } /** * Run an async function after acquiring a list of locks, identified * by string tokens. */ async runSequentialized( tokens: string[], f: () => Promise, ): Promise { // Make sure locks are always acquired in the same order tokens = [...tokens].sort(); for (const token of tokens) { if (this.resourceLocks.has(token)) { const p = openPromise(); let waitList = this.resourceWaiters[token]; if (!waitList) { waitList = this.resourceWaiters[token] = []; } waitList.push(p); await p.promise; } this.resourceLocks.add(token); } try { logger.trace(`begin exclusive execution on ${JSON.stringify(tokens)}`); const result = await f(); logger.trace(`end exclusive execution on ${JSON.stringify(tokens)}`); return result; } finally { for (const token of tokens) { this.resourceLocks.delete(token); let waiter = (this.resourceWaiters[token] ?? []).shift(); if (waiter) { waiter.resolve(); } } } } }