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