/*
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 {
AbsoluteTime,
Amounts,
CoinDumpJson,
CoinRefreshRequest,
CoinStatus,
CoreApiResponse,
DenomOperationMap,
DenominationInfo,
Duration,
ExchangeDetailedResponse,
ExchangeListItem,
ExchangesListResponse,
FeeDescription,
GetExchangeTosResult,
InitResponse,
KnownBankAccounts,
KnownBankAccountsInfo,
Logger,
ManualWithdrawalDetails,
MerchantUsingTemplateDetails,
NotificationType,
RefreshReason,
TalerError,
TalerErrorCode,
TransactionState,
TransactionType,
URL,
ValidateIbanResponse,
WalletCoreVersion,
WalletNotification,
codecForAbortTransaction,
codecForAcceptBankIntegratedWithdrawalRequest,
codecForAcceptExchangeTosRequest,
codecForAcceptManualWithdrawalRequet,
codecForAcceptPeerPullPaymentRequest,
codecForAcceptTipRequest,
codecForAddExchangeRequest,
codecForAddKnownBankAccounts,
codecForAny,
codecForApplyDevExperiment,
codecForCheckPeerPullPaymentRequest,
codecForCheckPeerPushDebitRequest,
codecForConfirmPayRequest,
codecForConfirmPeerPushPaymentRequest,
codecForConvertAmountRequest,
codecForCreateDepositGroupRequest,
codecForDeleteTransactionRequest,
codecForFailTransactionRequest,
codecForForceRefreshRequest,
codecForForgetKnownBankAccounts,
codecForGetAmountRequest,
codecForGetBalanceDetailRequest,
codecForGetContractTermsDetails,
codecForGetExchangeTosRequest,
codecForGetWithdrawalDetailsForAmountRequest,
codecForGetWithdrawalDetailsForUri,
codecForImportDbRequest,
codecForInitiatePeerPullPaymentRequest,
codecForInitiatePeerPushDebitRequest,
codecForIntegrationTestArgs,
codecForIntegrationTestV2Args,
codecForListKnownBankAccounts,
codecForMerchantPostOrderResponse,
codecForPrepareDepositRequest,
codecForPreparePayRequest,
codecForPreparePayTemplateRequest,
codecForPreparePeerPullPaymentRequest,
codecForPreparePeerPushCreditRequest,
codecForPrepareRefundRequest,
codecForPrepareRewardRequest,
codecForResumeTransaction,
codecForRetryTransactionRequest,
codecForSetCoinSuspendedRequest,
codecForSetWalletDeviceIdRequest,
codecForStartRefundQueryRequest,
codecForSuspendTransaction,
codecForTestPayArgs,
codecForTransactionByIdRequest,
codecForTransactionsRequest,
codecForUserAttentionByIdRequest,
codecForUserAttentionsRequest,
codecForValidateIbanRequest,
codecForWithdrawFakebankRequest,
codecForWithdrawTestBalance,
constructPayUri,
durationFromSpec,
durationMin,
getErrorDetailFromException,
j2s,
parsePayTemplateUri,
parsePaytoUri,
sampleWalletCoreTransactions,
validateIban,
codecForSharePaymentRequest,
GetCurrencySpecificationResponse,
codecForGetCurrencyInfoRequest,
CreateStoredBackupResponse,
StoredBackupList,
codecForDeleteStoredBackupRequest,
DeleteStoredBackupRequest,
RecoverStoredBackupRequest,
codecForRecoverStoredBackupRequest,
codecForTestingSetTimetravelRequest,
setDangerousTimetravel,
TestingWaitTransactionRequest,
codecForUpdateExchangeEntryRequest,
} from "@gnu-taler/taler-util";
import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
CryptoDispatcher,
CryptoWorkerFactory,
} from "./crypto/workers/crypto-dispatcher.js";
import {
CoinSourceType,
ConfigRecordKey,
DenominationRecord,
ExchangeDetailsRecord,
WalletStoresV1,
clearDatabase,
exportDb,
importDb,
openStoredBackupsDatabase,
openTalerDatabase,
} from "./db.js";
import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
import {
ActiveLongpollInfo,
CancelFn,
ExchangeOperations,
InternalWalletState,
MerchantInfo,
MerchantOperations,
NotificationListener,
RecoupOperations,
RefreshOperations,
} from "./internal-wallet-state.js";
import {
getUserAttentions,
getUserAttentionsUnreadCount,
markAttentionRequestAsRead,
} from "./operations/attention.js";
import {
addBackupProvider,
codecForAddBackupProviderRequest,
codecForRemoveBackupProvider,
codecForRunBackupCycle,
getBackupInfo,
getBackupRecovery,
loadBackupRecovery,
processBackupForProvider,
removeBackupProvider,
runBackupCycle,
setWalletDeviceId,
} from "./operations/backup/index.js";
import { getBalanceDetail, getBalances } from "./operations/balance.js";
import {
TaskIdentifiers,
TaskRunResult,
getExchangeTosStatus,
makeExchangeListItem,
runTaskWithErrorReporting,
} from "./operations/common.js";
import {
computeDepositTransactionStatus,
createDepositGroup,
generateDepositGroupTxId,
prepareDepositGroup,
processDepositGroup,
} from "./operations/deposits.js";
import {
acceptExchangeTermsOfService,
addPresetExchangeEntry,
downloadTosFromAcceptedFormat,
getExchangeDetails,
getExchangeRequestTimeout,
updateExchangeFromUrl,
updateExchangeFromUrlHandler,
} from "./operations/exchanges.js";
import { getMerchantInfo } from "./operations/merchants.js";
import {
computePayMerchantTransactionState,
computeRefundTransactionState,
confirmPay,
getContractTermsDetails,
preparePayForUri,
processPurchase,
sharePayment,
startQueryRefund,
startRefundQueryForUri,
} from "./operations/pay-merchant.js";
import {
checkPeerPullPaymentInitiation,
computePeerPullCreditTransactionState,
initiatePeerPullPayment,
processPeerPullCredit,
} from "./operations/pay-peer-pull-credit.js";
import {
computePeerPullDebitTransactionState,
confirmPeerPullDebit,
preparePeerPullDebit,
processPeerPullDebit,
} from "./operations/pay-peer-pull-debit.js";
import {
computePeerPushCreditTransactionState,
confirmPeerPushCredit,
preparePeerPushCredit,
processPeerPushCredit,
} from "./operations/pay-peer-push-credit.js";
import {
checkPeerPushDebit,
computePeerPushDebitTransactionState,
initiatePeerPushDebit,
processPeerPushDebit,
} from "./operations/pay-peer-push-debit.js";
import { getPendingOperations } from "./operations/pending.js";
import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
import {
autoRefresh,
computeRefreshTransactionState,
createRefreshGroup,
processRefreshGroup,
} from "./operations/refresh.js";
import {
runIntegrationTest,
runIntegrationTest2,
testPay,
waitTransactionState,
waitUntilDone,
waitUntilRefreshesDone,
withdrawTestBalance,
} from "./operations/testing.js";
import {
acceptTip,
computeRewardTransactionStatus,
prepareTip,
processTip,
} from "./operations/reward.js";
import {
abortTransaction,
deleteTransaction,
failTransaction,
getTransactionById,
getTransactions,
parseTransactionIdentifier,
resumeTransaction,
retryTransaction,
suspendTransaction,
} from "./operations/transactions.js";
import {
acceptWithdrawalFromUri,
computeWithdrawalTransactionStatus,
createManualWithdrawal,
getExchangeWithdrawalInfo,
getWithdrawalDetailsForUri,
processWithdrawalGroup,
} from "./operations/withdraw.js";
import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
import { assertUnreachable } from "./util/assertUnreachable.js";
import {
createTimeline,
selectBestForOverlappingDenominations,
selectMinimumFee,
} from "./util/denominations.js";
import { checkDbInvariant } from "./util/invariants.js";
import {
AsyncCondition,
OpenedPromise,
openPromise,
} from "./util/promiseUtils.js";
import {
DbAccess,
GetReadOnlyAccess,
GetReadWriteAccess,
} from "./util/query.js";
import { TimerAPI, TimerGroup } from "./util/timer.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_CORE_IMPLEMENTATION_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
WALLET_MERCHANT_PROTOCOL_VERSION,
} from "./versions.js";
import {
WalletApiOperation,
WalletConfig,
WalletConfigParameter,
WalletCoreApiClient,
WalletCoreResponseType,
} from "./wallet-api-types.js";
import {
convertDepositAmount,
getMaxDepositAmount,
convertPeerPushAmount,
getMaxPeerPushAmount,
convertWithdrawalAmount,
} from "./util/instructedAmountConversion.js";
import { IDBFactory } from "@gnu-taler/idb-bridge";
const logger = new Logger("wallet.ts");
/**
* Call the right handler for a pending operation without doing
* any special error handling.
*/
async function callOperationHandler(
ws: InternalWalletState,
pending: PendingTaskInfo,
): Promise {
switch (pending.type) {
case PendingTaskType.ExchangeUpdate:
return await updateExchangeFromUrlHandler(ws, pending.exchangeBaseUrl);
case PendingTaskType.Refresh:
return await processRefreshGroup(ws, pending.refreshGroupId);
case PendingTaskType.Withdraw:
return await processWithdrawalGroup(ws, pending.withdrawalGroupId);
case PendingTaskType.RewardPickup:
return await processTip(ws, pending.tipId);
case PendingTaskType.Purchase:
return await processPurchase(ws, pending.proposalId);
case PendingTaskType.Recoup:
return await processRecoupGroup(ws, pending.recoupGroupId);
case PendingTaskType.ExchangeCheckRefresh:
return await autoRefresh(ws, pending.exchangeBaseUrl);
case PendingTaskType.Deposit:
return await processDepositGroup(ws, pending.depositGroupId);
case PendingTaskType.Backup:
return await processBackupForProvider(ws, pending.backupProviderBaseUrl);
case PendingTaskType.PeerPushDebit:
return await processPeerPushDebit(ws, pending.pursePub);
case PendingTaskType.PeerPullCredit:
return await processPeerPullCredit(ws, pending.pursePub);
case PendingTaskType.PeerPullDebit:
return await processPeerPullDebit(ws, pending.peerPullDebitId);
case PendingTaskType.PeerPushCredit:
return await processPeerPushCredit(ws, pending.peerPushCreditId);
default:
return assertUnreachable(pending);
}
throw Error(`not reached ${pending.type}`);
}
/**
* Process pending operations.
*/
export async function runPending(ws: InternalWalletState): Promise {
const pendingOpsResponse = await getPendingOperations(ws);
for (const p of pendingOpsResponse.pendingOperations) {
if (!AbsoluteTime.isExpired(p.timestampDue)) {
continue;
}
await runTaskWithErrorReporting(ws, p.id, async () => {
logger.trace(`running pending ${JSON.stringify(p, undefined, 2)}`);
return await callOperationHandler(ws, p);
});
}
}
export interface RetryLoopOpts {
/**
* Stop when the number of retries is exceeded for any pending
* operation.
*/
maxRetries?: number;
/**
* Stop the retry loop when all lifeness-giving pending operations
* are done.
*
* Defaults to false.
*/
stopWhenDone?: boolean;
}
export interface TaskLoopResult {
/**
* Was the maximum number of retries exceeded in a task?
*/
retriesExceeded: boolean;
}
/**
* Main retry loop of the wallet.
*
* Looks up pending operations from the wallet, runs them, repeat.
*/
async function runTaskLoop(
ws: InternalWalletState,
opts: RetryLoopOpts = {},
): Promise {
logger.info(`running task loop opts=${j2s(opts)}`);
if (ws.isTaskLoopRunning) {
logger.warn(
"task loop already running, nesting the wallet-core task loop is deprecated and should be avoided",
);
}
ws.isTaskLoopRunning = true;
let retriesExceeded = false;
for (let iteration = 0; !ws.stopped; iteration++) {
const pending = await getPendingOperations(ws);
logger.trace(`pending operations: ${j2s(pending)}`);
let numGivingLiveness = 0;
let numDue = 0;
let minDue: AbsoluteTime = AbsoluteTime.never();
for (const p of pending.pendingOperations) {
const maxRetries = opts.maxRetries;
if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) {
retriesExceeded = true;
logger.warn(
`skipping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`,
);
continue;
}
if (p.givesLifeness) {
numGivingLiveness++;
}
if (!p.isDue) {
continue;
}
minDue = AbsoluteTime.min(minDue, p.timestampDue);
numDue++;
}
logger.info(
`running task loop, iter=${iteration}, #tasks=${pending.pendingOperations.length} #lifeness=${numGivingLiveness}, #due=${numDue}`,
);
if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) {
logger.warn(`stopping, as no pending operations have lifeness`);
ws.isTaskLoopRunning = false;
return {
retriesExceeded,
};
}
if (ws.stopped) {
ws.isTaskLoopRunning = false;
return {
retriesExceeded,
};
}
// Make sure that we run tasks that don't give lifeness at least
// one time.
if (iteration !== 0 && numDue === 0) {
// We've executed pending, due operations at least one.
// Now we don't have any more operations available,
// and need to wait.
// Wait for at most 5 seconds to the next check.
const dt = durationMin(
durationFromSpec({
seconds: 5,
}),
Duration.getRemaining(minDue),
);
logger.trace(`waiting for at most ${dt.d_ms} ms`);
const timeout = ws.timerGroup.resolveAfter(dt);
// Wait until either the timeout, or we are notified (via the latch)
// that more work might be available.
await Promise.race([timeout, ws.workAvailable.wait()]);
logger.trace(`done waiting for available work`);
} else {
logger.trace(
`running ${pending.pendingOperations.length} pending operations`,
);
for (const p of pending.pendingOperations) {
if (!AbsoluteTime.isExpired(p.timestampDue)) {
continue;
}
logger.info(`running task ${p.id}`);
await runTaskWithErrorReporting(ws, p.id, async () => {
return await callOperationHandler(ws, p);
});
ws.notify({
type: NotificationType.PendingOperationProcessed,
id: p.id,
});
if (ws.stopped) {
ws.isTaskLoopRunning = false;
return {
retriesExceeded,
};
}
}
}
}
logger.trace("exiting wallet task loop");
ws.isTaskLoopRunning = false;
return {
retriesExceeded,
};
}
/**
* Insert the hard-coded defaults for exchanges, coins and
* auditors into the database, unless these defaults have
* already been applied.
*/
async function fillDefaults(ws: InternalWalletState): Promise {
await ws.db
.mktx((x) => [x.config, x.exchanges, x.exchangeDetails])
.runReadWrite(async (tx) => {
const appliedRec = await tx.config.get("currencyDefaultsApplied");
let alreadyApplied = appliedRec ? !!appliedRec.value : false;
if (alreadyApplied) {
logger.trace("defaults already applied");
return;
}
for (const exch of ws.config.builtin.exchanges) {
await addPresetExchangeEntry(
tx,
exch.exchangeBaseUrl,
exch.currencyHint,
);
}
await tx.config.put({
key: ConfigRecordKey.CurrencyDefaultsApplied,
value: true,
});
});
}
/**
* Get the exchange ToS in the requested format.
* Try to download in the accepted format not cached.
*/
async function getExchangeTos(
ws: InternalWalletState,
exchangeBaseUrl: string,
acceptedFormat?: string[],
): Promise {
// FIXME: download ToS in acceptable format if passed!
const { exchangeDetails } = await updateExchangeFromUrl(ws, exchangeBaseUrl);
const tosDownload = await downloadTosFromAcceptedFormat(
ws,
exchangeBaseUrl,
getExchangeRequestTimeout(),
acceptedFormat,
);
await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails])
.runReadWrite(async (tx) => {
const d = await getExchangeDetails(tx, exchangeBaseUrl);
if (d) {
d.tosCurrentEtag = tosDownload.tosEtag;
await tx.exchangeDetails.put(d);
}
});
return {
acceptedEtag: exchangeDetails.tosAccepted?.etag,
currentEtag: tosDownload.tosEtag,
content: tosDownload.tosText,
contentType: tosDownload.tosContentType,
tosStatus: getExchangeTosStatus(exchangeDetails),
};
}
/**
* List bank accounts known to the wallet from
* previous withdrawals.
*/
async function listKnownBankAccounts(
ws: InternalWalletState,
currency?: string,
): Promise {
const accounts: KnownBankAccountsInfo[] = [];
await ws.db
.mktx((x) => [x.bankAccounts])
.runReadOnly(async (tx) => {
const knownAccounts = await tx.bankAccounts.iter().toArray();
for (const r of knownAccounts) {
if (currency && currency !== r.currency) {
continue;
}
const payto = parsePaytoUri(r.uri);
if (payto) {
accounts.push({
uri: payto,
alias: r.alias,
kyc_completed: r.kycCompleted,
currency: r.currency,
});
}
}
});
return { accounts };
}
/**
*/
async function addKnownBankAccounts(
ws: InternalWalletState,
payto: string,
alias: string,
currency: string,
): Promise {
await ws.db
.mktx((x) => [x.bankAccounts])
.runReadWrite(async (tx) => {
tx.bankAccounts.put({
uri: payto,
alias: alias,
currency: currency,
kycCompleted: false,
});
});
return;
}
/**
*/
async function forgetKnownBankAccounts(
ws: InternalWalletState,
payto: string,
): Promise {
await ws.db
.mktx((x) => [x.bankAccounts])
.runReadWrite(async (tx) => {
const account = await tx.bankAccounts.get(payto);
if (!account) {
throw Error(`account not found: ${payto}`);
}
tx.bankAccounts.delete(account.uri);
});
return;
}
async function getExchanges(
ws: InternalWalletState,
): Promise {
const exchanges: ExchangeListItem[] = [];
await ws.db
.mktx((x) => [
x.exchanges,
x.exchangeDetails,
x.denominations,
x.operationRetries,
])
.runReadOnly(async (tx) => {
const exchangeRecords = await tx.exchanges.iter().toArray();
for (const r of exchangeRecords) {
const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
const opRetryRecord = await tx.operationRetries.get(
TaskIdentifiers.forExchangeUpdate(r),
);
exchanges.push(
makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError),
);
}
});
return { exchanges };
}
async function getExchangeDetailedInfo(
ws: InternalWalletState,
exchangeBaseurl: string,
): Promise {
//TODO: should we use the forceUpdate parameter?
const exchange = await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails, x.denominations])
.runReadOnly(async (tx) => {
const ex = await tx.exchanges.get(exchangeBaseurl);
const dp = ex?.detailsPointer;
if (!dp) {
return;
}
const { currency } = dp;
const exchangeDetails = await getExchangeDetails(tx, ex.baseUrl);
if (!exchangeDetails) {
return;
}
const denominationRecords =
await tx.denominations.indexes.byExchangeBaseUrl
.iter(ex.baseUrl)
.toArray();
if (!denominationRecords) {
return;
}
const denominations: DenominationInfo[] = denominationRecords.map((x) =>
DenominationRecord.toDenomInfo(x),
);
return {
info: {
exchangeBaseUrl: ex.baseUrl,
currency,
paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
auditors: exchangeDetails.auditors,
wireInfo: exchangeDetails.wireInfo,
globalFees: exchangeDetails.globalFees,
},
denominations,
};
});
if (!exchange) {
throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
}
const denoms = exchange.denominations.map((d) => ({
...d,
group: Amounts.stringifyValue(d.value),
}));
const denomFees: DenomOperationMap = {
deposit: createTimeline(
denoms,
"denomPubHash",
"stampStart",
"stampExpireDeposit",
"feeDeposit",
"group",
selectBestForOverlappingDenominations,
),
refresh: createTimeline(
denoms,
"denomPubHash",
"stampStart",
"stampExpireWithdraw",
"feeRefresh",
"group",
selectBestForOverlappingDenominations,
),
refund: createTimeline(
denoms,
"denomPubHash",
"stampStart",
"stampExpireWithdraw",
"feeRefund",
"group",
selectBestForOverlappingDenominations,
),
withdraw: createTimeline(
denoms,
"denomPubHash",
"stampStart",
"stampExpireWithdraw",
"feeWithdraw",
"group",
selectBestForOverlappingDenominations,
),
};
const transferFees = Object.entries(
exchange.info.wireInfo.feesForType,
).reduce((prev, [wireType, infoForType]) => {
const feesByGroup = [
...infoForType.map((w) => ({
...w,
fee: Amounts.stringify(w.closingFee),
group: "closing",
})),
...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
];
prev[wireType] = createTimeline(
feesByGroup,
"sig",
"startStamp",
"endStamp",
"fee",
"group",
selectMinimumFee,
);
return prev;
}, {} as Record);
const globalFeesByGroup = [
...exchange.info.globalFees.map((w) => ({
...w,
fee: w.accountFee,
group: "account",
})),
...exchange.info.globalFees.map((w) => ({
...w,
fee: w.historyFee,
group: "history",
})),
...exchange.info.globalFees.map((w) => ({
...w,
fee: w.purseFee,
group: "purse",
})),
];
const globalFees = createTimeline(
globalFeesByGroup,
"signature",
"startDate",
"endDate",
"fee",
"group",
selectMinimumFee,
);
return {
exchange: {
...exchange.info,
denomFees,
transferFees,
globalFees,
},
};
}
async function setCoinSuspended(
ws: InternalWalletState,
coinPub: string,
suspended: boolean,
): Promise {
await ws.db
.mktx((x) => [x.coins, x.coinAvailability])
.runReadWrite(async (tx) => {
const c = await tx.coins.get(coinPub);
if (!c) {
logger.warn(`coin ${coinPub} not found, won't suspend`);
return;
}
const coinAvailability = await tx.coinAvailability.get([
c.exchangeBaseUrl,
c.denomPubHash,
c.maxAge,
]);
checkDbInvariant(!!coinAvailability);
if (suspended) {
if (c.status !== CoinStatus.Fresh) {
return;
}
if (coinAvailability.freshCoinCount === 0) {
throw Error(
`invalid coin count ${coinAvailability.freshCoinCount} in DB`,
);
}
coinAvailability.freshCoinCount--;
c.status = CoinStatus.FreshSuspended;
} else {
if (c.status == CoinStatus.Dormant) {
return;
}
coinAvailability.freshCoinCount++;
c.status = CoinStatus.Fresh;
}
await tx.coins.put(c);
await tx.coinAvailability.put(coinAvailability);
});
}
/**
* Dump the public information of coins we have in an easy-to-process format.
*/
async function dumpCoins(ws: InternalWalletState): Promise {
const coinsJson: CoinDumpJson = { coins: [] };
logger.info("dumping coins");
await ws.db
.mktx((x) => [x.coins, x.denominations, x.withdrawalGroups])
.runReadOnly(async (tx) => {
const coins = await tx.coins.iter().toArray();
for (const c of coins) {
const denom = await tx.denominations.get([
c.exchangeBaseUrl,
c.denomPubHash,
]);
if (!denom) {
logger.warn("no denom found for coin");
continue;
}
const cs = c.coinSource;
let refreshParentCoinPub: string | undefined;
if (cs.type == CoinSourceType.Refresh) {
refreshParentCoinPub = cs.oldCoinPub;
}
let withdrawalReservePub: string | undefined;
if (cs.type == CoinSourceType.Withdraw) {
withdrawalReservePub = cs.reservePub;
}
const denomInfo = await ws.getDenomInfo(
ws,
tx,
c.exchangeBaseUrl,
c.denomPubHash,
);
if (!denomInfo) {
logger.warn("no denomination found for coin");
continue;
}
coinsJson.coins.push({
coin_pub: c.coinPub,
denom_pub: denomInfo.denomPub,
denom_pub_hash: c.denomPubHash,
denom_value: denom.value,
exchange_base_url: c.exchangeBaseUrl,
refresh_parent_coin_pub: refreshParentCoinPub,
withdrawal_reserve_pub: withdrawalReservePub,
coin_status: c.status,
ageCommitmentProof: c.ageCommitmentProof,
spend_allocation: c.spendAllocation
? {
amount: c.spendAllocation.amount,
id: c.spendAllocation.id,
}
: undefined,
});
}
});
return coinsJson;
}
/**
* Get an API client from an internal wallet state object.
*/
export async function getClientFromWalletState(
ws: InternalWalletState,
): Promise {
let id = 0;
const client: WalletCoreApiClient = {
async call(op, payload): Promise {
const res = await handleCoreApiRequest(ws, op, `${id++}`, payload);
switch (res.type) {
case "error":
throw TalerError.fromUncheckedDetail(res.error);
case "response":
return res.result;
}
},
};
return client;
}
async function createStoredBackup(
ws: InternalWalletState,
): Promise {
const backup = await exportDb(ws.idb);
const backupsDb = await openStoredBackupsDatabase(ws.idb);
const name = `backup-${new Date().getTime()}`;
await backupsDb.mktxAll().runReadWrite(async (tx) => {
await tx.backupMeta.add({
name,
});
await tx.backupData.add(backup, name);
});
return {
name,
};
}
async function listStoredBackups(
ws: InternalWalletState,
): Promise {
const storedBackups: StoredBackupList = {
storedBackups: [],
};
const backupsDb = await openStoredBackupsDatabase(ws.idb);
await backupsDb.mktxAll().runReadWrite(async (tx) => {
await tx.backupMeta.iter().forEach((x) => {
storedBackups.storedBackups.push({
name: x.name,
});
});
});
return storedBackups;
}
async function deleteStoredBackup(
ws: InternalWalletState,
req: DeleteStoredBackupRequest,
): Promise {
const backupsDb = await openStoredBackupsDatabase(ws.idb);
await backupsDb.mktxAll().runReadWrite(async (tx) => {
await tx.backupData.delete(req.name);
await tx.backupMeta.delete(req.name);
});
}
async function recoverStoredBackup(
ws: InternalWalletState,
req: RecoverStoredBackupRequest,
): Promise {
logger.info(`Recovering stored backup ${req.name}`);
const { name } = req;
const backupsDb = await openStoredBackupsDatabase(ws.idb);
const bd = await backupsDb.mktxAll().runReadWrite(async (tx) => {
const backupMeta = tx.backupMeta.get(name);
if (!backupMeta) {
throw Error("backup not found");
}
const backupData = await tx.backupData.get(name);
if (!backupData) {
throw Error("no backup data (DB corrupt)");
}
return backupData;
});
logger.info(`backup found, now importing`);
await importDb(ws.db.idbHandle(), bd);
logger.info(`import done`);
}
/**
* Implementation of the "wallet-core" API.
*/
async function dispatchRequestInternal(
ws: InternalWalletState,
operation: WalletApiOperation,
payload: unknown,
): Promise> {
if (!ws.initCalled && operation !== WalletApiOperation.InitWallet) {
throw Error(
`wallet must be initialized before running operation ${operation}`,
);
}
// FIXME: Can we make this more type-safe by using the request/response type
// definitions we already have?
switch (operation) {
case WalletApiOperation.CreateStoredBackup:
return createStoredBackup(ws);
case WalletApiOperation.DeleteStoredBackup: {
const req = codecForDeleteStoredBackupRequest().decode(payload);
await deleteStoredBackup(ws, req);
return {};
}
case WalletApiOperation.ListStoredBackups:
return listStoredBackups(ws);
case WalletApiOperation.RecoverStoredBackup: {
const req = codecForRecoverStoredBackupRequest().decode(payload);
await recoverStoredBackup(ws, req);
return {};
}
case WalletApiOperation.InitWallet: {
logger.trace("initializing wallet");
ws.initCalled = true;
if (typeof payload === "object" && (payload as any).skipDefaults) {
logger.trace("skipping defaults");
} else {
logger.trace("filling defaults");
await fillDefaults(ws);
}
const resp: InitResponse = {
versionInfo: getVersion(ws),
};
return resp;
}
case WalletApiOperation.WithdrawTestkudos: {
await withdrawTestBalance(ws, {
amount: "TESTKUDOS:10",
corebankApiBaseUrl: "https://bank.test.taler.net/",
exchangeBaseUrl: "https://exchange.test.taler.net/",
});
return {
versionInfo: getVersion(ws),
};
}
case WalletApiOperation.WithdrawTestBalance: {
const req = codecForWithdrawTestBalance().decode(payload);
await withdrawTestBalance(ws, req);
return {};
}
case WalletApiOperation.RunIntegrationTest: {
const req = codecForIntegrationTestArgs().decode(payload);
await runIntegrationTest(ws, req);
return {};
}
case WalletApiOperation.RunIntegrationTestV2: {
const req = codecForIntegrationTestV2Args().decode(payload);
await runIntegrationTest2(ws, req);
return {};
}
case WalletApiOperation.ValidateIban: {
const req = codecForValidateIbanRequest().decode(payload);
const valRes = validateIban(req.iban);
const resp: ValidateIbanResponse = {
valid: valRes.type === "valid",
};
return resp;
}
case WalletApiOperation.TestPay: {
const req = codecForTestPayArgs().decode(payload);
return await testPay(ws, req);
}
case WalletApiOperation.GetTransactions: {
const req = codecForTransactionsRequest().decode(payload);
return await getTransactions(ws, req);
}
case WalletApiOperation.GetTransactionById: {
const req = codecForTransactionByIdRequest().decode(payload);
return await getTransactionById(ws, req);
}
case WalletApiOperation.AddExchange: {
const req = codecForAddExchangeRequest().decode(payload);
await updateExchangeFromUrl(ws, req.exchangeBaseUrl, {
checkMasterPub: req.masterPub,
forceNow: req.forceUpdate,
});
return {};
}
case WalletApiOperation.UpdateExchangeEntry: {
const req = codecForUpdateExchangeEntryRequest().decode(payload);
await updateExchangeFromUrl(ws, req.exchangeBaseUrl, {});
return {};
}
case WalletApiOperation.ListExchanges: {
return await getExchanges(ws);
}
case WalletApiOperation.GetExchangeDetailedInfo: {
const req = codecForAddExchangeRequest().decode(payload);
return await getExchangeDetailedInfo(ws, req.exchangeBaseUrl);
}
case WalletApiOperation.ListKnownBankAccounts: {
const req = codecForListKnownBankAccounts().decode(payload);
return await listKnownBankAccounts(ws, req.currency);
}
case WalletApiOperation.AddKnownBankAccounts: {
const req = codecForAddKnownBankAccounts().decode(payload);
await addKnownBankAccounts(ws, req.payto, req.alias, req.currency);
return {};
}
case WalletApiOperation.ForgetKnownBankAccounts: {
const req = codecForForgetKnownBankAccounts().decode(payload);
await forgetKnownBankAccounts(ws, req.payto);
return {};
}
case WalletApiOperation.GetWithdrawalDetailsForUri: {
const req = codecForGetWithdrawalDetailsForUri().decode(payload);
return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri);
}
case WalletApiOperation.AcceptManualWithdrawal: {
const req = codecForAcceptManualWithdrawalRequet().decode(payload);
const res = await createManualWithdrawal(ws, {
amount: Amounts.parseOrThrow(req.amount),
exchangeBaseUrl: req.exchangeBaseUrl,
restrictAge: req.restrictAge,
});
return res;
}
case WalletApiOperation.GetWithdrawalDetailsForAmount: {
const req =
codecForGetWithdrawalDetailsForAmountRequest().decode(payload);
const wi = await getExchangeWithdrawalInfo(
ws,
req.exchangeBaseUrl,
Amounts.parseOrThrow(req.amount),
req.restrictAge,
);
let numCoins = 0;
for (const x of wi.selectedDenoms.selectedDenoms) {
numCoins += x.count;
}
const resp: ManualWithdrawalDetails = {
amountRaw: req.amount,
amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
paytoUris: wi.exchangePaytoUris,
tosAccepted: wi.termsOfServiceAccepted,
ageRestrictionOptions: wi.ageRestrictionOptions,
numCoins,
};
return resp;
}
case WalletApiOperation.GetBalances: {
return await getBalances(ws);
}
case WalletApiOperation.GetBalanceDetail: {
const req = codecForGetBalanceDetailRequest().decode(payload);
return await getBalanceDetail(ws, req);
}
case WalletApiOperation.GetUserAttentionRequests: {
const req = codecForUserAttentionsRequest().decode(payload);
return await getUserAttentions(ws, req);
}
case WalletApiOperation.MarkAttentionRequestAsRead: {
const req = codecForUserAttentionByIdRequest().decode(payload);
return await markAttentionRequestAsRead(ws, req);
}
case WalletApiOperation.GetUserAttentionUnreadCount: {
const req = codecForUserAttentionsRequest().decode(payload);
return await getUserAttentionsUnreadCount(ws, req);
}
case WalletApiOperation.GetPendingOperations: {
return await getPendingOperations(ws);
}
case WalletApiOperation.SetExchangeTosAccepted: {
const req = codecForAcceptExchangeTosRequest().decode(payload);
await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag);
return {};
}
case WalletApiOperation.AcceptBankIntegratedWithdrawal: {
const req =
codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
return await acceptWithdrawalFromUri(ws, {
selectedExchange: req.exchangeBaseUrl,
talerWithdrawUri: req.talerWithdrawUri,
forcedDenomSel: req.forcedDenomSel,
restrictAge: req.restrictAge,
});
}
case WalletApiOperation.GetExchangeTos: {
const req = codecForGetExchangeTosRequest().decode(payload);
return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat);
}
case WalletApiOperation.GetContractTermsDetails: {
const req = codecForGetContractTermsDetails().decode(payload);
return getContractTermsDetails(ws, req.proposalId);
}
case WalletApiOperation.RetryPendingNow: {
// FIXME: Should we reset all operation retries here?
await runPending(ws);
return {};
}
case WalletApiOperation.SharePayment: {
const req = codecForSharePaymentRequest().decode(payload);
return await sharePayment(ws, req.merchantBaseUrl, req.orderId);
}
case WalletApiOperation.PreparePayForUri: {
const req = codecForPreparePayRequest().decode(payload);
return await preparePayForUri(ws, req.talerPayUri);
}
case WalletApiOperation.PreparePayForTemplate: {
const req = codecForPreparePayTemplateRequest().decode(payload);
const url = parsePayTemplateUri(req.talerPayTemplateUri);
const templateDetails: MerchantUsingTemplateDetails = {};
if (!url) {
throw Error("invalid taler-template URI");
}
if (
url.templateParams.amount !== undefined &&
typeof url.templateParams.amount === "string"
) {
templateDetails.amount =
req.templateParams.amount ?? url.templateParams.amount;
}
if (
url.templateParams.summary !== undefined &&
typeof url.templateParams.summary === "string"
) {
templateDetails.summary =
req.templateParams.summary ?? url.templateParams.summary;
}
const reqUrl = new URL(
`templates/${url.templateId}`,
url.merchantBaseUrl,
);
const httpReq = await ws.http.fetch(reqUrl.href, {
method: "POST",
body: templateDetails,
});
const resp = await readSuccessResponseJsonOrThrow(
httpReq,
codecForMerchantPostOrderResponse(),
);
const payUri = constructPayUri(
url.merchantBaseUrl,
resp.order_id,
"",
resp.token,
);
return await preparePayForUri(ws, payUri);
}
case WalletApiOperation.ConfirmPay: {
const req = codecForConfirmPayRequest().decode(payload);
let proposalId;
if (req.proposalId) {
// legacy client support
proposalId = req.proposalId;
} else if (req.transactionId) {
const txIdParsed = parseTransactionIdentifier(req.transactionId);
if (txIdParsed?.tag != TransactionType.Payment) {
throw Error("payment transaction ID required");
}
proposalId = txIdParsed.proposalId;
} else {
throw Error("transactionId or (deprecated) proposalId required");
}
return await confirmPay(ws, proposalId, req.sessionId);
}
case WalletApiOperation.AbortTransaction: {
const req = codecForAbortTransaction().decode(payload);
await abortTransaction(ws, req.transactionId);
return {};
}
case WalletApiOperation.SuspendTransaction: {
const req = codecForSuspendTransaction().decode(payload);
await suspendTransaction(ws, req.transactionId);
return {};
}
case WalletApiOperation.FailTransaction: {
const req = codecForFailTransactionRequest().decode(payload);
await failTransaction(ws, req.transactionId);
return {};
}
case WalletApiOperation.ResumeTransaction: {
const req = codecForResumeTransaction().decode(payload);
await resumeTransaction(ws, req.transactionId);
return {};
}
case WalletApiOperation.DumpCoins: {
return await dumpCoins(ws);
}
case WalletApiOperation.SetCoinSuspended: {
const req = codecForSetCoinSuspendedRequest().decode(payload);
await setCoinSuspended(ws, req.coinPub, req.suspended);
return {};
}
case WalletApiOperation.TestingGetSampleTransactions:
return { transactions: sampleWalletCoreTransactions };
case WalletApiOperation.ForceRefresh: {
const req = codecForForceRefreshRequest().decode(payload);
if (req.coinPubList.length == 0) {
throw Error("refusing to create empty refresh group");
}
const refreshGroupId = await ws.db
.mktx((x) => [
x.refreshGroups,
x.coinAvailability,
x.denominations,
x.coins,
])
.runReadWrite(async (tx) => {
let coinPubs: CoinRefreshRequest[] = [];
for (const c of req.coinPubList) {
const coin = await tx.coins.get(c);
if (!coin) {
throw Error(`coin (pubkey ${c}) not found`);
}
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
checkDbInvariant(!!denom);
coinPubs.push({
coinPub: c,
amount: denom?.value,
});
}
return await createRefreshGroup(
ws,
tx,
Amounts.currencyOf(coinPubs[0].amount),
coinPubs,
RefreshReason.Manual,
);
});
processRefreshGroup(ws, refreshGroupId.refreshGroupId).catch((x) => {
logger.error(x);
});
return {
refreshGroupId,
};
}
case WalletApiOperation.PrepareReward: {
const req = codecForPrepareRewardRequest().decode(payload);
return await prepareTip(ws, req.talerRewardUri);
}
case WalletApiOperation.StartRefundQueryForUri: {
const req = codecForPrepareRefundRequest().decode(payload);
return await startRefundQueryForUri(ws, req.talerRefundUri);
}
case WalletApiOperation.StartRefundQuery: {
const req = codecForStartRefundQueryRequest().decode(payload);
const txIdParsed = parseTransactionIdentifier(req.transactionId);
if (!txIdParsed) {
throw Error("invalid transaction ID");
}
if (txIdParsed.tag !== TransactionType.Payment) {
throw Error("expected payment transaction ID");
}
await startQueryRefund(ws, txIdParsed.proposalId);
return {};
}
case WalletApiOperation.AcceptReward: {
const req = codecForAcceptTipRequest().decode(payload);
return await acceptTip(ws, req.walletRewardId);
}
case WalletApiOperation.AddBackupProvider: {
const req = codecForAddBackupProviderRequest().decode(payload);
return await addBackupProvider(ws, req);
}
case WalletApiOperation.RunBackupCycle: {
const req = codecForRunBackupCycle().decode(payload);
await runBackupCycle(ws, req);
return {};
}
case WalletApiOperation.RemoveBackupProvider: {
const req = codecForRemoveBackupProvider().decode(payload);
await removeBackupProvider(ws, req);
return {};
}
case WalletApiOperation.ExportBackupRecovery: {
const resp = await getBackupRecovery(ws);
return resp;
}
case WalletApiOperation.TestingWaitTransactionState: {
const req = payload as TestingWaitTransactionRequest;
await waitTransactionState(ws, req.transactionId, req.txState);
return {};
}
case WalletApiOperation.GetCurrencySpecification: {
// Ignore result, just validate in this mock implementation
const req = codecForGetCurrencyInfoRequest().decode(payload);
// Hard-coded mock for KUDOS and TESTKUDOS
if (req.scope.currency === "KUDOS") {
const kudosResp: GetCurrencySpecificationResponse = {
currencySpecification: {
decimal_separator: ",",
name: "Kudos (Taler Demonstrator)",
fractional_input_digits: 2,
fractional_normal_digits: 2,
fractional_trailing_zero_digits: 2,
is_currency_name_leading: true,
alt_unit_names: {
"0": "ク",
},
},
};
return kudosResp;
} else if (req.scope.currency === "TESTKUDOS") {
const testkudosResp: GetCurrencySpecificationResponse = {
currencySpecification: {
decimal_separator: ",",
name: "Test (Taler Unstable Demonstrator)",
fractional_input_digits: 0,
fractional_normal_digits: 0,
fractional_trailing_zero_digits: 0,
is_currency_name_leading: false,
alt_unit_names: {},
},
};
return testkudosResp;
}
const defaultResp: GetCurrencySpecificationResponse = {
currencySpecification: {
decimal_separator: ",",
name: "Unknown",
fractional_input_digits: 2,
fractional_normal_digits: 2,
fractional_trailing_zero_digits: 2,
is_currency_name_leading: true,
alt_unit_names: {},
},
};
return defaultResp;
}
case WalletApiOperation.ImportBackupRecovery: {
const req = codecForAny().decode(payload);
await loadBackupRecovery(ws, req);
return {};
}
// case WalletApiOperation.GetPlanForOperation: {
// const req = codecForGetPlanForOperationRequest().decode(payload);
// return await getPlanForOperation(ws, req);
// }
case WalletApiOperation.ConvertDepositAmount: {
const req = codecForConvertAmountRequest.decode(payload);
return await convertDepositAmount(ws, req);
}
case WalletApiOperation.GetMaxDepositAmount: {
const req = codecForGetAmountRequest.decode(payload);
return await getMaxDepositAmount(ws, req);
}
case WalletApiOperation.ConvertPeerPushAmount: {
const req = codecForConvertAmountRequest.decode(payload);
return await convertPeerPushAmount(ws, req);
}
case WalletApiOperation.GetMaxPeerPushAmount: {
const req = codecForGetAmountRequest.decode(payload);
return await getMaxPeerPushAmount(ws, req);
}
case WalletApiOperation.ConvertWithdrawalAmount: {
const req = codecForConvertAmountRequest.decode(payload);
return await convertWithdrawalAmount(ws, req);
}
case WalletApiOperation.GetBackupInfo: {
const resp = await getBackupInfo(ws);
return resp;
}
case WalletApiOperation.PrepareDeposit: {
const req = codecForPrepareDepositRequest().decode(payload);
return await prepareDepositGroup(ws, req);
}
case WalletApiOperation.GenerateDepositGroupTxId:
return {
transactionId: generateDepositGroupTxId(),
};
case WalletApiOperation.CreateDepositGroup: {
const req = codecForCreateDepositGroupRequest().decode(payload);
return await createDepositGroup(ws, req);
}
case WalletApiOperation.DeleteTransaction: {
const req = codecForDeleteTransactionRequest().decode(payload);
await deleteTransaction(ws, req.transactionId);
return {};
}
case WalletApiOperation.RetryTransaction: {
const req = codecForRetryTransactionRequest().decode(payload);
await retryTransaction(ws, req.transactionId);
return {};
}
case WalletApiOperation.SetWalletDeviceId: {
const req = codecForSetWalletDeviceIdRequest().decode(payload);
await setWalletDeviceId(ws, req.walletDeviceId);
return {};
}
case WalletApiOperation.ListCurrencies: {
// FIXME: Remove / change to scoped currency approach.
return {
trustedAuditors: [],
trustedExchanges: [],
};
}
case WalletApiOperation.TestCrypto: {
return await ws.cryptoApi.hashString({ str: "hello world" });
}
case WalletApiOperation.ClearDb:
await clearDatabase(ws.db.idbHandle());
return {};
case WalletApiOperation.Recycle: {
throw Error("not implemented");
return {};
}
case WalletApiOperation.ExportDb: {
const dbDump = await exportDb(ws.idb);
return dbDump;
}
case WalletApiOperation.ImportDb: {
const req = codecForImportDbRequest().decode(payload);
await importDb(ws.db.idbHandle(), req.dump);
return [];
}
case WalletApiOperation.CheckPeerPushDebit: {
const req = codecForCheckPeerPushDebitRequest().decode(payload);
return await checkPeerPushDebit(ws, req);
}
case WalletApiOperation.InitiatePeerPushDebit: {
const req = codecForInitiatePeerPushDebitRequest().decode(payload);
return await initiatePeerPushDebit(ws, req);
}
case WalletApiOperation.PreparePeerPushCredit: {
const req = codecForPreparePeerPushCreditRequest().decode(payload);
return await preparePeerPushCredit(ws, req);
}
case WalletApiOperation.ConfirmPeerPushCredit: {
const req = codecForConfirmPeerPushPaymentRequest().decode(payload);
return await confirmPeerPushCredit(ws, req);
}
case WalletApiOperation.CheckPeerPullCredit: {
const req = codecForPreparePeerPullPaymentRequest().decode(payload);
return await checkPeerPullPaymentInitiation(ws, req);
}
case WalletApiOperation.InitiatePeerPullCredit: {
const req = codecForInitiatePeerPullPaymentRequest().decode(payload);
return await initiatePeerPullPayment(ws, req);
}
case WalletApiOperation.PreparePeerPullDebit: {
const req = codecForCheckPeerPullPaymentRequest().decode(payload);
return await preparePeerPullDebit(ws, req);
}
case WalletApiOperation.ConfirmPeerPullDebit: {
const req = codecForAcceptPeerPullPaymentRequest().decode(payload);
return await confirmPeerPullDebit(ws, req);
}
case WalletApiOperation.ApplyDevExperiment: {
const req = codecForApplyDevExperiment().decode(payload);
await applyDevExperiment(ws, req.devExperimentUri);
return {};
}
case WalletApiOperation.GetVersion: {
return getVersion(ws);
}
case WalletApiOperation.TestingWaitTransactionsFinal:
return await waitUntilDone(ws);
case WalletApiOperation.TestingWaitRefreshesFinal:
return await waitUntilRefreshesDone(ws);
case WalletApiOperation.TestingSetTimetravel: {
const req = codecForTestingSetTimetravelRequest().decode(payload);
setDangerousTimetravel(req.offsetMs);
ws.workAvailable.trigger();
return {};
}
// default:
// assertUnreachable(operation);
}
throw TalerError.fromDetail(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
{
operation,
},
"unknown operation",
);
}
export function getVersion(ws: InternalWalletState): WalletCoreVersion {
const result: WalletCoreVersion = {
hash: undefined,
version: WALLET_CORE_IMPLEMENTATION_VERSION,
exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
devMode: false,
};
return result;
}
/**
* Handle a request to the wallet-core API.
*/
async function handleCoreApiRequest(
ws: InternalWalletState,
operation: string,
id: string,
payload: unknown,
): Promise {
try {
const result = await dispatchRequestInternal(ws, operation as any, payload);
return {
type: "response",
operation,
id,
result,
};
} catch (e: any) {
const err = getErrorDetailFromException(e);
logger.info(
`finished wallet core request ${operation} with error: ${j2s(err)}`,
);
return {
type: "error",
operation,
id,
error: err,
};
}
}
/**
* Public handle to a running wallet.
*/
export class Wallet {
private ws: InternalWalletState;
private _client: WalletCoreApiClient | undefined;
private constructor(
idb: IDBFactory,
http: HttpRequestLibrary,
timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
config?: WalletConfigParameter,
) {
this.ws = new InternalWalletStateImpl(
idb,
http,
timer,
cryptoWorkerFactory,
Wallet.getEffectiveConfig(config),
);
}
get client(): WalletCoreApiClient {
if (!this._client) {
throw Error();
}
return this._client;
}
static async create(
idb: IDBFactory,
http: HttpRequestLibrary,
timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
config?: WalletConfigParameter,
): Promise {
const w = new Wallet(idb, http, timer, cryptoWorkerFactory, config);
w._client = await getClientFromWalletState(w.ws);
return w;
}
public static defaultConfig: Readonly = {
builtin: {
exchanges: [
{
exchangeBaseUrl: "https://exchange.demo.taler.net/",
currencyHint: "KUDOS",
},
],
},
features: {
allowHttp: false,
},
testing: {
preventThrottling: false,
devModeActive: false,
insecureTrustExchange: false,
denomselAllowLate: false,
},
};
static getEffectiveConfig(
param?: WalletConfigParameter,
): Readonly {
return deepMerge(Wallet.defaultConfig, param ?? {});
}
addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
return this.ws.addNotificationListener(f);
}
stop(): void {
this.ws.stop();
}
async runPending(): Promise {
await this.ws.ensureWalletDbOpen();
return runPending(this.ws);
}
async runTaskLoop(opts?: RetryLoopOpts): Promise {
await this.ws.ensureWalletDbOpen();
return runTaskLoop(this.ws, opts);
}
async handleCoreApiRequest(
operation: string,
id: string,
payload: unknown,
): Promise {
await this.ws.ensureWalletDbOpen();
return handleCoreApiRequest(this.ws, operation, id, payload);
}
}
/**
* Internal state of the wallet.
*
* This ties together all the operation implementations.
*/
class InternalWalletStateImpl implements InternalWalletState {
/**
* @see {@link InternalWalletState.activeLongpoll}
*/
activeLongpoll: ActiveLongpollInfo = {};
cryptoApi: TalerCryptoInterface;
cryptoDispatcher: CryptoDispatcher;
merchantInfoCache: Record = {};
readonly timerGroup: TimerGroup;
workAvailable = new AsyncCondition();
stopped = false;
listeners: NotificationListener[] = [];
initCalled = false;
exchangeOps: ExchangeOperations = {
getExchangeDetails,
updateExchangeFromUrl,
};
recoupOps: RecoupOperations = {
createRecoupGroup,
};
merchantOps: MerchantOperations = {
getMerchantInfo,
};
refreshOps: RefreshOperations = {
createRefreshGroup,
};
// FIXME: Use an LRU cache here.
private denomCache: Record = {};
/**
* Promises that are waiting for a particular resource.
*/
private resourceWaiters: Record[]> = {};
/**
* Resources that are currently locked.
*/
private resourceLocks: Set = new Set();
isTaskLoopRunning: boolean = false;
config: Readonly;
private _db: DbAccess | undefined = undefined;
get db(): DbAccess {
if (!this._db) {
throw Error("db not initialized");
}
return this._db;
}
constructor(
public idb: IDBFactory,
public http: HttpRequestLibrary,
public timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
configParam: WalletConfig,
) {
this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory);
this.cryptoApi = this.cryptoDispatcher.cryptoApi;
this.timerGroup = new TimerGroup(timer);
this.config = configParam;
if (this.config.testing.devModeActive) {
this.http = new DevExperimentHttpLib(this.http);
}
}
async ensureWalletDbOpen(): Promise {
if (this._db) {
return;
}
const myVersionChange = async (): Promise => {
logger.info("version change requested for Taler DB");
};
const myDb = await openTalerDatabase(this.idb, myVersionChange);
this._db = myDb;
}
async getTransactionState(
ws: InternalWalletState,
tx: GetReadOnlyAccess,
transactionId: string,
): Promise {
const parsedTxId = parseTransactionIdentifier(transactionId);
if (!parsedTxId) {
throw Error("invalid tx identifier");
}
switch (parsedTxId.tag) {
case TransactionType.Deposit: {
const rec = await tx.depositGroups.get(parsedTxId.depositGroupId);
if (!rec) {
return undefined;
}
return computeDepositTransactionStatus(rec);
}
case TransactionType.InternalWithdrawal:
case TransactionType.Withdrawal: {
const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId);
if (!rec) {
return undefined;
}
return computeWithdrawalTransactionStatus(rec);
}
case TransactionType.Payment: {
const rec = await tx.purchases.get(parsedTxId.proposalId);
if (!rec) {
return;
}
return computePayMerchantTransactionState(rec);
}
case TransactionType.Refund: {
const rec = await tx.refundGroups.get(parsedTxId.refundGroupId);
if (!rec) {
return undefined;
}
return computeRefundTransactionState(rec);
}
case TransactionType.PeerPullCredit:
const rec = await tx.peerPullCredit.get(parsedTxId.pursePub);
if (!rec) {
return undefined;
}
return computePeerPullCreditTransactionState(rec);
case TransactionType.PeerPullDebit: {
const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId);
if (!rec) {
return undefined;
}
return computePeerPullDebitTransactionState(rec);
}
case TransactionType.PeerPushCredit: {
const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId);
if (!rec) {
return undefined;
}
return computePeerPushCreditTransactionState(rec);
}
case TransactionType.PeerPushDebit: {
const rec = await tx.peerPushDebit.get(parsedTxId.pursePub);
if (!rec) {
return undefined;
}
return computePeerPushDebitTransactionState(rec);
}
case TransactionType.Refresh: {
const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId);
if (!rec) {
return undefined;
}
return computeRefreshTransactionState(rec);
}
case TransactionType.Reward: {
const rec = await tx.rewards.get(parsedTxId.walletRewardId);
if (!rec) {
return undefined;
}
return computeRewardTransactionStatus(rec);
}
default:
assertUnreachable(parsedTxId);
}
}
async getDenomInfo(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
denominations: typeof WalletStoresV1.denominations;
}>,
exchangeBaseUrl: string,
denomPubHash: string,
): Promise {
const key = `${exchangeBaseUrl}:${denomPubHash}`;
const cached = this.denomCache[key];
if (cached) {
return cached;
}
const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
if (d) {
return DenominationRecord.toDenomInfo(d);
}
return undefined;
}
notify(n: WalletNotification): void {
logger.trace("Notification", j2s(n));
for (const l of this.listeners) {
const nc = JSON.parse(JSON.stringify(n));
setTimeout(() => {
l(nc);
}, 0);
}
}
addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
this.listeners.push(f);
return () => {
const idx = this.listeners.indexOf(f);
if (idx >= 0) {
this.listeners.splice(idx, 1);
}
};
}
/**
* Stop ongoing processing.
*/
stop(): void {
logger.trace("stopping (at internal wallet state)");
this.stopped = true;
this.timerGroup.stopCurrentAndFutureTimers();
this.cryptoDispatcher.stop();
for (const key of Object.keys(this.activeLongpoll)) {
logger.trace(`cancelling active longpoll ${key}`);
this.activeLongpoll[key].cancel();
delete this.activeLongpoll[key];
}
}
/**
* Run an async function after acquiring a list of locks, identified
* by string tokens.
*/
async runSequentialized(
tokens: string[],
f: () => Promise,
): Promise {
// Make sure locks are always acquired in the same order
tokens = [...tokens].sort();
for (const token of tokens) {
if (this.resourceLocks.has(token)) {
const p = openPromise();
let waitList = this.resourceWaiters[token];
if (!waitList) {
waitList = this.resourceWaiters[token] = [];
}
waitList.push(p);
await p.promise;
}
this.resourceLocks.add(token);
}
try {
logger.trace(`begin exclusive execution on ${JSON.stringify(tokens)}`);
const result = await f();
logger.trace(`end exclusive execution on ${JSON.stringify(tokens)}`);
return result;
} finally {
for (const token of tokens) {
this.resourceLocks.delete(token);
let waiter = (this.resourceWaiters[token] ?? []).shift();
if (waiter) {
waiter.resolve();
}
}
}
}
ensureTaskLoopRunning(): void {
if (this.isTaskLoopRunning) {
return;
}
runTaskLoop(this)
.catch((e) => {
logger.error("error running task loop");
logger.error(`err: ${e}`);
})
.then(() => {
logger.info("done running task loop");
});
}
}
/**
* Take the full object as template, create a new result with all the values.
* Use the override object to change the values in the result
* return result
* @param full
* @param override
* @returns
*/
function deepMerge(full: T, override: object): T {
const keys = Object.keys(full);
const result = { ...full };
for (const k of keys) {
// @ts-ignore
const newVal = override[k];
if (newVal === undefined) continue;
// @ts-ignore
result[k] =
// @ts-ignore
typeof newVal === "object" ? deepMerge(full[k], newVal) : newVal;
}
return result;
}