/* This file is part of GNU Taler (C) 2015-2019 GNUnet e.V. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /** * High-level wallet operations that should be independent from the underlying * browser extension interface. */ /** * Imports. */ import { IDBFactory } from "@gnu-taler/idb-bridge"; import { AmountString, Amounts, CoinDumpJson, CoinStatus, CoreApiResponse, CreateStoredBackupResponse, DeleteStoredBackupRequest, DenominationInfo, ExchangesShortListResponse, GetCurrencySpecificationResponse, InitResponse, KnownBankAccounts, KnownBankAccountsInfo, ListGlobalCurrencyAuditorsResponse, ListGlobalCurrencyExchangesResponse, Logger, PrepareWithdrawExchangeRequest, PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, RetryLoopOpts, StoredBackupList, TalerError, TalerErrorCode, TalerUriAction, TestingWaitTransactionRequest, TransactionState, TransactionType, ValidateIbanResponse, WalletCoreVersion, WalletNotification, WithdrawalDetailsForAmount, codecForAbortTransaction, codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptExchangeTosRequest, codecForAcceptManualWithdrawalRequet, codecForAcceptPeerPullPaymentRequest, codecForAddExchangeRequest, codecForAddGlobalCurrencyAuditorRequest, codecForAddGlobalCurrencyExchangeRequest, codecForAddKnownBankAccounts, codecForAny, codecForApplyDevExperiment, codecForCheckPeerPullPaymentRequest, codecForCheckPeerPushDebitRequest, codecForConfirmPayRequest, codecForConfirmPeerPushPaymentRequest, codecForConvertAmountRequest, codecForCreateDepositGroupRequest, codecForDeleteExchangeRequest, codecForDeleteStoredBackupRequest, codecForDeleteTransactionRequest, codecForFailTransactionRequest, codecForForceRefreshRequest, codecForForgetKnownBankAccounts, codecForGetAmountRequest, codecForGetBalanceDetailRequest, codecForGetContractTermsDetails, codecForGetCurrencyInfoRequest, codecForGetExchangeEntryByUrlRequest, codecForGetExchangeResourcesRequest, codecForGetExchangeTosRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForImportDbRequest, codecForInitiatePeerPullPaymentRequest, codecForInitiatePeerPushDebitRequest, codecForIntegrationTestArgs, codecForIntegrationTestV2Args, codecForListExchangesForScopedCurrencyRequest, codecForListKnownBankAccounts, codecForPrepareDepositRequest, codecForPreparePayRequest, codecForPreparePayTemplateRequest, codecForPreparePeerPullPaymentRequest, codecForPreparePeerPushCreditRequest, codecForPrepareRefundRequest, codecForPrepareWithdrawExchangeRequest, codecForRecoverStoredBackupRequest, codecForRemoveGlobalCurrencyAuditorRequest, codecForRemoveGlobalCurrencyExchangeRequest, codecForResumeTransaction, codecForRetryTransactionRequest, codecForSetCoinSuspendedRequest, codecForSetWalletDeviceIdRequest, codecForSharePaymentRequest, codecForStartRefundQueryRequest, codecForSuspendTransaction, codecForTestPayArgs, codecForTestingSetTimetravelRequest, codecForTransactionByIdRequest, codecForTransactionsRequest, codecForUpdateExchangeEntryRequest, codecForUserAttentionByIdRequest, codecForUserAttentionsRequest, codecForValidateIbanRequest, codecForWithdrawTestBalance, getErrorDetailFromException, j2s, parsePaytoUri, parseTalerUri, sampleWalletCoreTransactions, setDangerousTimetravel, validateIban, } from "@gnu-taler/taler-util"; import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { CryptoDispatcher, CryptoWorkerFactory, } from "./crypto/workers/crypto-dispatcher.js"; import { CoinSourceType, ConfigRecordKey, DenominationRecord, WalletStoresV1, clearDatabase, exportDb, importDb, openStoredBackupsDatabase, openTalerDatabase, } from "./db.js"; import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js"; import { CancelFn, InternalWalletState, MerchantInfo, NotificationListener, RecoupOperations, } from "./internal-wallet-state.js"; import { getUserAttentions, getUserAttentionsUnreadCount, markAttentionRequestAsRead, } from "./operations/attention.js"; import { addBackupProvider, codecForAddBackupProviderRequest, codecForRemoveBackupProvider, codecForRunBackupCycle, getBackupInfo, getBackupRecovery, loadBackupRecovery, removeBackupProvider, runBackupCycle, setWalletDeviceId, } from "./operations/backup/index.js"; import { getBalanceDetail, getBalances } from "./operations/balance.js"; import { computeDepositTransactionStatus, createDepositGroup, generateDepositGroupTxId, prepareDepositGroup, } from "./operations/deposits.js"; import { acceptExchangeTermsOfService, addPresetExchangeEntry, deleteExchange, fetchFreshExchange, getExchangeDetailedInfo, getExchangeResources, getExchangeTos, listExchanges, lookupExchangeByUri, } from "./operations/exchanges.js"; import { computePayMerchantTransactionState, computeRefundTransactionState, confirmPay, getContractTermsDetails, preparePayForTemplate, preparePayForUri, sharePayment, startQueryRefund, startRefundQueryForUri, } from "./operations/pay-merchant.js"; import { checkPeerPullPaymentInitiation, computePeerPullCreditTransactionState, initiatePeerPullPayment, } from "./operations/pay-peer-pull-credit.js"; import { computePeerPullDebitTransactionState, confirmPeerPullDebit, preparePeerPullDebit, } from "./operations/pay-peer-pull-debit.js"; import { computePeerPushCreditTransactionState, confirmPeerPushCredit, preparePeerPushCredit, } from "./operations/pay-peer-push-credit.js"; import { checkPeerPushDebit, computePeerPushDebitTransactionState, initiatePeerPushDebit, } from "./operations/pay-peer-push-debit.js"; import { createRecoupGroup } from "./operations/recoup.js"; import { computeRefreshTransactionState, forceRefresh, } from "./operations/refresh.js"; import { computeRewardTransactionStatus } from "./operations/reward.js"; import { runIntegrationTest, runIntegrationTest2, testPay, waitTransactionState, waitUntilAllTransactionsFinal, waitUntilRefreshesDone, withdrawTestBalance, } from "./operations/testing.js"; import { abortTransaction, constructTransactionIdentifier, deleteTransaction, failTransaction, getTransactionById, getTransactions, getWithdrawalTransactionByUri, parseTransactionIdentifier, resumeTransaction, retryTransaction, suspendTransaction, } from "./operations/transactions.js"; import { acceptWithdrawalFromUri, computeWithdrawalTransactionStatus, createManualWithdrawal, getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, } from "./operations/withdraw.js"; import { PendingOperationsResponse } from "./pending-types.js"; import { TaskScheduler } from "./shepherd.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; import { convertDepositAmount, convertPeerPushAmount, convertWithdrawalAmount, getMaxDepositAmount, getMaxPeerPushAmount, } from "./util/instructedAmountConversion.js"; import { checkDbInvariant } from "./util/invariants.js"; import { AsyncCondition, OpenedPromise, openPromise, } from "./util/promiseUtils.js"; import { DbAccess, GetReadOnlyAccess, GetReadWriteAccess, } from "./util/query.js"; import { TimerAPI, TimerGroup } from "./util/timer.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_COREBANK_API_PROTOCOL_VERSION, WALLET_CORE_API_IMPLEMENTATION_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_MERCHANT_PROTOCOL_VERSION, } from "./versions.js"; import { WalletApiOperation, WalletConfig, WalletConfigParameter, WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; const logger = new Logger("wallet.ts"); async function runTaskLoop( ws: InternalWalletState, opts: RetryLoopOpts = {}, ): Promise { await ws.taskScheduler.run(opts); } /** * 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 { const notifications: WalletNotification[] = []; await ws.db .mktx((x) => [x.config, x.exchanges, x.exchangeDetails]) .runReadWrite(async (tx) => { const appliedRec = await tx.config.get("currencyDefaultsApplied"); let alreadyApplied = appliedRec ? !!appliedRec.value : false; if (alreadyApplied) { logger.trace("defaults already applied"); return; } for (const exch of ws.config.builtin.exchanges) { const resp = await addPresetExchangeEntry( tx, exch.exchangeBaseUrl, exch.currencyHint, ); if (resp.notification) { notifications.push(resp.notification); } } await tx.config.put({ key: ConfigRecordKey.CurrencyDefaultsApplied, value: true, }); }); for (const notif of notifications) { ws.notify(notif); } } /** * 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 setCoinSuspended( ws: InternalWalletState, coinPub: string, suspended: boolean, ): Promise { await ws.db .mktx((x) => [x.coins, x.coinAvailability]) .runReadWrite(async (tx) => { const c = await tx.coins.get(coinPub); if (!c) { logger.warn(`coin ${coinPub} not found, won't suspend`); return; } const coinAvailability = await tx.coinAvailability.get([ c.exchangeBaseUrl, c.denomPubHash, c.maxAge, ]); checkDbInvariant(!!coinAvailability); if (suspended) { if (c.status !== CoinStatus.Fresh) { return; } if (coinAvailability.freshCoinCount === 0) { throw Error( `invalid coin count ${coinAvailability.freshCoinCount} in DB`, ); } coinAvailability.freshCoinCount--; c.status = CoinStatus.FreshSuspended; } else { if (c.status == CoinStatus.Dormant) { return; } coinAvailability.freshCoinCount++; c.status = CoinStatus.Fresh; } await tx.coins.put(c); await tx.coinAvailability.put(coinAvailability); }); } /** * Dump the public information of coins we have in an easy-to-process format. */ async function dumpCoins(ws: InternalWalletState): Promise { const coinsJson: CoinDumpJson = { coins: [] }; logger.info("dumping coins"); await ws.db .mktx((x) => [x.coins, x.denominations, x.withdrawalGroups]) .runReadOnly(async (tx) => { const coins = await tx.coins.iter().toArray(); for (const c of coins) { const denom = await tx.denominations.get([ c.exchangeBaseUrl, c.denomPubHash, ]); if (!denom) { logger.warn("no denom found for coin"); continue; } const cs = c.coinSource; let refreshParentCoinPub: string | undefined; if (cs.type == CoinSourceType.Refresh) { refreshParentCoinPub = cs.oldCoinPub; } let withdrawalReservePub: string | undefined; if (cs.type == CoinSourceType.Withdraw) { withdrawalReservePub = cs.reservePub; } const denomInfo = await ws.getDenomInfo( ws, tx, c.exchangeBaseUrl, c.denomPubHash, ); if (!denomInfo) { logger.warn("no denomination found for coin"); continue; } coinsJson.coins.push({ coin_pub: c.coinPub, denom_pub: denomInfo.denomPub, denom_pub_hash: c.denomPubHash, denom_value: denom.value, exchange_base_url: c.exchangeBaseUrl, refresh_parent_coin_pub: refreshParentCoinPub, withdrawal_reserve_pub: withdrawalReservePub, coin_status: c.status, ageCommitmentProof: c.ageCommitmentProof, spend_allocation: c.spendAllocation ? { amount: c.spendAllocation.amount, id: c.spendAllocation.id, } : undefined, }); } }); return coinsJson; } /** * Get an API client from an internal wallet state object. */ export async function getClientFromWalletState( ws: InternalWalletState, ): Promise { let id = 0; const client: WalletCoreApiClient = { async call(op, payload): Promise { const res = await handleCoreApiRequest(ws, op, `${id++}`, payload); switch (res.type) { case "error": throw TalerError.fromUncheckedDetail(res.error); case "response": return res.result; } }, }; return client; } async function createStoredBackup( ws: InternalWalletState, ): Promise { const backup = await exportDb(ws.idb); const backupsDb = await openStoredBackupsDatabase(ws.idb); const name = `backup-${new Date().getTime()}`; await backupsDb.mktxAll().runReadWrite(async (tx) => { await tx.backupMeta.add({ name, }); await tx.backupData.add(backup, name); }); return { name, }; } async function listStoredBackups( ws: InternalWalletState, ): Promise { const storedBackups: StoredBackupList = { storedBackups: [], }; const backupsDb = await openStoredBackupsDatabase(ws.idb); await backupsDb.mktxAll().runReadWrite(async (tx) => { await tx.backupMeta.iter().forEach((x) => { storedBackups.storedBackups.push({ name: x.name, }); }); }); return storedBackups; } async function deleteStoredBackup( ws: InternalWalletState, req: DeleteStoredBackupRequest, ): Promise { const backupsDb = await openStoredBackupsDatabase(ws.idb); await backupsDb.mktxAll().runReadWrite(async (tx) => { await tx.backupData.delete(req.name); await tx.backupMeta.delete(req.name); }); } async function recoverStoredBackup( ws: InternalWalletState, req: RecoverStoredBackupRequest, ): Promise { logger.info(`Recovering stored backup ${req.name}`); const { name } = req; const backupsDb = await openStoredBackupsDatabase(ws.idb); const bd = await backupsDb.mktxAll().runReadWrite(async (tx) => { const backupMeta = tx.backupMeta.get(name); if (!backupMeta) { throw Error("backup not found"); } const backupData = await tx.backupData.get(name); if (!backupData) { throw Error("no backup data (DB corrupt)"); } return backupData; }); logger.info(`backup found, now importing`); await importDb(ws.db.idbHandle(), bd); logger.info(`import done`); } async function handlePrepareWithdrawExchange( ws: InternalWalletState, req: PrepareWithdrawExchangeRequest, ): Promise { const parsedUri = parseTalerUri(req.talerUri); if (parsedUri?.type !== TalerUriAction.WithdrawExchange) { throw Error("expected a taler://withdraw-exchange URI"); } const exchangeBaseUrl = parsedUri.exchangeBaseUrl; const exchange = await fetchFreshExchange(ws, exchangeBaseUrl); if (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, }; } /** * Implementation of the "wallet-core" API. */ async function dispatchRequestInternal( ws: InternalWalletState, operation: WalletApiOperation, payload: unknown, ): Promise> { if (!ws.initCalled && operation !== WalletApiOperation.InitWallet) { throw Error( `wallet must be initialized before running operation ${operation}`, ); } // FIXME: Can we make this more type-safe by using the request/response type // definitions we already have? switch (operation) { case WalletApiOperation.CreateStoredBackup: return createStoredBackup(ws); case WalletApiOperation.DeleteStoredBackup: { const req = codecForDeleteStoredBackupRequest().decode(payload); await deleteStoredBackup(ws, req); return {}; } case WalletApiOperation.ListStoredBackups: return listStoredBackups(ws); case WalletApiOperation.RecoverStoredBackup: { const req = codecForRecoverStoredBackupRequest().decode(payload); await recoverStoredBackup(ws, req); return {}; } case WalletApiOperation.InitWallet: { logger.trace("initializing wallet"); ws.initCalled = true; if (ws.config.testing.skipDefaults) { logger.trace("skipping defaults"); } else { logger.trace("filling defaults"); await fillDefaults(ws); } const resp: InitResponse = { versionInfo: getVersion(ws), }; return resp; } case WalletApiOperation.WithdrawTestkudos: { await withdrawTestBalance(ws, { amount: "TESTKUDOS:10" as AmountString, corebankApiBaseUrl: "https://bank.test.taler.net/", exchangeBaseUrl: "https://exchange.test.taler.net/", }); return { versionInfo: getVersion(ws), }; } case WalletApiOperation.WithdrawTestBalance: { const req = codecForWithdrawTestBalance().decode(payload); await withdrawTestBalance(ws, req); return {}; } case WalletApiOperation.RunIntegrationTest: { const req = codecForIntegrationTestArgs().decode(payload); await runIntegrationTest(ws, req); return {}; } case WalletApiOperation.RunIntegrationTestV2: { const req = codecForIntegrationTestV2Args().decode(payload); await runIntegrationTest2(ws, req); return {}; } case WalletApiOperation.ValidateIban: { const req = codecForValidateIbanRequest().decode(payload); const valRes = validateIban(req.iban); const resp: ValidateIbanResponse = { valid: valRes.type === "valid", }; return resp; } case WalletApiOperation.TestPay: { const req = codecForTestPayArgs().decode(payload); return await testPay(ws, req); } case WalletApiOperation.GetTransactions: { const req = codecForTransactionsRequest().decode(payload); return await getTransactions(ws, req); } case WalletApiOperation.GetTransactionById: { const req = codecForTransactionByIdRequest().decode(payload); return await getTransactionById(ws, req); } case WalletApiOperation.GetWithdrawalTransactionByUri: { const req = codecForGetWithdrawalDetailsForUri().decode(payload); return await getWithdrawalTransactionByUri(ws, req); } case WalletApiOperation.AddExchange: { const req = codecForAddExchangeRequest().decode(payload); await fetchFreshExchange(ws, req.exchangeBaseUrl, { expectedMasterPub: req.masterPub, }); return {}; } case WalletApiOperation.UpdateExchangeEntry: { const req = codecForUpdateExchangeEntryRequest().decode(payload); await fetchFreshExchange(ws, req.exchangeBaseUrl, { forceUpdate: !!req.force, }); return {}; } case WalletApiOperation.ListExchanges: { return await listExchanges(ws); } case WalletApiOperation.GetExchangeEntryByUrl: { const req = codecForGetExchangeEntryByUrlRequest().decode(payload); return lookupExchangeByUri(ws, req); } case WalletApiOperation.ListExchangesForScopedCurrency: { const req = codecForListExchangesForScopedCurrencyRequest().decode(payload); const exchangesResp = await listExchanges(ws); 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(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, { notifyChangeFromPendingTimeoutMs: req.notifyChangeFromPendingTimeoutMs, restrictAge: req.restrictAge, }); } case WalletApiOperation.AcceptManualWithdrawal: { const req = codecForAcceptManualWithdrawalRequet().decode(payload); const res = await createManualWithdrawal(ws, { amount: Amounts.parseOrThrow(req.amount), exchangeBaseUrl: req.exchangeBaseUrl, restrictAge: req.restrictAge, }); return res; } case WalletApiOperation.GetWithdrawalDetailsForAmount: { const req = codecForGetWithdrawalDetailsForAmountRequest().decode(payload); const wi = await getExchangeWithdrawalInfo( ws, req.exchangeBaseUrl, Amounts.parseOrThrow(req.amount), req.restrictAge, ); let numCoins = 0; for (const x of wi.selectedDenoms.selectedDenoms) { numCoins += x.count; } const amt = Amounts.parseOrThrow(req.amount); const resp: WithdrawalDetailsForAmount = { amountRaw: req.amount, amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), paytoUris: wi.exchangePaytoUris, tosAccepted: wi.termsOfServiceAccepted, ageRestrictionOptions: wi.ageRestrictionOptions, withdrawalAccountsList: wi.exchangeCreditAccountDetails, numCoins, // FIXME: Once we have proper scope info support, return correct info here. scopeInfo: wi.scopeInfo, }; 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: { // 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); return {}; } case WalletApiOperation.AcceptBankIntegratedWithdrawal: { const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(payload); return await acceptWithdrawalFromUri(ws, { selectedExchange: req.exchangeBaseUrl, talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, }); } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); return getExchangeTos( ws, req.exchangeBaseUrl, req.acceptedFormat, req.acceptLanguage, ); } case WalletApiOperation.GetContractTermsDetails: { const req = codecForGetContractTermsDetails().decode(payload); return getContractTermsDetails(ws, req.proposalId); } case WalletApiOperation.RetryPendingNow: { logger.error("retryPendingNow currently not implemented"); return {}; } case WalletApiOperation.SharePayment: { const req = codecForSharePaymentRequest().decode(payload); return await sharePayment(ws, req.merchantBaseUrl, req.orderId); } case WalletApiOperation.PrepareWithdrawExchange: { const req = codecForPrepareWithdrawExchangeRequest().decode(payload); return handlePrepareWithdrawExchange(ws, req); } case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(ws, req.talerPayUri); } case WalletApiOperation.PreparePayForTemplate: { const req = codecForPreparePayTemplateRequest().decode(payload); return preparePayForTemplate(ws, req); } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); let transactionId; if (req.proposalId) { // legacy client support transactionId = constructTransactionIdentifier({ tag: TransactionType.Payment, proposalId: req.proposalId, }); } else if (req.transactionId) { transactionId = req.transactionId; } else { throw Error("transactionId or (deprecated) proposalId required"); } return await confirmPay(ws, transactionId, req.sessionId); } case WalletApiOperation.AbortTransaction: { const req = codecForAbortTransaction().decode(payload); await abortTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.SuspendTransaction: { const req = codecForSuspendTransaction().decode(payload); await suspendTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.FailTransaction: { const req = codecForFailTransactionRequest().decode(payload); await failTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.ResumeTransaction: { const req = codecForResumeTransaction().decode(payload); await resumeTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.DumpCoins: { return await dumpCoins(ws); } case WalletApiOperation.SetCoinSuspended: { const req = codecForSetCoinSuspendedRequest().decode(payload); await setCoinSuspended(ws, req.coinPub, req.suspended); return {}; } case WalletApiOperation.TestingGetSampleTransactions: return { transactions: sampleWalletCoreTransactions }; case WalletApiOperation.ForceRefresh: { const req = codecForForceRefreshRequest().decode(payload); return await forceRefresh(ws, req); } case WalletApiOperation.StartRefundQueryForUri: { const req = codecForPrepareRefundRequest().decode(payload); return await startRefundQueryForUri(ws, req.talerRefundUri); } case WalletApiOperation.StartRefundQuery: { const req = codecForStartRefundQueryRequest().decode(payload); const txIdParsed = parseTransactionIdentifier(req.transactionId); if (!txIdParsed) { throw Error("invalid transaction ID"); } if (txIdParsed.tag !== TransactionType.Payment) { throw Error("expected payment transaction ID"); } await startQueryRefund(ws, txIdParsed.proposalId); return {}; } case WalletApiOperation.AddBackupProvider: { const req = codecForAddBackupProviderRequest().decode(payload); return await addBackupProvider(ws, req); } case WalletApiOperation.RunBackupCycle: { const req = codecForRunBackupCycle().decode(payload); await runBackupCycle(ws, req); return {}; } case WalletApiOperation.RemoveBackupProvider: { const req = codecForRemoveBackupProvider().decode(payload); await removeBackupProvider(ws, req); return {}; } case WalletApiOperation.ExportBackupRecovery: { const resp = await getBackupRecovery(ws); return resp; } case WalletApiOperation.TestingWaitTransactionState: { const req = payload as TestingWaitTransactionRequest; await waitTransactionState(ws, req.transactionId, req.txState); return {}; } case WalletApiOperation.GetCurrencySpecification: { // Ignore result, just validate in this mock implementation const req = codecForGetCurrencyInfoRequest().decode(payload); // Hard-coded mock for KUDOS and TESTKUDOS if (req.scope.currency === "KUDOS") { const kudosResp: GetCurrencySpecificationResponse = { currencySpecification: { 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); return {}; } // case WalletApiOperation.GetPlanForOperation: { // const req = codecForGetPlanForOperationRequest().decode(payload); // return await getPlanForOperation(ws, req); // } case WalletApiOperation.ConvertDepositAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertDepositAmount(ws, req); } case WalletApiOperation.GetMaxDepositAmount: { const req = codecForGetAmountRequest.decode(payload); return await getMaxDepositAmount(ws, req); } case WalletApiOperation.ConvertPeerPushAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertPeerPushAmount(ws, req); } case WalletApiOperation.GetMaxPeerPushAmount: { const req = codecForGetAmountRequest.decode(payload); return await getMaxPeerPushAmount(ws, req); } case WalletApiOperation.ConvertWithdrawalAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertWithdrawalAmount(ws, req); } case WalletApiOperation.GetBackupInfo: { const resp = await getBackupInfo(ws); return resp; } case WalletApiOperation.PrepareDeposit: { const req = codecForPrepareDepositRequest().decode(payload); return await prepareDepositGroup(ws, req); } case WalletApiOperation.GenerateDepositGroupTxId: return { transactionId: generateDepositGroupTxId(), }; case WalletApiOperation.CreateDepositGroup: { const req = codecForCreateDepositGroupRequest().decode(payload); return await createDepositGroup(ws, req); } case WalletApiOperation.DeleteTransaction: { const req = codecForDeleteTransactionRequest().decode(payload); await deleteTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.RetryTransaction: { const req = codecForRetryTransactionRequest().decode(payload); await retryTransaction(ws, req.transactionId); return {}; } case WalletApiOperation.SetWalletDeviceId: { const req = codecForSetWalletDeviceIdRequest().decode(payload); await setWalletDeviceId(ws, req.walletDeviceId); return {}; } case WalletApiOperation.TestCrypto: { return await ws.cryptoApi.hashString({ str: "hello world" }); } case WalletApiOperation.ClearDb: await clearDatabase(ws.db.idbHandle()); return {}; case WalletApiOperation.Recycle: { throw Error("not implemented"); return {}; } case WalletApiOperation.ExportDb: { const dbDump = await exportDb(ws.idb); return dbDump; } case WalletApiOperation.ListGlobalCurrencyExchanges: { const resp: ListGlobalCurrencyExchangesResponse = { exchanges: [], }; await ws.db.runReadOnlyTx(["globalCurrencyExchanges"], async (tx) => { const gceList = await tx.globalCurrencyExchanges.iter().toArray(); for (const gce of gceList) { resp.exchanges.push({ currency: gce.currency, exchangeBaseUrl: gce.exchangeBaseUrl, exchangeMasterPub: gce.exchangeMasterPub, }); } }); return resp; } case WalletApiOperation.ListGlobalCurrencyAuditors: { const resp: ListGlobalCurrencyAuditorsResponse = { auditors: [], }; await ws.db.runReadOnlyTx(["globalCurrencyAuditors"], async (tx) => { const gcaList = await tx.globalCurrencyAuditors.iter().toArray(); for (const gca of gcaList) { resp.auditors.push({ currency: gca.currency, auditorBaseUrl: gca.auditorBaseUrl, auditorPub: gca.auditorPub, }); } }); return resp; } case WalletApiOperation.AddGlobalCurrencyExchange: { const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload); await ws.db.runReadWriteTx(["globalCurrencyExchanges"], async (tx) => { const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub]; const existingRec = await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( key, ); if (existingRec) { return; } await tx.globalCurrencyExchanges.add({ currency: req.currency, exchangeBaseUrl: req.exchangeBaseUrl, exchangeMasterPub: req.exchangeMasterPub, }); }); return {}; } case WalletApiOperation.RemoveGlobalCurrencyExchange: { const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload); await ws.db.runReadWriteTx(["globalCurrencyExchanges"], async (tx) => { const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub]; const existingRec = await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( key, ); if (!existingRec) { return; } checkDbInvariant(!!existingRec.id); await tx.globalCurrencyExchanges.delete(existingRec.id); }); return {}; } case WalletApiOperation.AddGlobalCurrencyAuditor: { const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload); await ws.db.runReadWriteTx(["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, }); }); return {}; } case WalletApiOperation.RemoveGlobalCurrencyAuditor: { const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload); await ws.db.runReadWriteTx(["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); }); return {}; } case WalletApiOperation.ImportDb: { const req = codecForImportDbRequest().decode(payload); await importDb(ws.db.idbHandle(), req.dump); return []; } case WalletApiOperation.CheckPeerPushDebit: { const req = codecForCheckPeerPushDebitRequest().decode(payload); return await checkPeerPushDebit(ws, req); } case WalletApiOperation.InitiatePeerPushDebit: { const req = codecForInitiatePeerPushDebitRequest().decode(payload); return await initiatePeerPushDebit(ws, req); } case WalletApiOperation.PreparePeerPushCredit: { const req = codecForPreparePeerPushCreditRequest().decode(payload); return await preparePeerPushCredit(ws, req); } case WalletApiOperation.ConfirmPeerPushCredit: { const req = codecForConfirmPeerPushPaymentRequest().decode(payload); return await confirmPeerPushCredit(ws, req); } case WalletApiOperation.CheckPeerPullCredit: { const req = codecForPreparePeerPullPaymentRequest().decode(payload); return await checkPeerPullPaymentInitiation(ws, req); } case WalletApiOperation.InitiatePeerPullCredit: { const req = codecForInitiatePeerPullPaymentRequest().decode(payload); return await initiatePeerPullPayment(ws, req); } case WalletApiOperation.PreparePeerPullDebit: { const req = codecForCheckPeerPullPaymentRequest().decode(payload); return await preparePeerPullDebit(ws, req); } case WalletApiOperation.ConfirmPeerPullDebit: { const req = codecForAcceptPeerPullPaymentRequest().decode(payload); return await confirmPeerPullDebit(ws, req); } case WalletApiOperation.ApplyDevExperiment: { const req = codecForApplyDevExperiment().decode(payload); await applyDevExperiment(ws, req.devExperimentUri); return {}; } case WalletApiOperation.GetVersion: { return getVersion(ws); } case WalletApiOperation.TestingWaitTransactionsFinal: return await waitUntilAllTransactionsFinal(ws); case WalletApiOperation.TestingWaitRefreshesFinal: return await waitUntilRefreshesDone(ws); case WalletApiOperation.TestingSetTimetravel: { const req = codecForTestingSetTimetravelRequest().decode(payload); setDangerousTimetravel(req.offsetMs); ws.taskScheduler.reload(); return {}; } case WalletApiOperation.DeleteExchange: { const req = codecForDeleteExchangeRequest().decode(payload); await deleteExchange(ws, req); return {}; } case WalletApiOperation.GetExchangeResources: { const req = codecForGetExchangeResourcesRequest().decode(payload); return await getExchangeResources(ws, 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 ws.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 ws.db.runReadWriteTx(["config"], async (tx) => { await tx.config.put({ key: ConfigRecordKey.TestLoopTx, value: loopCount, }); }); if (myDelayMs != 0) { await new Promise((resolve, reject) => { setTimeout(() => resolve(), myDelayMs); }); } loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1); } } // default: // assertUnreachable(operation); } throw TalerError.fromDetail( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, { operation, }, "unknown operation", ); } export function getVersion(ws: InternalWalletState): WalletCoreVersion { const result: WalletCoreVersion = { implementationSemver: walletCoreBuildInfo.implementationSemver, implementationGitHash: walletCoreBuildInfo.implementationGitHash, hash: undefined, version: WALLET_CORE_API_IMPLEMENTATION_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: false, }; return result; } /** * Handle a request to the wallet-core API. */ async function handleCoreApiRequest( ws: InternalWalletState, operation: string, id: string, payload: unknown, ): Promise { await ws.ensureWalletDbOpen(); try { const result = await dispatchRequestInternal(ws, operation as any, payload); return { type: "response", operation, id, result, }; } catch (e: any) { const err = getErrorDetailFromException(e); logger.info( `finished wallet core request ${operation} with error: ${j2s(err)}`, ); return { type: "error", operation, id, error: err, }; } } /** * Public handle to a running wallet. */ export class Wallet { private ws: InternalWalletState; private _client: WalletCoreApiClient | undefined; private constructor( idb: IDBFactory, http: HttpRequestLibrary, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, config?: WalletConfigParameter, ) { this.ws = new InternalWalletStateImpl( idb, http, timer, cryptoWorkerFactory, Wallet.getEffectiveConfig(config), ); } get client(): WalletCoreApiClient { if (!this._client) { throw Error(); } return this._client; } static async create( idb: IDBFactory, http: HttpRequestLibrary, timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, config?: WalletConfigParameter, ): Promise { const w = new Wallet(idb, http, timer, cryptoWorkerFactory, config); w._client = await getClientFromWalletState(w.ws); return w; } public static defaultConfig: Readonly = { builtin: { exchanges: [ { exchangeBaseUrl: "https://exchange.demo.taler.net/", currencyHint: "KUDOS", }, ], }, features: { allowHttp: false, }, testing: { preventThrottling: false, devModeActive: false, insecureTrustExchange: false, denomselAllowLate: false, skipDefaults: false, }, }; static getEffectiveConfig( param?: WalletConfigParameter, ): Readonly { return deepMerge(Wallet.defaultConfig, param ?? {}); } addNotificationListener(f: (n: WalletNotification) => void): CancelFn { return this.ws.addNotificationListener(f); } stop(): void { this.ws.stop(); } async runTaskLoop(opts?: RetryLoopOpts): Promise { await this.ws.ensureWalletDbOpen(); return runTaskLoop(this.ws, opts); } async handleCoreApiRequest( operation: string, id: string, payload: unknown, ): Promise { await this.ws.ensureWalletDbOpen(); return handleCoreApiRequest(this.ws, operation, id, payload); } } /** * Internal state of the wallet. * * This ties together all the operation implementations. */ class InternalWalletStateImpl implements InternalWalletState { cryptoApi: TalerCryptoInterface; cryptoDispatcher: CryptoDispatcher; merchantInfoCache: Record = {}; readonly timerGroup: TimerGroup; workAvailable = new AsyncCondition(); stopped = false; listeners: NotificationListener[] = []; initCalled = false; recoupOps: RecoupOperations = { createRecoupGroup, }; // FIXME: Use an LRU cache here. private denomCache: Record = {}; /** * Promises that are waiting for a particular resource. */ private resourceWaiters: Record[]> = {}; /** * Resources that are currently locked. */ private resourceLocks: Set = new Set(); isTaskLoopRunning: boolean = false; taskScheduler: TaskScheduler = new TaskScheduler(this); config: Readonly; private _db: DbAccess | undefined = undefined; get db(): DbAccess { if (!this._db) { throw Error("db not initialized"); } return this._db; } constructor( public idb: IDBFactory, public http: HttpRequestLibrary, public timer: TimerAPI, cryptoWorkerFactory: CryptoWorkerFactory, configParam: WalletConfig, ) { this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); this.cryptoApi = this.cryptoDispatcher.cryptoApi; this.timerGroup = new TimerGroup(timer); this.config = configParam; if (this.config.testing.devModeActive) { this.http = new DevExperimentHttpLib(this.http); } } async ensureWalletDbOpen(): Promise { if (this._db) { return; } const myVersionChange = async (): Promise => { logger.info("version change requested for Taler DB"); }; const myDb = await openTalerDatabase(this.idb, myVersionChange); this._db = myDb; } async getTransactionState( ws: InternalWalletState, tx: GetReadOnlyAccess, transactionId: string, ): Promise { const parsedTxId = parseTransactionIdentifier(transactionId); if (!parsedTxId) { throw Error("invalid tx identifier"); } switch (parsedTxId.tag) { case TransactionType.Deposit: { const rec = await tx.depositGroups.get(parsedTxId.depositGroupId); if (!rec) { return undefined; } return computeDepositTransactionStatus(rec); } case TransactionType.InternalWithdrawal: case TransactionType.Withdrawal: { const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId); if (!rec) { return undefined; } return computeWithdrawalTransactionStatus(rec); } case TransactionType.Payment: { const rec = await tx.purchases.get(parsedTxId.proposalId); if (!rec) { return; } return computePayMerchantTransactionState(rec); } case TransactionType.Refund: { const rec = await tx.refundGroups.get(parsedTxId.refundGroupId); if (!rec) { return undefined; } return computeRefundTransactionState(rec); } case TransactionType.PeerPullCredit: const rec = await tx.peerPullCredit.get(parsedTxId.pursePub); if (!rec) { return undefined; } return computePeerPullCreditTransactionState(rec); case TransactionType.PeerPullDebit: { const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId); if (!rec) { return undefined; } return computePeerPullDebitTransactionState(rec); } case TransactionType.PeerPushCredit: { const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId); if (!rec) { return undefined; } return computePeerPushCreditTransactionState(rec); } case TransactionType.PeerPushDebit: { const rec = await tx.peerPushDebit.get(parsedTxId.pursePub); if (!rec) { return undefined; } return computePeerPushDebitTransactionState(rec); } case TransactionType.Refresh: { const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId); if (!rec) { return undefined; } return computeRefreshTransactionState(rec); } case TransactionType.Reward: { const rec = await tx.rewards.get(parsedTxId.walletRewardId); if (!rec) { return undefined; } return computeRewardTransactionStatus(rec); } case TransactionType.Recoup: throw Error("not yet supported"); default: assertUnreachable(parsedTxId); } } async getDenomInfo( ws: InternalWalletState, tx: GetReadWriteAccess<{ denominations: typeof WalletStoresV1.denominations; }>, exchangeBaseUrl: string, denomPubHash: string, ): Promise { const key = `${exchangeBaseUrl}:${denomPubHash}`; const cached = this.denomCache[key]; if (cached) { return cached; } const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); if (d) { return DenominationRecord.toDenomInfo(d); } return undefined; } notify(n: WalletNotification): void { logger.trace(`Notification: ${j2s(n)}`); for (const l of this.listeners) { const nc = JSON.parse(JSON.stringify(n)); setTimeout(() => { l(nc); }, 0); } } addNotificationListener(f: (n: WalletNotification) => void): CancelFn { this.listeners.push(f); return () => { const idx = this.listeners.indexOf(f); if (idx >= 0) { this.listeners.splice(idx, 1); } }; } /** * Stop ongoing processing. */ stop(): void { logger.trace("stopping (at internal wallet state)"); this.stopped = true; this.timerGroup.stopCurrentAndFutureTimers(); this.cryptoDispatcher.stop(); } /** * Run an async function after acquiring a list of locks, identified * by string tokens. */ async runSequentialized( tokens: string[], f: () => Promise, ): Promise { // Make sure locks are always acquired in the same order tokens = [...tokens].sort(); for (const token of tokens) { if (this.resourceLocks.has(token)) { const p = openPromise(); let waitList = this.resourceWaiters[token]; if (!waitList) { waitList = this.resourceWaiters[token] = []; } waitList.push(p); await p.promise; } this.resourceLocks.add(token); } try { logger.trace(`begin exclusive execution on ${JSON.stringify(tokens)}`); const result = await f(); logger.trace(`end exclusive execution on ${JSON.stringify(tokens)}`); return result; } finally { for (const token of tokens) { this.resourceLocks.delete(token); let waiter = (this.resourceWaiters[token] ?? []).shift(); if (waiter) { waiter.resolve(); } } } } ensureTaskLoopRunning(): void { if (this.isTaskLoopRunning) { return; } runTaskLoop(this) .catch((e) => { logger.error("error running task loop"); logger.error(`err: ${e}`); }) .then(() => { logger.info("done running task loop"); }); } } /** * Take the full object as template, create a new result with all the values. * Use the override object to change the values in the result * return result * @param full * @param override * @returns */ function deepMerge(full: T, override: object): T { const keys = Object.keys(full); const result = { ...full }; for (const k of keys) { // @ts-ignore const newVal = override[k]; if (newVal === undefined) continue; // @ts-ignore result[k] = // @ts-ignore typeof newVal === "object" ? deepMerge(full[k], newVal) : newVal; } return result; }