diff options
Diffstat (limited to 'packages/taler-wallet-core/src/wallet.ts')
-rw-r--r-- | packages/taler-wallet-core/src/wallet.ts | 2409 |
1 files changed, 1624 insertions, 785 deletions
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 32e3945e8..fc612b189 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -15,33 +15,149 @@ */ /** - * High-level wallet operations that should be indepentent from the underlying + * High-level wallet operations that should be independent from the underlying * browser extension interface. */ /** * Imports. */ +import { IDBDatabase, IDBFactory } from "@gnu-taler/idb-bridge"; import { - BalancesResponse, + 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, + codecForConfirmWithdrawalRequestRequest, + 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, + codecForPrepareBankIntegratedWithdrawalRequest, + codecForPrepareDepositRequest, + codecForPreparePayRequest, + codecForPreparePayTemplateRequest, + codecForPreparePeerPullPaymentRequest, + codecForPreparePeerPushCreditRequest, + codecForPrepareRefundRequest, + codecForPrepareWithdrawExchangeRequest, + codecForRecoverStoredBackupRequest, + codecForRemoveGlobalCurrencyAuditorRequest, + codecForRemoveGlobalCurrencyExchangeRequest, + codecForResumeTransaction, codecForRetryTransactionRequest, + codecForSetCoinSuspendedRequest, codecForSetWalletDeviceIdRequest, - codecForGetExchangeWithdrawalInfo, - durationFromSpec, - durationMin, - getDurationRemaining, - isTimestampExpired, + codecForSharePaymentRequest, + codecForStartRefundQueryRequest, + codecForSuspendTransaction, + codecForTestPayArgs, + codecForTestingGetDenomStatsRequest, + codecForTestingListTasksForTransactionRequest, + codecForTestingSetTimetravelRequest, + codecForTransactionByIdRequest, + codecForTransactionsRequest, + codecForUpdateExchangeEntryRequest, + codecForUserAttentionByIdRequest, + codecForUserAttentionsRequest, + codecForValidateIbanRequest, + codecForWithdrawTestBalance, + getErrorDetailFromException, j2s, - TalerErrorCode, - Timestamp, - timestampMin, - WalletNotification, - codecForWithdrawFakebankRequest, - URL, + 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, @@ -50,544 +166,342 @@ import { getBackupInfo, getBackupRecovery, loadBackupRecovery, - processBackupForProvider, removeBackupProvider, runBackupCycle, -} from "./operations/backup/index.js"; -import { exportBackup } from "./operations/backup/export.js"; -import { getBalances } from "./operations/balance.js"; + setWalletDeviceId, +} from "./backup/index.js"; +import { getBalanceDetail, getBalances } from "./balance.js"; +import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { - createDepositGroup, - processDepositGroup, - trackDepositGroup, -} from "./operations/deposits.js"; + 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 { - makeErrorDetails, - OperationFailedAndReportedError, - OperationFailedError, -} from "./errors.js"; + checkDepositGroup, + createDepositGroup, + generateDepositGroupTxId, +} from "./deposits.js"; +import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; import { + ReadyExchangeSummary, acceptExchangeTermsOfService, - getExchangeDetails, - getExchangeTrust, - updateExchangeFromUrl, -} from "./operations/exchanges.js"; + 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, - processDownloadProposal, - processPurchasePay, -} from "./operations/pay.js"; -import { getPendingOperations } from "./operations/pending.js"; -import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js"; + sharePayment, + startQueryRefund, + startRefundQueryForUri, +} from "./pay-merchant.js"; +import { + checkPeerPullPaymentInitiation, + initiatePeerPullPayment, +} from "./pay-peer-pull-credit.js"; import { - autoRefresh, - createRefreshGroup, - processRefreshGroup, -} from "./operations/refresh.js"; + confirmPeerPullDebit, + preparePeerPullDebit, +} from "./pay-peer-pull-debit.js"; import { - abortFailedPayWithRefund, - applyRefund, - processPurchaseQueryRefund, -} from "./operations/refund.js"; + confirmPeerPushCredit, + preparePeerPushCredit, +} from "./pay-peer-push-credit.js"; import { - createReserve, - createTalerWithdrawReserve, - getFundingPaytoUris, - processReserve, -} from "./operations/reserves.js"; + checkPeerPushDebit, + initiatePeerPushDebit, +} from "./pay-peer-push-debit.js"; import { - ExchangeOperations, - InternalWalletState, - NotificationListener, - RecoupOperations, -} from "./common.js"; + 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 "./operations/testing.js"; -import { acceptTip, prepareTip, processTip } from "./operations/tip.js"; +} from "./testing.js"; import { + abortTransaction, + constructTransactionIdentifier, deleteTransaction, + failTransaction, + getTransactionById, getTransactions, + getWithdrawalTransactionByUri, + parseTransactionIdentifier, + resumeTransaction, retryTransaction, -} from "./operations/transactions.js"; -import { - getExchangeWithdrawalInfo, - getWithdrawalDetailsForUri, - processWithdrawGroup, -} from "./operations/withdraw.js"; -import { - AuditorTrustRecord, - CoinSourceType, - ReserveRecordStatus, - WalletStoresV1, -} from "./db.js"; -import { NotificationType } from "@gnu-taler/taler-util"; + suspendTransaction, +} from "./transactions.js"; import { - PendingTaskInfo, - PendingOperationsResponse, - PendingTaskType, -} from "./pending-types.js"; -import { CoinDumpJson } from "@gnu-taler/taler-util"; -import { codecForTransactionsRequest } from "@gnu-taler/taler-util"; -import { - AcceptManualWithdrawalResult, - AcceptWithdrawalResponse, - codecForAbortPayWithRefundRequest, - codecForAcceptBankIntegratedWithdrawalRequest, - codecForAcceptExchangeTosRequest, - codecForAcceptManualWithdrawalRequet, - codecForAcceptTipRequest, - codecForAddExchangeRequest, - codecForApplyRefundRequest, - codecForConfirmPayRequest, - codecForCreateDepositGroupRequest, - codecForForceRefreshRequest, - codecForGetExchangeTosRequest, - codecForGetWithdrawalDetailsForAmountRequest, - codecForGetWithdrawalDetailsForUri, - codecForIntegrationTestArgs, - codecForPreparePayRequest, - codecForPrepareTipRequest, - codecForSetCoinSuspendedRequest, - codecForTestPayArgs, - codecForTrackDepositGroupRequest, - codecForWithdrawTestBalance, - CoreApiResponse, - ExchangeListItem, - ExchangesListRespose, - GetExchangeTosResult, - ManualWithdrawalDetails, - RefreshReason, -} from "@gnu-taler/taler-util"; -import { AmountJson, Amounts } from "@gnu-taler/taler-util"; -import { assertUnreachable } from "./util/assertUnreachable.js"; -import { Logger } from "@gnu-taler/taler-util"; -import { setWalletDeviceId } from "./operations/backup/state.js"; -import { WalletCoreApiClient } from "./wallet-api-types.js"; -import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js"; -import { CryptoApi, CryptoWorkerFactory } from "./crypto/workers/cryptoApi.js"; -import { TimerGroup } from "./util/timer.js"; + 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 { - AsyncCondition, - OpenedPromise, - openPromise, -} from "./util/promiseUtils.js"; -import { DbAccess } from "./util/query.js"; + WalletApiOperation, + WalletCoreApiClient, + WalletCoreResponseType, +} from "./wallet-api-types.js"; import { - HttpRequestLibrary, - readSuccessResponseJsonOrThrow, -} from "./util/http.js"; - -const builtinAuditors: AuditorTrustRecord[] = [ - { - currency: "KUDOS", - auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0", - auditorBaseUrl: "https://auditor.demo.taler.net/", - uids: ["5P25XF8TVQP9AW6VYGY2KV47WT5Y3ZXFSJAA570GJPX5SVJXKBVG"], - }, -]; + acceptWithdrawalFromUri, + confirmWithdrawal, + createManualWithdrawal, + getWithdrawalDetailsForAmount, + getWithdrawalDetailsForUri, + prepareBankIntegratedWithdrawal, +} from "./withdraw.js"; const logger = new Logger("wallet.ts"); -async function getWithdrawalDetailsForAmount( - ws: InternalWalletState, - exchangeBaseUrl: string, - amount: AmountJson, -): Promise<ManualWithdrawalDetails> { - const wi = await getExchangeWithdrawalInfo(ws, exchangeBaseUrl, amount); - const paytoUris = wi.exchangeDetails.wireInfo.accounts.map( - (x) => x.payto_uri, - ); - if (!paytoUris) { - throw Error("exchange is in invalid state"); - } - return { - amountRaw: Amounts.stringify(amount), - amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), - paytoUris, - tosAccepted: wi.termsOfServiceAccepted, - }; -} - /** - * Execute one operation based on the pending operation info record. - */ -async function processOnePendingOperation( - ws: InternalWalletState, - pending: PendingTaskInfo, - forceNow = false, -): Promise<void> { - logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`); - switch (pending.type) { - case PendingTaskType.ExchangeUpdate: - await updateExchangeFromUrl( - ws, - pending.exchangeBaseUrl, - undefined, - forceNow, - ); - break; - case PendingTaskType.Refresh: - await processRefreshGroup(ws, pending.refreshGroupId, forceNow); - break; - case PendingTaskType.Reserve: - await processReserve(ws, pending.reservePub, forceNow); - break; - case PendingTaskType.Withdraw: - await processWithdrawGroup(ws, pending.withdrawalGroupId, forceNow); - break; - case PendingTaskType.ProposalDownload: - await processDownloadProposal(ws, pending.proposalId, forceNow); - break; - case PendingTaskType.TipPickup: - await processTip(ws, pending.tipId, forceNow); - break; - case PendingTaskType.Pay: - await processPurchasePay(ws, pending.proposalId, forceNow); - break; - case PendingTaskType.RefundQuery: - await processPurchaseQueryRefund(ws, pending.proposalId, forceNow); - break; - case PendingTaskType.Recoup: - await processRecoupGroup(ws, pending.recoupGroupId, forceNow); - break; - case PendingTaskType.ExchangeCheckRefresh: - await autoRefresh(ws, pending.exchangeBaseUrl); - break; - case PendingTaskType.Deposit: - await processDepositGroup(ws, pending.depositGroupId); - break; - case PendingTaskType.Backup: - await processBackupForProvider(ws, pending.backupProviderBaseUrl); - break; - default: - assertUnreachable(pending); - } -} - -/** - * Process pending operations. - */ -export async function runPending( - ws: InternalWalletState, - forceNow = false, -): Promise<void> { - const pendingOpsResponse = await getPendingOperations(ws); - for (const p of pendingOpsResponse.pendingOperations) { - if (!forceNow && !isTimestampExpired(p.timestampDue)) { - continue; - } - try { - await processOnePendingOperation(ws, p, forceNow); - } catch (e) { - if (e instanceof OperationFailedAndReportedError) { - console.error( - "Operation failed:", - JSON.stringify(e.operationError, undefined, 2), - ); - } else { - console.error(e); - } - } - } -} - -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; -} - -/** - * Main retry loop of the wallet. + * Execution context for code that is run in the wallet. * - * Looks up pending operations from the wallet, runs them, repeat. + * Typically the execution context is either for a wallet-core + * request handler or for a shepherded task. */ -async function runTaskLoop( - ws: InternalWalletState, - opts: RetryLoopOpts = {}, -): Promise<void> { - 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: Timestamp = { t_ms: "never" }; - for (const p of pending.pendingOperations) { - minDue = timestampMin(minDue, p.timestampDue); - if (isTimestampExpired(p.timestampDue)) { - numDue++; - } - if (p.givesLifeness) { - numGivingLiveness++; - } +export interface WalletExecutionContext { + readonly ws: InternalWalletState; + readonly cryptoApi: TalerCryptoInterface; + readonly cancellationToken: CancellationToken; + readonly http: HttpRequestLibrary; + readonly db: DbAccess<typeof WalletStoresV1>; + readonly oc: ObservabilityContext; + readonly taskScheduler: TaskScheduler; +} - const maxRetries = opts.maxRetries; +export const EXCHANGE_COINS_LOCK = "exchange-coins-lock"; +export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock"; - if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) { - logger.warn( - `stopping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`, - ); - return; - } - } +export type NotificationListener = (n: WalletNotification) => void; - if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) { - logger.warn(`stopping, as no pending operations have lifeness`); - return; - } - - // 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, - }), - getDurationRemaining(minDue), - ); - logger.trace(`waiting for at most ${dt.d_ms} ms`); - const timeout = ws.timerGroup.resolveAfter(dt); - ws.notify({ - type: NotificationType.WaitingForRetry, - numGivingLiveness, - numPending: pending.pendingOperations.length, - }); - // Wait until either the timeout, or we are notified (via the latch) - // that more work might be available. - await Promise.race([timeout, ws.latch.wait()]); - } else { - logger.trace( - `running ${pending.pendingOperations.length} pending operations`, - ); - for (const p of pending.pendingOperations) { - if (!isTimestampExpired(p.timestampDue)) { - continue; - } - try { - await processOnePendingOperation(ws, p); - } catch (e) { - if (e instanceof OperationFailedAndReportedError) { - logger.warn("operation processed resulted in reported error"); - } else { - logger.error("Uncaught exception", e); - ws.notify({ - type: NotificationType.InternalError, - message: "uncaught exception", - exception: e, - }); - } - } - ws.notify({ - type: NotificationType.PendingOperationProcessed, - }); - } - } - } - logger.trace("exiting wallet retry loop"); -} +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(ws: InternalWalletState): Promise<void> { - await ws.db - .mktx((x) => ({ config: x.config, auditorTrustStore: x.auditorTrust })) - .runReadWrite(async (tx) => { - let applied = false; - await tx.config.iter().forEach((x) => { - if (x.key == "currencyDefaultsApplied" && x.value == true) { - applied = true; - } - }); - if (!applied) { - for (const c of builtinAuditors) { - await tx.auditorTrustStore.put(c); +async function fillDefaults(wex: WalletExecutionContext): Promise<void> { + 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); } } - }); -} - -/** - * Create a reserve for a manual withdrawal. - * - * Adds the corresponding exchange as a trusted exchange if it is neither - * audited nor trusted already. - */ -async function acceptManualWithdrawal( - ws: InternalWalletState, - exchangeBaseUrl: string, - amount: AmountJson, -): Promise<AcceptManualWithdrawalResult> { - try { - const resp = await createReserve(ws, { - amount, - exchange: exchangeBaseUrl, - }); - const exchangePaytoUris = await ws.db - .mktx((x) => ({ - exchanges: x.exchanges, - exchangeDetails: x.exchangeDetails, - reserves: x.reserves, - })) - .runReadWrite((tx) => getFundingPaytoUris(tx, resp.reservePub)); - return { - reservePub: resp.reservePub, - exchangePaytoUris, - }; - } finally { - ws.latch.trigger(); + await tx.config.put({ + key: ConfigRecordKey.CurrencyDefaultsApplied, + value: true, + }); + }, + ); + for (const notif of notifications) { + wex.ws.notify(notif); } } -async function getExchangeTos( - ws: InternalWalletState, +export async function getDenomInfo( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction<["denominations"]>, exchangeBaseUrl: string, - acceptedFormat?: string[], -): Promise<GetExchangeTosResult> { - const { exchangeDetails } = await updateExchangeFromUrl( - ws, - exchangeBaseUrl, - acceptedFormat, - ); - const content = exchangeDetails.termsOfServiceText; - const currentEtag = exchangeDetails.termsOfServiceLastEtag; - const contentType = exchangeDetails.termsOfServiceContentType; - if ( - content === undefined || - currentEtag === undefined || - contentType === undefined - ) { - throw Error("exchange is in invalid state"); + denomPubHash: string, +): Promise<DenominationInfo | undefined> { + const cacheKey = `${exchangeBaseUrl}:${denomPubHash}`; + const cached = wex.ws.denomInfoCache.get(cacheKey); + if (cached) { + return cached; } - return { - acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag, - currentEtag, - content, - contentType, - }; + 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; } -async function getExchanges( - ws: InternalWalletState, -): Promise<ExchangesListRespose> { - const exchanges: ExchangeListItem[] = []; - await ws.db - .mktx((x) => ({ - exchanges: x.exchanges, - exchangeDetails: x.exchangeDetails, - })) - .runReadOnly(async (tx) => { - const exchangeRecords = await tx.exchanges.iter().toArray(); - for (const r of exchangeRecords) { - const dp = r.detailsPointer; - if (!dp) { - continue; - } - const { currency } = dp; - const exchangeDetails = await getExchangeDetails(tx, r.baseUrl); - if (!exchangeDetails) { - continue; - } - exchanges.push({ - exchangeBaseUrl: r.baseUrl, - currency, - paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), +/** + * List bank accounts known to the wallet from + * previous withdrawals. + */ +async function listKnownBankAccounts( + wex: WalletExecutionContext, + currency?: string, +): Promise<KnownBankAccounts> { + 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 { exchanges }; + } + }); + return { accounts }; } -async function acceptWithdrawal( - ws: InternalWalletState, - talerWithdrawUri: string, - selectedExchange: string, -): Promise<AcceptWithdrawalResponse> { - try { - return createTalerWithdrawReserve(ws, talerWithdrawUri, selectedExchange); - } finally { - ws.latch.trigger(); - } +/** + */ +async function addKnownBankAccounts( + wex: WalletExecutionContext, + payto: string, + alias: string, + currency: string, +): Promise<void> { + await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => { + tx.bankAccounts.put({ + uri: payto, + alias: alias, + currency: currency, + kycCompleted: false, + }); + }); + return; } /** - * Inform the wallet that the status of a reserve has changed (e.g. due to a - * confirmation from the bank.). */ -export async function handleNotifyReserve( - ws: InternalWalletState, +async function forgetKnownBankAccounts( + wex: WalletExecutionContext, + payto: string, ): Promise<void> { - const reserves = await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadOnly(async (tx) => { - return tx.reserves.iter().toArray(); - }); - for (const r of reserves) { - if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) { - try { - processReserve(ws, r.reservePub); - } catch (e) { - console.error(e); - } + 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( - ws: InternalWalletState, + wex: WalletExecutionContext, coinPub: string, suspended: boolean, ): Promise<void> { - await ws.db - .mktx((x) => ({ - coins: x.coins, - })) - .runReadWrite(async (tx) => { + 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; } - c.suspended = suspended; + 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<CoinDumpJson> { +async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> { const coinsJson: CoinDumpJson = { coins: [] }; - await ws.db - .mktx((x) => ({ - coins: x.coins, - denominations: x.denominations, - withdrawalGroups: x.withdrawalGroups, - })) - .runReadOnly(async (tx) => { + 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([ @@ -595,7 +509,7 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> { c.denomPubHash, ]); if (!denom) { - console.error("no denom session found for coin"); + logger.warn("no denom found for coin"); continue; } const cs = c.coinSource; @@ -605,42 +519,55 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> { } let withdrawalReservePub: string | undefined; if (cs.type == CoinSourceType.Withdraw) { - const ws = await tx.withdrawalGroups.get(cs.withdrawalGroupId); - if (!ws) { - console.error("no withdrawal session found for coin"); - continue; - } - withdrawalReservePub = ws.reservePub; + 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: c.denomPub, + denom_pub: denomInfo.denomPub, denom_pub_hash: c.denomPubHash, - denom_value: Amounts.stringify(denom.value), + denom_value: denom.value, exchange_base_url: c.exchangeBaseUrl, refresh_parent_coin_pub: refreshParentCoinPub, - remaining_value: Amounts.stringify(c.currentAmount), withdrawal_reserve_pub: withdrawalReservePub, - coin_suspended: c.suspended, + 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( +let id = 0; +async function getClientFromWalletState( ws: InternalWalletState, ): Promise<WalletCoreApiClient> { - let id = 0; const client: WalletCoreApiClient = { async call(op, payload): Promise<any> { - const res = await handleCoreApiRequest(ws, op, `${id++}`, payload); + id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100); + const res = await handleCoreApiRequest(ws, op, String(id), payload); switch (res.type) { case "error": - throw new OperationFailedError(res.error); + throw TalerError.fromUncheckedDetail(res.error); case "response": return res.result; } @@ -649,320 +576,1059 @@ export async function getClientFromWalletState( return client; } +async function createStoredBackup( + wex: WalletExecutionContext, +): Promise<CreateStoredBackupResponse> { + 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<StoredBackupList> { + 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<void> { + 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<void> { + 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<PrepareWithdrawExchangeResponse> { + 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( - ws: InternalWalletState, - operation: string, + wex: WalletExecutionContext, + cts: CancellationToken.Source, + operation: WalletApiOperation, payload: unknown, -): Promise<Record<string, any>> { - if (!ws.initCalled && operation !== "initWallet") { +): Promise<WalletCoreResponseType<typeof operation>> { + 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 "initWallet": { - ws.initCalled = true; - await fillDefaults(ws); + case WalletApiOperation.CreateStoredBackup: + return createStoredBackup(wex); + case WalletApiOperation.DeleteStoredBackup: { + const req = codecForDeleteStoredBackupRequest().decode(payload); + await deleteStoredBackup(wex, req); return {}; } - case "withdrawTestkudos": { - await withdrawTestBalance( - ws, - "TESTKUDOS:10", - "https://bank.test.taler.net/", - "https://exchange.test.taler.net/", - ); + case WalletApiOperation.ListStoredBackups: + return listStoredBackups(wex); + case WalletApiOperation.RecoverStoredBackup: { + const req = codecForRecoverStoredBackupRequest().decode(payload); + await recoverStoredBackup(wex, req); return {}; } - case "withdrawTestBalance": { + 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( - ws, - req.amount, - req.bankBaseUrl, - req.exchangeBaseUrl, - ); + await withdrawTestBalance(wex, req); return {}; } - case "runIntegrationTest": { + 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(ws, req); + await runIntegrationTest(wex, req); return {}; } - case "testPay": { - const req = codecForTestPayArgs().decode(payload); - await testPay(ws, req); + case WalletApiOperation.RunIntegrationTestV2: { + const req = codecForIntegrationTestV2Args().decode(payload); + await runIntegrationTest2(wex, req); return {}; } - case "getTransactions": { + 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(ws, req); + return await getTransactions(wex, req); } - case "addExchange": { + 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 updateExchangeFromUrl( - ws, - req.exchangeBaseUrl, - undefined, - req.forceUpdate, - ); + await fetchFreshExchange(wex, req.exchangeBaseUrl, { + expectedMasterPub: req.masterPub, + }); return {}; } - case "listExchanges": { - return await getExchanges(ws); + case WalletApiOperation.TestingPing: { + return {}; } - case "getWithdrawalDetailsForUri": { - const req = codecForGetWithdrawalDetailsForUri().decode(payload); - return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri); + case WalletApiOperation.UpdateExchangeEntry: { + const req = codecForUpdateExchangeEntryRequest().decode(payload); + await fetchFreshExchange(wex, req.exchangeBaseUrl, { + forceUpdate: !!req.force, + }); + return {}; } - case "getExchangeWithdrawalInfo": { - const req = codecForGetExchangeWithdrawalInfo().decode(payload); - return await getExchangeWithdrawalInfo( - ws, - req.exchangeBaseUrl, - req.amount, + 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 "acceptManualWithdrawal": { - const req = codecForAcceptManualWithdrawalRequet().decode(payload); - const res = await acceptManualWithdrawal( - ws, - req.exchangeBaseUrl, - Amounts.parseOrThrow(req.amount), - ); + 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, { + 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 "getWithdrawalDetailsForAmount": { - const req = codecForGetWithdrawalDetailsForAmountRequest().decode( - payload, - ); - return await getWithdrawalDetailsForAmount( - ws, - req.exchangeBaseUrl, - Amounts.parseOrThrow(req.amount), - ); + 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 "getBalances": { - return await getBalances(ws); + case WalletApiOperation.GetBalanceDetail: { + const req = codecForGetBalanceDetailRequest().decode(payload); + return await getBalanceDetail(wex, req); } - case "getPendingOperations": { - return await getPendingOperations(ws); + case WalletApiOperation.GetUserAttentionRequests: { + const req = codecForUserAttentionsRequest().decode(payload); + return await getUserAttentions(wex, req); } - case "setExchangeTosAccepted": { + 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(ws, req.exchangeBaseUrl, req.etag); + await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl); return {}; } - case "applyRefund": { - const req = codecForApplyRefundRequest().decode(payload); - return await applyRefund(ws, req.talerRefundUri); + case WalletApiOperation.SetExchangeTosForgotten: { + const req = codecForAcceptExchangeTosRequest().decode(payload); + await forgetExchangeTermsOfService(wex, req.exchangeBaseUrl); + return {}; } - case "acceptBankIntegratedWithdrawal": { - const req = codecForAcceptBankIntegratedWithdrawalRequest().decode( - payload, - ); - return await acceptWithdrawal( - ws, - req.talerWithdrawUri, + 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.ConfirmWithdrawal: { + const req = codecForConfirmWithdrawalRequestRequest().decode(payload); + return confirmWithdrawal(wex, req.transactionId); + } + case WalletApiOperation.PrepareBankIntegratedWithdrawal: { + const req = + codecForPrepareBankIntegratedWithdrawalRequest().decode(payload); + return prepareBankIntegratedWithdrawal(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 "getExchangeTos": { - const req = codecForGetExchangeTosRequest().decode(payload); - return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat); + 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 "retryPendingNow": { - await runPending(ws, true); + case WalletApiOperation.RetryPendingNow: { + logger.error("retryPendingNow currently not implemented"); return {}; } - // FIXME: Deprecate one of the aliases! - case "preparePayForUri": - case "preparePay": { + 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(ws, req.talerPayUri); + return await preparePayForUri(wex, req.talerPayUri); + } + case WalletApiOperation.PreparePayForTemplate: { + const req = codecForPreparePayTemplateRequest().decode(payload); + return preparePayForTemplate(wex, req); } - case "confirmPay": { + case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); - return await confirmPay(ws, req.proposalId, req.sessionId); + 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 "abortFailedPayWithRefund": { - const req = codecForAbortPayWithRefundRequest().decode(payload); - await abortFailedPayWithRefund(ws, req.proposalId); + case WalletApiOperation.AbortTransaction: { + const req = codecForAbortTransaction().decode(payload); + await abortTransaction(wex, req.transactionId); return {}; } - case "dumpCoins": { - return await dumpCoins(ws); - } - case "setCoinSuspended": { - const req = codecForSetCoinSuspendedRequest().decode(payload); - await setCoinSuspended(ws, req.coinPub, req.suspended); + case WalletApiOperation.SuspendTransaction: { + const req = codecForSuspendTransaction().decode(payload); + await suspendTransaction(wex, req.transactionId); return {}; } - case "forceRefresh": { - const req = codecForForceRefreshRequest().decode(payload); - const coinPubs = req.coinPubList.map((x) => ({ coinPub: x })); - const refreshGroupId = await ws.db - .mktx((x) => ({ - refreshGroups: x.refreshGroups, - denominations: x.denominations, - coins: x.coins, - })) - .runReadWrite(async (tx) => { - return await createRefreshGroup( - ws, - tx, - coinPubs, - RefreshReason.Manual, + 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); + }, ); - }); - processRefreshGroup(ws, refreshGroupId.refreshGroupId, true).catch( - (x) => { - logger.error(x); - }, + }), ); - return { - refreshGroupId, - }; + + 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 "prepareTip": { - const req = codecForPrepareTipRequest().decode(payload); - return await prepareTip(ws, req.talerTipUri); + case WalletApiOperation.FailTransaction: { + const req = codecForFailTransactionRequest().decode(payload); + await failTransaction(wex, req.transactionId); + return {}; } - case "acceptTip": { - const req = codecForAcceptTipRequest().decode(payload); - await acceptTip(ws, req.walletTipId); + case WalletApiOperation.ResumeTransaction: { + const req = codecForResumeTransaction().decode(payload); + await resumeTransaction(wex, req.transactionId); return {}; } - case "exportBackupPlain": { - return exportBackup(ws); + case WalletApiOperation.DumpCoins: { + return await dumpCoins(wex); } - case "addBackupProvider": { - const req = codecForAddBackupProviderRequest().decode(payload); - await addBackupProvider(ws, req); + 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 "runBackupCycle": { + case WalletApiOperation.AddBackupProvider: { + const req = codecForAddBackupProviderRequest().decode(payload); + return await addBackupProvider(wex, req); + } + case WalletApiOperation.RunBackupCycle: { const req = codecForRunBackupCycle().decode(payload); - await runBackupCycle(ws, req); + await runBackupCycle(wex, req); return {}; } - case "removeBackupProvider": { + case WalletApiOperation.RemoveBackupProvider: { const req = codecForRemoveBackupProvider().decode(payload); - await removeBackupProvider(ws, req); + await removeBackupProvider(wex, req); return {}; } - case "exportBackupRecovery": { - const resp = await getBackupRecovery(ws); + case WalletApiOperation.ExportBackupRecovery: { + const resp = await getBackupRecovery(wex); return resp; } - case "importBackupRecovery": { + 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(ws, req); + await loadBackupRecovery(wex, req); return {}; } - case "getBackupInfo": { - const resp = await getBackupInfo(ws); + // 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 "createDepositGroup": { - const req = codecForCreateDepositGroupRequest().decode(payload); - return await createDepositGroup(ws, req); + case WalletApiOperation.PrepareDeposit: { + const req = codecForPrepareDepositRequest().decode(payload); + return await checkDepositGroup(wex, req); } - case "trackDepositGroup": { - const req = codecForTrackDepositGroupRequest().decode(payload); - return trackDepositGroup(ws, req); + case WalletApiOperation.GenerateDepositGroupTxId: + return { + transactionId: generateDepositGroupTxId(), + }; + case WalletApiOperation.CreateDepositGroup: { + const req = codecForCreateDepositGroupRequest().decode(payload); + return await createDepositGroup(wex, req); } - case "deleteTransaction": { + case WalletApiOperation.DeleteTransaction: { const req = codecForDeleteTransactionRequest().decode(payload); - await deleteTransaction(ws, req.transactionId); + await deleteTransaction(wex, req.transactionId); return {}; } - case "retryTransaction": { + case WalletApiOperation.RetryTransaction: { const req = codecForRetryTransactionRequest().decode(payload); - await retryTransaction(ws, req.transactionId); + await retryTransaction(wex, req.transactionId); return {}; } - case "setWalletDeviceId": { + case WalletApiOperation.SetWalletDeviceId: { const req = codecForSetWalletDeviceIdRequest().decode(payload); - await setWalletDeviceId(ws, req.walletDeviceId); + await setWalletDeviceId(wex, req.walletDeviceId); return {}; } - case "listCurrencies": { - return await ws.db - .mktx((x) => ({ - auditorTrust: x.auditorTrust, - exchangeTrust: x.exchangeTrust, - })) - .runReadOnly(async (tx) => { - const trustedAuditors = await tx.auditorTrust.iter().toArray(); - const trustedExchanges = await tx.exchangeTrust.iter().toArray(); - return { - trustedAuditors: trustedAuditors.map((x) => ({ - currency: x.currency, - auditorBaseUrl: x.auditorBaseUrl, - auditorPub: x.auditorPub, - })), - trustedExchanges: trustedExchanges.map((x) => ({ - currency: x.currency, - exchangeBaseUrl: x.exchangeBaseUrl, - exchangeMasterPub: x.exchangeMasterPub, - })), - }; - }); + case 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 "withdrawFakebank": { - const req = codecForWithdrawFakebankRequest().decode(payload); - const amount = Amounts.parseOrThrow(req.amount); - const details = await getWithdrawalDetailsForAmount( - ws, - req.exchange, - amount, + 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, + }); + } + }, ); - const wres = await acceptManualWithdrawal(ws, req.exchange, amount); - const paytoUri = details.paytoUris[0]; - const pt = parsePaytoUri(paytoUri); - if (!pt) { - throw Error("failed to parse payto URI"); - } - const components = pt.targetPath.split("/"); - const creditorAcct = components[components.length - 1]; - logger.info(`making testbank transfer to '${creditorAcct}''`) - const fbReq = await ws.http.postJson( - new URL(`${creditorAcct}/admin/add-incoming`, req.bank).href, - { - amount: Amounts.stringify(amount), - reserve_pub: wres.reservePub, - debit_account: "payto://x-taler-bank/localhost/testdebtor", + 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); }, ); - const fbResp = await readSuccessResponseJsonOrThrow(fbReq, codecForAny()); - logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`); 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<void>((resolve, reject) => { + setTimeout(() => resolve(), myDelayMs); + }); + } + loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1); + } + } + // default: + // assertUnreachable(operation); } - throw OperationFailedError.fromCode( + throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, - "unknown operation", { 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. */ -export async function handleCoreApiRequest( +async function handleCoreApiRequest( ws: InternalWalletState, operation: string, id: string, payload: unknown, ): Promise<CoreApiResponse> { + 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 result = await dispatchRequestInternal(ws, operation, payload); + 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, @@ -970,86 +1636,170 @@ export async function handleCoreApiRequest( result, }; } catch (e: any) { - if ( - e instanceof OperationFailedError || - e instanceof OperationFailedAndReportedError - ) { - return { - type: "error", - operation, - id, - error: e.operationError, - }; - } else { - try { - logger.error("Caught unexpected exception:"); - logger.error(e.stack); - } catch (e) {} - return { - type: "error", - operation, - id, - error: makeErrorDetails( - TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - `unexpected exception: ${e}`, - {}, - ), - }; - } + 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; + private _client: WalletCoreApiClient | undefined; private constructor( - db: DbAccess<typeof WalletStoresV1>, - http: HttpRequestLibrary, + idb: IDBFactory, + httpFactory: HttpFactory, + timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ) { - this.ws = new InternalWalletStateImpl(db, http, cryptoWorkerFactory); + this.ws = new InternalWalletState( + idb, + httpFactory, + timer, + cryptoWorkerFactory, + ); } - get client() { + get client(): WalletCoreApiClient { + if (!this._client) { + throw Error(); + } return this._client; } static async create( - db: DbAccess<typeof WalletStoresV1>, - http: HttpRequestLibrary, + idb: IDBFactory, + httpFactory: HttpFactory, + timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ): Promise<Wallet> { - const w = new Wallet(db, http, cryptoWorkerFactory); + const w = new Wallet(idb, httpFactory, timer, cryptoWorkerFactory); w._client = await getClientFromWalletState(w.ws); return w; } - addNotificationListener(f: (n: WalletNotification) => void): void { + addNotificationListener(f: (n: WalletNotification) => void): CancelFn { return this.ws.addNotificationListener(f); } - stop(): void { - this.ws.stop(); + async handleCoreApiRequest( + operation: string, + id: string, + payload: unknown, + ): Promise<CoreApiResponse> { + await this.ws.ensureWalletDbOpen(); + return handleCoreApiRequest(this.ws, operation, id, payload); } +} + +export interface DevExperimentState { + blockRefreshes?: boolean; +} + +export class Cache<T> { + private map: Map<string, [AbsoluteTime, T]> = new Map(); + + constructor( + private maxCapacity: number, + private cacheDuration: Duration, + ) {} + + get(key: string): T | undefined { + const r = this.map.get(key); + if (!r) { + return undefined; + } - runPending(forceNow: boolean = false) { - return runPending(this.ws, forceNow); + if (AbsoluteTime.isExpired(r[0])) { + this.map.delete(key); + return undefined; + } + + return r[1]; } - runTaskLoop(opts?: RetryLoopOpts) { - return runTaskLoop(this.ws, opts); + clear(): void { + this.map.clear(); } - handleCoreApiRequest( - operation: string, - id: string, - payload: unknown, - ): Promise<CoreApiResponse> { - return handleCoreApiRequest(this.ws, operation, id, payload); + 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(); + } } } @@ -1058,34 +1808,32 @@ export class Wallet { * * This ties together all the operation implementations. */ -class InternalWalletStateImpl implements InternalWalletState { - memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); - memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); - memoGetPending: AsyncOpMemoSingle<PendingOperationsResponse> = new AsyncOpMemoSingle(); - memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle(); - memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); - memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); - memoProcessDeposit: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); - cryptoApi: CryptoApi; - - timerGroup: TimerGroup = new TimerGroup(); - latch = new AsyncCondition(); +export class InternalWalletState { + cryptoApi: TalerCryptoInterface; + cryptoDispatcher: CryptoDispatcher; + + readonly timerGroup: TimerGroup; + workAvailable = new AsyncCondition(); stopped = false; - listeners: NotificationListener[] = []; + private listeners: NotificationListener[] = []; - initCalled: boolean = false; + initCalled = false; - exchangeOps: ExchangeOperations = { - getExchangeDetails, - getExchangeTrust, - updateExchangeFromUrl, - }; + refreshCostCache: Cache<AmountJson> = new Cache( + 1000, + Duration.fromSpec({ minutes: 1 }), + ); - recoupOps: RecoupOperations = { - createRecoupGroup: createRecoupGroup, - processRecoupGroup: processRecoupGroup, - }; + denomInfoCache: Cache<DenominationInfo> = new Cache( + 1000, + Duration.fromSpec({ minutes: 1 }), + ); + + exchangeCache: Cache<ReadyExchangeSummary> = new Cache( + 1000, + Duration.fromSpec({ minutes: 1 }), + ); /** * Promises that are waiting for a particular resource. @@ -1097,20 +1845,106 @@ class InternalWalletStateImpl implements InternalWalletState { */ private resourceLocks: Set<string> = new Set(); + taskScheduler: TaskScheduler = new TaskSchedulerImpl(this); + + private _config: Readonly<WalletRunConfig> | undefined; + + private _indexedDbHandle: IDBDatabase | undefined = undefined; + + private _dbAccessHandle: DbAccess<typeof WalletStoresV1> | undefined; + + private _http: HttpRequestLibrary | undefined = undefined; + + get db(): DbAccess<typeof WalletStoresV1> { + if (!this._dbAccessHandle) { + this._dbAccessHandle = this.createDbAccessHandle( + CancellationToken.CONTINUE, + ); + } + return this._dbAccessHandle; + } + + devExperimentState: DevExperimentState = {}; + + clientCancellationMap: Map<string, CancellationToken.Source> = 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<typeof WalletStoresV1> { + 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( - // FIXME: Make this a getter and make - // the actual value nullable. - // Check if we are in a DB migration / garbage collection - // and throw an error in that case. - public db: DbAccess<typeof WalletStoresV1>, - public http: HttpRequestLibrary, + public idb: IDBFactory, + private httpFactory: HttpFactory, + public timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ) { - this.cryptoApi = new CryptoApi(cryptoWorkerFactory); + this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); + this.cryptoApi = this.cryptoDispatcher.cryptoApi; + this.timerGroup = new TimerGroup(timer); + } + + async ensureWalletDbOpen(): Promise<void> { + if (this._indexedDbHandle) { + return; + } + const myVersionChange = async (): Promise<void> => { + 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", n); + logger.trace(`Notification: ${j2s(n)}`); for (const l of this.listeners) { const nc = JSON.parse(JSON.stringify(n)); setTimeout(() => { @@ -1119,32 +1953,37 @@ class InternalWalletStateImpl implements InternalWalletState { } } - addNotificationListener(f: (n: WalletNotification) => void): void { + 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.cryptoApi.stop(); - } - - async runUntilDone( - req: { - maxRetries?: number; - } = {}, - ): Promise<void> { - await runTaskLoop(this, { ...req, stopWhenDone: true }); + 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<T>(tokens: string[], f: () => Promise<T>) { + async runSequentialized<T>( + tokens: string[], + f: () => Promise<T>, + ): Promise<T> { // Make sure locks are always acquired in the same order tokens = [...tokens].sort(); @@ -1169,7 +2008,7 @@ class InternalWalletStateImpl implements InternalWalletState { } finally { for (const token of tokens) { this.resourceLocks.delete(token); - let waiter = (this.resourceWaiters[token] ?? []).shift(); + const waiter = (this.resourceWaiters[token] ?? []).shift(); if (waiter) { waiter.resolve(); } |