/* This file is part of GNU Taler (C) 2015-2019 GNUnet e.V. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /** * High-level wallet operations that should be indepentent from the underlying * browser extension interface. */ /** * Imports. */ import { AbsoluteTime, Amounts, codecForAbortTransaction, codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, codecForAcceptManualWithdrawalRequet, codecForAcceptPeerPullPaymentRequest, codecForAcceptPeerPushPaymentRequest as codecForConfirmPeerPushPaymentRequest, codecForAcceptTipRequest, codecForAddExchangeRequest, codecForAddKnownBankAccounts, codecForAny, codecForApplyDevExperiment, codecForApplyRefundFromPurchaseIdRequest, codecForApplyRefundRequest, codecForCheckPeerPullPaymentRequest, codecForPreparePeerPushCreditRequest, codecForConfirmPayRequest, codecForCreateDepositGroupRequest, codecForDeleteTransactionRequest, codecForForceRefreshRequest, codecForForgetKnownBankAccounts, codecForGetBalanceDetailRequest, codecForGetContractTermsDetails, codecForGetExchangeTosRequest, codecForGetFeeForDeposit, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForImportDbRequest, codecForInitiatePeerPullPaymentRequest, codecForInitiatePeerPushPaymentRequest, codecForIntegrationTestArgs, codecForListKnownBankAccounts, codecForMerchantPostOrderResponse, codecForPrepareDepositRequest, codecForPreparePayRequest, codecForPreparePayTemplateRequest, codecForPreparePeerPullPaymentRequest, codecForCheckPeerPushDebitRequest, codecForPrepareRefundRequest, codecForPrepareTipRequest, codecForRetryTransactionRequest, codecForSetCoinSuspendedRequest, codecForSetDevModeRequest, codecForSetWalletDeviceIdRequest, codecForTestPayArgs, codecForTrackDepositGroupRequest, codecForTransactionByIdRequest, codecForTransactionsRequest, codecForUserAttentionByIdRequest, codecForUserAttentionsRequest, codecForWithdrawFakebankRequest, codecForWithdrawTestBalance, CoinDumpJson, CoinRefreshRequest, CoinStatus, constructPayUri, CoreApiResponse, DenominationInfo, DenomOperationMap, Duration, durationFromSpec, durationMin, ExchangeDetailedResponse, ExchangeListItem, ExchangesListResponse, ExchangeTosStatusDetails, FeeDescription, GetExchangeTosResult, InitResponse, j2s, KnownBankAccounts, KnownBankAccountsInfo, Logger, ManualWithdrawalDetails, MerchantUsingTemplateDetails, NotificationType, parsePayTemplateUri, parsePaytoUri, RefreshReason, TalerErrorCode, URL, WalletCoreVersion, WalletNotification, } from "@gnu-taler/taler-util"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { CryptoDispatcher, CryptoWorkerFactory, } from "./crypto/workers/crypto-dispatcher.js"; import { AuditorTrustRecord, clearDatabase, CoinSourceType, ConfigRecordKey, DenominationRecord, ExchangeDetailsRecord, exportDb, importDb, WalletStoresV1, } from "./db.js"; import { applyDevExperiment, maybeInitDevMode, setDevMode, } from "./dev-experiments.js"; import { getErrorDetailFromException, TalerError } from "@gnu-taler/taler-util"; import { ActiveLongpollInfo, ExchangeOperations, InternalWalletState, MerchantInfo, MerchantOperations, NotificationListener, RecoupOperations, RefreshOperations, } from "./internal-wallet-state.js"; import { getUserAttentions, getUserAttentionsUnreadCount, markAttentionRequestAsRead, } from "./operations/attention.js"; import { exportBackup } from "./operations/backup/export.js"; import { addBackupProvider, codecForAddBackupProviderRequest, codecForRemoveBackupProvider, codecForRunBackupCycle, getBackupInfo, getBackupRecovery, importBackupPlain, loadBackupRecovery, processBackupForProvider, removeBackupProvider, runBackupCycle, } from "./operations/backup/index.js"; import { setWalletDeviceId } from "./operations/backup/state.js"; import { getBalanceDetail, getBalances } from "./operations/balance.js"; import { getExchangeTosStatus, makeExchangeListItem, runOperationWithErrorReporting, } from "./operations/common.js"; import { createDepositGroup, getFeeForDeposit, prepareDepositGroup, processDepositGroup, trackDepositGroup, } from "./operations/deposits.js"; import { acceptExchangeTermsOfService, downloadTosFromAcceptedFormat, getExchangeDetails, getExchangeRequestTimeout, getExchangeTrust, provideExchangeRecordInTx, updateExchangeFromUrl, updateExchangeFromUrlHandler, updateExchangeTermsOfService, } from "./operations/exchanges.js"; import { getMerchantInfo } from "./operations/merchants.js"; import { applyRefund, applyRefundFromPurchaseId, confirmPay, getContractTermsDetails, preparePayForUri, prepareRefund, processPurchase, } from "./operations/pay-merchant.js"; import { acceptIncomingPeerPullPayment, confirmPeerPushPayment, preparePeerPullCredit, preparePeerPushCredit, initiatePeerPullPayment, initiatePeerPushPayment, checkPeerPullPaymentInitiation, checkPeerPushDebit, processPeerPullCredit, processPeerPushInitiation, processPeerPullDebit, processPeerPushCredit, } from "./operations/pay-peer.js"; import { getPendingOperations } from "./operations/pending.js"; import { createRecoupGroup, processRecoupGroup, processRecoupGroupHandler, } from "./operations/recoup.js"; import { autoRefresh, createRefreshGroup, processRefreshGroup, } from "./operations/refresh.js"; import { runIntegrationTest, testPay, withdrawTestBalance, } from "./operations/testing.js"; import { acceptTip, prepareTip, processTip } from "./operations/tip.js"; import { abortTransaction, deleteTransaction, getTransactionById, getTransactions, retryTransaction, } from "./operations/transactions.js"; import { acceptWithdrawalFromUri, createManualWithdrawal, getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, processWithdrawalGroup, } from "./operations/withdraw.js"; import { PendingTaskInfo, PendingTaskType } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; import { createTimeline, selectBestForOverlappingDenominations, selectMinimumFee, } from "./util/denominations.js"; import { HttpRequestLibrary, readSuccessResponseJsonOrThrow, } from "@gnu-taler/taler-util/http"; import { checkDbInvariant } from "./util/invariants.js"; import { AsyncCondition, OpenedPromise, openPromise, } from "./util/promiseUtils.js"; import { DbAccess, GetReadOnlyAccess, GetReadWriteAccess, } from "./util/query.js"; import { OperationAttemptResult, RetryTags } from "./util/retries.js"; import { TimerAPI, TimerGroup } from "./util/timer.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_MERCHANT_PROTOCOL_VERSION, } from "./versions.js"; import { WalletApiOperation, WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; const builtinAuditors: AuditorTrustRecord[] = [ { currency: "KUDOS", auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0", auditorBaseUrl: "https://auditor.demo.taler.net/", uids: ["5P25XF8TVQP9AW6VYGY2KV47WT5Y3ZXFSJAA570GJPX5SVJXKBVG"], }, ]; const builtinExchanges: string[] = ["https://exchange.demo.taler.net/"]; const logger = new Logger("wallet.ts"); /** * Call the right handler for a pending operation without doing * any special error handling. */ async function callOperationHandler( ws: InternalWalletState, pending: PendingTaskInfo, forceNow = false, ): Promise { switch (pending.type) { case PendingTaskType.ExchangeUpdate: return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl, { forceNow, }); case PendingTaskType.Refresh: return await processRefreshGroup(ws, pending.refreshGroupId, { forceNow, }); case PendingTaskType.Withdraw: return await processWithdrawalGroup(ws, pending.withdrawalGroupId, { forceNow, }); case PendingTaskType.TipPickup: return await processTip(ws, pending.tipId, { forceNow }); case PendingTaskType.Purchase: return await processPurchase(ws, pending.proposalId, { forceNow }); case PendingTaskType.Recoup: return await processRecoupGroupHandler(ws, pending.recoupGroupId, { forceNow, }); case PendingTaskType.ExchangeCheckRefresh: return await autoRefresh(ws, pending.exchangeBaseUrl); case PendingTaskType.Deposit: { return await processDepositGroup(ws, pending.depositGroupId, { forceNow, }); } case PendingTaskType.Backup: return await processBackupForProvider(ws, pending.backupProviderBaseUrl); case PendingTaskType.PeerPushInitiation: return await processPeerPushInitiation(ws, pending.pursePub); case PendingTaskType.PeerPullInitiation: return await processPeerPullCredit(ws, pending.pursePub); case PendingTaskType.PeerPullDebit: return await processPeerPullDebit(ws, pending.peerPullPaymentIncomingId); case PendingTaskType.PeerPushCredit: return await processPeerPushCredit(ws, pending.peerPushPaymentIncomingId); default: return assertUnreachable(pending); } throw Error(`not reached ${pending.type}`); } /** * Process pending operations. */ export async function runPending( ws: InternalWalletState, forceNow = false, ): Promise { const pendingOpsResponse = await getPendingOperations(ws); for (const p of pendingOpsResponse.pendingOperations) { if (!forceNow && !AbsoluteTime.isExpired(p.timestampDue)) { continue; } await runOperationWithErrorReporting(ws, p.id, async () => { logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`); return await callOperationHandler(ws, p, forceNow); }); } } export interface RetryLoopOpts { /** * Stop when the number of retries is exceeded for any pending * operation. */ maxRetries?: number; /** * Stop the retry loop when all lifeness-giving pending operations * are done. * * Defaults to false. */ stopWhenDone?: boolean; } export interface TaskLoopResult { /** * Was the maximum number of retries exceeded in a task? */ retriesExceeded: boolean; } /** * Main retry loop of the wallet. * * Looks up pending operations from the wallet, runs them, repeat. */ async function runTaskLoop( ws: InternalWalletState, opts: RetryLoopOpts = {}, ): Promise { logger.info(`running task loop opts=${j2s(opts)}`); let retriesExceeded = false; for (let iteration = 0; !ws.stopped; iteration++) { const pending = await getPendingOperations(ws); logger.trace(`pending operations: ${j2s(pending)}`); let numGivingLiveness = 0; let numDue = 0; let minDue: AbsoluteTime = AbsoluteTime.never(); for (const p of pending.pendingOperations) { const maxRetries = opts.maxRetries; if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) { retriesExceeded = true; logger.warn( `skipping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`, ); continue; } if (p.givesLifeness) { numGivingLiveness++; } if (!p.isDue) { continue; } minDue = AbsoluteTime.min(minDue, p.timestampDue); numDue++; } if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) { logger.warn(`stopping, as no pending operations have lifeness`); return { retriesExceeded, }; } // Make sure that we run tasks that don't give lifeness at least // one time. if (iteration !== 0 && numDue === 0) { // We've executed pending, due operations at least one. // Now we don't have any more operations available, // and need to wait. // Wait for at most 5 seconds to the next check. const dt = durationMin( durationFromSpec({ seconds: 5, }), Duration.getRemaining(minDue), ); logger.trace(`waiting for at most ${dt.d_ms} ms`); const timeout = ws.timerGroup.resolveAfter(dt); ws.notify({ type: NotificationType.WaitingForRetry, numGivingLiveness, numDue, numPending: pending.pendingOperations.length, }); // Wait until either the timeout, or we are notified (via the latch) // that more work might be available. await Promise.race([timeout, ws.latch.wait()]); } else { logger.trace( `running ${pending.pendingOperations.length} pending operations`, ); for (const p of pending.pendingOperations) { if (!AbsoluteTime.isExpired(p.timestampDue)) { continue; } await runOperationWithErrorReporting(ws, p.id, async () => { logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`); return await callOperationHandler(ws, p); }); ws.notify({ type: NotificationType.PendingOperationProcessed, id: p.id, }); } } } logger.trace("exiting wallet retry loop"); return { retriesExceeded, }; } /** * Insert the hard-coded defaults for exchanges, coins and * auditors into the database, unless these defaults have * already been applied. */ async function fillDefaults(ws: InternalWalletState): Promise { await ws.db .mktx((x) => [x.config, x.auditorTrust, x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { const appliedRec = await tx.config.get("currencyDefaultsApplied"); let alreadyApplied = appliedRec ? !!appliedRec.value : false; if (alreadyApplied) { logger.trace("defaults already applied"); return; } logger.info("importing default exchanges and auditors"); for (const c of builtinAuditors) { await tx.auditorTrust.put(c); } for (const baseUrl of builtinExchanges) { const now = AbsoluteTime.now(); provideExchangeRecordInTx(ws, tx, baseUrl, now); } await tx.config.put({ key: ConfigRecordKey.CurrencyDefaultsApplied, value: true, }); }); } /** * Get the exchange ToS in the requested format. * Try to download in the accepted format not cached. */ async function getExchangeTos( ws: InternalWalletState, exchangeBaseUrl: string, acceptedFormat?: string[], ): Promise { // FIXME: download ToS in acceptable format if passed! const { exchangeDetails } = await updateExchangeFromUrl(ws, exchangeBaseUrl); const tosDetails = await ws.db .mktx((x) => [x.exchangeTos]) .runReadOnly(async (tx) => { return await getExchangeTosStatusDetails(tx, exchangeDetails); }); const content = tosDetails.content; const currentEtag = tosDetails.currentVersion; const contentType = tosDetails.contentType; if ( content === undefined || currentEtag === undefined || contentType === undefined ) { throw Error("exchange is in invalid state"); } if ( acceptedFormat && acceptedFormat.findIndex((f) => f === contentType) !== -1 ) { return { acceptedEtag: exchangeDetails.tosAccepted?.etag, currentEtag, content, contentType, tosStatus: getExchangeTosStatus(exchangeDetails), }; } const tosDownload = await downloadTosFromAcceptedFormat( ws, exchangeBaseUrl, getExchangeRequestTimeout(), acceptedFormat, ); if (tosDownload.tosContentType === contentType) { return { acceptedEtag: exchangeDetails.tosAccepted?.etag, currentEtag, content, contentType, tosStatus: getExchangeTosStatus(exchangeDetails), }; } await updateExchangeTermsOfService(ws, exchangeBaseUrl, tosDownload); return { acceptedEtag: exchangeDetails.tosAccepted?.etag, currentEtag: tosDownload.tosEtag, content: tosDownload.tosText, contentType: tosDownload.tosContentType, tosStatus: getExchangeTosStatus(exchangeDetails), }; } /** * List bank accounts known to the wallet from * previous withdrawals. */ async function listKnownBankAccounts( ws: InternalWalletState, currency?: string, ): Promise { const accounts: KnownBankAccountsInfo[] = []; await ws.db .mktx((x) => [x.bankAccounts]) .runReadOnly(async (tx) => { const knownAccounts = await tx.bankAccounts.iter().toArray(); for (const r of knownAccounts) { if (currency && currency !== r.currency) { continue; } const payto = parsePaytoUri(r.uri); if (payto) { accounts.push({ uri: payto, alias: r.alias, kyc_completed: r.kycCompleted, currency: r.currency, }); } } }); return { accounts }; } /** */ async function addKnownBankAccounts( ws: InternalWalletState, payto: string, alias: string, currency: string, ): Promise { await ws.db .mktx((x) => [x.bankAccounts]) .runReadWrite(async (tx) => { tx.bankAccounts.put({ uri: payto, alias: alias, currency: currency, kycCompleted: false, }); }); return; } /** */ async function forgetKnownBankAccounts( ws: InternalWalletState, payto: string, ): Promise { await ws.db .mktx((x) => [x.bankAccounts]) .runReadWrite(async (tx) => { const account = await tx.bankAccounts.get(payto); if (!account) { throw Error(`account not found: ${payto}`); } tx.bankAccounts.delete(account.uri); }); return; } async function getExchangeTosStatusDetails( tx: GetReadOnlyAccess<{ exchangeTos: typeof WalletStoresV1.exchangeTos }>, exchangeDetails: ExchangeDetailsRecord, ): Promise { let exchangeTos = await tx.exchangeTos.get([ exchangeDetails.exchangeBaseUrl, exchangeDetails.tosCurrentEtag, ]); if (!exchangeTos) { exchangeTos = { etag: "not-available", termsOfServiceContentType: "text/plain", termsOfServiceText: "terms of service unavailable", exchangeBaseUrl: exchangeDetails.exchangeBaseUrl, }; } return { acceptedVersion: exchangeDetails.tosAccepted?.etag, content: exchangeTos.termsOfServiceText, contentType: exchangeTos.termsOfServiceContentType, currentVersion: exchangeTos.etag, }; } async function getExchanges( ws: InternalWalletState, ): Promise { const exchanges: ExchangeListItem[] = []; await ws.db .mktx((x) => [ x.exchanges, x.exchangeDetails, x.exchangeTos, x.denominations, x.operationRetries, ]) .runReadOnly(async (tx) => { const exchangeRecords = await tx.exchanges.iter().toArray(); for (const r of exchangeRecords) { const exchangeDetails = await getExchangeDetails(tx, r.baseUrl); const opRetryRecord = await tx.operationRetries.get( RetryTags.forExchangeUpdate(r), ); exchanges.push( makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError), ); } }); return { exchanges }; } async function getExchangeDetailedInfo( ws: InternalWalletState, exchangeBaseurl: string, ): Promise { //TODO: should we use the forceUpdate parameter? const exchange = await ws.db .mktx((x) => [ x.exchanges, x.exchangeTos, x.exchangeDetails, x.denominations, ]) .runReadOnly(async (tx) => { const ex = await tx.exchanges.get(exchangeBaseurl); const dp = ex?.detailsPointer; if (!dp) { return; } const { currency } = dp; const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl); if (!exchangeDetails) { return; } const denominationRecords = await tx.denominations.indexes.byExchangeBaseUrl .iter(ex.baseUrl) .toArray(); if (!denominationRecords) { return; } const tos = await getExchangeTosStatusDetails(tx, exchangeDetails); const denominations: DenominationInfo[] = denominationRecords.map((x) => DenominationRecord.toDenomInfo(x), ); return { info: { exchangeBaseUrl: ex.baseUrl, currency, tos, paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), auditors: exchangeDetails.auditors, wireInfo: exchangeDetails.wireInfo, globalFees: exchangeDetails.globalFees, }, denominations, }; }); if (!exchange) { throw Error(`exchange with base url "${exchangeBaseurl}" not found`); } const denoms = exchange.denominations.map((d) => ({ ...d, group: Amounts.stringifyValue(d.value), })); const denomFees: DenomOperationMap = { deposit: createTimeline( denoms, "denomPubHash", "stampStart", "stampExpireDeposit", "feeDeposit", "group", selectBestForOverlappingDenominations, ), refresh: createTimeline( denoms, "denomPubHash", "stampStart", "stampExpireWithdraw", "feeRefresh", "group", selectBestForOverlappingDenominations, ), refund: createTimeline( denoms, "denomPubHash", "stampStart", "stampExpireWithdraw", "feeRefund", "group", selectBestForOverlappingDenominations, ), withdraw: createTimeline( denoms, "denomPubHash", "stampStart", "stampExpireWithdraw", "feeWithdraw", "group", selectBestForOverlappingDenominations, ), }; const transferFees = Object.entries( exchange.info.wireInfo.feesForType, ).reduce((prev, [wireType, infoForType]) => { const feesByGroup = [ ...infoForType.map((w) => ({ ...w, fee: Amounts.stringify(w.closingFee), group: "closing", })), ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })), ]; prev[wireType] = createTimeline( feesByGroup, "sig", "startStamp", "endStamp", "fee", "group", selectMinimumFee, ); return prev; }, {} as Record); const globalFeesByGroup = [ ...exchange.info.globalFees.map((w) => ({ ...w, fee: w.accountFee, group: "account", })), ...exchange.info.globalFees.map((w) => ({ ...w, fee: w.historyFee, group: "history", })), ...exchange.info.globalFees.map((w) => ({ ...w, fee: w.purseFee, group: "purse", })), ]; const globalFees = createTimeline( globalFeesByGroup, "signature", "startDate", "endDate", "fee", "group", selectMinimumFee, ); return { exchange: { ...exchange.info, denomFees, transferFees, globalFees, }, }; } async function setCoinSuspended( ws: InternalWalletState, coinPub: string, suspended: boolean, ): Promise { await ws.db .mktx((x) => [x.coins, x.coinAvailability]) .runReadWrite(async (tx) => { const c = await tx.coins.get(coinPub); if (!c) { logger.warn(`coin ${coinPub} not found, won't suspend`); return; } const coinAvailability = await tx.coinAvailability.get([ c.exchangeBaseUrl, c.denomPubHash, c.maxAge, ]); checkDbInvariant(!!coinAvailability); if (suspended) { if (c.status !== CoinStatus.Fresh) { return; } if (coinAvailability.freshCoinCount === 0) { throw Error( `invalid coin count ${coinAvailability.freshCoinCount} in DB`, ); } coinAvailability.freshCoinCount--; c.status = CoinStatus.FreshSuspended; } else { if (c.status == CoinStatus.Dormant) { return; } coinAvailability.freshCoinCount++; c.status = CoinStatus.Fresh; } await tx.coins.put(c); await tx.coinAvailability.put(coinAvailability); }); } /** * Dump the public information of coins we have in an easy-to-process format. */ async function dumpCoins(ws: InternalWalletState): Promise { const coinsJson: CoinDumpJson = { coins: [] }; logger.info("dumping coins"); await ws.db .mktx((x) => [x.coins, x.denominations, x.withdrawalGroups]) .runReadOnly(async (tx) => { const coins = await tx.coins.iter().toArray(); for (const c of coins) { const denom = await tx.denominations.get([ c.exchangeBaseUrl, c.denomPubHash, ]); if (!denom) { logger.warn("no denom found for coin"); continue; } const cs = c.coinSource; let refreshParentCoinPub: string | undefined; if (cs.type == CoinSourceType.Refresh) { refreshParentCoinPub = cs.oldCoinPub; } let withdrawalReservePub: string | undefined; if (cs.type == CoinSourceType.Withdraw) { withdrawalReservePub = cs.reservePub; } const denomInfo = await ws.getDenomInfo( ws, tx, c.exchangeBaseUrl, c.denomPubHash, ); if (!denomInfo) { logger.warn("no denomination found for coin"); continue; } coinsJson.coins.push({ coin_pub: c.coinPub, denom_pub: denomInfo.denomPub, denom_pub_hash: c.denomPubHash, denom_value: Amounts.stringify({ value: denom.amountVal, currency: denom.currency, fraction: denom.amountFrac, }), exchange_base_url: c.exchangeBaseUrl, refresh_parent_coin_pub: refreshParentCoinPub, withdrawal_reserve_pub: withdrawalReservePub, coin_status: c.status, ageCommitmentProof: c.ageCommitmentProof, spend_allocation: c.spendAllocation ? { amount: c.spendAllocation.amount, id: c.spendAllocation.id, } : undefined, }); } }); return coinsJson; } /** * Get an API client from an internal wallet state object. */ export async function getClientFromWalletState( ws: InternalWalletState, ): Promise { let id = 0; const client: WalletCoreApiClient = { async call(op, payload): Promise { const res = await handleCoreApiRequest(ws, op, `${id++}`, payload); switch (res.type) { case "error": throw TalerError.fromUncheckedDetail(res.error); case "response": return res.result; } }, }; return client; } declare const __VERSION__: string; declare const __GIT_HASH__: string; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev"; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; /** * Implementation of the "wallet-core" API. */ async function dispatchRequestInternal( ws: InternalWalletState, operation: WalletApiOperation, payload: unknown, ): Promise> { if (!ws.initCalled && operation !== WalletApiOperation.InitWallet) { throw Error( `wallet must be initialized before running operation ${operation}`, ); } // FIXME: Can we make this more type-safe by using the request/response type // definitions we already have? switch (operation) { case WalletApiOperation.InitWallet: { logger.trace("initializing wallet"); ws.initCalled = true; if (typeof payload === "object" && (payload as any).skipDefaults) { logger.trace("skipping defaults"); } else { logger.trace("filling defaults"); await fillDefaults(ws); } await maybeInitDevMode(ws); const resp: InitResponse = { versionInfo: getVersion(ws), }; return resp; } case WalletApiOperation.WithdrawTestkudos: { await withdrawTestBalance(ws, { amount: "TESTKUDOS:10", bankBaseUrl: "https://bank.test.taler.net/", bankAccessApiBaseUrl: "https://bank.test.taler.net/", exchangeBaseUrl: "https://exchange.test.taler.net/", }); return { versionInfo: getVersion(ws), }; } case WalletApiOperation.WithdrawTestBalance: { const req = codecForWithdrawTestBalance().decode(payload); await withdrawTestBalance(ws, req); return {}; } case WalletApiOperation.RunIntegrationTest: { const req = codecForIntegrationTestArgs().decode(payload); await runIntegrationTest(ws, req); return {}; } case WalletApiOperation.TestPay: { const req = codecForTestPayArgs().decode(payload); return await testPay(ws, req); } case WalletApiOperation.GetTransactions: { const req = codecForTransactionsRequest().decode(payload); return await getTransactions(ws, req); } case WalletApiOperation.GetTransactionById: { const req = codecForTransactionByIdRequest().decode(payload); return await getTransactionById(ws, req); } case WalletApiOperation.AddExchange: { const req = codecForAddExchangeRequest().decode(payload); await updateExchangeFromUrl(ws, req.exchangeBaseUrl, { forceNow: req.forceUpdate, }); return {}; } case WalletApiOperation.ListExchanges: { return await getExchanges(ws); } case WalletApiOperation.GetExchangeDetailedInfo: { const req = codecForAddExchangeRequest().decode(payload); return await getExchangeDetailedInfo(ws, req.exchangeBaseUrl); } case WalletApiOperation.ListKnownBankAccounts: { const req = codecForListKnownBankAccounts().decode(payload); return await listKnownBankAccounts(ws, req.currency); } case WalletApiOperation.AddKnownBankAccounts: { const req = codecForAddKnownBankAccounts().decode(payload); await addKnownBankAccounts(ws, req.payto, req.alias, req.currency); return {}; } case WalletApiOperation.ForgetKnownBankAccounts: { const req = codecForForgetKnownBankAccounts().decode(payload); await forgetKnownBankAccounts(ws, req.payto); return {}; } case WalletApiOperation.GetWithdrawalDetailsForUri: { const req = codecForGetWithdrawalDetailsForUri().decode(payload); return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri); } case WalletApiOperation.AcceptManualWithdrawal: { const req = codecForAcceptManualWithdrawalRequet().decode(payload); const res = await createManualWithdrawal(ws, { amount: Amounts.parseOrThrow(req.amount), exchangeBaseUrl: req.exchangeBaseUrl, restrictAge: req.restrictAge, }); return res; } case WalletApiOperation.GetWithdrawalDetailsForAmount: { const req = codecForGetWithdrawalDetailsForAmountRequest().decode(payload); const wi = await getExchangeWithdrawalInfo( ws, req.exchangeBaseUrl, Amounts.parseOrThrow(req.amount), req.restrictAge, ); const resp: ManualWithdrawalDetails = { amountRaw: req.amount, amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), paytoUris: wi.exchangePaytoUris, tosAccepted: wi.termsOfServiceAccepted, ageRestrictionOptions: wi.ageRestrictionOptions, }; return resp; } case WalletApiOperation.GetBalances: { return await getBalances(ws); } case WalletApiOperation.GetBalanceDetail: { const req = codecForGetBalanceDetailRequest().decode(payload); return await getBalanceDetail(ws, req); } case WalletApiOperation.GetUserAttentionRequests: { const req = codecForUserAttentionsRequest().decode(payload); return await getUserAttentions(ws, req); } case WalletApiOperation.MarkAttentionRequestAsRead: { const req = codecForUserAttentionByIdRequest().decode(payload); return await markAttentionRequestAsRead(ws, req); } case WalletApiOperation.GetUserAttentionUnreadCount: { const req = codecForUserAttentionsRequest().decode(payload); return await getUserAttentionsUnreadCount(ws, req); } case WalletApiOperation.GetPendingOperations: { return await getPendingOperations(ws); } case WalletApiOperation.SetExchangeTosAccepted: { const req = codecForAcceptExchangeTosRequest().decode(payload); await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag); return {}; } case WalletApiOperation.ApplyRefund: { const req = codecForApplyRefundRequest().decode(payload); return await applyRefund(ws, req.talerRefundUri); } case WalletApiOperation.ApplyRefundFromPurchaseId: { const req = codecForApplyRefundFromPurchaseIdRequest().decode(payload); return await applyRefundFromPurchaseId(ws, req.purchaseId); } case WalletApiOperation.AcceptBankIntegratedWithdrawal: { const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); return await acceptWithdrawalFromUri(ws, { selectedExchange: req.exchangeBaseUrl, talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, }); } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat); } case WalletApiOperation.GetContractTermsDetails: { const req = codecForGetContractTermsDetails().decode(payload); return getContractTermsDetails(ws, req.proposalId); } case WalletApiOperation.RetryPendingNow: { await runPending(ws, true); return {}; } case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(ws, req.talerPayUri); } case WalletApiOperation.PreparePayForTemplate: { const req = codecForPreparePayTemplateRequest().decode(payload); const url = parsePayTemplateUri(req.talerPayTemplateUri); const templateDetails: MerchantUsingTemplateDetails = {}; if (!url) { throw Error("invalid taler-template URI"); } if ( url.templateParams.amount && typeof url.templateParams.amount === "string" ) { templateDetails.amount = req.templateParams.amount ?? url.templateParams.amount; } if ( url.templateParams.summary && typeof url.templateParams.summary === "string" ) { templateDetails.summary = req.templateParams.summary ?? url.templateParams.summary; } const reqUrl = new URL( `templates/${url.templateId}`, url.merchantBaseUrl, ); const httpReq = await ws.http.postJson(reqUrl.href, templateDetails); const resp = await readSuccessResponseJsonOrThrow( httpReq, codecForMerchantPostOrderResponse(), ); const payUri = constructPayUri( url.merchantBaseUrl, resp.order_id, "", resp.token, ); return await preparePayForUri(ws, payUri); } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); return await confirmPay(ws, req.proposalId, req.sessionId); } case WalletApiOperation.AbortTransaction: { const req = codecForAbortTransaction().decode(payload); await abortTransaction(ws, req.transactionId, req.forceImmediateAbort); return {}; } case WalletApiOperation.DumpCoins: { return await dumpCoins(ws); } case WalletApiOperation.SetCoinSuspended: { const req = codecForSetCoinSuspendedRequest().decode(payload); await setCoinSuspended(ws, req.coinPub, req.suspended); return {}; } case WalletApiOperation.ForceRefresh: { const req = codecForForceRefreshRequest().decode(payload); if (req.coinPubList.length == 0) { throw Error("refusing to create empty refresh group"); } const refreshGroupId = await ws.db .mktx((x) => [ x.refreshGroups, x.coinAvailability, x.denominations, x.coins, ]) .runReadWrite(async (tx) => { let coinPubs: CoinRefreshRequest[] = []; for (const c of req.coinPubList) { const coin = await tx.coins.get(c); if (!coin) { throw Error(`coin (pubkey ${c}) not found`); } const denom = await ws.getDenomInfo( ws, tx, coin.exchangeBaseUrl, coin.denomPubHash, ); checkDbInvariant(!!denom); coinPubs.push({ coinPub: c, amount: denom?.value, }); } return await createRefreshGroup( ws, tx, Amounts.currencyOf(coinPubs[0].amount), coinPubs, RefreshReason.Manual, ); }); processRefreshGroup(ws, refreshGroupId.refreshGroupId, { forceNow: true, }).catch((x) => { logger.error(x); }); return { refreshGroupId, }; } case WalletApiOperation.PrepareTip: { const req = codecForPrepareTipRequest().decode(payload); return await prepareTip(ws, req.talerTipUri); } case WalletApiOperation.PrepareRefund: { const req = codecForPrepareRefundRequest().decode(payload); return await prepareRefund(ws, req.talerRefundUri); } case WalletApiOperation.AcceptTip: { const req = codecForAcceptTipRequest().decode(payload); return await acceptTip(ws, req.walletTipId); } case WalletApiOperation.ExportBackupPlain: { return exportBackup(ws); } case WalletApiOperation.AddBackupProvider: { const req = codecForAddBackupProviderRequest().decode(payload); return await addBackupProvider(ws, req); } case WalletApiOperation.RunBackupCycle: { const req = codecForRunBackupCycle().decode(payload); await runBackupCycle(ws, req); return {}; } case WalletApiOperation.RemoveBackupProvider: { const req = codecForRemoveBackupProvider().decode(payload); await removeBackupProvider(ws, req); return {}; } case WalletApiOperation.ExportBackupRecovery: { const resp = await getBackupRecovery(ws); return resp; } case WalletApiOperation.ImportBackupRecovery: { const req = codecForAny().decode(payload); await loadBackupRecovery(ws, req); return {}; } case WalletApiOperation.GetBackupInfo: { const resp = await getBackupInfo(ws); return resp; } case WalletApiOperation.GetFeeForDeposit: { const req = codecForGetFeeForDeposit().decode(payload); return await getFeeForDeposit(ws, req); } case WalletApiOperation.PrepareDeposit: { const req = codecForPrepareDepositRequest().decode(payload); return await prepareDepositGroup(ws, req); } case WalletApiOperation.CreateDepositGroup: { const req = codecForCreateDepositGroupRequest().decode(payload); return await createDepositGroup(ws, req); } case WalletApiOperation.TrackDepositGroup: { const req = codecForTrackDepositGroupRequest().decode(payload); return trackDepositGroup(ws, req); } case WalletApiOperation.DeleteTransaction: { const req = codecForDeleteTransactionRequest().decode(payload); await deleteTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.RetryTransaction: { const req = codecForRetryTransactionRequest().decode(payload); await retryTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.SetWalletDeviceId: { const req = codecForSetWalletDeviceIdRequest().decode(payload); await setWalletDeviceId(ws, req.walletDeviceId); return {}; } case WalletApiOperation.ListCurrencies: { return await ws.db .mktx((x) => [x.auditorTrust, x.exchangeTrust]) .runReadOnly(async (tx) => { const trustedAuditors = await tx.auditorTrust.iter().toArray(); const trustedExchanges = await tx.exchangeTrust.iter().toArray(); return { trustedAuditors: trustedAuditors.map((x) => ({ currency: x.currency, auditorBaseUrl: x.auditorBaseUrl, auditorPub: x.auditorPub, })), trustedExchanges: trustedExchanges.map((x) => ({ currency: x.currency, exchangeBaseUrl: x.exchangeBaseUrl, exchangeMasterPub: x.exchangeMasterPub, })), }; }); } case WalletApiOperation.WithdrawFakebank: { const req = codecForWithdrawFakebankRequest().decode(payload); const amount = Amounts.parseOrThrow(req.amount); const details = await getExchangeWithdrawalInfo( ws, req.exchange, amount, undefined, ); const wres = await createManualWithdrawal(ws, { amount: amount, exchangeBaseUrl: req.exchange, }); const paytoUri = details.exchangePaytoUris[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?receiver-name=Foo", }, ); const fbResp = await readSuccessResponseJsonOrThrow(fbReq, codecForAny()); logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`); return {}; } case WalletApiOperation.TestCrypto: { return await ws.cryptoApi.hashString({ str: "hello world" }); } case WalletApiOperation.ClearDb: await clearDatabase(ws.db.idbHandle()); return {}; case WalletApiOperation.Recycle: { const backup = await exportBackup(ws); await clearDatabase(ws.db.idbHandle()); await importBackupPlain(ws, backup); return {}; } case WalletApiOperation.ExportDb: { const dbDump = await exportDb(ws.db.idbHandle()); return dbDump; } case WalletApiOperation.ImportDb: { const req = codecForImportDbRequest().decode(payload); await importDb(ws.db.idbHandle(), req.dump); return []; } case WalletApiOperation.CheckPeerPushDebit: { const req = codecForCheckPeerPushDebitRequest().decode(payload); return await checkPeerPushDebit(ws, req); } case WalletApiOperation.PreparePeerPushCredit: { const req = codecForPreparePeerPushCreditRequest().decode(payload); return await preparePeerPushCredit(ws, req); } case WalletApiOperation.InitiatePeerPushDebit: { const req = codecForInitiatePeerPushPaymentRequest().decode(payload); return await initiatePeerPushPayment(ws, req); } case WalletApiOperation.PreparePeerPushCredit: { const req = codecForPreparePeerPushCreditRequest().decode(payload); return await preparePeerPushCredit(ws, req); } case WalletApiOperation.ConfirmPeerPushCredit: { const req = codecForConfirmPeerPushPaymentRequest().decode(payload); return await confirmPeerPushPayment(ws, req); } case WalletApiOperation.CheckPeerPullCredit: { const req = codecForPreparePeerPullPaymentRequest().decode(payload); return await checkPeerPullPaymentInitiation(ws, req); } case WalletApiOperation.InitiatePeerPullCredit: { const req = codecForInitiatePeerPullPaymentRequest().decode(payload); return await initiatePeerPullPayment(ws, req); } case WalletApiOperation.PreparePeerPullDebit: { const req = codecForCheckPeerPullPaymentRequest().decode(payload); return await preparePeerPullCredit(ws, req); } case WalletApiOperation.ConfirmPeerPullDebit: { const req = codecForAcceptPeerPullPaymentRequest().decode(payload); return await acceptIncomingPeerPullPayment(ws, req); } case WalletApiOperation.ApplyDevExperiment: { const req = codecForApplyDevExperiment().decode(payload); await applyDevExperiment(ws, req.devExperimentUri); return {}; } case WalletApiOperation.SetDevMode: { const req = codecForSetDevModeRequest().decode(payload); await setDevMode(ws, req.devModeEnabled); return {}; } case WalletApiOperation.GetVersion: { return getVersion(ws); } //default: // assertUnreachable(operation); } throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, { operation, }, "unknown operation", ); } export function getVersion(ws: InternalWalletState): WalletCoreVersion { const version: WalletCoreVersion = { hash: GIT_HASH, version: VERSION, exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, devMode: ws.devModeActive, }; return version; } function translateLegacyOperationNames(operation: string): string { switch (operation) { case "initiatePeerPullPayment": return WalletApiOperation.InitiatePeerPullCredit.toString(); case "initiatePeerPushPayment": return WalletApiOperation.InitiatePeerPushDebit.toString(); case "checkPeerPullPayment": return WalletApiOperation.PreparePeerPullDebit.toString(); case "acceptPeerPullPayment": return WalletApiOperation.ConfirmPeerPullDebit.toString(); case "checkPeerPushPayment": return WalletApiOperation.PreparePeerPushCredit.toString(); case "acceptPeerPushPayment": return WalletApiOperation.ConfirmPeerPushCredit.toString(); } return operation; } /** * Handle a request to the wallet-core API. */ export async function handleCoreApiRequest( ws: InternalWalletState, operation: string, id: string, payload: unknown, ): Promise { operation = translateLegacyOperationNames(operation); try { const result = await dispatchRequestInternal(ws, operation as any, payload); return { type: "response", operation, id, result, }; } catch (e: any) { const err = getErrorDetailFromException(e); logger.info(`finished wallet core request with error: ${j2s(err)}`); return { type: "error", operation, id, error: err, }; } } /** * Public handle to a running wallet. */ export class Wallet { private ws: InternalWalletState; private _client: WalletCoreApiClient | undefined; private constructor( db: DbAccess, http: HttpRequestLibrary, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ) { this.ws = new InternalWalletStateImpl(db, http, timer, cryptoWorkerFactory); } get client(): WalletCoreApiClient { if (!this._client) { throw Error(); } return this._client; } /** * Trust the exchange, do not validate signatures. * Only used to benchmark the exchange. */ setInsecureTrustExchange(): void { this.ws.insecureTrustExchange = true; } setBatchWithdrawal(enable: boolean): void { this.ws.batchWithdrawal = enable; } static async create( db: DbAccess, http: HttpRequestLibrary, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ): Promise { const w = new Wallet(db, http, timer, cryptoWorkerFactory); w._client = await getClientFromWalletState(w.ws); return w; } addNotificationListener(f: (n: WalletNotification) => void): void { return this.ws.addNotificationListener(f); } stop(): void { this.ws.stop(); } runPending(forceNow = false): Promise { return runPending(this.ws, forceNow); } runTaskLoop(opts?: RetryLoopOpts): Promise { return runTaskLoop(this.ws, opts); } handleCoreApiRequest( operation: string, id: string, payload: unknown, ): Promise { return handleCoreApiRequest(this.ws, operation, id, payload); } } /** * Internal state of the wallet. * * This ties together all the operation implementations. */ class InternalWalletStateImpl implements InternalWalletState { /** * @see {@link InternalWalletState.activeLongpoll} */ activeLongpoll: ActiveLongpollInfo = {}; cryptoApi: TalerCryptoInterface; cryptoDispatcher: CryptoDispatcher; merchantInfoCache: Record = {}; insecureTrustExchange = false; batchWithdrawal = false; readonly timerGroup: TimerGroup; latch = new AsyncCondition(); stopped = false; listeners: NotificationListener[] = []; initCalled = false; devModeActive = false; exchangeOps: ExchangeOperations = { getExchangeDetails, getExchangeTrust, updateExchangeFromUrl, }; recoupOps: RecoupOperations = { createRecoupGroup, processRecoupGroup, }; merchantOps: MerchantOperations = { getMerchantInfo, }; refreshOps: RefreshOperations = { createRefreshGroup, }; // FIXME: Use an LRU cache here. private denomCache: Record = {}; /** * Promises that are waiting for a particular resource. */ private resourceWaiters: Record[]> = {}; /** * Resources that are currently locked. */ private resourceLocks: Set = new Set(); constructor( // FIXME: Make this a getter and make // the actual value nullable. // Check if we are in a DB migration / garbage collection // and throw an error in that case. public db: DbAccess, public http: HttpRequestLibrary, public timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, ) { this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); this.cryptoApi = this.cryptoDispatcher.cryptoApi; this.timerGroup = new TimerGroup(timer); } async getDenomInfo( ws: InternalWalletState, tx: GetReadWriteAccess<{ denominations: typeof WalletStoresV1.denominations; }>, exchangeBaseUrl: string, denomPubHash: string, ): Promise { const key = `${exchangeBaseUrl}:${denomPubHash}`; const cached = this.denomCache[key]; if (cached) { return cached; } const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); if (d) { return DenominationRecord.toDenomInfo(d); } return undefined; } notify(n: WalletNotification): void { logger.trace("Notification", j2s(n)); for (const l of this.listeners) { const nc = JSON.parse(JSON.stringify(n)); setTimeout(() => { l(nc); }, 0); } } addNotificationListener(f: (n: WalletNotification) => void): void { this.listeners.push(f); } /** * Stop ongoing processing. */ stop(): void { logger.trace("stopping (at internal wallet state)"); this.stopped = true; this.timerGroup.stopCurrentAndFutureTimers(); this.cryptoDispatcher.stop(); for (const key of Object.keys(this.activeLongpoll)) { logger.trace(`cancelling active longpoll ${key}`); this.activeLongpoll[key].cancel(); } } async runUntilDone( req: { maxRetries?: number; } = {}, ): Promise { await runTaskLoop(this, { ...req, stopWhenDone: true }); } /** * Run an async function after acquiring a list of locks, identified * by string tokens. */ async runSequentialized( tokens: string[], f: () => Promise, ): Promise { // Make sure locks are always acquired in the same order tokens = [...tokens].sort(); for (const token of tokens) { if (this.resourceLocks.has(token)) { const p = openPromise(); let waitList = this.resourceWaiters[token]; if (!waitList) { waitList = this.resourceWaiters[token] = []; } waitList.push(p); await p.promise; } this.resourceLocks.add(token); } try { logger.trace(`begin exclusive execution on ${JSON.stringify(tokens)}`); const result = await f(); logger.trace(`end exclusive execution on ${JSON.stringify(tokens)}`); return result; } finally { for (const token of tokens) { this.resourceLocks.delete(token); let waiter = (this.resourceWaiters[token] ?? []).shift(); if (waiter) { waiter.resolve(); } } } } }