/* 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 { IDBDatabase, IDBFactory } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, ActiveTask, AmountJson, AmountString, Amounts, AsyncCondition, CancellationToken, CoinDumpJson, CoinStatus, CoreApiResponse, CreateStoredBackupResponse, DeleteStoredBackupRequest, DenominationInfo, Duration, ExchangesShortListResponse, GetCurrencySpecificationResponse, InitResponse, KnownBankAccounts, KnownBankAccountsInfo, ListGlobalCurrencyAuditorsResponse, ListGlobalCurrencyExchangesResponse, Logger, NotificationType, ObservabilityContext, ObservabilityEventType, ObservableHttpClientLibrary, OpenedPromise, PartialWalletRunConfig, PrepareWithdrawExchangeRequest, PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, StoredBackupList, TalerError, TalerErrorCode, TalerProtocolTimestamp, TalerUriAction, TestingGetDenomStatsResponse, TestingListTasksForTransactionsResponse, TestingWaitTransactionRequest, TimerAPI, TimerGroup, TransactionType, ValidateIbanResponse, WalletCoreVersion, WalletNotification, WalletRunConfig, checkDbInvariant, codecForAbortTransaction, codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, codecForAcceptManualWithdrawalRequest, codecForAcceptPeerPullPaymentRequest, codecForAddExchangeRequest, codecForAddGlobalCurrencyAuditorRequest, codecForAddGlobalCurrencyExchangeRequest, codecForAddKnownBankAccounts, codecForAny, codecForApplyDevExperiment, codecForCheckPeerPullPaymentRequest, codecForCheckPeerPushDebitRequest, codecForConfirmPayRequest, codecForConfirmPeerPushPaymentRequest, codecForConvertAmountRequest, codecForCreateDepositGroupRequest, codecForDeleteExchangeRequest, codecForDeleteStoredBackupRequest, codecForDeleteTransactionRequest, codecForFailTransactionRequest, codecForForceRefreshRequest, codecForForgetKnownBankAccounts, codecForGetAmountRequest, codecForGetBalanceDetailRequest, codecForGetContractTermsDetails, codecForGetCurrencyInfoRequest, codecForGetExchangeEntryByUrlRequest, codecForGetExchangeResourcesRequest, codecForGetExchangeTosRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForImportDbRequest, codecForInitRequest, codecForInitiatePeerPullPaymentRequest, codecForInitiatePeerPushDebitRequest, codecForIntegrationTestArgs, codecForIntegrationTestV2Args, codecForListExchangesForScopedCurrencyRequest, codecForListKnownBankAccounts, codecForPrepareDepositRequest, codecForPreparePayRequest, codecForPreparePayTemplateRequest, codecForPreparePeerPullPaymentRequest, codecForPreparePeerPushCreditRequest, codecForPrepareRefundRequest, codecForPrepareWithdrawExchangeRequest, codecForRecoverStoredBackupRequest, codecForRemoveGlobalCurrencyAuditorRequest, codecForRemoveGlobalCurrencyExchangeRequest, codecForResumeTransaction, codecForRetryTransactionRequest, codecForSetCoinSuspendedRequest, codecForSetWalletDeviceIdRequest, codecForSharePaymentRequest, codecForStartRefundQueryRequest, codecForSuspendTransaction, codecForTestPayArgs, codecForTestingGetDenomStatsRequest, codecForTestingListTasksForTransactionRequest, codecForTestingSetTimetravelRequest, codecForTransactionByIdRequest, codecForTransactionsRequest, codecForUpdateExchangeEntryRequest, codecForUserAttentionByIdRequest, codecForUserAttentionsRequest, codecForValidateIbanRequest, codecForWithdrawTestBalance, getErrorDetailFromException, j2s, openPromise, parsePaytoUri, parseTalerUri, performanceNow, safeStringifyException, sampleWalletCoreTransactions, setDangerousTimetravel, validateIban, } from "@gnu-taler/taler-util"; import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { getUserAttentions, getUserAttentionsUnreadCount, markAttentionRequestAsRead, } from "./attention.js"; import { addBackupProvider, codecForAddBackupProviderRequest, codecForRemoveBackupProvider, codecForRunBackupCycle, getBackupInfo, getBackupRecovery, loadBackupRecovery, removeBackupProvider, runBackupCycle, setWalletDeviceId, } from "./backup/index.js"; import { getBalanceDetail, getBalances } from "./balance.js"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { CryptoDispatcher, CryptoWorkerFactory, } from "./crypto/workers/crypto-dispatcher.js"; import { CoinSourceType, ConfigRecordKey, DenominationRecord, WalletDbReadOnlyTransaction, WalletStoresV1, clearDatabase, exportDb, importDb, openStoredBackupsDatabase, openTalerDatabase, timestampAbsoluteFromDb, timestampProtocolToDb, } from "./db.js"; import { checkDepositGroup, createDepositGroup, generateDepositGroupTxId, } from "./deposits.js"; import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; import { ReadyExchangeSummary, acceptExchangeTermsOfService, addPresetExchangeEntry, deleteExchange, fetchFreshExchange, forgetExchangeTermsOfService, getExchangeDetailedInfo, getExchangeResources, getExchangeTos, listExchanges, lookupExchangeByUri, } from "./exchanges.js"; import { convertDepositAmount, convertPeerPushAmount, convertWithdrawalAmount, getMaxDepositAmount, getMaxPeerPushAmount, } from "./instructedAmountConversion.js"; import { ObservableDbAccess, ObservableTaskScheduler, observeTalerCrypto, } from "./observable-wrappers.js"; import { confirmPay, getContractTermsDetails, preparePayForTemplate, preparePayForUri, sharePayment, startQueryRefund, startRefundQueryForUri, } from "./pay-merchant.js"; import { checkPeerPullPaymentInitiation, initiatePeerPullPayment, } from "./pay-peer-pull-credit.js"; import { confirmPeerPullDebit, preparePeerPullDebit, } from "./pay-peer-pull-debit.js"; import { confirmPeerPushCredit, preparePeerPushCredit, } from "./pay-peer-push-credit.js"; import { checkPeerPushDebit, initiatePeerPushDebit, } from "./pay-peer-push-debit.js"; import { AfterCommitInfo, DbAccess, DbAccessImpl, TriggerSpec, } from "./query.js"; import { forceRefresh } from "./refresh.js"; import { TaskScheduler, TaskSchedulerImpl, convertTaskToTransactionId, listTaskForTransactionId, } from "./shepherd.js"; import { runIntegrationTest, runIntegrationTest2, testPay, waitTasksDone, waitTransactionState, waitUntilAllTransactionsFinal, waitUntilRefreshesDone, withdrawTestBalance, } from "./testing.js"; import { abortTransaction, constructTransactionIdentifier, deleteTransaction, failTransaction, getTransactionById, getTransactions, getWithdrawalTransactionByUri, parseTransactionIdentifier, resumeTransaction, retryTransaction, suspendTransaction, } from "./transactions.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_COREBANK_API_PROTOCOL_VERSION, WALLET_CORE_API_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_MERCHANT_PROTOCOL_VERSION, } from "./versions.js"; import { WalletApiOperation, WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; import { acceptWithdrawalFromUri, createManualWithdrawal, getWithdrawalDetailsForAmount, getWithdrawalDetailsForUri, } from "./withdraw.js"; const logger = new Logger("wallet.ts"); /** * Execution context for code that is run in the wallet. * * Typically the execution context is either for a wallet-core * request handler or for a shepherded task. */ export interface WalletExecutionContext { readonly ws: InternalWalletState; readonly cryptoApi: TalerCryptoInterface; readonly cancellationToken: CancellationToken; readonly http: HttpRequestLibrary; readonly db: DbAccess; readonly oc: ObservabilityContext; readonly taskScheduler: TaskScheduler; } export const EXCHANGE_COINS_LOCK = "exchange-coins-lock"; export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock"; export type NotificationListener = (n: WalletNotification) => void; type CancelFn = () => void; /** * Insert the hard-coded defaults for exchanges, coins and * auditors into the database, unless these defaults have * already been applied. */ async function fillDefaults(wex: WalletExecutionContext): Promise { const notifications: WalletNotification[] = []; await wex.db.runReadWriteTx( { storeNames: ["config", "exchanges"] }, 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 wex.ws.config.builtin.exchanges) { const resp = await addPresetExchangeEntry( tx, exch.exchangeBaseUrl, exch.currencyHint, ); if (resp.notification) { notifications.push(resp.notification); } } await tx.config.put({ key: ConfigRecordKey.CurrencyDefaultsApplied, value: true, }); }, ); for (const notif of notifications) { wex.ws.notify(notif); } } export async function getDenomInfo( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction<["denominations"]>, exchangeBaseUrl: string, denomPubHash: string, ): Promise { const cacheKey = `${exchangeBaseUrl}:${denomPubHash}`; const cached = wex.ws.denomInfoCache.get(cacheKey); if (cached) { return cached; } const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); if (d) { const denomInfo = DenominationRecord.toDenomInfo(d); wex.ws.denomInfoCache.put(cacheKey, denomInfo); return denomInfo; } return undefined; } /** * List bank accounts known to the wallet from * previous withdrawals. */ async function listKnownBankAccounts( wex: WalletExecutionContext, currency?: string, ): Promise { const accounts: KnownBankAccountsInfo[] = []; await wex.db.runReadOnlyTx({ storeNames: ["bankAccounts"] }, 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( wex: WalletExecutionContext, payto: string, alias: string, currency: string, ): Promise { await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => { tx.bankAccounts.put({ uri: payto, alias: alias, currency: currency, kycCompleted: false, }); }); return; } /** */ async function forgetKnownBankAccounts( wex: WalletExecutionContext, payto: string, ): Promise { await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, 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 setCoinSuspended( wex: WalletExecutionContext, coinPub: string, suspended: boolean, ): Promise { await wex.db.runReadWriteTx( { storeNames: ["coins", "coinAvailability"] }, 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(wex: WalletExecutionContext): Promise { const coinsJson: CoinDumpJson = { coins: [] }; logger.info("dumping coins"); await wex.db.runReadOnlyTx( { storeNames: ["coins", "denominations"] }, 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 getDenomInfo( wex, 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. */ let id = 0; async function getClientFromWalletState( ws: InternalWalletState, ): Promise { const client: WalletCoreApiClient = { async call(op, payload): Promise { id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100); const res = await handleCoreApiRequest(ws, op, String(id), payload); switch (res.type) { case "error": throw TalerError.fromUncheckedDetail(res.error); case "response": return res.result; } }, }; return client; } async function createStoredBackup( wex: WalletExecutionContext, ): Promise { const backup = await exportDb(wex.ws.idb); const backupsDb = await openStoredBackupsDatabase(wex.ws.idb); const name = `backup-${new Date().getTime()}`; await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupMeta.add({ name, }); await tx.backupData.add(backup, name); }); return { name, }; } async function listStoredBackups( wex: WalletExecutionContext, ): Promise { const storedBackups: StoredBackupList = { storedBackups: [], }; const backupsDb = await openStoredBackupsDatabase(wex.ws.idb); await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupMeta.iter().forEach((x) => { storedBackups.storedBackups.push({ name: x.name, }); }); }); return storedBackups; } async function deleteStoredBackup( wex: WalletExecutionContext, req: DeleteStoredBackupRequest, ): Promise { const backupsDb = await openStoredBackupsDatabase(wex.ws.idb); await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupData.delete(req.name); await tx.backupMeta.delete(req.name); }); } async function recoverStoredBackup( wex: WalletExecutionContext, req: RecoverStoredBackupRequest, ): Promise { logger.info(`Recovering stored backup ${req.name}`); const { name } = req; const backupsDb = await openStoredBackupsDatabase(wex.ws.idb); const bd = await backupsDb.runAllStoresReadWriteTx({}, 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(wex.db.idbHandle(), bd); logger.info(`import done`); } async function handlePrepareWithdrawExchange( wex: WalletExecutionContext, req: PrepareWithdrawExchangeRequest, ): Promise { const parsedUri = parseTalerUri(req.talerUri); if (parsedUri?.type !== TalerUriAction.WithdrawExchange) { throw Error("expected a taler://withdraw-exchange URI"); } const exchangeBaseUrl = parsedUri.exchangeBaseUrl; const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); if (parsedUri.exchangePub && exchange.masterPub != parsedUri.exchangePub) { throw Error("mismatch of exchange master public key (URI vs actual)"); } if (parsedUri.amount) { const amt = Amounts.parseOrThrow(parsedUri.amount); if (amt.currency !== exchange.currency) { throw Error("mismatch of currency (URI vs exchange)"); } } return { exchangeBaseUrl, amount: parsedUri.amount, }; } /** * Response returned from the pending operations API. * * @deprecated this is a placeholder for the response type of a deprecated wallet-core request. */ export interface PendingOperationsResponse { /** * List of pending operations. */ pendingOperations: any[]; } /** * Implementation of the "wallet-core" API. */ async function dispatchRequestInternal( wex: WalletExecutionContext, cts: CancellationToken.Source, operation: WalletApiOperation, payload: unknown, ): Promise> { if (!wex.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(wex); case WalletApiOperation.DeleteStoredBackup: { const req = codecForDeleteStoredBackupRequest().decode(payload); await deleteStoredBackup(wex, req); return {}; } case WalletApiOperation.ListStoredBackups: return listStoredBackups(wex); case WalletApiOperation.RecoverStoredBackup: { const req = codecForRecoverStoredBackupRequest().decode(payload); await recoverStoredBackup(wex, req); return {}; } case WalletApiOperation.SetWalletRunConfig: case WalletApiOperation.InitWallet: { const req = codecForInitRequest().decode(payload); logger.info(`init request: ${j2s(req)}`); if (wex.ws.initCalled) { logger.info("initializing wallet (repeat initialization)"); } else { logger.info("initializing wallet (first initialization)"); } // Write to the DB to make sure that we're failing early in // case the DB is not writeable. try { await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { tx.config.put({ key: ConfigRecordKey.LastInitInfo, value: timestampProtocolToDb(TalerProtocolTimestamp.now()), }); }); } catch (e) { logger.error("error writing to database during initialization"); throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, { innerError: getErrorDetailFromException(e), }); } wex.ws.initWithConfig(applyRunConfigDefaults(req.config)); if (wex.ws.config.testing.skipDefaults) { logger.trace("skipping defaults"); } else { logger.trace("filling defaults"); await fillDefaults(wex); } const resp: InitResponse = { versionInfo: getVersion(wex), }; // After initialization, task loop should run. await wex.taskScheduler.ensureRunning(); wex.ws.initCalled = true; return resp; } case WalletApiOperation.WithdrawTestkudos: { await withdrawTestBalance(wex, { amount: "TESTKUDOS:10" as AmountString, corebankApiBaseUrl: "https://bank.test.taler.net/", exchangeBaseUrl: "https://exchange.test.taler.net/", }); return { versionInfo: getVersion(wex), }; } case WalletApiOperation.WithdrawTestBalance: { const req = codecForWithdrawTestBalance().decode(payload); await withdrawTestBalance(wex, req); return {}; } case WalletApiOperation.TestingListTaskForTransaction: { const req = codecForTestingListTasksForTransactionRequest().decode(payload); return { taskIdList: listTaskForTransactionId(req.transactionId), } satisfies TestingListTasksForTransactionsResponse; } case WalletApiOperation.RunIntegrationTest: { const req = codecForIntegrationTestArgs().decode(payload); await runIntegrationTest(wex, req); return {}; } case WalletApiOperation.RunIntegrationTestV2: { const req = codecForIntegrationTestV2Args().decode(payload); await runIntegrationTest2(wex, 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(wex, req); } case WalletApiOperation.GetTransactions: { const req = codecForTransactionsRequest().decode(payload); return await getTransactions(wex, req); } case WalletApiOperation.GetTransactionById: { const req = codecForTransactionByIdRequest().decode(payload); return await getTransactionById(wex, req); } case WalletApiOperation.GetWithdrawalTransactionByUri: { const req = codecForGetWithdrawalDetailsForUri().decode(payload); return await getWithdrawalTransactionByUri(wex, req); } case WalletApiOperation.AddExchange: { const req = codecForAddExchangeRequest().decode(payload); await fetchFreshExchange(wex, req.exchangeBaseUrl, { expectedMasterPub: req.masterPub, }); return {}; } case WalletApiOperation.TestingPing: { return {}; } case WalletApiOperation.UpdateExchangeEntry: { const req = codecForUpdateExchangeEntryRequest().decode(payload); await fetchFreshExchange(wex, req.exchangeBaseUrl, { forceUpdate: !!req.force, }); return {}; } case WalletApiOperation.TestingGetDenomStats: { const req = codecForTestingGetDenomStatsRequest().decode(payload); const denomStats: TestingGetDenomStatsResponse = { numKnown: 0, numLost: 0, numOffered: 0, }; await wex.db.runReadOnlyTx( { storeNames: ["denominations"] }, async (tx) => { const denoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll( req.exchangeBaseUrl, ); for (const d of denoms) { denomStats.numKnown++; if (d.isOffered) { denomStats.numOffered++; } if (d.isLost) { denomStats.numLost++; } } }, ); return denomStats; } case WalletApiOperation.ListExchanges: { return await listExchanges(wex); } case WalletApiOperation.GetExchangeEntryByUrl: { const req = codecForGetExchangeEntryByUrlRequest().decode(payload); return lookupExchangeByUri(wex, req); } case WalletApiOperation.ListExchangesForScopedCurrency: { const req = codecForListExchangesForScopedCurrencyRequest().decode(payload); const exchangesResp = await listExchanges(wex); const result: ExchangesShortListResponse = { exchanges: [], }; // Right now we only filter on the currency, as wallet-core doesn't // fully support scoped currencies yet. for (const exch of exchangesResp.exchanges) { if (exch.currency === req.scope.currency) { result.exchanges.push({ exchangeBaseUrl: exch.exchangeBaseUrl, }); } } return result; } case WalletApiOperation.GetExchangeDetailedInfo: { const req = codecForAddExchangeRequest().decode(payload); return await getExchangeDetailedInfo(wex, req.exchangeBaseUrl); } case WalletApiOperation.ListKnownBankAccounts: { const req = codecForListKnownBankAccounts().decode(payload); return await listKnownBankAccounts(wex, req.currency); } case WalletApiOperation.AddKnownBankAccounts: { const req = codecForAddKnownBankAccounts().decode(payload); await addKnownBankAccounts(wex, req.payto, req.alias, req.currency); return {}; } case WalletApiOperation.ForgetKnownBankAccounts: { const req = codecForForgetKnownBankAccounts().decode(payload); await forgetKnownBankAccounts(wex, req.payto); return {}; } case WalletApiOperation.GetWithdrawalDetailsForUri: { const req = codecForGetWithdrawalDetailsForUri().decode(payload); return await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri, { notifyChangeFromPendingTimeoutMs: req.notifyChangeFromPendingTimeoutMs, restrictAge: req.restrictAge, }); } case WalletApiOperation.AcceptManualWithdrawal: { const req = codecForAcceptManualWithdrawalRequest().decode(payload); const res = await createManualWithdrawal(wex, { amount: Amounts.parseOrThrow(req.amount), exchangeBaseUrl: req.exchangeBaseUrl, restrictAge: req.restrictAge, forceReservePriv: req.forceReservePriv, }); return res; } case WalletApiOperation.GetWithdrawalDetailsForAmount: { const req = codecForGetWithdrawalDetailsForAmountRequest().decode(payload); const resp = await getWithdrawalDetailsForAmount(wex, cts, req); return resp; } case WalletApiOperation.GetBalances: { return await getBalances(wex); } case WalletApiOperation.GetBalanceDetail: { const req = codecForGetBalanceDetailRequest().decode(payload); return await getBalanceDetail(wex, req); } case WalletApiOperation.GetUserAttentionRequests: { const req = codecForUserAttentionsRequest().decode(payload); return await getUserAttentions(wex, req); } case WalletApiOperation.MarkAttentionRequestAsRead: { const req = codecForUserAttentionByIdRequest().decode(payload); return await markAttentionRequestAsRead(wex, req); } case WalletApiOperation.GetUserAttentionUnreadCount: { const req = codecForUserAttentionsRequest().decode(payload); return await getUserAttentionsUnreadCount(wex, req); } case WalletApiOperation.GetPendingOperations: { // FIXME: Eventually remove the handler after deprecation period. return { pendingOperations: [], } satisfies PendingOperationsResponse; } case WalletApiOperation.SetExchangeTosAccepted: { const req = codecForAcceptExchangeTosRequest().decode(payload); await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl); return {}; } case WalletApiOperation.SetExchangeTosForgotten: { const req = codecForAcceptExchangeTosRequest().decode(payload); await forgetExchangeTermsOfService(wex, req.exchangeBaseUrl); return {}; } case WalletApiOperation.AcceptBankIntegratedWithdrawal: { const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); return await acceptWithdrawalFromUri(wex, { selectedExchange: req.exchangeBaseUrl, talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, }); } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); return getExchangeTos( wex, req.exchangeBaseUrl, req.acceptedFormat, req.acceptLanguage, ); } case WalletApiOperation.GetContractTermsDetails: { const req = codecForGetContractTermsDetails().decode(payload); if (req.proposalId) { // FIXME: deprecated path return getContractTermsDetails(wex, req.proposalId); } if (req.transactionId) { const parsedTx = parseTransactionIdentifier(req.transactionId); if (parsedTx?.tag === TransactionType.Payment) { return getContractTermsDetails(wex, parsedTx.proposalId); } throw Error("transactionId is not a payment transaction"); } throw Error("transactionId missing"); } case WalletApiOperation.RetryPendingNow: { logger.error("retryPendingNow currently not implemented"); return {}; } case WalletApiOperation.SharePayment: { const req = codecForSharePaymentRequest().decode(payload); return await sharePayment(wex, req.merchantBaseUrl, req.orderId); } case WalletApiOperation.PrepareWithdrawExchange: { const req = codecForPrepareWithdrawExchangeRequest().decode(payload); return handlePrepareWithdrawExchange(wex, req); } case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(wex, req.talerPayUri); } case WalletApiOperation.PreparePayForTemplate: { const req = codecForPreparePayTemplateRequest().decode(payload); return preparePayForTemplate(wex, req); } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); let transactionId; if (req.proposalId) { // legacy client support transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: req.proposalId, }); } else if (req.transactionId) { transactionId = req.transactionId; } else { throw Error("transactionId or (deprecated) proposalId required"); } return await confirmPay(wex, transactionId, req.sessionId); } case WalletApiOperation.AbortTransaction: { const req = codecForAbortTransaction().decode(payload); await abortTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.SuspendTransaction: { const req = codecForSuspendTransaction().decode(payload); await suspendTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.GetActiveTasks: { const allTasksId = wex.taskScheduler.getActiveTasks(); const tasksInfo = await Promise.all( allTasksId.map(async (id) => { return await wex.db.runReadOnlyTx( { storeNames: ["operationRetries"] }, async (tx) => { return tx.operationRetries.get(id); }, ); }), ); const tasks = allTasksId.map((taskId, i): ActiveTask => { const transaction = convertTaskToTransactionId(taskId); const d = tasksInfo[i]; const firstTry = !d ? undefined : timestampAbsoluteFromDb(d.retryInfo.firstTry); const nextTry = !d ? undefined : timestampAbsoluteFromDb(d.retryInfo.nextRetry); const counter = d?.retryInfo.retryCounter; const lastError = d?.lastError; return { taskId: taskId, retryCounter: counter, firstTry, nextTry, lastError, transaction, }; }); return { tasks }; } case WalletApiOperation.FailTransaction: { const req = codecForFailTransactionRequest().decode(payload); await failTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.ResumeTransaction: { const req = codecForResumeTransaction().decode(payload); await resumeTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.DumpCoins: { return await dumpCoins(wex); } case WalletApiOperation.SetCoinSuspended: { const req = codecForSetCoinSuspendedRequest().decode(payload); await setCoinSuspended(wex, req.coinPub, req.suspended); return {}; } case WalletApiOperation.TestingGetSampleTransactions: return { transactions: sampleWalletCoreTransactions }; case WalletApiOperation.ForceRefresh: { const req = codecForForceRefreshRequest().decode(payload); return await forceRefresh(wex, req); } case WalletApiOperation.StartRefundQueryForUri: { const req = codecForPrepareRefundRequest().decode(payload); return await startRefundQueryForUri(wex, 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(wex, txIdParsed.proposalId); return {}; } case WalletApiOperation.AddBackupProvider: { const req = codecForAddBackupProviderRequest().decode(payload); return await addBackupProvider(wex, req); } case WalletApiOperation.RunBackupCycle: { const req = codecForRunBackupCycle().decode(payload); await runBackupCycle(wex, req); return {}; } case WalletApiOperation.RemoveBackupProvider: { const req = codecForRemoveBackupProvider().decode(payload); await removeBackupProvider(wex, req); return {}; } case WalletApiOperation.ExportBackupRecovery: { const resp = await getBackupRecovery(wex); return resp; } case WalletApiOperation.TestingWaitTransactionState: { const req = payload as TestingWaitTransactionRequest; await waitTransactionState(wex, 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: { name: "Kudos (Taler Demonstrator)", num_fractional_input_digits: 2, num_fractional_normal_digits: 2, num_fractional_trailing_zero_digits: 2, alt_unit_names: { "0": "ク", }, }, }; return kudosResp; } else if (req.scope.currency === "TESTKUDOS") { const testkudosResp: GetCurrencySpecificationResponse = { currencySpecification: { name: "Test (Taler Unstable Demonstrator)", num_fractional_input_digits: 0, num_fractional_normal_digits: 0, num_fractional_trailing_zero_digits: 0, alt_unit_names: { "0": "テ", }, }, }; return testkudosResp; } const defaultResp: GetCurrencySpecificationResponse = { currencySpecification: { name: req.scope.currency, num_fractional_input_digits: 2, num_fractional_normal_digits: 2, num_fractional_trailing_zero_digits: 2, alt_unit_names: { "0": req.scope.currency, }, }, }; return defaultResp; } case WalletApiOperation.ImportBackupRecovery: { const req = codecForAny().decode(payload); await loadBackupRecovery(wex, 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(wex, req); } case WalletApiOperation.GetMaxDepositAmount: { const req = codecForGetAmountRequest.decode(payload); return await getMaxDepositAmount(wex, req); } case WalletApiOperation.ConvertPeerPushAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertPeerPushAmount(wex, req); } case WalletApiOperation.GetMaxPeerPushAmount: { const req = codecForGetAmountRequest.decode(payload); return await getMaxPeerPushAmount(wex, req); } case WalletApiOperation.ConvertWithdrawalAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertWithdrawalAmount(wex, req); } case WalletApiOperation.GetBackupInfo: { const resp = await getBackupInfo(wex); return resp; } case WalletApiOperation.PrepareDeposit: { const req = codecForPrepareDepositRequest().decode(payload); return await checkDepositGroup(wex, req); } case WalletApiOperation.GenerateDepositGroupTxId: return { transactionId: generateDepositGroupTxId(), }; case WalletApiOperation.CreateDepositGroup: { const req = codecForCreateDepositGroupRequest().decode(payload); return await createDepositGroup(wex, req); } case WalletApiOperation.DeleteTransaction: { const req = codecForDeleteTransactionRequest().decode(payload); await deleteTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.RetryTransaction: { const req = codecForRetryTransactionRequest().decode(payload); await retryTransaction(wex, req.transactionId); return {}; } case WalletApiOperation.SetWalletDeviceId: { const req = codecForSetWalletDeviceIdRequest().decode(payload); await setWalletDeviceId(wex, req.walletDeviceId); return {}; } case WalletApiOperation.TestCrypto: { return await wex.cryptoApi.hashString({ str: "hello world" }); } case WalletApiOperation.ClearDb: { wex.ws.clearAllCaches(); await clearDatabase(wex.db.idbHandle()); return {}; } case WalletApiOperation.Recycle: { throw Error("not implemented"); return {}; } case WalletApiOperation.ExportDb: { const dbDump = await exportDb(wex.ws.idb); return dbDump; } case WalletApiOperation.ListGlobalCurrencyExchanges: { const resp: ListGlobalCurrencyExchangesResponse = { exchanges: [], }; await wex.db.runReadOnlyTx( { storeNames: ["globalCurrencyExchanges"] }, async (tx) => { const gceList = await tx.globalCurrencyExchanges.iter().toArray(); for (const gce of gceList) { resp.exchanges.push({ currency: gce.currency, exchangeBaseUrl: gce.exchangeBaseUrl, exchangeMasterPub: gce.exchangeMasterPub, }); } }, ); return resp; } case WalletApiOperation.ListGlobalCurrencyAuditors: { const resp: ListGlobalCurrencyAuditorsResponse = { auditors: [], }; await wex.db.runReadOnlyTx( { storeNames: ["globalCurrencyAuditors"] }, async (tx) => { const gcaList = await tx.globalCurrencyAuditors.iter().toArray(); for (const gca of gcaList) { resp.auditors.push({ currency: gca.currency, auditorBaseUrl: gca.auditorBaseUrl, auditorPub: gca.auditorPub, }); } }, ); return resp; } case WalletApiOperation.AddGlobalCurrencyExchange: { const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload); await wex.db.runReadWriteTx( { storeNames: ["globalCurrencyExchanges"] }, async (tx) => { const key = [ req.currency, req.exchangeBaseUrl, req.exchangeMasterPub, ]; const existingRec = await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( key, ); if (existingRec) { return; } wex.ws.exchangeCache.clear(); await tx.globalCurrencyExchanges.add({ currency: req.currency, exchangeBaseUrl: req.exchangeBaseUrl, exchangeMasterPub: req.exchangeMasterPub, }); }, ); return {}; } case WalletApiOperation.RemoveGlobalCurrencyExchange: { const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload); await wex.db.runReadWriteTx( { storeNames: ["globalCurrencyExchanges"] }, async (tx) => { const key = [ req.currency, req.exchangeBaseUrl, req.exchangeMasterPub, ]; const existingRec = await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( key, ); if (!existingRec) { return; } wex.ws.exchangeCache.clear(); checkDbInvariant(!!existingRec.id); await tx.globalCurrencyExchanges.delete(existingRec.id); }, ); return {}; } case WalletApiOperation.AddGlobalCurrencyAuditor: { const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload); await wex.db.runReadWriteTx( { storeNames: ["globalCurrencyAuditors"] }, async (tx) => { const key = [req.currency, req.auditorBaseUrl, req.auditorPub]; const existingRec = await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( key, ); if (existingRec) { return; } await tx.globalCurrencyAuditors.add({ currency: req.currency, auditorBaseUrl: req.auditorBaseUrl, auditorPub: req.auditorPub, }); wex.ws.exchangeCache.clear(); }, ); return {}; } case WalletApiOperation.TestingWaitTasksDone: { await waitTasksDone(wex); return {}; } case WalletApiOperation.RemoveGlobalCurrencyAuditor: { const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload); await wex.db.runReadWriteTx( { storeNames: ["globalCurrencyAuditors"] }, async (tx) => { const key = [req.currency, req.auditorBaseUrl, req.auditorPub]; const existingRec = await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( key, ); if (!existingRec) { return; } checkDbInvariant(!!existingRec.id); await tx.globalCurrencyAuditors.delete(existingRec.id); wex.ws.exchangeCache.clear(); }, ); return {}; } case WalletApiOperation.ImportDb: { const req = codecForImportDbRequest().decode(payload); await importDb(wex.db.idbHandle(), req.dump); return []; } case WalletApiOperation.CheckPeerPushDebit: { const req = codecForCheckPeerPushDebitRequest().decode(payload); return await checkPeerPushDebit(wex, req); } case WalletApiOperation.InitiatePeerPushDebit: { const req = codecForInitiatePeerPushDebitRequest().decode(payload); return await initiatePeerPushDebit(wex, req); } case WalletApiOperation.PreparePeerPushCredit: { const req = codecForPreparePeerPushCreditRequest().decode(payload); return await preparePeerPushCredit(wex, req); } case WalletApiOperation.ConfirmPeerPushCredit: { const req = codecForConfirmPeerPushPaymentRequest().decode(payload); return await confirmPeerPushCredit(wex, req); } case WalletApiOperation.CheckPeerPullCredit: { const req = codecForPreparePeerPullPaymentRequest().decode(payload); return await checkPeerPullPaymentInitiation(wex, req); } case WalletApiOperation.InitiatePeerPullCredit: { const req = codecForInitiatePeerPullPaymentRequest().decode(payload); return await initiatePeerPullPayment(wex, req); } case WalletApiOperation.PreparePeerPullDebit: { const req = codecForCheckPeerPullPaymentRequest().decode(payload); return await preparePeerPullDebit(wex, req); } case WalletApiOperation.ConfirmPeerPullDebit: { const req = codecForAcceptPeerPullPaymentRequest().decode(payload); return await confirmPeerPullDebit(wex, req); } case WalletApiOperation.ApplyDevExperiment: { const req = codecForApplyDevExperiment().decode(payload); await applyDevExperiment(wex, req.devExperimentUri); return {}; } case WalletApiOperation.Shutdown: { wex.ws.stop(); return {}; } case WalletApiOperation.GetVersion: { return getVersion(wex); } case WalletApiOperation.TestingWaitTransactionsFinal: return await waitUntilAllTransactionsFinal(wex); case WalletApiOperation.TestingWaitRefreshesFinal: return await waitUntilRefreshesDone(wex); case WalletApiOperation.TestingSetTimetravel: { const req = codecForTestingSetTimetravelRequest().decode(payload); setDangerousTimetravel(req.offsetMs); await wex.taskScheduler.reload(); return {}; } case WalletApiOperation.DeleteExchange: { const req = codecForDeleteExchangeRequest().decode(payload); await deleteExchange(wex, req); return {}; } case WalletApiOperation.GetExchangeResources: { const req = codecForGetExchangeResourcesRequest().decode(payload); return await getExchangeResources(wex, req.exchangeBaseUrl); } case WalletApiOperation.TestingInfiniteTransactionLoop: { const myDelayMs = (payload as any).delayMs ?? 5; const shouldFetch = !!(payload as any).shouldFetch; const doFetch = async () => { while (1) { const url = "https://exchange.demo.taler.net/reserves/01PMMB9PJN0QBWAFBXV6R0KNJJMAKXCV4D6FDG0GJFDJQXGYP32G?timeout_ms=30000"; logger.info(`fetching ${url}`); const res = await wex.http.fetch(url); logger.info(`fetch result ${res.status}`); } }; if (shouldFetch) { // In the background! doFetch(); } let loopCount = 0; while (true) { logger.info(`looping test write tx, iteration ${loopCount}`); await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { await tx.config.put({ key: ConfigRecordKey.TestLoopTx, value: loopCount, }); }); if (myDelayMs != 0) { await new Promise((resolve, reject) => { setTimeout(() => resolve(), myDelayMs); }); } loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1); } } // default: // assertUnreachable(operation); } throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, { operation, }, "unknown operation", ); } export function getVersion(wex: WalletExecutionContext): WalletCoreVersion { const result: WalletCoreVersion = { implementationSemver: walletCoreBuildInfo.implementationSemver, implementationGitHash: walletCoreBuildInfo.implementationGitHash, hash: undefined, version: WALLET_CORE_API_PROTOCOL_VERSION, exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION, bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, devMode: wex.ws.config.testing.devModeActive, }; return result; } export function getObservedWalletExecutionContext( ws: InternalWalletState, cancellationToken: CancellationToken, oc: ObservabilityContext, ): WalletExecutionContext { const wex: WalletExecutionContext = { ws, cancellationToken, cryptoApi: observeTalerCrypto(ws.cryptoApi, oc), db: new ObservableDbAccess(ws.db, oc), http: new ObservableHttpClientLibrary(ws.http, oc), taskScheduler: new ObservableTaskScheduler(ws.taskScheduler, oc), oc, }; return wex; } export function getNormalWalletExecutionContext( ws: InternalWalletState, cancellationToken: CancellationToken, oc: ObservabilityContext, ): WalletExecutionContext { const wex: WalletExecutionContext = { ws, cancellationToken, cryptoApi: ws.cryptoApi, db: ws.db, get http() { if (ws.initCalled) { return ws.http; } throw Error("wallet not initialized"); }, taskScheduler: ws.taskScheduler, oc, }; return wex; } /** * Handle a request to the wallet-core API. */ async function handleCoreApiRequest( ws: InternalWalletState, operation: string, id: string, payload: unknown, ): Promise { let wex: WalletExecutionContext; let oc: ObservabilityContext; const cts = CancellationToken.create(); if (ws.initCalled && ws.config.testing.emitObservabilityEvents) { oc = { observe(evt) { ws.notify({ type: NotificationType.RequestObservabilityEvent, operation, requestId: id, event: evt, }); }, }; wex = getObservedWalletExecutionContext(ws, cts.token, oc); } else { oc = { observe(evt) {}, }; wex = getNormalWalletExecutionContext(ws, cts.token, oc); } try { const start = performanceNow(); await ws.ensureWalletDbOpen(); oc.observe({ type: ObservabilityEventType.RequestStart, }); const result = await dispatchRequestInternal( wex, cts, operation as any, payload, ); const end = performanceNow(); oc.observe({ type: ObservabilityEventType.RequestFinishSuccess, durationMs: Number((end - start) / 1000n / 1000n), }); return { type: "response", operation, id, result, }; } catch (e: any) { const err = getErrorDetailFromException(e); logger.info( `finished wallet core request ${operation} with error: ${j2s(err)}`, ); oc.observe({ type: ObservabilityEventType.RequestFinishError, }); return { type: "error", operation, id, error: err, }; } } export function applyRunConfigDefaults( wcp?: PartialWalletRunConfig, ): WalletRunConfig { return { builtin: { exchanges: wcp?.builtin?.exchanges ?? [ { exchangeBaseUrl: "https://exchange.demo.taler.net/", currencyHint: "KUDOS", }, ], }, features: { allowHttp: wcp?.features?.allowHttp ?? false, }, testing: { denomselAllowLate: wcp?.testing?.denomselAllowLate ?? false, devModeActive: wcp?.testing?.devModeActive ?? false, insecureTrustExchange: wcp?.testing?.insecureTrustExchange ?? false, preventThrottling: wcp?.testing?.preventThrottling ?? false, skipDefaults: wcp?.testing?.skipDefaults ?? false, emitObservabilityEvents: wcp?.testing?.emitObservabilityEvents ?? false, }, }; } export type HttpFactory = (config: WalletRunConfig) => HttpRequestLibrary; /** * Public handle to a running wallet. */ export class Wallet { private ws: InternalWalletState; private _client: WalletCoreApiClient | undefined; private constructor( idb: IDBFactory, httpFactory: HttpFactory, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ) { this.ws = new InternalWalletState( idb, httpFactory, timer, cryptoWorkerFactory, ); } get client(): WalletCoreApiClient { if (!this._client) { throw Error(); } return this._client; } static async create( idb: IDBFactory, httpFactory: HttpFactory, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ): Promise { const w = new Wallet(idb, httpFactory, timer, cryptoWorkerFactory); w._client = await getClientFromWalletState(w.ws); return w; } addNotificationListener(f: (n: WalletNotification) => void): CancelFn { return this.ws.addNotificationListener(f); } async handleCoreApiRequest( operation: string, id: string, payload: unknown, ): Promise { await this.ws.ensureWalletDbOpen(); return handleCoreApiRequest(this.ws, operation, id, payload); } } export interface DevExperimentState { blockRefreshes?: boolean; } export class Cache { private map: Map = new Map(); constructor( private maxCapacity: number, private cacheDuration: Duration, ) {} get(key: string): T | undefined { const r = this.map.get(key); if (!r) { return undefined; } if (AbsoluteTime.isExpired(r[0])) { this.map.delete(key); return undefined; } return r[1]; } clear(): void { this.map.clear(); } put(key: string, value: T): void { if (this.map.size > this.maxCapacity) { this.map.clear(); } const expiry = AbsoluteTime.addDuration( AbsoluteTime.now(), this.cacheDuration, ); this.map.set(key, [expiry, value]); } } /** * Implementation of triggers for the wallet DB. */ class WalletDbTriggerSpec implements TriggerSpec { constructor(public ws: InternalWalletState) {} afterCommit(info: AfterCommitInfo): void { if (info.mode !== "readwrite") { return; } logger.info( `in after commit callback for readwrite, modified ${j2s([ ...info.modifiedStores, ])}`, ); const modified = info.accessedStores; if ( modified.has(WalletStoresV1.exchanges.storeName) || modified.has(WalletStoresV1.exchangeDetails.storeName) || modified.has(WalletStoresV1.denominations.storeName) || modified.has(WalletStoresV1.globalCurrencyAuditors.storeName) || modified.has(WalletStoresV1.globalCurrencyExchanges.storeName) ) { this.ws.clearAllCaches(); } } } /** * Internal state of the wallet. * * This ties together all the operation implementations. */ export class InternalWalletState { cryptoApi: TalerCryptoInterface; cryptoDispatcher: CryptoDispatcher; readonly timerGroup: TimerGroup; workAvailable = new AsyncCondition(); stopped = false; private listeners: NotificationListener[] = []; initCalled = false; refreshCostCache: Cache = new Cache( 1000, Duration.fromSpec({ minutes: 1 }), ); denomInfoCache: Cache = new Cache( 1000, Duration.fromSpec({ minutes: 1 }), ); exchangeCache: Cache = new Cache( 1000, Duration.fromSpec({ minutes: 1 }), ); /** * Promises that are waiting for a particular resource. */ private resourceWaiters: Record[]> = {}; /** * Resources that are currently locked. */ private resourceLocks: Set = new Set(); taskScheduler: TaskScheduler = new TaskSchedulerImpl(this); private _config: Readonly | undefined; private _indexedDbHandle: IDBDatabase | undefined = undefined; private _dbAccessHandle: DbAccess | undefined; private _http: HttpRequestLibrary | undefined = undefined; get db(): DbAccess { if (!this._dbAccessHandle) { this._dbAccessHandle = this.createDbAccessHandle( CancellationToken.CONTINUE, ); } return this._dbAccessHandle; } devExperimentState: DevExperimentState = {}; clientCancellationMap: Map = new Map(); clearAllCaches(): void { this.exchangeCache.clear(); this.denomInfoCache.clear(); this.refreshCostCache.clear(); } initWithConfig(newConfig: WalletRunConfig): void { this._config = newConfig; logger.info(`setting new config to ${j2s(newConfig)}`); this._http = this.httpFactory(newConfig); if (this.config.testing.devModeActive) { this._http = new DevExperimentHttpLib(this.http); } } createDbAccessHandle( cancellationToken: CancellationToken, ): DbAccess { if (!this._indexedDbHandle) { throw Error("db not initialized"); } return new DbAccessImpl( this._indexedDbHandle, WalletStoresV1, new WalletDbTriggerSpec(this), cancellationToken, ); } get config(): WalletRunConfig { if (!this._config) { throw Error("config not initialized"); } return this._config; } get http(): HttpRequestLibrary { if (!this._http) { throw Error("wallet not initialized"); } return this._http; } constructor( public idb: IDBFactory, private httpFactory: HttpFactory, public timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ) { this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); this.cryptoApi = this.cryptoDispatcher.cryptoApi; this.timerGroup = new TimerGroup(timer); } async ensureWalletDbOpen(): Promise { if (this._indexedDbHandle) { return; } const myVersionChange = async (): Promise => { logger.info("version change requested for Taler DB"); }; try { const myDb = await openTalerDatabase(this.idb, myVersionChange); this._indexedDbHandle = myDb; } catch (e) { logger.error("error writing to database during initialization"); throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, { innerError: getErrorDetailFromException(e), }); } } 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(); this.taskScheduler.shutdown().catch((e) => { logger.warn(`shutdown failed: ${safeStringifyException(e)}`); }); } /** * 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); const waiter = (this.resourceWaiters[token] ?? []).shift(); if (waiter) { waiter.resolve(); } } } } }