/* 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 independent from the underlying * browser extension interface. */ /** * Imports. */ import { AbsoluteTime, Amounts, CoinDumpJson, CoinRefreshRequest, CoinStatus, CoreApiResponse, DenomOperationMap, DenominationInfo, Duration, ExchangeDetailedResponse, ExchangeListItem, ExchangesListResponse, FeeDescription, GetExchangeTosResult, InitResponse, KnownBankAccounts, KnownBankAccountsInfo, Logger, ManualWithdrawalDetails, MerchantUsingTemplateDetails, NotificationType, RefreshReason, TalerError, TalerErrorCode, TransactionState, TransactionType, URL, ValidateIbanResponse, WalletCoreVersion, WalletNotification, codecForAbortTransaction, codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, codecForAcceptManualWithdrawalRequet, codecForAcceptPeerPullPaymentRequest, codecForAcceptTipRequest, codecForAddExchangeRequest, codecForAddKnownBankAccounts, codecForAny, codecForApplyDevExperiment, codecForCheckPeerPullPaymentRequest, codecForCheckPeerPushDebitRequest, codecForConfirmPayRequest, codecForConfirmPeerPushPaymentRequest, codecForConvertAmountRequest, codecForCreateDepositGroupRequest, codecForDeleteTransactionRequest, codecForFailTransactionRequest, codecForForceRefreshRequest, codecForForgetKnownBankAccounts, codecForGetAmountRequest, codecForGetBalanceDetailRequest, codecForGetContractTermsDetails, codecForGetExchangeTosRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForImportDbRequest, codecForInitiatePeerPullPaymentRequest, codecForInitiatePeerPushDebitRequest, codecForIntegrationTestArgs, codecForIntegrationTestV2Args, codecForListKnownBankAccounts, codecForMerchantPostOrderResponse, codecForPrepareDepositRequest, codecForPreparePayRequest, codecForPreparePayTemplateRequest, codecForPreparePeerPullPaymentRequest, codecForPreparePeerPushCreditRequest, codecForPrepareRefundRequest, codecForPrepareRewardRequest, codecForResumeTransaction, codecForRetryTransactionRequest, codecForSetCoinSuspendedRequest, codecForSetWalletDeviceIdRequest, codecForStartRefundQueryRequest, codecForSuspendTransaction, codecForTestPayArgs, codecForTransactionByIdRequest, codecForTransactionsRequest, codecForUserAttentionByIdRequest, codecForUserAttentionsRequest, codecForValidateIbanRequest, codecForWithdrawFakebankRequest, codecForWithdrawTestBalance, constructPayUri, durationFromSpec, durationMin, getErrorDetailFromException, j2s, parsePayTemplateUri, parsePaytoUri, sampleWalletCoreTransactions, validateIban, codecForSharePaymentRequest, GetCurrencySpecificationResponse, codecForGetCurrencyInfoRequest, CreateStoredBackupResponse, StoredBackupList, codecForDeleteStoredBackupRequest, DeleteStoredBackupRequest, RecoverStoredBackupRequest, codecForRecoverStoredBackupRequest, codecForTestingSetTimetravelRequest, setDangerousTimetravel, TestingWaitTransactionRequest, codecForUpdateExchangeEntryRequest, } from "@gnu-taler/taler-util"; import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { CryptoDispatcher, CryptoWorkerFactory, } from "./crypto/workers/crypto-dispatcher.js"; import { CoinSourceType, ConfigRecordKey, DenominationRecord, ExchangeDetailsRecord, WalletStoresV1, clearDatabase, exportDb, importDb, openStoredBackupsDatabase, openTalerDatabase, } from "./db.js"; import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; import { ActiveLongpollInfo, CancelFn, ExchangeOperations, InternalWalletState, MerchantInfo, MerchantOperations, NotificationListener, RecoupOperations, RefreshOperations, } from "./internal-wallet-state.js"; import { getUserAttentions, getUserAttentionsUnreadCount, markAttentionRequestAsRead, } from "./operations/attention.js"; import { addBackupProvider, codecForAddBackupProviderRequest, codecForRemoveBackupProvider, codecForRunBackupCycle, getBackupInfo, getBackupRecovery, loadBackupRecovery, processBackupForProvider, removeBackupProvider, runBackupCycle, setWalletDeviceId, } from "./operations/backup/index.js"; import { getBalanceDetail, getBalances } from "./operations/balance.js"; import { TaskIdentifiers, TaskRunResult, getExchangeTosStatus, makeExchangeListItem, runTaskWithErrorReporting, } from "./operations/common.js"; import { computeDepositTransactionStatus, createDepositGroup, generateDepositGroupTxId, prepareDepositGroup, processDepositGroup, } from "./operations/deposits.js"; import { acceptExchangeTermsOfService, addPresetExchangeEntry, downloadTosFromAcceptedFormat, getExchangeDetails, getExchangeRequestTimeout, updateExchangeFromUrl, updateExchangeFromUrlHandler, } from "./operations/exchanges.js"; import { getMerchantInfo } from "./operations/merchants.js"; import { computePayMerchantTransactionState, computeRefundTransactionState, confirmPay, getContractTermsDetails, preparePayForUri, processPurchase, sharePayment, startQueryRefund, startRefundQueryForUri, } from "./operations/pay-merchant.js"; import { checkPeerPullPaymentInitiation, computePeerPullCreditTransactionState, initiatePeerPullPayment, processPeerPullCredit, } from "./operations/pay-peer-pull-credit.js"; import { computePeerPullDebitTransactionState, confirmPeerPullDebit, preparePeerPullDebit, processPeerPullDebit, } from "./operations/pay-peer-pull-debit.js"; import { computePeerPushCreditTransactionState, confirmPeerPushCredit, preparePeerPushCredit, processPeerPushCredit, } from "./operations/pay-peer-push-credit.js"; import { checkPeerPushDebit, computePeerPushDebitTransactionState, initiatePeerPushDebit, processPeerPushDebit, } from "./operations/pay-peer-push-debit.js"; import { getPendingOperations } from "./operations/pending.js"; import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js"; import { autoRefresh, computeRefreshTransactionState, createRefreshGroup, processRefreshGroup, } from "./operations/refresh.js"; import { runIntegrationTest, runIntegrationTest2, testPay, waitTransactionState, waitUntilDone, waitUntilRefreshesDone, withdrawTestBalance, } from "./operations/testing.js"; import { acceptTip, computeRewardTransactionStatus, prepareTip, processTip, } from "./operations/reward.js"; import { abortTransaction, deleteTransaction, failTransaction, getTransactionById, getTransactions, parseTransactionIdentifier, resumeTransaction, retryTransaction, suspendTransaction, } from "./operations/transactions.js"; import { acceptWithdrawalFromUri, computeWithdrawalTransactionStatus, createManualWithdrawal, getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, processWithdrawalGroup, } from "./operations/withdraw.js"; import { PendingTaskInfo, PendingTaskType } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; import { createTimeline, selectBestForOverlappingDenominations, selectMinimumFee, } from "./util/denominations.js"; import { checkDbInvariant } from "./util/invariants.js"; import { AsyncCondition, OpenedPromise, openPromise, } from "./util/promiseUtils.js"; import { DbAccess, GetReadOnlyAccess, GetReadWriteAccess, } from "./util/query.js"; import { TimerAPI, TimerGroup } from "./util/timer.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_CORE_IMPLEMENTATION_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_MERCHANT_PROTOCOL_VERSION, } from "./versions.js"; import { WalletApiOperation, WalletConfig, WalletConfigParameter, WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; import { convertDepositAmount, getMaxDepositAmount, convertPeerPushAmount, getMaxPeerPushAmount, convertWithdrawalAmount, } from "./util/instructedAmountConversion.js"; import { IDBFactory } from "@gnu-taler/idb-bridge"; const logger = new Logger("wallet.ts"); /** * Call the right handler for a pending operation without doing * any special error handling. */ async function callOperationHandler( ws: InternalWalletState, pending: PendingTaskInfo, ): Promise { switch (pending.type) { case PendingTaskType.ExchangeUpdate: return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl); case PendingTaskType.Refresh: return await processRefreshGroup(ws, pending.refreshGroupId); case PendingTaskType.Withdraw: return await processWithdrawalGroup(ws, pending.withdrawalGroupId); case PendingTaskType.RewardPickup: return await processTip(ws, pending.tipId); case PendingTaskType.Purchase: return await processPurchase(ws, pending.proposalId); case PendingTaskType.Recoup: return await processRecoupGroup(ws, pending.recoupGroupId); case PendingTaskType.ExchangeCheckRefresh: return await autoRefresh(ws, pending.exchangeBaseUrl); case PendingTaskType.Deposit: return await processDepositGroup(ws, pending.depositGroupId); case PendingTaskType.Backup: return await processBackupForProvider(ws, pending.backupProviderBaseUrl); case PendingTaskType.PeerPushDebit: return await processPeerPushDebit(ws, pending.pursePub); case PendingTaskType.PeerPullCredit: return await processPeerPullCredit(ws, pending.pursePub); case PendingTaskType.PeerPullDebit: return await processPeerPullDebit(ws, pending.peerPullDebitId); case PendingTaskType.PeerPushCredit: return await processPeerPushCredit(ws, pending.peerPushCreditId); default: return assertUnreachable(pending); } throw Error(`not reached ${pending.type}`); } /** * Process pending operations. */ export async function runPending(ws: InternalWalletState): Promise { const pendingOpsResponse = await getPendingOperations(ws); for (const p of pendingOpsResponse.pendingOperations) { if (!AbsoluteTime.isExpired(p.timestampDue)) { continue; } await runTaskWithErrorReporting(ws, p.id, async () => { logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`); return await callOperationHandler(ws, p); }); } } 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)}`); if (ws.isTaskLoopRunning) { logger.warn( "task loop already running, nesting the wallet-core task loop is deprecated and should be avoided", ); } ws.isTaskLoopRunning = true; 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; } if (p.givesLifeness) { numGivingLiveness++; } if (!p.isDue) { continue; } minDue = AbsoluteTime.min(minDue, p.timestampDue); numDue++; } logger.info( `running task loop, iter=${iteration}, #tasks=${pending.pendingOperations.length} #lifeness=${numGivingLiveness}, #due=${numDue}`, ); if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) { logger.warn(`stopping, as no pending operations have lifeness`); ws.isTaskLoopRunning = false; return { retriesExceeded, }; } if (ws.stopped) { ws.isTaskLoopRunning = false; 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); // Wait until either the timeout, or we are notified (via the latch) // that more work might be available. await Promise.race([timeout, ws.workAvailable.wait()]); logger.trace(`done waiting for available work`); } else { logger.trace( `running ${pending.pendingOperations.length} pending operations`, ); for (const p of pending.pendingOperations) { if (!AbsoluteTime.isExpired(p.timestampDue)) { continue; } logger.info(`running task ${p.id}`); await runTaskWithErrorReporting(ws, p.id, async () => { return await callOperationHandler(ws, p); }); ws.notify({ type: NotificationType.PendingOperationProcessed, id: p.id, }); if (ws.stopped) { ws.isTaskLoopRunning = false; return { retriesExceeded, }; } } } } logger.trace("exiting wallet task loop"); ws.isTaskLoopRunning = false; 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.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { const appliedRec = await tx.config.get("currencyDefaultsApplied"); let alreadyApplied = appliedRec ? !!appliedRec.value : false; if (alreadyApplied) { logger.trace("defaults already applied"); return; } for (const exch of ws.config.builtin.exchanges) { await addPresetExchangeEntry( tx, exch.exchangeBaseUrl, exch.currencyHint, ); } await tx.config.put({ key: ConfigRecordKey.CurrencyDefaultsApplied, value: true, }); }); } /** * Get the exchange ToS in the requested format. * Try to download in the accepted format not cached. */ 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 tosDownload = await downloadTosFromAcceptedFormat( ws, exchangeBaseUrl, getExchangeRequestTimeout(), acceptedFormat, ); await ws.db .mktx((x) => [x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { const d = await getExchangeDetails(tx, exchangeBaseUrl); if (d) { d.tosCurrentEtag = tosDownload.tosEtag; await tx.exchangeDetails.put(d); } }); return { acceptedEtag: exchangeDetails.tosAccepted?.etag, currentEtag: tosDownload.tosEtag, content: tosDownload.tosText, contentType: tosDownload.tosContentType, tosStatus: getExchangeTosStatus(exchangeDetails), }; } /** * 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, x.operationRetries, ]) .runReadOnly(async (tx) => { const exchangeRecords = await tx.exchanges.iter().toArray(); for (const r of exchangeRecords) { const exchangeDetails = await getExchangeDetails(tx, r.baseUrl); const opRetryRecord = await tx.operationRetries.get( TaskIdentifiers.forExchangeUpdate(r), ); exchanges.push( makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError), ); } }); 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, paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), auditors: exchangeDetails.auditors, wireInfo: exchangeDetails.wireInfo, globalFees: exchangeDetails.globalFees, }, denominations, }; }); if (!exchange) { throw Error(`exchange with base url "${exchangeBaseurl}" not found`); } const denoms = exchange.denominations.map((d) => ({ ...d, group: Amounts.stringifyValue(d.value), })); const denomFees: DenomOperationMap = { deposit: createTimeline( denoms, "denomPubHash", "stampStart", "stampExpireDeposit", "feeDeposit", "group", selectBestForOverlappingDenominations, ), refresh: createTimeline( denoms, "denomPubHash", "stampStart", "stampExpireWithdraw", "feeRefresh", "group", selectBestForOverlappingDenominations, ), refund: createTimeline( denoms, "denomPubHash", "stampStart", "stampExpireWithdraw", "feeRefund", "group", selectBestForOverlappingDenominations, ), withdraw: createTimeline( denoms, "denomPubHash", "stampStart", "stampExpireWithdraw", "feeWithdraw", "group", selectBestForOverlappingDenominations, ), }; const transferFees = Object.entries( exchange.info.wireInfo.feesForType, ).reduce((prev, [wireType, infoForType]) => { const feesByGroup = [ ...infoForType.map((w) => ({ ...w, fee: Amounts.stringify(w.closingFee), group: "closing", })), ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })), ]; prev[wireType] = createTimeline( feesByGroup, "sig", "startStamp", "endStamp", "fee", "group", selectMinimumFee, ); return prev; }, {} as Record); const globalFeesByGroup = [ ...exchange.info.globalFees.map((w) => ({ ...w, fee: w.accountFee, group: "account", })), ...exchange.info.globalFees.map((w) => ({ ...w, fee: w.historyFee, group: "history", })), ...exchange.info.globalFees.map((w) => ({ ...w, fee: w.purseFee, group: "purse", })), ]; const globalFees = createTimeline( globalFeesByGroup, "signature", "startDate", "endDate", "fee", "group", selectMinimumFee, ); return { exchange: { ...exchange.info, denomFees, transferFees, globalFees, }, }; } async function 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) { logger.warn("no denom 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) { withdrawalReservePub = cs.reservePub; } const denomInfo = await ws.getDenomInfo( ws, tx, c.exchangeBaseUrl, c.denomPubHash, ); if (!denomInfo) { logger.warn("no denomination found for coin"); continue; } coinsJson.coins.push({ coin_pub: c.coinPub, denom_pub: denomInfo.denomPub, denom_pub_hash: c.denomPubHash, denom_value: denom.value, exchange_base_url: c.exchangeBaseUrl, refresh_parent_coin_pub: refreshParentCoinPub, withdrawal_reserve_pub: withdrawalReservePub, coin_status: c.status, ageCommitmentProof: c.ageCommitmentProof, spend_allocation: c.spendAllocation ? { amount: c.spendAllocation.amount, id: c.spendAllocation.id, } : undefined, }); } }); 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; } async function createStoredBackup( ws: InternalWalletState, ): Promise { const backup = await exportDb(ws.idb); const backupsDb = await openStoredBackupsDatabase(ws.idb); const name = `backup-${new Date().getTime()}`; await backupsDb.mktxAll().runReadWrite(async (tx) => { await tx.backupMeta.add({ name, }); await tx.backupData.add(backup, name); }); return { name, }; } async function listStoredBackups( ws: InternalWalletState, ): Promise { const storedBackups: StoredBackupList = { storedBackups: [], }; const backupsDb = await openStoredBackupsDatabase(ws.idb); await backupsDb.mktxAll().runReadWrite(async (tx) => { await tx.backupMeta.iter().forEach((x) => { storedBackups.storedBackups.push({ name: x.name, }); }); }); return storedBackups; } async function deleteStoredBackup( ws: InternalWalletState, req: DeleteStoredBackupRequest, ): Promise { const backupsDb = await openStoredBackupsDatabase(ws.idb); await backupsDb.mktxAll().runReadWrite(async (tx) => { await tx.backupData.delete(req.name); await tx.backupMeta.delete(req.name); }); } async function recoverStoredBackup( ws: InternalWalletState, req: RecoverStoredBackupRequest, ): Promise { logger.info(`Recovering stored backup ${req.name}`); const { name } = req; const backupsDb = await openStoredBackupsDatabase(ws.idb); const bd = await backupsDb.mktxAll().runReadWrite(async (tx) => { const backupMeta = tx.backupMeta.get(name); if (!backupMeta) { throw Error("backup not found"); } const backupData = await tx.backupData.get(name); if (!backupData) { throw Error("no backup data (DB corrupt)"); } return backupData; }); logger.info(`backup found, now importing`); await importDb(ws.db.idbHandle(), bd); logger.info(`import done`); } /** * Implementation of the "wallet-core" API. */ async function dispatchRequestInternal( ws: InternalWalletState, operation: WalletApiOperation, payload: unknown, ): Promise> { if (!ws.initCalled && operation !== WalletApiOperation.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 WalletApiOperation.CreateStoredBackup: return createStoredBackup(ws); case WalletApiOperation.DeleteStoredBackup: { const req = codecForDeleteStoredBackupRequest().decode(payload); await deleteStoredBackup(ws, req); return {}; } case WalletApiOperation.ListStoredBackups: return listStoredBackups(ws); case WalletApiOperation.RecoverStoredBackup: { const req = codecForRecoverStoredBackupRequest().decode(payload); await recoverStoredBackup(ws, req); return {}; } case WalletApiOperation.InitWallet: { logger.trace("initializing wallet"); ws.initCalled = true; if (typeof payload === "object" && (payload as any).skipDefaults) { logger.trace("skipping defaults"); } else { logger.trace("filling defaults"); await fillDefaults(ws); } const resp: InitResponse = { versionInfo: getVersion(ws), }; return resp; } case WalletApiOperation.WithdrawTestkudos: { await withdrawTestBalance(ws, { amount: "TESTKUDOS:10", corebankApiBaseUrl: "https://bank.test.taler.net/", exchangeBaseUrl: "https://exchange.test.taler.net/", }); return { versionInfo: getVersion(ws), }; } case WalletApiOperation.WithdrawTestBalance: { const req = codecForWithdrawTestBalance().decode(payload); await withdrawTestBalance(ws, req); return {}; } case WalletApiOperation.RunIntegrationTest: { const req = codecForIntegrationTestArgs().decode(payload); await runIntegrationTest(ws, req); return {}; } case WalletApiOperation.RunIntegrationTestV2: { const req = codecForIntegrationTestV2Args().decode(payload); await runIntegrationTest2(ws, req); return {}; } case WalletApiOperation.ValidateIban: { const req = codecForValidateIbanRequest().decode(payload); const valRes = validateIban(req.iban); const resp: ValidateIbanResponse = { valid: valRes.type === "valid", }; return resp; } case WalletApiOperation.TestPay: { const req = codecForTestPayArgs().decode(payload); return await testPay(ws, req); } case WalletApiOperation.GetTransactions: { const req = codecForTransactionsRequest().decode(payload); return await getTransactions(ws, req); } case WalletApiOperation.GetTransactionById: { const req = codecForTransactionByIdRequest().decode(payload); return await getTransactionById(ws, req); } case WalletApiOperation.AddExchange: { const req = codecForAddExchangeRequest().decode(payload); await updateExchangeFromUrl(ws, req.exchangeBaseUrl, { checkMasterPub: req.masterPub, forceNow: req.forceUpdate, }); return {}; } case WalletApiOperation.UpdateExchangeEntry: { const req = codecForUpdateExchangeEntryRequest().decode(payload); await updateExchangeFromUrl(ws, req.exchangeBaseUrl, {}); return {}; } case WalletApiOperation.ListExchanges: { return await getExchanges(ws); } case WalletApiOperation.GetExchangeDetailedInfo: { const req = codecForAddExchangeRequest().decode(payload); return await getExchangeDetailedInfo(ws, req.exchangeBaseUrl); } case WalletApiOperation.ListKnownBankAccounts: { const req = codecForListKnownBankAccounts().decode(payload); return await listKnownBankAccounts(ws, req.currency); } case WalletApiOperation.AddKnownBankAccounts: { const req = codecForAddKnownBankAccounts().decode(payload); await addKnownBankAccounts(ws, req.payto, req.alias, req.currency); return {}; } case WalletApiOperation.ForgetKnownBankAccounts: { const req = codecForForgetKnownBankAccounts().decode(payload); await forgetKnownBankAccounts(ws, req.payto); return {}; } case WalletApiOperation.GetWithdrawalDetailsForUri: { const req = codecForGetWithdrawalDetailsForUri().decode(payload); return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri); } case WalletApiOperation.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 WalletApiOperation.GetWithdrawalDetailsForAmount: { const req = codecForGetWithdrawalDetailsForAmountRequest().decode(payload); const wi = await getExchangeWithdrawalInfo( ws, req.exchangeBaseUrl, Amounts.parseOrThrow(req.amount), req.restrictAge, ); let numCoins = 0; for (const x of wi.selectedDenoms.selectedDenoms) { numCoins += x.count; } const resp: ManualWithdrawalDetails = { amountRaw: req.amount, amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), paytoUris: wi.exchangePaytoUris, tosAccepted: wi.termsOfServiceAccepted, ageRestrictionOptions: wi.ageRestrictionOptions, numCoins, }; return resp; } case WalletApiOperation.GetBalances: { return await getBalances(ws); } case WalletApiOperation.GetBalanceDetail: { const req = codecForGetBalanceDetailRequest().decode(payload); return await getBalanceDetail(ws, req); } case WalletApiOperation.GetUserAttentionRequests: { const req = codecForUserAttentionsRequest().decode(payload); return await getUserAttentions(ws, req); } case WalletApiOperation.MarkAttentionRequestAsRead: { const req = codecForUserAttentionByIdRequest().decode(payload); return await markAttentionRequestAsRead(ws, req); } case WalletApiOperation.GetUserAttentionUnreadCount: { const req = codecForUserAttentionsRequest().decode(payload); return await getUserAttentionsUnreadCount(ws, req); } case WalletApiOperation.GetPendingOperations: { return await getPendingOperations(ws); } case WalletApiOperation.SetExchangeTosAccepted: { const req = codecForAcceptExchangeTosRequest().decode(payload); await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag); return {}; } case WalletApiOperation.AcceptBankIntegratedWithdrawal: { const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); return await acceptWithdrawalFromUri(ws, { selectedExchange: req.exchangeBaseUrl, talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, }); } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat); } case WalletApiOperation.GetContractTermsDetails: { const req = codecForGetContractTermsDetails().decode(payload); return getContractTermsDetails(ws, req.proposalId); } case WalletApiOperation.RetryPendingNow: { // FIXME: Should we reset all operation retries here? await runPending(ws); return {}; } case WalletApiOperation.SharePayment: { const req = codecForSharePaymentRequest().decode(payload); return await sharePayment(ws, req.merchantBaseUrl, req.orderId); } case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(ws, req.talerPayUri); } case WalletApiOperation.PreparePayForTemplate: { const req = codecForPreparePayTemplateRequest().decode(payload); const url = parsePayTemplateUri(req.talerPayTemplateUri); const templateDetails: MerchantUsingTemplateDetails = {}; if (!url) { throw Error("invalid taler-template URI"); } if ( url.templateParams.amount !== undefined && typeof url.templateParams.amount === "string" ) { templateDetails.amount = req.templateParams.amount ?? url.templateParams.amount; } if ( url.templateParams.summary !== undefined && typeof url.templateParams.summary === "string" ) { templateDetails.summary = req.templateParams.summary ?? url.templateParams.summary; } const reqUrl = new URL( `templates/${url.templateId}`, url.merchantBaseUrl, ); const httpReq = await ws.http.fetch(reqUrl.href, { method: "POST", body: templateDetails, }); const resp = await readSuccessResponseJsonOrThrow( httpReq, codecForMerchantPostOrderResponse(), ); const payUri = constructPayUri( url.merchantBaseUrl, resp.order_id, "", resp.token, ); return await preparePayForUri(ws, payUri); } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); let proposalId; if (req.proposalId) { // legacy client support proposalId = req.proposalId; } else if (req.transactionId) { const txIdParsed = parseTransactionIdentifier(req.transactionId); if (txIdParsed?.tag != TransactionType.Payment) { throw Error("payment transaction ID required"); } proposalId = txIdParsed.proposalId; } else { throw Error("transactionId or (deprecated) proposalId required"); } return await confirmPay(ws, proposalId, req.sessionId); } case WalletApiOperation.AbortTransaction: { const req = codecForAbortTransaction().decode(payload); await abortTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.SuspendTransaction: { const req = codecForSuspendTransaction().decode(payload); await suspendTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.FailTransaction: { const req = codecForFailTransactionRequest().decode(payload); await failTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.ResumeTransaction: { const req = codecForResumeTransaction().decode(payload); await resumeTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.DumpCoins: { return await dumpCoins(ws); } case WalletApiOperation.SetCoinSuspended: { const req = codecForSetCoinSuspendedRequest().decode(payload); await setCoinSuspended(ws, req.coinPub, req.suspended); return {}; } case WalletApiOperation.TestingGetSampleTransactions: return { transactions: sampleWalletCoreTransactions }; case WalletApiOperation.ForceRefresh: { const req = codecForForceRefreshRequest().decode(payload); if (req.coinPubList.length == 0) { throw Error("refusing to create empty refresh group"); } const refreshGroupId = await ws.db .mktx((x) => [ x.refreshGroups, x.coinAvailability, x.denominations, x.coins, ]) .runReadWrite(async (tx) => { let coinPubs: CoinRefreshRequest[] = []; for (const c of req.coinPubList) { const coin = await tx.coins.get(c); if (!coin) { throw Error(`coin (pubkey ${c}) not found`); } const denom = await ws.getDenomInfo( ws, tx, coin.exchangeBaseUrl, coin.denomPubHash, ); checkDbInvariant(!!denom); coinPubs.push({ coinPub: c, amount: denom?.value, }); } return await createRefreshGroup( ws, tx, Amounts.currencyOf(coinPubs[0].amount), coinPubs, RefreshReason.Manual, ); }); processRefreshGroup(ws, refreshGroupId.refreshGroupId).catch((x) => { logger.error(x); }); return { refreshGroupId, }; } case WalletApiOperation.PrepareReward: { const req = codecForPrepareRewardRequest().decode(payload); return await prepareTip(ws, req.talerRewardUri); } case WalletApiOperation.StartRefundQueryForUri: { const req = codecForPrepareRefundRequest().decode(payload); return await startRefundQueryForUri(ws, req.talerRefundUri); } case WalletApiOperation.StartRefundQuery: { const req = codecForStartRefundQueryRequest().decode(payload); const txIdParsed = parseTransactionIdentifier(req.transactionId); if (!txIdParsed) { throw Error("invalid transaction ID"); } if (txIdParsed.tag !== TransactionType.Payment) { throw Error("expected payment transaction ID"); } await startQueryRefund(ws, txIdParsed.proposalId); return {}; } case WalletApiOperation.AcceptReward: { const req = codecForAcceptTipRequest().decode(payload); return await acceptTip(ws, req.walletRewardId); } case WalletApiOperation.AddBackupProvider: { const req = codecForAddBackupProviderRequest().decode(payload); return await addBackupProvider(ws, req); } case WalletApiOperation.RunBackupCycle: { const req = codecForRunBackupCycle().decode(payload); await runBackupCycle(ws, req); return {}; } case WalletApiOperation.RemoveBackupProvider: { const req = codecForRemoveBackupProvider().decode(payload); await removeBackupProvider(ws, req); return {}; } case WalletApiOperation.ExportBackupRecovery: { const resp = await getBackupRecovery(ws); return resp; } case WalletApiOperation.TestingWaitTransactionState: { const req = payload as TestingWaitTransactionRequest; await waitTransactionState(ws, req.transactionId, req.txState); return {}; } case WalletApiOperation.GetCurrencySpecification: { // Ignore result, just validate in this mock implementation const req = codecForGetCurrencyInfoRequest().decode(payload); // Hard-coded mock for KUDOS and TESTKUDOS if (req.scope.currency === "KUDOS") { const kudosResp: GetCurrencySpecificationResponse = { currencySpecification: { decimal_separator: ",", name: "Kudos (Taler Demonstrator)", fractional_input_digits: 2, fractional_normal_digits: 2, fractional_trailing_zero_digits: 2, is_currency_name_leading: true, alt_unit_names: { "0": "ク", }, }, }; return kudosResp; } else if (req.scope.currency === "TESTKUDOS") { const testkudosResp: GetCurrencySpecificationResponse = { currencySpecification: { decimal_separator: ",", name: "Test (Taler Unstable Demonstrator)", fractional_input_digits: 0, fractional_normal_digits: 0, fractional_trailing_zero_digits: 0, is_currency_name_leading: false, alt_unit_names: {}, }, }; return testkudosResp; } const defaultResp: GetCurrencySpecificationResponse = { currencySpecification: { decimal_separator: ",", name: "Unknown", fractional_input_digits: 2, fractional_normal_digits: 2, fractional_trailing_zero_digits: 2, is_currency_name_leading: true, alt_unit_names: {}, }, }; return defaultResp; } case WalletApiOperation.ImportBackupRecovery: { const req = codecForAny().decode(payload); await loadBackupRecovery(ws, req); return {}; } // case WalletApiOperation.GetPlanForOperation: { // const req = codecForGetPlanForOperationRequest().decode(payload); // return await getPlanForOperation(ws, req); // } case WalletApiOperation.ConvertDepositAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertDepositAmount(ws, req); } case WalletApiOperation.GetMaxDepositAmount: { const req = codecForGetAmountRequest.decode(payload); return await getMaxDepositAmount(ws, req); } case WalletApiOperation.ConvertPeerPushAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertPeerPushAmount(ws, req); } case WalletApiOperation.GetMaxPeerPushAmount: { const req = codecForGetAmountRequest.decode(payload); return await getMaxPeerPushAmount(ws, req); } case WalletApiOperation.ConvertWithdrawalAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertWithdrawalAmount(ws, req); } case WalletApiOperation.GetBackupInfo: { const resp = await getBackupInfo(ws); return resp; } case WalletApiOperation.PrepareDeposit: { const req = codecForPrepareDepositRequest().decode(payload); return await prepareDepositGroup(ws, req); } case WalletApiOperation.GenerateDepositGroupTxId: return { transactionId: generateDepositGroupTxId(), }; case WalletApiOperation.CreateDepositGroup: { const req = codecForCreateDepositGroupRequest().decode(payload); return await createDepositGroup(ws, req); } case WalletApiOperation.DeleteTransaction: { const req = codecForDeleteTransactionRequest().decode(payload); await deleteTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.RetryTransaction: { const req = codecForRetryTransactionRequest().decode(payload); await retryTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.SetWalletDeviceId: { const req = codecForSetWalletDeviceIdRequest().decode(payload); await setWalletDeviceId(ws, req.walletDeviceId); return {}; } case WalletApiOperation.ListCurrencies: { // FIXME: Remove / change to scoped currency approach. return { trustedAuditors: [], trustedExchanges: [], }; } case WalletApiOperation.TestCrypto: { return await ws.cryptoApi.hashString({ str: "hello world" }); } case WalletApiOperation.ClearDb: await clearDatabase(ws.db.idbHandle()); return {}; case WalletApiOperation.Recycle: { throw Error("not implemented"); return {}; } case WalletApiOperation.ExportDb: { const dbDump = await exportDb(ws.idb); return dbDump; } case WalletApiOperation.ImportDb: { const req = codecForImportDbRequest().decode(payload); await importDb(ws.db.idbHandle(), req.dump); return []; } case WalletApiOperation.CheckPeerPushDebit: { const req = codecForCheckPeerPushDebitRequest().decode(payload); return await checkPeerPushDebit(ws, req); } case WalletApiOperation.InitiatePeerPushDebit: { const req = codecForInitiatePeerPushDebitRequest().decode(payload); return await initiatePeerPushDebit(ws, req); } case WalletApiOperation.PreparePeerPushCredit: { const req = codecForPreparePeerPushCreditRequest().decode(payload); return await preparePeerPushCredit(ws, req); } case WalletApiOperation.ConfirmPeerPushCredit: { const req = codecForConfirmPeerPushPaymentRequest().decode(payload); return await confirmPeerPushCredit(ws, req); } case WalletApiOperation.CheckPeerPullCredit: { const req = codecForPreparePeerPullPaymentRequest().decode(payload); return await checkPeerPullPaymentInitiation(ws, req); } case WalletApiOperation.InitiatePeerPullCredit: { const req = codecForInitiatePeerPullPaymentRequest().decode(payload); return await initiatePeerPullPayment(ws, req); } case WalletApiOperation.PreparePeerPullDebit: { const req = codecForCheckPeerPullPaymentRequest().decode(payload); return await preparePeerPullDebit(ws, req); } case WalletApiOperation.ConfirmPeerPullDebit: { const req = codecForAcceptPeerPullPaymentRequest().decode(payload); return await confirmPeerPullDebit(ws, req); } case WalletApiOperation.ApplyDevExperiment: { const req = codecForApplyDevExperiment().decode(payload); await applyDevExperiment(ws, req.devExperimentUri); return {}; } case WalletApiOperation.GetVersion: { return getVersion(ws); } case WalletApiOperation.TestingWaitTransactionsFinal: return await waitUntilDone(ws); case WalletApiOperation.TestingWaitRefreshesFinal: return await waitUntilRefreshesDone(ws); case WalletApiOperation.TestingSetTimetravel: { const req = codecForTestingSetTimetravelRequest().decode(payload); setDangerousTimetravel(req.offsetMs); ws.workAvailable.trigger(); return {}; } // default: // assertUnreachable(operation); } throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, { operation, }, "unknown operation", ); } export function getVersion(ws: InternalWalletState): WalletCoreVersion { const result: WalletCoreVersion = { hash: undefined, version: WALLET_CORE_IMPLEMENTATION_VERSION, exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, devMode: false, }; return result; } /** * Handle a request to the wallet-core API. */ async function handleCoreApiRequest( ws: InternalWalletState, operation: string, id: string, payload: unknown, ): Promise { try { const result = await dispatchRequestInternal(ws, operation as any, payload); return { type: "response", operation, id, result, }; } catch (e: any) { const err = getErrorDetailFromException(e); logger.info( `finished wallet core request ${operation} 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( idb: IDBFactory, http: HttpRequestLibrary, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, config?: WalletConfigParameter, ) { this.ws = new InternalWalletStateImpl( idb, http, timer, cryptoWorkerFactory, Wallet.getEffectiveConfig(config), ); } get client(): WalletCoreApiClient { if (!this._client) { throw Error(); } return this._client; } static async create( idb: IDBFactory, http: HttpRequestLibrary, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, config?: WalletConfigParameter, ): Promise { const w = new Wallet(idb, http, timer, cryptoWorkerFactory, config); w._client = await getClientFromWalletState(w.ws); return w; } public static defaultConfig: Readonly = { builtin: { exchanges: [ { exchangeBaseUrl: "https://exchange.demo.taler.net/", currencyHint: "KUDOS", }, ], }, features: { allowHttp: false, }, testing: { preventThrottling: false, devModeActive: false, insecureTrustExchange: false, denomselAllowLate: false, }, }; static getEffectiveConfig( param?: WalletConfigParameter, ): Readonly { return deepMerge(Wallet.defaultConfig, param ?? {}); } addNotificationListener(f: (n: WalletNotification) => void): CancelFn { return this.ws.addNotificationListener(f); } stop(): void { this.ws.stop(); } async runPending(): Promise { await this.ws.ensureWalletDbOpen(); return runPending(this.ws); } async runTaskLoop(opts?: RetryLoopOpts): Promise { await this.ws.ensureWalletDbOpen(); return runTaskLoop(this.ws, opts); } async handleCoreApiRequest( operation: string, id: string, payload: unknown, ): Promise { await this.ws.ensureWalletDbOpen(); 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 = {}; readonly timerGroup: TimerGroup; workAvailable = new AsyncCondition(); stopped = false; listeners: NotificationListener[] = []; initCalled = false; exchangeOps: ExchangeOperations = { getExchangeDetails, updateExchangeFromUrl, }; recoupOps: RecoupOperations = { createRecoupGroup, }; merchantOps: MerchantOperations = { getMerchantInfo, }; refreshOps: RefreshOperations = { createRefreshGroup, }; // 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(); isTaskLoopRunning: boolean = false; config: Readonly; private _db: DbAccess | undefined = undefined; get db(): DbAccess { if (!this._db) { throw Error("db not initialized"); } return this._db; } constructor( public idb: IDBFactory, public http: HttpRequestLibrary, public timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, configParam: WalletConfig, ) { this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); this.cryptoApi = this.cryptoDispatcher.cryptoApi; this.timerGroup = new TimerGroup(timer); this.config = configParam; if (this.config.testing.devModeActive) { this.http = new DevExperimentHttpLib(this.http); } } async ensureWalletDbOpen(): Promise { if (this._db) { return; } const myVersionChange = async (): Promise => { logger.info("version change requested for Taler DB"); }; const myDb = await openTalerDatabase(this.idb, myVersionChange); this._db = myDb; } async getTransactionState( ws: InternalWalletState, tx: GetReadOnlyAccess, transactionId: string, ): Promise { const parsedTxId = parseTransactionIdentifier(transactionId); if (!parsedTxId) { throw Error("invalid tx identifier"); } switch (parsedTxId.tag) { case TransactionType.Deposit: { const rec = await tx.depositGroups.get(parsedTxId.depositGroupId); if (!rec) { return undefined; } return computeDepositTransactionStatus(rec); } case TransactionType.InternalWithdrawal: case TransactionType.Withdrawal: { const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId); if (!rec) { return undefined; } return computeWithdrawalTransactionStatus(rec); } case TransactionType.Payment: { const rec = await tx.purchases.get(parsedTxId.proposalId); if (!rec) { return; } return computePayMerchantTransactionState(rec); } case TransactionType.Refund: { const rec = await tx.refundGroups.get(parsedTxId.refundGroupId); if (!rec) { return undefined; } return computeRefundTransactionState(rec); } case TransactionType.PeerPullCredit: const rec = await tx.peerPullCredit.get(parsedTxId.pursePub); if (!rec) { return undefined; } return computePeerPullCreditTransactionState(rec); case TransactionType.PeerPullDebit: { const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId); if (!rec) { return undefined; } return computePeerPullDebitTransactionState(rec); } case TransactionType.PeerPushCredit: { const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId); if (!rec) { return undefined; } return computePeerPushCreditTransactionState(rec); } case TransactionType.PeerPushDebit: { const rec = await tx.peerPushDebit.get(parsedTxId.pursePub); if (!rec) { return undefined; } return computePeerPushDebitTransactionState(rec); } case TransactionType.Refresh: { const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId); if (!rec) { return undefined; } return computeRefreshTransactionState(rec); } case TransactionType.Reward: { const rec = await tx.rewards.get(parsedTxId.walletRewardId); if (!rec) { return undefined; } return computeRewardTransactionStatus(rec); } default: assertUnreachable(parsedTxId); } } 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", j2s(n)); for (const l of this.listeners) { const nc = JSON.parse(JSON.stringify(n)); setTimeout(() => { l(nc); }, 0); } } addNotificationListener(f: (n: WalletNotification) => void): CancelFn { this.listeners.push(f); return () => { const idx = this.listeners.indexOf(f); if (idx >= 0) { this.listeners.splice(idx, 1); } }; } /** * 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(); delete this.activeLongpoll[key]; } } /** * 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(); } } } } ensureTaskLoopRunning(): void { if (this.isTaskLoopRunning) { return; } runTaskLoop(this) .catch((e) => { logger.error("error running task loop"); logger.error(`err: ${e}`); }) .then(() => { logger.info("done running task loop"); }); } } /** * Take the full object as template, create a new result with all the values. * Use the override object to change the values in the result * return result * @param full * @param override * @returns */ function deepMerge(full: T, override: object): T { const keys = Object.keys(full); const result = { ...full }; for (const k of keys) { // @ts-ignore const newVal = override[k]; if (newVal === undefined) continue; // @ts-ignore result[k] = // @ts-ignore typeof newVal === "object" ? deepMerge(full[k], newVal) : newVal; } return result; }