summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/wallet.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/wallet.ts')
-rw-r--r--packages/taler-wallet-core/src/wallet.ts2409
1 files changed, 1624 insertions, 785 deletions
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index 32e3945e8..fc612b189 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -15,33 +15,149 @@
*/
/**
- * High-level wallet operations that should be indepentent from the underlying
+ * High-level wallet operations that should be independent from the underlying
* browser extension interface.
*/
/**
* Imports.
*/
+import { IDBDatabase, IDBFactory } from "@gnu-taler/idb-bridge";
import {
- BalancesResponse,
+ AbsoluteTime,
+ ActiveTask,
+ AmountJson,
+ AmountString,
+ Amounts,
+ AsyncCondition,
+ CancellationToken,
+ CoinDumpJson,
+ CoinStatus,
+ CoreApiResponse,
+ CreateStoredBackupResponse,
+ DeleteStoredBackupRequest,
+ DenominationInfo,
+ Duration,
+ ExchangesShortListResponse,
+ GetCurrencySpecificationResponse,
+ InitResponse,
+ KnownBankAccounts,
+ KnownBankAccountsInfo,
+ ListGlobalCurrencyAuditorsResponse,
+ ListGlobalCurrencyExchangesResponse,
+ Logger,
+ NotificationType,
+ ObservabilityContext,
+ ObservabilityEventType,
+ ObservableHttpClientLibrary,
+ OpenedPromise,
+ PartialWalletRunConfig,
+ PrepareWithdrawExchangeRequest,
+ PrepareWithdrawExchangeResponse,
+ RecoverStoredBackupRequest,
+ StoredBackupList,
+ TalerError,
+ TalerErrorCode,
+ TalerProtocolTimestamp,
+ TalerUriAction,
+ TestingGetDenomStatsResponse,
+ TestingListTasksForTransactionsResponse,
+ TestingWaitTransactionRequest,
+ TimerAPI,
+ TimerGroup,
+ TransactionType,
+ ValidateIbanResponse,
+ WalletCoreVersion,
+ WalletNotification,
+ WalletRunConfig,
+ checkDbInvariant,
+ codecForAbortTransaction,
+ codecForAcceptBankIntegratedWithdrawalRequest,
+ codecForAcceptExchangeTosRequest,
+ codecForAcceptManualWithdrawalRequest,
+ codecForAcceptPeerPullPaymentRequest,
+ codecForAddExchangeRequest,
+ codecForAddGlobalCurrencyAuditorRequest,
+ codecForAddGlobalCurrencyExchangeRequest,
+ codecForAddKnownBankAccounts,
codecForAny,
+ codecForApplyDevExperiment,
+ codecForCheckPeerPullPaymentRequest,
+ codecForCheckPeerPushDebitRequest,
+ codecForConfirmPayRequest,
+ codecForConfirmPeerPushPaymentRequest,
+ codecForConfirmWithdrawalRequestRequest,
+ codecForConvertAmountRequest,
+ codecForCreateDepositGroupRequest,
+ codecForDeleteExchangeRequest,
+ codecForDeleteStoredBackupRequest,
codecForDeleteTransactionRequest,
+ codecForFailTransactionRequest,
+ codecForForceRefreshRequest,
+ codecForForgetKnownBankAccounts,
+ codecForGetAmountRequest,
+ codecForGetBalanceDetailRequest,
+ codecForGetContractTermsDetails,
+ codecForGetCurrencyInfoRequest,
+ codecForGetExchangeEntryByUrlRequest,
+ codecForGetExchangeResourcesRequest,
+ codecForGetExchangeTosRequest,
+ codecForGetWithdrawalDetailsForAmountRequest,
+ codecForGetWithdrawalDetailsForUri,
+ codecForImportDbRequest,
+ codecForInitRequest,
+ codecForInitiatePeerPullPaymentRequest,
+ codecForInitiatePeerPushDebitRequest,
+ codecForIntegrationTestArgs,
+ codecForIntegrationTestV2Args,
+ codecForListExchangesForScopedCurrencyRequest,
+ codecForListKnownBankAccounts,
+ codecForPrepareBankIntegratedWithdrawalRequest,
+ codecForPrepareDepositRequest,
+ codecForPreparePayRequest,
+ codecForPreparePayTemplateRequest,
+ codecForPreparePeerPullPaymentRequest,
+ codecForPreparePeerPushCreditRequest,
+ codecForPrepareRefundRequest,
+ codecForPrepareWithdrawExchangeRequest,
+ codecForRecoverStoredBackupRequest,
+ codecForRemoveGlobalCurrencyAuditorRequest,
+ codecForRemoveGlobalCurrencyExchangeRequest,
+ codecForResumeTransaction,
codecForRetryTransactionRequest,
+ codecForSetCoinSuspendedRequest,
codecForSetWalletDeviceIdRequest,
- codecForGetExchangeWithdrawalInfo,
- durationFromSpec,
- durationMin,
- getDurationRemaining,
- isTimestampExpired,
+ codecForSharePaymentRequest,
+ codecForStartRefundQueryRequest,
+ codecForSuspendTransaction,
+ codecForTestPayArgs,
+ codecForTestingGetDenomStatsRequest,
+ codecForTestingListTasksForTransactionRequest,
+ codecForTestingSetTimetravelRequest,
+ codecForTransactionByIdRequest,
+ codecForTransactionsRequest,
+ codecForUpdateExchangeEntryRequest,
+ codecForUserAttentionByIdRequest,
+ codecForUserAttentionsRequest,
+ codecForValidateIbanRequest,
+ codecForWithdrawTestBalance,
+ getErrorDetailFromException,
j2s,
- TalerErrorCode,
- Timestamp,
- timestampMin,
- WalletNotification,
- codecForWithdrawFakebankRequest,
- URL,
+ openPromise,
parsePaytoUri,
+ parseTalerUri,
+ performanceNow,
+ safeStringifyException,
+ sampleWalletCoreTransactions,
+ setDangerousTimetravel,
+ validateIban,
} from "@gnu-taler/taler-util";
+import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
+import {
+ getUserAttentions,
+ getUserAttentionsUnreadCount,
+ markAttentionRequestAsRead,
+} from "./attention.js";
import {
addBackupProvider,
codecForAddBackupProviderRequest,
@@ -50,544 +166,342 @@ import {
getBackupInfo,
getBackupRecovery,
loadBackupRecovery,
- processBackupForProvider,
removeBackupProvider,
runBackupCycle,
-} from "./operations/backup/index.js";
-import { exportBackup } from "./operations/backup/export.js";
-import { getBalances } from "./operations/balance.js";
+ setWalletDeviceId,
+} from "./backup/index.js";
+import { getBalanceDetail, getBalances } from "./balance.js";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
- createDepositGroup,
- processDepositGroup,
- trackDepositGroup,
-} from "./operations/deposits.js";
+ CryptoDispatcher,
+ CryptoWorkerFactory,
+} from "./crypto/workers/crypto-dispatcher.js";
+import {
+ CoinSourceType,
+ ConfigRecordKey,
+ DenominationRecord,
+ WalletDbReadOnlyTransaction,
+ WalletStoresV1,
+ clearDatabase,
+ exportDb,
+ importDb,
+ openStoredBackupsDatabase,
+ openTalerDatabase,
+ timestampAbsoluteFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
import {
- makeErrorDetails,
- OperationFailedAndReportedError,
- OperationFailedError,
-} from "./errors.js";
+ checkDepositGroup,
+ createDepositGroup,
+ generateDepositGroupTxId,
+} from "./deposits.js";
+import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
import {
+ ReadyExchangeSummary,
acceptExchangeTermsOfService,
- getExchangeDetails,
- getExchangeTrust,
- updateExchangeFromUrl,
-} from "./operations/exchanges.js";
+ addPresetExchangeEntry,
+ deleteExchange,
+ fetchFreshExchange,
+ forgetExchangeTermsOfService,
+ getExchangeDetailedInfo,
+ getExchangeResources,
+ getExchangeTos,
+ listExchanges,
+ lookupExchangeByUri,
+} from "./exchanges.js";
+import {
+ convertDepositAmount,
+ convertPeerPushAmount,
+ convertWithdrawalAmount,
+ getMaxDepositAmount,
+ getMaxPeerPushAmount,
+} from "./instructedAmountConversion.js";
+import {
+ ObservableDbAccess,
+ ObservableTaskScheduler,
+ observeTalerCrypto,
+} from "./observable-wrappers.js";
import {
confirmPay,
+ getContractTermsDetails,
+ preparePayForTemplate,
preparePayForUri,
- processDownloadProposal,
- processPurchasePay,
-} from "./operations/pay.js";
-import { getPendingOperations } from "./operations/pending.js";
-import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
+ sharePayment,
+ startQueryRefund,
+ startRefundQueryForUri,
+} from "./pay-merchant.js";
+import {
+ checkPeerPullPaymentInitiation,
+ initiatePeerPullPayment,
+} from "./pay-peer-pull-credit.js";
import {
- autoRefresh,
- createRefreshGroup,
- processRefreshGroup,
-} from "./operations/refresh.js";
+ confirmPeerPullDebit,
+ preparePeerPullDebit,
+} from "./pay-peer-pull-debit.js";
import {
- abortFailedPayWithRefund,
- applyRefund,
- processPurchaseQueryRefund,
-} from "./operations/refund.js";
+ confirmPeerPushCredit,
+ preparePeerPushCredit,
+} from "./pay-peer-push-credit.js";
import {
- createReserve,
- createTalerWithdrawReserve,
- getFundingPaytoUris,
- processReserve,
-} from "./operations/reserves.js";
+ checkPeerPushDebit,
+ initiatePeerPushDebit,
+} from "./pay-peer-push-debit.js";
import {
- ExchangeOperations,
- InternalWalletState,
- NotificationListener,
- RecoupOperations,
-} from "./common.js";
+ AfterCommitInfo,
+ DbAccess,
+ DbAccessImpl,
+ TriggerSpec,
+} from "./query.js";
+import { forceRefresh } from "./refresh.js";
+import {
+ TaskScheduler,
+ TaskSchedulerImpl,
+ convertTaskToTransactionId,
+ listTaskForTransactionId,
+} from "./shepherd.js";
import {
runIntegrationTest,
+ runIntegrationTest2,
testPay,
+ waitTasksDone,
+ waitTransactionState,
+ waitUntilAllTransactionsFinal,
+ waitUntilRefreshesDone,
withdrawTestBalance,
-} from "./operations/testing.js";
-import { acceptTip, prepareTip, processTip } from "./operations/tip.js";
+} from "./testing.js";
import {
+ abortTransaction,
+ constructTransactionIdentifier,
deleteTransaction,
+ failTransaction,
+ getTransactionById,
getTransactions,
+ getWithdrawalTransactionByUri,
+ parseTransactionIdentifier,
+ resumeTransaction,
retryTransaction,
-} from "./operations/transactions.js";
-import {
- getExchangeWithdrawalInfo,
- getWithdrawalDetailsForUri,
- processWithdrawGroup,
-} from "./operations/withdraw.js";
-import {
- AuditorTrustRecord,
- CoinSourceType,
- ReserveRecordStatus,
- WalletStoresV1,
-} from "./db.js";
-import { NotificationType } from "@gnu-taler/taler-util";
+ suspendTransaction,
+} from "./transactions.js";
import {
- PendingTaskInfo,
- PendingOperationsResponse,
- PendingTaskType,
-} from "./pending-types.js";
-import { CoinDumpJson } from "@gnu-taler/taler-util";
-import { codecForTransactionsRequest } from "@gnu-taler/taler-util";
-import {
- AcceptManualWithdrawalResult,
- AcceptWithdrawalResponse,
- codecForAbortPayWithRefundRequest,
- codecForAcceptBankIntegratedWithdrawalRequest,
- codecForAcceptExchangeTosRequest,
- codecForAcceptManualWithdrawalRequet,
- codecForAcceptTipRequest,
- codecForAddExchangeRequest,
- codecForApplyRefundRequest,
- codecForConfirmPayRequest,
- codecForCreateDepositGroupRequest,
- codecForForceRefreshRequest,
- codecForGetExchangeTosRequest,
- codecForGetWithdrawalDetailsForAmountRequest,
- codecForGetWithdrawalDetailsForUri,
- codecForIntegrationTestArgs,
- codecForPreparePayRequest,
- codecForPrepareTipRequest,
- codecForSetCoinSuspendedRequest,
- codecForTestPayArgs,
- codecForTrackDepositGroupRequest,
- codecForWithdrawTestBalance,
- CoreApiResponse,
- ExchangeListItem,
- ExchangesListRespose,
- GetExchangeTosResult,
- ManualWithdrawalDetails,
- RefreshReason,
-} from "@gnu-taler/taler-util";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { assertUnreachable } from "./util/assertUnreachable.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { setWalletDeviceId } from "./operations/backup/state.js";
-import { WalletCoreApiClient } from "./wallet-api-types.js";
-import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
-import { CryptoApi, CryptoWorkerFactory } from "./crypto/workers/cryptoApi.js";
-import { TimerGroup } from "./util/timer.js";
+ WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_COREBANK_API_PROTOCOL_VERSION,
+ WALLET_CORE_API_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ WALLET_MERCHANT_PROTOCOL_VERSION,
+} from "./versions.js";
import {
- AsyncCondition,
- OpenedPromise,
- openPromise,
-} from "./util/promiseUtils.js";
-import { DbAccess } from "./util/query.js";
+ WalletApiOperation,
+ WalletCoreApiClient,
+ WalletCoreResponseType,
+} from "./wallet-api-types.js";
import {
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
-} from "./util/http.js";
-
-const builtinAuditors: AuditorTrustRecord[] = [
- {
- currency: "KUDOS",
- auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0",
- auditorBaseUrl: "https://auditor.demo.taler.net/",
- uids: ["5P25XF8TVQP9AW6VYGY2KV47WT5Y3ZXFSJAA570GJPX5SVJXKBVG"],
- },
-];
+ acceptWithdrawalFromUri,
+ confirmWithdrawal,
+ createManualWithdrawal,
+ getWithdrawalDetailsForAmount,
+ getWithdrawalDetailsForUri,
+ prepareBankIntegratedWithdrawal,
+} from "./withdraw.js";
const logger = new Logger("wallet.ts");
-async function getWithdrawalDetailsForAmount(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- amount: AmountJson,
-): Promise<ManualWithdrawalDetails> {
- const wi = await getExchangeWithdrawalInfo(ws, exchangeBaseUrl, amount);
- const paytoUris = wi.exchangeDetails.wireInfo.accounts.map(
- (x) => x.payto_uri,
- );
- if (!paytoUris) {
- throw Error("exchange is in invalid state");
- }
- return {
- amountRaw: Amounts.stringify(amount),
- amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
- paytoUris,
- tosAccepted: wi.termsOfServiceAccepted,
- };
-}
-
/**
- * Execute one operation based on the pending operation info record.
- */
-async function processOnePendingOperation(
- ws: InternalWalletState,
- pending: PendingTaskInfo,
- forceNow = false,
-): Promise<void> {
- logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`);
- switch (pending.type) {
- case PendingTaskType.ExchangeUpdate:
- await updateExchangeFromUrl(
- ws,
- pending.exchangeBaseUrl,
- undefined,
- forceNow,
- );
- break;
- case PendingTaskType.Refresh:
- await processRefreshGroup(ws, pending.refreshGroupId, forceNow);
- break;
- case PendingTaskType.Reserve:
- await processReserve(ws, pending.reservePub, forceNow);
- break;
- case PendingTaskType.Withdraw:
- await processWithdrawGroup(ws, pending.withdrawalGroupId, forceNow);
- break;
- case PendingTaskType.ProposalDownload:
- await processDownloadProposal(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.TipPickup:
- await processTip(ws, pending.tipId, forceNow);
- break;
- case PendingTaskType.Pay:
- await processPurchasePay(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.RefundQuery:
- await processPurchaseQueryRefund(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.Recoup:
- await processRecoupGroup(ws, pending.recoupGroupId, forceNow);
- break;
- case PendingTaskType.ExchangeCheckRefresh:
- await autoRefresh(ws, pending.exchangeBaseUrl);
- break;
- case PendingTaskType.Deposit:
- await processDepositGroup(ws, pending.depositGroupId);
- break;
- case PendingTaskType.Backup:
- await processBackupForProvider(ws, pending.backupProviderBaseUrl);
- break;
- default:
- assertUnreachable(pending);
- }
-}
-
-/**
- * Process pending operations.
- */
-export async function runPending(
- ws: InternalWalletState,
- forceNow = false,
-): Promise<void> {
- const pendingOpsResponse = await getPendingOperations(ws);
- for (const p of pendingOpsResponse.pendingOperations) {
- if (!forceNow && !isTimestampExpired(p.timestampDue)) {
- continue;
- }
- try {
- await processOnePendingOperation(ws, p, forceNow);
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- console.error(
- "Operation failed:",
- JSON.stringify(e.operationError, undefined, 2),
- );
- } else {
- console.error(e);
- }
- }
- }
-}
-
-export interface RetryLoopOpts {
- /**
- * Stop when the number of retries is exceeded for any pending
- * operation.
- */
- maxRetries?: number;
-
- /**
- * Stop the retry loop when all lifeness-giving pending operations
- * are done.
- *
- * Defaults to false.
- */
- stopWhenDone?: boolean;
-}
-
-/**
- * Main retry loop of the wallet.
+ * Execution context for code that is run in the wallet.
*
- * Looks up pending operations from the wallet, runs them, repeat.
+ * Typically the execution context is either for a wallet-core
+ * request handler or for a shepherded task.
*/
-async function runTaskLoop(
- ws: InternalWalletState,
- opts: RetryLoopOpts = {},
-): Promise<void> {
- for (let iteration = 0; !ws.stopped; iteration++) {
- const pending = await getPendingOperations(ws);
- logger.trace(`pending operations: ${j2s(pending)}`);
- let numGivingLiveness = 0;
- let numDue = 0;
- let minDue: Timestamp = { t_ms: "never" };
- for (const p of pending.pendingOperations) {
- minDue = timestampMin(minDue, p.timestampDue);
- if (isTimestampExpired(p.timestampDue)) {
- numDue++;
- }
- if (p.givesLifeness) {
- numGivingLiveness++;
- }
+export interface WalletExecutionContext {
+ readonly ws: InternalWalletState;
+ readonly cryptoApi: TalerCryptoInterface;
+ readonly cancellationToken: CancellationToken;
+ readonly http: HttpRequestLibrary;
+ readonly db: DbAccess<typeof WalletStoresV1>;
+ readonly oc: ObservabilityContext;
+ readonly taskScheduler: TaskScheduler;
+}
- const maxRetries = opts.maxRetries;
+export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
+export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";
- if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) {
- logger.warn(
- `stopping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`,
- );
- return;
- }
- }
+export type NotificationListener = (n: WalletNotification) => void;
- if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) {
- logger.warn(`stopping, as no pending operations have lifeness`);
- return;
- }
-
- // Make sure that we run tasks that don't give lifeness at least
- // one time.
- if (iteration !== 0 && numDue === 0) {
- // We've executed pending, due operations at least one.
- // Now we don't have any more operations available,
- // and need to wait.
-
- // Wait for at most 5 seconds to the next check.
- const dt = durationMin(
- durationFromSpec({
- seconds: 5,
- }),
- getDurationRemaining(minDue),
- );
- logger.trace(`waiting for at most ${dt.d_ms} ms`);
- const timeout = ws.timerGroup.resolveAfter(dt);
- ws.notify({
- type: NotificationType.WaitingForRetry,
- numGivingLiveness,
- numPending: pending.pendingOperations.length,
- });
- // Wait until either the timeout, or we are notified (via the latch)
- // that more work might be available.
- await Promise.race([timeout, ws.latch.wait()]);
- } else {
- logger.trace(
- `running ${pending.pendingOperations.length} pending operations`,
- );
- for (const p of pending.pendingOperations) {
- if (!isTimestampExpired(p.timestampDue)) {
- continue;
- }
- try {
- await processOnePendingOperation(ws, p);
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- logger.warn("operation processed resulted in reported error");
- } else {
- logger.error("Uncaught exception", e);
- ws.notify({
- type: NotificationType.InternalError,
- message: "uncaught exception",
- exception: e,
- });
- }
- }
- ws.notify({
- type: NotificationType.PendingOperationProcessed,
- });
- }
- }
- }
- logger.trace("exiting wallet retry loop");
-}
+type CancelFn = () => void;
/**
* Insert the hard-coded defaults for exchanges, coins and
* auditors into the database, unless these defaults have
* already been applied.
*/
-async function fillDefaults(ws: InternalWalletState): Promise<void> {
- await ws.db
- .mktx((x) => ({ config: x.config, auditorTrustStore: x.auditorTrust }))
- .runReadWrite(async (tx) => {
- let applied = false;
- await tx.config.iter().forEach((x) => {
- if (x.key == "currencyDefaultsApplied" && x.value == true) {
- applied = true;
- }
- });
- if (!applied) {
- for (const c of builtinAuditors) {
- await tx.auditorTrustStore.put(c);
+async function fillDefaults(wex: WalletExecutionContext): Promise<void> {
+ const notifications: WalletNotification[] = [];
+ await wex.db.runReadWriteTx(
+ { storeNames: ["config", "exchanges"] },
+ async (tx) => {
+ const appliedRec = await tx.config.get("currencyDefaultsApplied");
+ let alreadyApplied = appliedRec ? !!appliedRec.value : false;
+ if (alreadyApplied) {
+ logger.trace("defaults already applied");
+ return;
+ }
+ for (const exch of wex.ws.config.builtin.exchanges) {
+ const resp = await addPresetExchangeEntry(
+ tx,
+ exch.exchangeBaseUrl,
+ exch.currencyHint,
+ );
+ if (resp.notification) {
+ notifications.push(resp.notification);
}
}
- });
-}
-
-/**
- * Create a reserve for a manual withdrawal.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- */
-async function acceptManualWithdrawal(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- amount: AmountJson,
-): Promise<AcceptManualWithdrawalResult> {
- try {
- const resp = await createReserve(ws, {
- amount,
- exchange: exchangeBaseUrl,
- });
- const exchangePaytoUris = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- reserves: x.reserves,
- }))
- .runReadWrite((tx) => getFundingPaytoUris(tx, resp.reservePub));
- return {
- reservePub: resp.reservePub,
- exchangePaytoUris,
- };
- } finally {
- ws.latch.trigger();
+ await tx.config.put({
+ key: ConfigRecordKey.CurrencyDefaultsApplied,
+ value: true,
+ });
+ },
+ );
+ for (const notif of notifications) {
+ wex.ws.notify(notif);
}
}
-async function getExchangeTos(
- ws: InternalWalletState,
+export async function getDenomInfo(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
exchangeBaseUrl: string,
- acceptedFormat?: string[],
-): Promise<GetExchangeTosResult> {
- const { exchangeDetails } = await updateExchangeFromUrl(
- ws,
- exchangeBaseUrl,
- acceptedFormat,
- );
- const content = exchangeDetails.termsOfServiceText;
- const currentEtag = exchangeDetails.termsOfServiceLastEtag;
- const contentType = exchangeDetails.termsOfServiceContentType;
- if (
- content === undefined ||
- currentEtag === undefined ||
- contentType === undefined
- ) {
- throw Error("exchange is in invalid state");
+ denomPubHash: string,
+): Promise<DenominationInfo | undefined> {
+ const cacheKey = `${exchangeBaseUrl}:${denomPubHash}`;
+ const cached = wex.ws.denomInfoCache.get(cacheKey);
+ if (cached) {
+ return cached;
}
- return {
- acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag,
- currentEtag,
- content,
- contentType,
- };
+ const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
+ if (d) {
+ const denomInfo = DenominationRecord.toDenomInfo(d);
+ wex.ws.denomInfoCache.put(cacheKey, denomInfo);
+ return denomInfo;
+ }
+ return undefined;
}
-async function getExchanges(
- ws: InternalWalletState,
-): Promise<ExchangesListRespose> {
- const exchanges: ExchangeListItem[] = [];
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const dp = r.detailsPointer;
- if (!dp) {
- continue;
- }
- const { currency } = dp;
- const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
- if (!exchangeDetails) {
- continue;
- }
- exchanges.push({
- exchangeBaseUrl: r.baseUrl,
- currency,
- paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+/**
+ * List bank accounts known to the wallet from
+ * previous withdrawals.
+ */
+async function listKnownBankAccounts(
+ wex: WalletExecutionContext,
+ currency?: string,
+): Promise<KnownBankAccounts> {
+ const accounts: KnownBankAccountsInfo[] = [];
+ await wex.db.runReadOnlyTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ const knownAccounts = await tx.bankAccounts.iter().toArray();
+ for (const r of knownAccounts) {
+ if (currency && currency !== r.currency) {
+ continue;
+ }
+ const payto = parsePaytoUri(r.uri);
+ if (payto) {
+ accounts.push({
+ uri: payto,
+ alias: r.alias,
+ kyc_completed: r.kycCompleted,
+ currency: r.currency,
});
}
- });
- return { exchanges };
+ }
+ });
+ return { accounts };
}
-async function acceptWithdrawal(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- selectedExchange: string,
-): Promise<AcceptWithdrawalResponse> {
- try {
- return createTalerWithdrawReserve(ws, talerWithdrawUri, selectedExchange);
- } finally {
- ws.latch.trigger();
- }
+/**
+ */
+async function addKnownBankAccounts(
+ wex: WalletExecutionContext,
+ payto: string,
+ alias: string,
+ currency: string,
+): Promise<void> {
+ await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ tx.bankAccounts.put({
+ uri: payto,
+ alias: alias,
+ currency: currency,
+ kycCompleted: false,
+ });
+ });
+ return;
}
/**
- * Inform the wallet that the status of a reserve has changed (e.g. due to a
- * confirmation from the bank.).
*/
-export async function handleNotifyReserve(
- ws: InternalWalletState,
+async function forgetKnownBankAccounts(
+ wex: WalletExecutionContext,
+ payto: string,
): Promise<void> {
- const reserves = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.iter().toArray();
- });
- for (const r of reserves) {
- if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) {
- try {
- processReserve(ws, r.reservePub);
- } catch (e) {
- console.error(e);
- }
+ await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => {
+ const account = await tx.bankAccounts.get(payto);
+ if (!account) {
+ throw Error(`account not found: ${payto}`);
}
- }
+ tx.bankAccounts.delete(account.uri);
+ });
+ return;
}
async function setCoinSuspended(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
coinPub: string,
suspended: boolean,
): Promise<void> {
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "coinAvailability"] },
+ async (tx) => {
const c = await tx.coins.get(coinPub);
if (!c) {
logger.warn(`coin ${coinPub} not found, won't suspend`);
return;
}
- c.suspended = suspended;
+ const coinAvailability = await tx.coinAvailability.get([
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ c.maxAge,
+ ]);
+ checkDbInvariant(!!coinAvailability);
+ if (suspended) {
+ if (c.status !== CoinStatus.Fresh) {
+ return;
+ }
+ if (coinAvailability.freshCoinCount === 0) {
+ throw Error(
+ `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+ );
+ }
+ coinAvailability.freshCoinCount--;
+ c.status = CoinStatus.FreshSuspended;
+ } else {
+ if (c.status == CoinStatus.Dormant) {
+ return;
+ }
+ coinAvailability.freshCoinCount++;
+ c.status = CoinStatus.Fresh;
+ }
await tx.coins.put(c);
- });
+ await tx.coinAvailability.put(coinAvailability);
+ },
+ );
}
/**
* Dump the public information of coins we have in an easy-to-process format.
*/
-async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
+async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> {
const coinsJson: CoinDumpJson = { coins: [] };
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadOnly(async (tx) => {
+ logger.info("dumping coins");
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
const coins = await tx.coins.iter().toArray();
for (const c of coins) {
const denom = await tx.denominations.get([
@@ -595,7 +509,7 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
c.denomPubHash,
]);
if (!denom) {
- console.error("no denom session found for coin");
+ logger.warn("no denom found for coin");
continue;
}
const cs = c.coinSource;
@@ -605,42 +519,55 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
}
let withdrawalReservePub: string | undefined;
if (cs.type == CoinSourceType.Withdraw) {
- const ws = await tx.withdrawalGroups.get(cs.withdrawalGroupId);
- if (!ws) {
- console.error("no withdrawal session found for coin");
- continue;
- }
- withdrawalReservePub = ws.reservePub;
+ withdrawalReservePub = cs.reservePub;
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ );
+ if (!denomInfo) {
+ logger.warn("no denomination found for coin");
+ continue;
}
coinsJson.coins.push({
coin_pub: c.coinPub,
- denom_pub: c.denomPub,
+ denom_pub: denomInfo.denomPub,
denom_pub_hash: c.denomPubHash,
- denom_value: Amounts.stringify(denom.value),
+ denom_value: denom.value,
exchange_base_url: c.exchangeBaseUrl,
refresh_parent_coin_pub: refreshParentCoinPub,
- remaining_value: Amounts.stringify(c.currentAmount),
withdrawal_reserve_pub: withdrawalReservePub,
- coin_suspended: c.suspended,
+ coin_status: c.status,
+ ageCommitmentProof: c.ageCommitmentProof,
+ spend_allocation: c.spendAllocation
+ ? {
+ amount: c.spendAllocation.amount,
+ id: c.spendAllocation.id,
+ }
+ : undefined,
});
}
- });
+ },
+ );
return coinsJson;
}
/**
* Get an API client from an internal wallet state object.
*/
-export async function getClientFromWalletState(
+let id = 0;
+async function getClientFromWalletState(
ws: InternalWalletState,
): Promise<WalletCoreApiClient> {
- let id = 0;
const client: WalletCoreApiClient = {
async call(op, payload): Promise<any> {
- const res = await handleCoreApiRequest(ws, op, `${id++}`, payload);
+ id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100);
+ const res = await handleCoreApiRequest(ws, op, String(id), payload);
switch (res.type) {
case "error":
- throw new OperationFailedError(res.error);
+ throw TalerError.fromUncheckedDetail(res.error);
case "response":
return res.result;
}
@@ -649,320 +576,1059 @@ export async function getClientFromWalletState(
return client;
}
+async function createStoredBackup(
+ wex: WalletExecutionContext,
+): Promise<CreateStoredBackupResponse> {
+ const backup = await exportDb(wex.ws.idb);
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ const name = `backup-${new Date().getTime()}`;
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupMeta.add({
+ name,
+ });
+ await tx.backupData.add(backup, name);
+ });
+ return {
+ name,
+ };
+}
+
+async function listStoredBackups(
+ wex: WalletExecutionContext,
+): Promise<StoredBackupList> {
+ const storedBackups: StoredBackupList = {
+ storedBackups: [],
+ };
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupMeta.iter().forEach((x) => {
+ storedBackups.storedBackups.push({
+ name: x.name,
+ });
+ });
+ });
+ return storedBackups;
+}
+
+async function deleteStoredBackup(
+ wex: WalletExecutionContext,
+ req: DeleteStoredBackupRequest,
+): Promise<void> {
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupData.delete(req.name);
+ await tx.backupMeta.delete(req.name);
+ });
+}
+
+async function recoverStoredBackup(
+ wex: WalletExecutionContext,
+ req: RecoverStoredBackupRequest,
+): Promise<void> {
+ logger.info(`Recovering stored backup ${req.name}`);
+ const { name } = req;
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ const bd = await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ const backupMeta = tx.backupMeta.get(name);
+ if (!backupMeta) {
+ throw Error("backup not found");
+ }
+ const backupData = await tx.backupData.get(name);
+ if (!backupData) {
+ throw Error("no backup data (DB corrupt)");
+ }
+ return backupData;
+ });
+ logger.info(`backup found, now importing`);
+ await importDb(wex.db.idbHandle(), bd);
+ logger.info(`import done`);
+}
+
+async function handlePrepareWithdrawExchange(
+ wex: WalletExecutionContext,
+ req: PrepareWithdrawExchangeRequest,
+): Promise<PrepareWithdrawExchangeResponse> {
+ const parsedUri = parseTalerUri(req.talerUri);
+ if (parsedUri?.type !== TalerUriAction.WithdrawExchange) {
+ throw Error("expected a taler://withdraw-exchange URI");
+ }
+ const exchangeBaseUrl = parsedUri.exchangeBaseUrl;
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
+ if (parsedUri.exchangePub && exchange.masterPub != parsedUri.exchangePub) {
+ throw Error("mismatch of exchange master public key (URI vs actual)");
+ }
+ if (parsedUri.amount) {
+ const amt = Amounts.parseOrThrow(parsedUri.amount);
+ if (amt.currency !== exchange.currency) {
+ throw Error("mismatch of currency (URI vs exchange)");
+ }
+ }
+ return {
+ exchangeBaseUrl,
+ amount: parsedUri.amount,
+ };
+}
+
+/**
+ * Response returned from the pending operations API.
+ *
+ * @deprecated this is a placeholder for the response type of a deprecated wallet-core request.
+ */
+export interface PendingOperationsResponse {
+ /**
+ * List of pending operations.
+ */
+ pendingOperations: any[];
+}
+
/**
* Implementation of the "wallet-core" API.
*/
async function dispatchRequestInternal(
- ws: InternalWalletState,
- operation: string,
+ wex: WalletExecutionContext,
+ cts: CancellationToken.Source,
+ operation: WalletApiOperation,
payload: unknown,
-): Promise<Record<string, any>> {
- if (!ws.initCalled && operation !== "initWallet") {
+): Promise<WalletCoreResponseType<typeof operation>> {
+ if (!wex.ws.initCalled && operation !== WalletApiOperation.InitWallet) {
throw Error(
`wallet must be initialized before running operation ${operation}`,
);
}
+ // FIXME: Can we make this more type-safe by using the request/response type
+ // definitions we already have?
switch (operation) {
- case "initWallet": {
- ws.initCalled = true;
- await fillDefaults(ws);
+ case WalletApiOperation.CreateStoredBackup:
+ return createStoredBackup(wex);
+ case WalletApiOperation.DeleteStoredBackup: {
+ const req = codecForDeleteStoredBackupRequest().decode(payload);
+ await deleteStoredBackup(wex, req);
return {};
}
- case "withdrawTestkudos": {
- await withdrawTestBalance(
- ws,
- "TESTKUDOS:10",
- "https://bank.test.taler.net/",
- "https://exchange.test.taler.net/",
- );
+ case WalletApiOperation.ListStoredBackups:
+ return listStoredBackups(wex);
+ case WalletApiOperation.RecoverStoredBackup: {
+ const req = codecForRecoverStoredBackupRequest().decode(payload);
+ await recoverStoredBackup(wex, req);
return {};
}
- case "withdrawTestBalance": {
+ case WalletApiOperation.SetWalletRunConfig:
+ case WalletApiOperation.InitWallet: {
+ const req = codecForInitRequest().decode(payload);
+
+ logger.info(`init request: ${j2s(req)}`);
+
+ if (wex.ws.initCalled) {
+ logger.info("initializing wallet (repeat initialization)");
+ } else {
+ logger.info("initializing wallet (first initialization)");
+ }
+
+ // Write to the DB to make sure that we're failing early in
+ // case the DB is not writeable.
+ try {
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ tx.config.put({
+ key: ConfigRecordKey.LastInitInfo,
+ value: timestampProtocolToDb(TalerProtocolTimestamp.now()),
+ });
+ });
+ } catch (e) {
+ logger.error("error writing to database during initialization");
+ throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
+ innerError: getErrorDetailFromException(e),
+ });
+ }
+
+ wex.ws.initWithConfig(applyRunConfigDefaults(req.config));
+
+ if (wex.ws.config.testing.skipDefaults) {
+ logger.trace("skipping defaults");
+ } else {
+ logger.trace("filling defaults");
+ await fillDefaults(wex);
+ }
+ const resp: InitResponse = {
+ versionInfo: getVersion(wex),
+ };
+
+ // After initialization, task loop should run.
+ await wex.taskScheduler.ensureRunning();
+
+ wex.ws.initCalled = true;
+ return resp;
+ }
+ case WalletApiOperation.WithdrawTestkudos: {
+ await withdrawTestBalance(wex, {
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: "https://bank.test.taler.net/",
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ });
+ return {
+ versionInfo: getVersion(wex),
+ };
+ }
+ case WalletApiOperation.WithdrawTestBalance: {
const req = codecForWithdrawTestBalance().decode(payload);
- await withdrawTestBalance(
- ws,
- req.amount,
- req.bankBaseUrl,
- req.exchangeBaseUrl,
- );
+ await withdrawTestBalance(wex, req);
return {};
}
- case "runIntegrationTest": {
+ case WalletApiOperation.TestingListTaskForTransaction: {
+ const req =
+ codecForTestingListTasksForTransactionRequest().decode(payload);
+ return {
+ taskIdList: listTaskForTransactionId(req.transactionId),
+ } satisfies TestingListTasksForTransactionsResponse;
+ }
+ case WalletApiOperation.RunIntegrationTest: {
const req = codecForIntegrationTestArgs().decode(payload);
- await runIntegrationTest(ws, req);
+ await runIntegrationTest(wex, req);
return {};
}
- case "testPay": {
- const req = codecForTestPayArgs().decode(payload);
- await testPay(ws, req);
+ case WalletApiOperation.RunIntegrationTestV2: {
+ const req = codecForIntegrationTestV2Args().decode(payload);
+ await runIntegrationTest2(wex, req);
return {};
}
- case "getTransactions": {
+ case WalletApiOperation.ValidateIban: {
+ const req = codecForValidateIbanRequest().decode(payload);
+ const valRes = validateIban(req.iban);
+ const resp: ValidateIbanResponse = {
+ valid: valRes.type === "valid",
+ };
+ return resp;
+ }
+ case WalletApiOperation.TestPay: {
+ const req = codecForTestPayArgs().decode(payload);
+ return await testPay(wex, req);
+ }
+ case WalletApiOperation.GetTransactions: {
const req = codecForTransactionsRequest().decode(payload);
- return await getTransactions(ws, req);
+ return await getTransactions(wex, req);
}
- case "addExchange": {
+ case WalletApiOperation.GetTransactionById: {
+ const req = codecForTransactionByIdRequest().decode(payload);
+ return await getTransactionById(wex, req);
+ }
+ case WalletApiOperation.GetWithdrawalTransactionByUri: {
+ const req = codecForGetWithdrawalDetailsForUri().decode(payload);
+ return await getWithdrawalTransactionByUri(wex, req);
+ }
+ case WalletApiOperation.AddExchange: {
const req = codecForAddExchangeRequest().decode(payload);
- await updateExchangeFromUrl(
- ws,
- req.exchangeBaseUrl,
- undefined,
- req.forceUpdate,
- );
+ await fetchFreshExchange(wex, req.exchangeBaseUrl, {
+ expectedMasterPub: req.masterPub,
+ });
return {};
}
- case "listExchanges": {
- return await getExchanges(ws);
+ case WalletApiOperation.TestingPing: {
+ return {};
}
- case "getWithdrawalDetailsForUri": {
- const req = codecForGetWithdrawalDetailsForUri().decode(payload);
- return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri);
+ case WalletApiOperation.UpdateExchangeEntry: {
+ const req = codecForUpdateExchangeEntryRequest().decode(payload);
+ await fetchFreshExchange(wex, req.exchangeBaseUrl, {
+ forceUpdate: !!req.force,
+ });
+ return {};
}
- case "getExchangeWithdrawalInfo": {
- const req = codecForGetExchangeWithdrawalInfo().decode(payload);
- return await getExchangeWithdrawalInfo(
- ws,
- req.exchangeBaseUrl,
- req.amount,
+ case WalletApiOperation.TestingGetDenomStats: {
+ const req = codecForTestingGetDenomStatsRequest().decode(payload);
+ const denomStats: TestingGetDenomStatsResponse = {
+ numKnown: 0,
+ numLost: 0,
+ numOffered: 0,
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ const denoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ req.exchangeBaseUrl,
+ );
+ for (const d of denoms) {
+ denomStats.numKnown++;
+ if (d.isOffered) {
+ denomStats.numOffered++;
+ }
+ if (d.isLost) {
+ denomStats.numLost++;
+ }
+ }
+ },
);
+ return denomStats;
}
- case "acceptManualWithdrawal": {
- const req = codecForAcceptManualWithdrawalRequet().decode(payload);
- const res = await acceptManualWithdrawal(
- ws,
- req.exchangeBaseUrl,
- Amounts.parseOrThrow(req.amount),
- );
+ case WalletApiOperation.ListExchanges: {
+ return await listExchanges(wex);
+ }
+ case WalletApiOperation.GetExchangeEntryByUrl: {
+ const req = codecForGetExchangeEntryByUrlRequest().decode(payload);
+ return lookupExchangeByUri(wex, req);
+ }
+ case WalletApiOperation.ListExchangesForScopedCurrency: {
+ const req =
+ codecForListExchangesForScopedCurrencyRequest().decode(payload);
+ const exchangesResp = await listExchanges(wex);
+ const result: ExchangesShortListResponse = {
+ exchanges: [],
+ };
+ // Right now we only filter on the currency, as wallet-core doesn't
+ // fully support scoped currencies yet.
+ for (const exch of exchangesResp.exchanges) {
+ if (exch.currency === req.scope.currency) {
+ result.exchanges.push({
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ });
+ }
+ }
+ return result;
+ }
+ case WalletApiOperation.GetExchangeDetailedInfo: {
+ const req = codecForAddExchangeRequest().decode(payload);
+ return await getExchangeDetailedInfo(wex, req.exchangeBaseUrl);
+ }
+ case WalletApiOperation.ListKnownBankAccounts: {
+ const req = codecForListKnownBankAccounts().decode(payload);
+ return await listKnownBankAccounts(wex, req.currency);
+ }
+ case WalletApiOperation.AddKnownBankAccounts: {
+ const req = codecForAddKnownBankAccounts().decode(payload);
+ await addKnownBankAccounts(wex, req.payto, req.alias, req.currency);
+ return {};
+ }
+ case WalletApiOperation.ForgetKnownBankAccounts: {
+ const req = codecForForgetKnownBankAccounts().decode(payload);
+ await forgetKnownBankAccounts(wex, req.payto);
+ return {};
+ }
+ case WalletApiOperation.GetWithdrawalDetailsForUri: {
+ const req = codecForGetWithdrawalDetailsForUri().decode(payload);
+ return await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri, {
+ restrictAge: req.restrictAge,
+ });
+ }
+ case WalletApiOperation.AcceptManualWithdrawal: {
+ const req = codecForAcceptManualWithdrawalRequest().decode(payload);
+ const res = await createManualWithdrawal(wex, {
+ amount: Amounts.parseOrThrow(req.amount),
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ restrictAge: req.restrictAge,
+ forceReservePriv: req.forceReservePriv,
+ });
return res;
}
- case "getWithdrawalDetailsForAmount": {
- const req = codecForGetWithdrawalDetailsForAmountRequest().decode(
- payload,
- );
- return await getWithdrawalDetailsForAmount(
- ws,
- req.exchangeBaseUrl,
- Amounts.parseOrThrow(req.amount),
- );
+ case WalletApiOperation.GetWithdrawalDetailsForAmount: {
+ const req =
+ codecForGetWithdrawalDetailsForAmountRequest().decode(payload);
+ const resp = await getWithdrawalDetailsForAmount(wex, cts, req);
+ return resp;
+ }
+ case WalletApiOperation.GetBalances: {
+ return await getBalances(wex);
}
- case "getBalances": {
- return await getBalances(ws);
+ case WalletApiOperation.GetBalanceDetail: {
+ const req = codecForGetBalanceDetailRequest().decode(payload);
+ return await getBalanceDetail(wex, req);
}
- case "getPendingOperations": {
- return await getPendingOperations(ws);
+ case WalletApiOperation.GetUserAttentionRequests: {
+ const req = codecForUserAttentionsRequest().decode(payload);
+ return await getUserAttentions(wex, req);
}
- case "setExchangeTosAccepted": {
+ case WalletApiOperation.MarkAttentionRequestAsRead: {
+ const req = codecForUserAttentionByIdRequest().decode(payload);
+ return await markAttentionRequestAsRead(wex, req);
+ }
+ case WalletApiOperation.GetUserAttentionUnreadCount: {
+ const req = codecForUserAttentionsRequest().decode(payload);
+ return await getUserAttentionsUnreadCount(wex, req);
+ }
+ case WalletApiOperation.GetPendingOperations: {
+ // FIXME: Eventually remove the handler after deprecation period.
+ return {
+ pendingOperations: [],
+ } satisfies PendingOperationsResponse;
+ }
+ case WalletApiOperation.SetExchangeTosAccepted: {
const req = codecForAcceptExchangeTosRequest().decode(payload);
- await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag);
+ await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl);
return {};
}
- case "applyRefund": {
- const req = codecForApplyRefundRequest().decode(payload);
- return await applyRefund(ws, req.talerRefundUri);
+ case WalletApiOperation.SetExchangeTosForgotten: {
+ const req = codecForAcceptExchangeTosRequest().decode(payload);
+ await forgetExchangeTermsOfService(wex, req.exchangeBaseUrl);
+ return {};
}
- case "acceptBankIntegratedWithdrawal": {
- const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(
- payload,
- );
- return await acceptWithdrawal(
- ws,
- req.talerWithdrawUri,
+ case WalletApiOperation.AcceptBankIntegratedWithdrawal: {
+ const req =
+ codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
+ return await acceptWithdrawalFromUri(wex, {
+ selectedExchange: req.exchangeBaseUrl,
+ talerWithdrawUri: req.talerWithdrawUri,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ });
+ }
+ case WalletApiOperation.ConfirmWithdrawal: {
+ const req = codecForConfirmWithdrawalRequestRequest().decode(payload);
+ return confirmWithdrawal(wex, req.transactionId);
+ }
+ case WalletApiOperation.PrepareBankIntegratedWithdrawal: {
+ const req =
+ codecForPrepareBankIntegratedWithdrawalRequest().decode(payload);
+ return prepareBankIntegratedWithdrawal(wex, {
+ selectedExchange: req.exchangeBaseUrl,
+ talerWithdrawUri: req.talerWithdrawUri,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ });
+ }
+ case WalletApiOperation.GetExchangeTos: {
+ const req = codecForGetExchangeTosRequest().decode(payload);
+ return getExchangeTos(
+ wex,
req.exchangeBaseUrl,
+ req.acceptedFormat,
+ req.acceptLanguage,
);
}
- case "getExchangeTos": {
- const req = codecForGetExchangeTosRequest().decode(payload);
- return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat);
+ case WalletApiOperation.GetContractTermsDetails: {
+ const req = codecForGetContractTermsDetails().decode(payload);
+ if (req.proposalId) {
+ // FIXME: deprecated path
+ return getContractTermsDetails(wex, req.proposalId);
+ }
+ if (req.transactionId) {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (parsedTx?.tag === TransactionType.Payment) {
+ return getContractTermsDetails(wex, parsedTx.proposalId);
+ }
+ throw Error("transactionId is not a payment transaction");
+ }
+ throw Error("transactionId missing");
}
- case "retryPendingNow": {
- await runPending(ws, true);
+ case WalletApiOperation.RetryPendingNow: {
+ logger.error("retryPendingNow currently not implemented");
return {};
}
- // FIXME: Deprecate one of the aliases!
- case "preparePayForUri":
- case "preparePay": {
+ case WalletApiOperation.SharePayment: {
+ const req = codecForSharePaymentRequest().decode(payload);
+ return await sharePayment(wex, req.merchantBaseUrl, req.orderId);
+ }
+ case WalletApiOperation.PrepareWithdrawExchange: {
+ const req = codecForPrepareWithdrawExchangeRequest().decode(payload);
+ return handlePrepareWithdrawExchange(wex, req);
+ }
+ case WalletApiOperation.PreparePayForUri: {
const req = codecForPreparePayRequest().decode(payload);
- return await preparePayForUri(ws, req.talerPayUri);
+ return await preparePayForUri(wex, req.talerPayUri);
+ }
+ case WalletApiOperation.PreparePayForTemplate: {
+ const req = codecForPreparePayTemplateRequest().decode(payload);
+ return preparePayForTemplate(wex, req);
}
- case "confirmPay": {
+ case WalletApiOperation.ConfirmPay: {
const req = codecForConfirmPayRequest().decode(payload);
- return await confirmPay(ws, req.proposalId, req.sessionId);
+ let transactionId;
+ if (req.proposalId) {
+ // legacy client support
+ transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: req.proposalId,
+ });
+ } else if (req.transactionId) {
+ transactionId = req.transactionId;
+ } else {
+ throw Error("transactionId or (deprecated) proposalId required");
+ }
+ return await confirmPay(wex, transactionId, req.sessionId);
}
- case "abortFailedPayWithRefund": {
- const req = codecForAbortPayWithRefundRequest().decode(payload);
- await abortFailedPayWithRefund(ws, req.proposalId);
+ case WalletApiOperation.AbortTransaction: {
+ const req = codecForAbortTransaction().decode(payload);
+ await abortTransaction(wex, req.transactionId);
return {};
}
- case "dumpCoins": {
- return await dumpCoins(ws);
- }
- case "setCoinSuspended": {
- const req = codecForSetCoinSuspendedRequest().decode(payload);
- await setCoinSuspended(ws, req.coinPub, req.suspended);
+ case WalletApiOperation.SuspendTransaction: {
+ const req = codecForSuspendTransaction().decode(payload);
+ await suspendTransaction(wex, req.transactionId);
return {};
}
- case "forceRefresh": {
- const req = codecForForceRefreshRequest().decode(payload);
- const coinPubs = req.coinPubList.map((x) => ({ coinPub: x }));
- const refreshGroupId = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- denominations: x.denominations,
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
- return await createRefreshGroup(
- ws,
- tx,
- coinPubs,
- RefreshReason.Manual,
+ case WalletApiOperation.GetActiveTasks: {
+ const allTasksId = wex.taskScheduler.getActiveTasks();
+
+ const tasksInfo = await Promise.all(
+ allTasksId.map(async (id) => {
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ return tx.operationRetries.get(id);
+ },
);
- });
- processRefreshGroup(ws, refreshGroupId.refreshGroupId, true).catch(
- (x) => {
- logger.error(x);
- },
+ }),
);
- return {
- refreshGroupId,
- };
+
+ const tasks = allTasksId.map((taskId, i): ActiveTask => {
+ const transaction = convertTaskToTransactionId(taskId);
+ const d = tasksInfo[i];
+
+ const firstTry = !d
+ ? undefined
+ : timestampAbsoluteFromDb(d.retryInfo.firstTry);
+ const nextTry = !d
+ ? undefined
+ : timestampAbsoluteFromDb(d.retryInfo.nextRetry);
+ const counter = d?.retryInfo.retryCounter;
+ const lastError = d?.lastError;
+
+ return {
+ taskId: taskId,
+ retryCounter: counter,
+ firstTry,
+ nextTry,
+ lastError,
+ transaction,
+ };
+ });
+ return { tasks };
}
- case "prepareTip": {
- const req = codecForPrepareTipRequest().decode(payload);
- return await prepareTip(ws, req.talerTipUri);
+ case WalletApiOperation.FailTransaction: {
+ const req = codecForFailTransactionRequest().decode(payload);
+ await failTransaction(wex, req.transactionId);
+ return {};
}
- case "acceptTip": {
- const req = codecForAcceptTipRequest().decode(payload);
- await acceptTip(ws, req.walletTipId);
+ case WalletApiOperation.ResumeTransaction: {
+ const req = codecForResumeTransaction().decode(payload);
+ await resumeTransaction(wex, req.transactionId);
return {};
}
- case "exportBackupPlain": {
- return exportBackup(ws);
+ case WalletApiOperation.DumpCoins: {
+ return await dumpCoins(wex);
}
- case "addBackupProvider": {
- const req = codecForAddBackupProviderRequest().decode(payload);
- await addBackupProvider(ws, req);
+ case WalletApiOperation.SetCoinSuspended: {
+ const req = codecForSetCoinSuspendedRequest().decode(payload);
+ await setCoinSuspended(wex, req.coinPub, req.suspended);
+ return {};
+ }
+ case WalletApiOperation.TestingGetSampleTransactions:
+ return { transactions: sampleWalletCoreTransactions };
+ case WalletApiOperation.ForceRefresh: {
+ const req = codecForForceRefreshRequest().decode(payload);
+ return await forceRefresh(wex, req);
+ }
+ case WalletApiOperation.StartRefundQueryForUri: {
+ const req = codecForPrepareRefundRequest().decode(payload);
+ return await startRefundQueryForUri(wex, req.talerRefundUri);
+ }
+ case WalletApiOperation.StartRefundQuery: {
+ const req = codecForStartRefundQueryRequest().decode(payload);
+ const txIdParsed = parseTransactionIdentifier(req.transactionId);
+ if (!txIdParsed) {
+ throw Error("invalid transaction ID");
+ }
+ if (txIdParsed.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ await startQueryRefund(wex, txIdParsed.proposalId);
return {};
}
- case "runBackupCycle": {
+ case WalletApiOperation.AddBackupProvider: {
+ const req = codecForAddBackupProviderRequest().decode(payload);
+ return await addBackupProvider(wex, req);
+ }
+ case WalletApiOperation.RunBackupCycle: {
const req = codecForRunBackupCycle().decode(payload);
- await runBackupCycle(ws, req);
+ await runBackupCycle(wex, req);
return {};
}
- case "removeBackupProvider": {
+ case WalletApiOperation.RemoveBackupProvider: {
const req = codecForRemoveBackupProvider().decode(payload);
- await removeBackupProvider(ws, req);
+ await removeBackupProvider(wex, req);
return {};
}
- case "exportBackupRecovery": {
- const resp = await getBackupRecovery(ws);
+ case WalletApiOperation.ExportBackupRecovery: {
+ const resp = await getBackupRecovery(wex);
return resp;
}
- case "importBackupRecovery": {
+ case WalletApiOperation.TestingWaitTransactionState: {
+ const req = payload as TestingWaitTransactionRequest;
+ await waitTransactionState(wex, req.transactionId, req.txState);
+ return {};
+ }
+ case WalletApiOperation.GetCurrencySpecification: {
+ // Ignore result, just validate in this mock implementation
+ const req = codecForGetCurrencyInfoRequest().decode(payload);
+ // Hard-coded mock for KUDOS and TESTKUDOS
+ if (req.scope.currency === "KUDOS") {
+ const kudosResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: "Kudos (Taler Demonstrator)",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: {
+ "0": "ク",
+ },
+ },
+ };
+ return kudosResp;
+ } else if (req.scope.currency === "TESTKUDOS") {
+ const testkudosResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: "Test (Taler Unstable Demonstrator)",
+ num_fractional_input_digits: 0,
+ num_fractional_normal_digits: 0,
+ num_fractional_trailing_zero_digits: 0,
+ alt_unit_names: {
+ "0": "テ",
+ },
+ },
+ };
+ return testkudosResp;
+ }
+ const defaultResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: req.scope.currency,
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: {
+ "0": req.scope.currency,
+ },
+ },
+ };
+ return defaultResp;
+ }
+ case WalletApiOperation.ImportBackupRecovery: {
const req = codecForAny().decode(payload);
- await loadBackupRecovery(ws, req);
+ await loadBackupRecovery(wex, req);
return {};
}
- case "getBackupInfo": {
- const resp = await getBackupInfo(ws);
+ // case WalletApiOperation.GetPlanForOperation: {
+ // const req = codecForGetPlanForOperationRequest().decode(payload);
+ // return await getPlanForOperation(ws, req);
+ // }
+ case WalletApiOperation.ConvertDepositAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertDepositAmount(wex, req);
+ }
+ case WalletApiOperation.GetMaxDepositAmount: {
+ const req = codecForGetAmountRequest.decode(payload);
+ return await getMaxDepositAmount(wex, req);
+ }
+ case WalletApiOperation.ConvertPeerPushAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertPeerPushAmount(wex, req);
+ }
+ case WalletApiOperation.GetMaxPeerPushAmount: {
+ const req = codecForGetAmountRequest.decode(payload);
+ return await getMaxPeerPushAmount(wex, req);
+ }
+ case WalletApiOperation.ConvertWithdrawalAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertWithdrawalAmount(wex, req);
+ }
+ case WalletApiOperation.GetBackupInfo: {
+ const resp = await getBackupInfo(wex);
return resp;
}
- case "createDepositGroup": {
- const req = codecForCreateDepositGroupRequest().decode(payload);
- return await createDepositGroup(ws, req);
+ case WalletApiOperation.PrepareDeposit: {
+ const req = codecForPrepareDepositRequest().decode(payload);
+ return await checkDepositGroup(wex, req);
}
- case "trackDepositGroup": {
- const req = codecForTrackDepositGroupRequest().decode(payload);
- return trackDepositGroup(ws, req);
+ case WalletApiOperation.GenerateDepositGroupTxId:
+ return {
+ transactionId: generateDepositGroupTxId(),
+ };
+ case WalletApiOperation.CreateDepositGroup: {
+ const req = codecForCreateDepositGroupRequest().decode(payload);
+ return await createDepositGroup(wex, req);
}
- case "deleteTransaction": {
+ case WalletApiOperation.DeleteTransaction: {
const req = codecForDeleteTransactionRequest().decode(payload);
- await deleteTransaction(ws, req.transactionId);
+ await deleteTransaction(wex, req.transactionId);
return {};
}
- case "retryTransaction": {
+ case WalletApiOperation.RetryTransaction: {
const req = codecForRetryTransactionRequest().decode(payload);
- await retryTransaction(ws, req.transactionId);
+ await retryTransaction(wex, req.transactionId);
return {};
}
- case "setWalletDeviceId": {
+ case WalletApiOperation.SetWalletDeviceId: {
const req = codecForSetWalletDeviceIdRequest().decode(payload);
- await setWalletDeviceId(ws, req.walletDeviceId);
+ await setWalletDeviceId(wex, req.walletDeviceId);
return {};
}
- case "listCurrencies": {
- return await ws.db
- .mktx((x) => ({
- auditorTrust: x.auditorTrust,
- exchangeTrust: x.exchangeTrust,
- }))
- .runReadOnly(async (tx) => {
- const trustedAuditors = await tx.auditorTrust.iter().toArray();
- const trustedExchanges = await tx.exchangeTrust.iter().toArray();
- return {
- trustedAuditors: trustedAuditors.map((x) => ({
- currency: x.currency,
- auditorBaseUrl: x.auditorBaseUrl,
- auditorPub: x.auditorPub,
- })),
- trustedExchanges: trustedExchanges.map((x) => ({
- currency: x.currency,
- exchangeBaseUrl: x.exchangeBaseUrl,
- exchangeMasterPub: x.exchangeMasterPub,
- })),
- };
- });
+ case WalletApiOperation.TestCrypto: {
+ return await wex.cryptoApi.hashString({ str: "hello world" });
+ }
+ case WalletApiOperation.ClearDb: {
+ wex.ws.clearAllCaches();
+ await clearDatabase(wex.db.idbHandle());
+ return {};
+ }
+ case WalletApiOperation.Recycle: {
+ throw Error("not implemented");
+ return {};
+ }
+ case WalletApiOperation.ExportDb: {
+ const dbDump = await exportDb(wex.ws.idb);
+ return dbDump;
+ }
+ case WalletApiOperation.ListGlobalCurrencyExchanges: {
+ const resp: ListGlobalCurrencyExchangesResponse = {
+ exchanges: [],
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const gceList = await tx.globalCurrencyExchanges.iter().toArray();
+ for (const gce of gceList) {
+ resp.exchanges.push({
+ currency: gce.currency,
+ exchangeBaseUrl: gce.exchangeBaseUrl,
+ exchangeMasterPub: gce.exchangeMasterPub,
+ });
+ }
+ },
+ );
+ return resp;
}
- case "withdrawFakebank": {
- const req = codecForWithdrawFakebankRequest().decode(payload);
- const amount = Amounts.parseOrThrow(req.amount);
- const details = await getWithdrawalDetailsForAmount(
- ws,
- req.exchange,
- amount,
+ case WalletApiOperation.ListGlobalCurrencyAuditors: {
+ const resp: ListGlobalCurrencyAuditorsResponse = {
+ auditors: [],
+ };
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const gcaList = await tx.globalCurrencyAuditors.iter().toArray();
+ for (const gca of gcaList) {
+ resp.auditors.push({
+ currency: gca.currency,
+ auditorBaseUrl: gca.auditorBaseUrl,
+ auditorPub: gca.auditorPub,
+ });
+ }
+ },
);
- const wres = await acceptManualWithdrawal(ws, req.exchange, amount);
- const paytoUri = details.paytoUris[0];
- const pt = parsePaytoUri(paytoUri);
- if (!pt) {
- throw Error("failed to parse payto URI");
- }
- const components = pt.targetPath.split("/");
- const creditorAcct = components[components.length - 1];
- logger.info(`making testbank transfer to '${creditorAcct}''`)
- const fbReq = await ws.http.postJson(
- new URL(`${creditorAcct}/admin/add-incoming`, req.bank).href,
- {
- amount: Amounts.stringify(amount),
- reserve_pub: wres.reservePub,
- debit_account: "payto://x-taler-bank/localhost/testdebtor",
+ return resp;
+ }
+ case WalletApiOperation.AddGlobalCurrencyExchange: {
+ const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const key = [
+ req.currency,
+ req.exchangeBaseUrl,
+ req.exchangeMasterPub,
+ ];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.globalCurrencyExchanges.add({
+ currency: req.currency,
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ exchangeMasterPub: req.exchangeMasterPub,
+ });
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.RemoveGlobalCurrencyExchange: {
+ const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const key = [
+ req.currency,
+ req.exchangeBaseUrl,
+ req.exchangeMasterPub,
+ ];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyExchanges.delete(existingRec.id);
},
);
- const fbResp = await readSuccessResponseJsonOrThrow(fbReq, codecForAny());
- logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`);
return {};
}
+ case WalletApiOperation.AddGlobalCurrencyAuditor: {
+ const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ await tx.globalCurrencyAuditors.add({
+ currency: req.currency,
+ auditorBaseUrl: req.auditorBaseUrl,
+ auditorPub: req.auditorPub,
+ });
+ wex.ws.exchangeCache.clear();
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.TestingWaitTasksDone: {
+ await waitTasksDone(wex);
+ return {};
+ }
+ case WalletApiOperation.RemoveGlobalCurrencyAuditor: {
+ const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyAuditors.delete(existingRec.id);
+ wex.ws.exchangeCache.clear();
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.ImportDb: {
+ const req = codecForImportDbRequest().decode(payload);
+ await importDb(wex.db.idbHandle(), req.dump);
+ return [];
+ }
+ case WalletApiOperation.CheckPeerPushDebit: {
+ const req = codecForCheckPeerPushDebitRequest().decode(payload);
+ return await checkPeerPushDebit(wex, req);
+ }
+ case WalletApiOperation.InitiatePeerPushDebit: {
+ const req = codecForInitiatePeerPushDebitRequest().decode(payload);
+ return await initiatePeerPushDebit(wex, req);
+ }
+ case WalletApiOperation.PreparePeerPushCredit: {
+ const req = codecForPreparePeerPushCreditRequest().decode(payload);
+ return await preparePeerPushCredit(wex, req);
+ }
+ case WalletApiOperation.ConfirmPeerPushCredit: {
+ const req = codecForConfirmPeerPushPaymentRequest().decode(payload);
+ return await confirmPeerPushCredit(wex, req);
+ }
+ case WalletApiOperation.CheckPeerPullCredit: {
+ const req = codecForPreparePeerPullPaymentRequest().decode(payload);
+ return await checkPeerPullPaymentInitiation(wex, req);
+ }
+ case WalletApiOperation.InitiatePeerPullCredit: {
+ const req = codecForInitiatePeerPullPaymentRequest().decode(payload);
+ return await initiatePeerPullPayment(wex, req);
+ }
+ case WalletApiOperation.PreparePeerPullDebit: {
+ const req = codecForCheckPeerPullPaymentRequest().decode(payload);
+ return await preparePeerPullDebit(wex, req);
+ }
+ case WalletApiOperation.ConfirmPeerPullDebit: {
+ const req = codecForAcceptPeerPullPaymentRequest().decode(payload);
+ return await confirmPeerPullDebit(wex, req);
+ }
+ case WalletApiOperation.ApplyDevExperiment: {
+ const req = codecForApplyDevExperiment().decode(payload);
+ await applyDevExperiment(wex, req.devExperimentUri);
+ return {};
+ }
+ case WalletApiOperation.Shutdown: {
+ wex.ws.stop();
+ return {};
+ }
+ case WalletApiOperation.GetVersion: {
+ return getVersion(wex);
+ }
+ case WalletApiOperation.TestingWaitTransactionsFinal:
+ return await waitUntilAllTransactionsFinal(wex);
+ case WalletApiOperation.TestingWaitRefreshesFinal:
+ return await waitUntilRefreshesDone(wex);
+ case WalletApiOperation.TestingSetTimetravel: {
+ const req = codecForTestingSetTimetravelRequest().decode(payload);
+ setDangerousTimetravel(req.offsetMs);
+ await wex.taskScheduler.reload();
+ return {};
+ }
+ case WalletApiOperation.DeleteExchange: {
+ const req = codecForDeleteExchangeRequest().decode(payload);
+ await deleteExchange(wex, req);
+ return {};
+ }
+ case WalletApiOperation.GetExchangeResources: {
+ const req = codecForGetExchangeResourcesRequest().decode(payload);
+ return await getExchangeResources(wex, req.exchangeBaseUrl);
+ }
+ case WalletApiOperation.TestingInfiniteTransactionLoop: {
+ const myDelayMs = (payload as any).delayMs ?? 5;
+ const shouldFetch = !!(payload as any).shouldFetch;
+ const doFetch = async () => {
+ while (1) {
+ const url =
+ "https://exchange.demo.taler.net/reserves/01PMMB9PJN0QBWAFBXV6R0KNJJMAKXCV4D6FDG0GJFDJQXGYP32G?timeout_ms=30000";
+ logger.info(`fetching ${url}`);
+ const res = await wex.http.fetch(url);
+ logger.info(`fetch result ${res.status}`);
+ }
+ };
+ if (shouldFetch) {
+ // In the background!
+ doFetch();
+ }
+ let loopCount = 0;
+ while (true) {
+ logger.info(`looping test write tx, iteration ${loopCount}`);
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
+ await tx.config.put({
+ key: ConfigRecordKey.TestLoopTx,
+ value: loopCount,
+ });
+ });
+ if (myDelayMs != 0) {
+ await new Promise<void>((resolve, reject) => {
+ setTimeout(() => resolve(), myDelayMs);
+ });
+ }
+ loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1);
+ }
+ }
+ // default:
+ // assertUnreachable(operation);
}
- throw OperationFailedError.fromCode(
+ throw TalerError.fromDetail(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
- "unknown operation",
{
operation,
},
+ "unknown operation",
);
}
+export function getVersion(wex: WalletExecutionContext): WalletCoreVersion {
+ const result: WalletCoreVersion = {
+ implementationSemver: walletCoreBuildInfo.implementationSemver,
+ implementationGitHash: walletCoreBuildInfo.implementationGitHash,
+ hash: undefined,
+ version: WALLET_CORE_API_PROTOCOL_VERSION,
+ exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
+ bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
+ bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION,
+ bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ devMode: wex.ws.config.testing.devModeActive,
+ };
+ return result;
+}
+
+export function getObservedWalletExecutionContext(
+ ws: InternalWalletState,
+ cancellationToken: CancellationToken,
+ oc: ObservabilityContext,
+): WalletExecutionContext {
+ const wex: WalletExecutionContext = {
+ ws,
+ cancellationToken,
+ cryptoApi: observeTalerCrypto(ws.cryptoApi, oc),
+ db: new ObservableDbAccess(ws.db, oc),
+ http: new ObservableHttpClientLibrary(ws.http, oc),
+ taskScheduler: new ObservableTaskScheduler(ws.taskScheduler, oc),
+ oc,
+ };
+ return wex;
+}
+
+export function getNormalWalletExecutionContext(
+ ws: InternalWalletState,
+ cancellationToken: CancellationToken,
+ oc: ObservabilityContext,
+): WalletExecutionContext {
+ const wex: WalletExecutionContext = {
+ ws,
+ cancellationToken,
+ cryptoApi: ws.cryptoApi,
+ db: ws.db,
+ get http() {
+ if (ws.initCalled) {
+ return ws.http;
+ }
+ throw Error("wallet not initialized");
+ },
+ taskScheduler: ws.taskScheduler,
+ oc,
+ };
+ return wex;
+}
+
/**
* Handle a request to the wallet-core API.
*/
-export async function handleCoreApiRequest(
+async function handleCoreApiRequest(
ws: InternalWalletState,
operation: string,
id: string,
payload: unknown,
): Promise<CoreApiResponse> {
+ let wex: WalletExecutionContext;
+ let oc: ObservabilityContext;
+
+ const cts = CancellationToken.create();
+
+ if (ws.initCalled && ws.config.testing.emitObservabilityEvents) {
+ oc = {
+ observe(evt) {
+ ws.notify({
+ type: NotificationType.RequestObservabilityEvent,
+ operation,
+ requestId: id,
+ event: evt,
+ });
+ },
+ };
+
+ wex = getObservedWalletExecutionContext(ws, cts.token, oc);
+ } else {
+ oc = {
+ observe(evt) {},
+ };
+ wex = getNormalWalletExecutionContext(ws, cts.token, oc);
+ }
+
try {
- const result = await dispatchRequestInternal(ws, operation, payload);
+ const start = performanceNow();
+ await ws.ensureWalletDbOpen();
+ oc.observe({
+ type: ObservabilityEventType.RequestStart,
+ });
+ const result = await dispatchRequestInternal(
+ wex,
+ cts,
+ operation as any,
+ payload,
+ );
+ const end = performanceNow();
+ oc.observe({
+ type: ObservabilityEventType.RequestFinishSuccess,
+ durationMs: Number((end - start) / 1000n / 1000n),
+ });
return {
type: "response",
operation,
@@ -970,86 +1636,170 @@ export async function handleCoreApiRequest(
result,
};
} catch (e: any) {
- if (
- e instanceof OperationFailedError ||
- e instanceof OperationFailedAndReportedError
- ) {
- return {
- type: "error",
- operation,
- id,
- error: e.operationError,
- };
- } else {
- try {
- logger.error("Caught unexpected exception:");
- logger.error(e.stack);
- } catch (e) {}
- return {
- type: "error",
- operation,
- id,
- error: makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception: ${e}`,
- {},
- ),
- };
- }
+ const err = getErrorDetailFromException(e);
+ logger.info(
+ `finished wallet core request ${operation} with error: ${j2s(err)}`,
+ );
+ oc.observe({
+ type: ObservabilityEventType.RequestFinishError,
+ });
+ return {
+ type: "error",
+ operation,
+ id,
+ error: err,
+ };
}
}
+export function applyRunConfigDefaults(
+ wcp?: PartialWalletRunConfig,
+): WalletRunConfig {
+ return {
+ builtin: {
+ exchanges: wcp?.builtin?.exchanges ?? [
+ {
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ currencyHint: "KUDOS",
+ },
+ ],
+ },
+ features: {
+ allowHttp: wcp?.features?.allowHttp ?? false,
+ },
+ testing: {
+ denomselAllowLate: wcp?.testing?.denomselAllowLate ?? false,
+ devModeActive: wcp?.testing?.devModeActive ?? false,
+ insecureTrustExchange: wcp?.testing?.insecureTrustExchange ?? false,
+ preventThrottling: wcp?.testing?.preventThrottling ?? false,
+ skipDefaults: wcp?.testing?.skipDefaults ?? false,
+ emitObservabilityEvents: wcp?.testing?.emitObservabilityEvents ?? false,
+ },
+ };
+}
+
+export type HttpFactory = (config: WalletRunConfig) => HttpRequestLibrary;
+
/**
* Public handle to a running wallet.
*/
export class Wallet {
private ws: InternalWalletState;
- private _client: WalletCoreApiClient;
+ private _client: WalletCoreApiClient | undefined;
private constructor(
- db: DbAccess<typeof WalletStoresV1>,
- http: HttpRequestLibrary,
+ idb: IDBFactory,
+ httpFactory: HttpFactory,
+ timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
) {
- this.ws = new InternalWalletStateImpl(db, http, cryptoWorkerFactory);
+ this.ws = new InternalWalletState(
+ idb,
+ httpFactory,
+ timer,
+ cryptoWorkerFactory,
+ );
}
- get client() {
+ get client(): WalletCoreApiClient {
+ if (!this._client) {
+ throw Error();
+ }
return this._client;
}
static async create(
- db: DbAccess<typeof WalletStoresV1>,
- http: HttpRequestLibrary,
+ idb: IDBFactory,
+ httpFactory: HttpFactory,
+ timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
): Promise<Wallet> {
- const w = new Wallet(db, http, cryptoWorkerFactory);
+ const w = new Wallet(idb, httpFactory, timer, cryptoWorkerFactory);
w._client = await getClientFromWalletState(w.ws);
return w;
}
- addNotificationListener(f: (n: WalletNotification) => void): void {
+ addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
return this.ws.addNotificationListener(f);
}
- stop(): void {
- this.ws.stop();
+ async handleCoreApiRequest(
+ operation: string,
+ id: string,
+ payload: unknown,
+ ): Promise<CoreApiResponse> {
+ await this.ws.ensureWalletDbOpen();
+ return handleCoreApiRequest(this.ws, operation, id, payload);
}
+}
+
+export interface DevExperimentState {
+ blockRefreshes?: boolean;
+}
+
+export class Cache<T> {
+ private map: Map<string, [AbsoluteTime, T]> = new Map();
+
+ constructor(
+ private maxCapacity: number,
+ private cacheDuration: Duration,
+ ) {}
+
+ get(key: string): T | undefined {
+ const r = this.map.get(key);
+ if (!r) {
+ return undefined;
+ }
- runPending(forceNow: boolean = false) {
- return runPending(this.ws, forceNow);
+ if (AbsoluteTime.isExpired(r[0])) {
+ this.map.delete(key);
+ return undefined;
+ }
+
+ return r[1];
}
- runTaskLoop(opts?: RetryLoopOpts) {
- return runTaskLoop(this.ws, opts);
+ clear(): void {
+ this.map.clear();
}
- handleCoreApiRequest(
- operation: string,
- id: string,
- payload: unknown,
- ): Promise<CoreApiResponse> {
- return handleCoreApiRequest(this.ws, operation, id, payload);
+ put(key: string, value: T): void {
+ if (this.map.size > this.maxCapacity) {
+ this.map.clear();
+ }
+ const expiry = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ this.cacheDuration,
+ );
+ this.map.set(key, [expiry, value]);
+ }
+}
+
+/**
+ * Implementation of triggers for the wallet DB.
+ */
+class WalletDbTriggerSpec implements TriggerSpec {
+ constructor(public ws: InternalWalletState) {}
+
+ afterCommit(info: AfterCommitInfo): void {
+ if (info.mode !== "readwrite") {
+ return;
+ }
+ logger.info(
+ `in after commit callback for readwrite, modified ${j2s([
+ ...info.modifiedStores,
+ ])}`,
+ );
+ const modified = info.accessedStores;
+ if (
+ modified.has(WalletStoresV1.exchanges.storeName) ||
+ modified.has(WalletStoresV1.exchangeDetails.storeName) ||
+ modified.has(WalletStoresV1.denominations.storeName) ||
+ modified.has(WalletStoresV1.globalCurrencyAuditors.storeName) ||
+ modified.has(WalletStoresV1.globalCurrencyExchanges.storeName)
+ ) {
+ this.ws.clearAllCaches();
+ }
}
}
@@ -1058,34 +1808,32 @@ export class Wallet {
*
* This ties together all the operation implementations.
*/
-class InternalWalletStateImpl implements InternalWalletState {
- memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoGetPending: AsyncOpMemoSingle<PendingOperationsResponse> = new AsyncOpMemoSingle();
- memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle();
- memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoProcessDeposit: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- cryptoApi: CryptoApi;
-
- timerGroup: TimerGroup = new TimerGroup();
- latch = new AsyncCondition();
+export class InternalWalletState {
+ cryptoApi: TalerCryptoInterface;
+ cryptoDispatcher: CryptoDispatcher;
+
+ readonly timerGroup: TimerGroup;
+ workAvailable = new AsyncCondition();
stopped = false;
- listeners: NotificationListener[] = [];
+ private listeners: NotificationListener[] = [];
- initCalled: boolean = false;
+ initCalled = false;
- exchangeOps: ExchangeOperations = {
- getExchangeDetails,
- getExchangeTrust,
- updateExchangeFromUrl,
- };
+ refreshCostCache: Cache<AmountJson> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
- recoupOps: RecoupOperations = {
- createRecoupGroup: createRecoupGroup,
- processRecoupGroup: processRecoupGroup,
- };
+ denomInfoCache: Cache<DenominationInfo> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
+
+ exchangeCache: Cache<ReadyExchangeSummary> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
/**
* Promises that are waiting for a particular resource.
@@ -1097,20 +1845,106 @@ class InternalWalletStateImpl implements InternalWalletState {
*/
private resourceLocks: Set<string> = new Set();
+ taskScheduler: TaskScheduler = new TaskSchedulerImpl(this);
+
+ private _config: Readonly<WalletRunConfig> | undefined;
+
+ private _indexedDbHandle: IDBDatabase | undefined = undefined;
+
+ private _dbAccessHandle: DbAccess<typeof WalletStoresV1> | undefined;
+
+ private _http: HttpRequestLibrary | undefined = undefined;
+
+ get db(): DbAccess<typeof WalletStoresV1> {
+ if (!this._dbAccessHandle) {
+ this._dbAccessHandle = this.createDbAccessHandle(
+ CancellationToken.CONTINUE,
+ );
+ }
+ return this._dbAccessHandle;
+ }
+
+ devExperimentState: DevExperimentState = {};
+
+ clientCancellationMap: Map<string, CancellationToken.Source> = new Map();
+
+ clearAllCaches(): void {
+ this.exchangeCache.clear();
+ this.denomInfoCache.clear();
+ this.refreshCostCache.clear();
+ }
+
+ initWithConfig(newConfig: WalletRunConfig): void {
+ this._config = newConfig;
+
+ logger.info(`setting new config to ${j2s(newConfig)}`);
+
+ this._http = this.httpFactory(newConfig);
+
+ if (this.config.testing.devModeActive) {
+ this._http = new DevExperimentHttpLib(this.http);
+ }
+ }
+
+ createDbAccessHandle(
+ cancellationToken: CancellationToken,
+ ): DbAccess<typeof WalletStoresV1> {
+ if (!this._indexedDbHandle) {
+ throw Error("db not initialized");
+ }
+ return new DbAccessImpl(
+ this._indexedDbHandle,
+ WalletStoresV1,
+ new WalletDbTriggerSpec(this),
+ cancellationToken,
+ );
+ }
+
+ get config(): WalletRunConfig {
+ if (!this._config) {
+ throw Error("config not initialized");
+ }
+ return this._config;
+ }
+
+ get http(): HttpRequestLibrary {
+ if (!this._http) {
+ throw Error("wallet not initialized");
+ }
+ return this._http;
+ }
+
constructor(
- // FIXME: Make this a getter and make
- // the actual value nullable.
- // Check if we are in a DB migration / garbage collection
- // and throw an error in that case.
- public db: DbAccess<typeof WalletStoresV1>,
- public http: HttpRequestLibrary,
+ public idb: IDBFactory,
+ private httpFactory: HttpFactory,
+ public timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
) {
- this.cryptoApi = new CryptoApi(cryptoWorkerFactory);
+ this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory);
+ this.cryptoApi = this.cryptoDispatcher.cryptoApi;
+ this.timerGroup = new TimerGroup(timer);
+ }
+
+ async ensureWalletDbOpen(): Promise<void> {
+ if (this._indexedDbHandle) {
+ return;
+ }
+ const myVersionChange = async (): Promise<void> => {
+ logger.info("version change requested for Taler DB");
+ };
+ try {
+ const myDb = await openTalerDatabase(this.idb, myVersionChange);
+ this._indexedDbHandle = myDb;
+ } catch (e) {
+ logger.error("error writing to database during initialization");
+ throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
+ innerError: getErrorDetailFromException(e),
+ });
+ }
}
notify(n: WalletNotification): void {
- logger.trace("Notification", n);
+ logger.trace(`Notification: ${j2s(n)}`);
for (const l of this.listeners) {
const nc = JSON.parse(JSON.stringify(n));
setTimeout(() => {
@@ -1119,32 +1953,37 @@ class InternalWalletStateImpl implements InternalWalletState {
}
}
- addNotificationListener(f: (n: WalletNotification) => void): void {
+ addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
this.listeners.push(f);
+ return () => {
+ const idx = this.listeners.indexOf(f);
+ if (idx >= 0) {
+ this.listeners.splice(idx, 1);
+ }
+ };
}
/**
* Stop ongoing processing.
*/
stop(): void {
+ logger.trace("stopping (at internal wallet state)");
this.stopped = true;
this.timerGroup.stopCurrentAndFutureTimers();
- this.cryptoApi.stop();
- }
-
- async runUntilDone(
- req: {
- maxRetries?: number;
- } = {},
- ): Promise<void> {
- await runTaskLoop(this, { ...req, stopWhenDone: true });
+ this.cryptoDispatcher.stop();
+ this.taskScheduler.shutdown().catch((e) => {
+ logger.warn(`shutdown failed: ${safeStringifyException(e)}`);
+ });
}
/**
* Run an async function after acquiring a list of locks, identified
* by string tokens.
*/
- async runSequentialized<T>(tokens: string[], f: () => Promise<T>) {
+ async runSequentialized<T>(
+ tokens: string[],
+ f: () => Promise<T>,
+ ): Promise<T> {
// Make sure locks are always acquired in the same order
tokens = [...tokens].sort();
@@ -1169,7 +2008,7 @@ class InternalWalletStateImpl implements InternalWalletState {
} finally {
for (const token of tokens) {
this.resourceLocks.delete(token);
- let waiter = (this.resourceWaiters[token] ?? []).shift();
+ const waiter = (this.resourceWaiters[token] ?? []).shift();
if (waiter) {
waiter.resolve();
}