/*
This file is part of GNU Taler
(C) 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
*/
/**
* @fileoverview
* Implementation of exchange entry management in wallet-core.
* The details of exchange entry management are specified in DD48.
*/
/**
* Imports.
*/
import {
AbsoluteTime,
AgeRestriction,
Amount,
Amounts,
CancellationToken,
CoinRefreshRequest,
CoinStatus,
DeleteExchangeRequest,
DenomKeyType,
DenomLossEventType,
DenomOperationMap,
DenominationInfo,
DenominationPubKey,
Duration,
EddsaPublicKeyString,
ExchangeAuditor,
ExchangeDetailedResponse,
ExchangeGlobalFees,
ExchangeListItem,
ExchangeSignKeyJson,
ExchangeTosStatus,
ExchangeWireAccount,
ExchangesListResponse,
FeeDescription,
GetExchangeEntryByUrlRequest,
GetExchangeResourcesResponse,
GetExchangeTosResult,
GlobalFees,
HttpStatusCode,
LibtoolVersion,
Logger,
NotificationType,
OperationErrorInfo,
Recoup,
RefreshReason,
ScopeInfo,
ScopeType,
TalerError,
TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolDuration,
TalerProtocolTimestamp,
TransactionIdStr,
TransactionMajorState,
TransactionState,
TransactionType,
URL,
WalletNotification,
WireFee,
WireFeeMap,
WireFeesJson,
WireInfo,
assertUnreachable,
checkDbInvariant,
checkLogicInvariant,
codecForExchangeKeysJson,
durationMul,
encodeCrock,
getRandomBytes,
hashDenomPub,
j2s,
makeErrorDetail,
parsePaytoUri,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
getExpiry,
readSuccessResponseJsonOrThrow,
readSuccessResponseTextOrThrow,
readTalerErrorResponse,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
PendingTaskType,
TaskIdStr,
TaskIdentifiers,
TaskRunResult,
TaskRunResultType,
TransactionContext,
computeDbBackoff,
constructTaskIdentifier,
genericWaitForState,
getAutoRefreshExecuteThreshold,
getExchangeEntryStatusFromRecord,
getExchangeState,
getExchangeTosStatusFromRecord,
getExchangeUpdateStatusFromRecord,
} from "./common.js";
import {
DenomLossEventRecord,
DenomLossStatus,
DenominationRecord,
DenominationVerificationStatus,
ExchangeDetailsRecord,
ExchangeEntryDbRecordStatus,
ExchangeEntryDbUpdateStatus,
ExchangeEntryRecord,
WalletDbReadOnlyTransaction,
WalletDbReadWriteTransaction,
WalletStoresV1,
timestampAbsoluteFromDb,
timestampOptionalPreciseFromDb,
timestampPreciseFromDb,
timestampPreciseToDb,
timestampProtocolFromDb,
timestampProtocolToDb,
} from "./db.js";
import {
createTimeline,
isWithdrawableDenom,
selectBestForOverlappingDenominations,
selectMinimumFee,
} from "./denominations.js";
import { DbReadOnlyTransaction } from "./query.js";
import { createRecoupGroup } from "./recoup.js";
import { createRefreshGroup } from "./refresh.js";
import {
constructTransactionIdentifier,
notifyTransition,
} from "./transactions.js";
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
const logger = new Logger("exchanges.ts");
function getExchangeRequestTimeout(): Duration {
return Duration.fromSpec({
seconds: 15,
});
}
interface ExchangeTosDownloadResult {
tosText: string;
tosEtag: string;
tosContentType: string;
tosContentLanguage: string | undefined;
tosAvailableLanguages: string[];
}
async function downloadExchangeWithTermsOfService(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
http: HttpRequestLibrary,
timeout: Duration,
acceptFormat: string,
acceptLanguage: string | undefined,
): Promise {
logger.trace(`downloading exchange tos (type ${acceptFormat})`);
const reqUrl = new URL("terms", exchangeBaseUrl);
const headers: {
Accept: string;
"Accept-Language"?: string;
} = {
Accept: acceptFormat,
};
if (acceptLanguage) {
headers["Accept-Language"] = acceptLanguage;
}
const resp = await http.fetch(reqUrl.href, {
headers,
timeout,
cancellationToken: wex.cancellationToken,
});
const tosText = await readSuccessResponseTextOrThrow(resp);
const tosEtag = resp.headers.get("etag") || "unknown";
const tosContentLanguage = resp.headers.get("content-language") || undefined;
const tosContentType = resp.headers.get("content-type") || "text/plain";
const availLangStr = resp.headers.get("avail-languages") || "";
// Work around exchange bug that reports the same language multiple times.
const availLangSet = new Set(
availLangStr.split(",").map((x) => x.trim()),
);
const tosAvailableLanguages = [...availLangSet];
return {
tosText,
tosEtag,
tosContentType,
tosContentLanguage,
tosAvailableLanguages,
};
}
/**
* Get exchange details from the database.
*/
async function getExchangeRecordsInternal(
tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
exchangeBaseUrl: string,
): Promise {
const r = await tx.exchanges.get(exchangeBaseUrl);
if (!r) {
logger.warn(`no exchange found for ${exchangeBaseUrl}`);
return;
}
const dp = r.detailsPointer;
if (!dp) {
logger.warn(`no exchange details pointer for ${exchangeBaseUrl}`);
return;
}
const { currency, masterPublicKey } = dp;
const details = await tx.exchangeDetails.indexes.byPointer.get([
r.baseUrl,
currency,
masterPublicKey,
]);
if (!details) {
logger.warn(
`no exchange details with pointer ${j2s(dp)} for ${exchangeBaseUrl}`,
);
}
return details;
}
export async function getExchangeScopeInfo(
tx: WalletDbReadOnlyTransaction<
[
"exchanges",
"exchangeDetails",
"globalCurrencyExchanges",
"globalCurrencyAuditors",
]
>,
exchangeBaseUrl: string,
currency: string,
): Promise {
const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
if (!det) {
return {
type: ScopeType.Exchange,
currency: currency,
url: exchangeBaseUrl,
};
}
return internalGetExchangeScopeInfo(tx, det);
}
async function internalGetExchangeScopeInfo(
tx: WalletDbReadOnlyTransaction<
["globalCurrencyExchanges", "globalCurrencyAuditors"]
>,
exchangeDetails: ExchangeDetailsRecord,
): Promise {
const globalExchangeRec =
await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
exchangeDetails.currency,
exchangeDetails.exchangeBaseUrl,
exchangeDetails.masterPublicKey,
]);
if (globalExchangeRec) {
return {
currency: exchangeDetails.currency,
type: ScopeType.Global,
};
} else {
for (const aud of exchangeDetails.auditors) {
const globalAuditorRec =
await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
exchangeDetails.currency,
aud.auditor_url,
aud.auditor_pub,
]);
if (globalAuditorRec) {
return {
currency: exchangeDetails.currency,
type: ScopeType.Auditor,
url: aud.auditor_url,
};
}
}
}
return {
currency: exchangeDetails.currency,
type: ScopeType.Exchange,
url: exchangeDetails.exchangeBaseUrl,
};
}
async function makeExchangeListItem(
tx: WalletDbReadOnlyTransaction<
["globalCurrencyExchanges", "globalCurrencyAuditors"]
>,
r: ExchangeEntryRecord,
exchangeDetails: ExchangeDetailsRecord | undefined,
lastError: TalerErrorDetail | undefined,
): Promise {
const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
? {
error: lastError,
}
: undefined;
let scopeInfo: ScopeInfo | undefined = undefined;
if (exchangeDetails) {
scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
}
return {
exchangeBaseUrl: r.baseUrl,
masterPub: exchangeDetails?.masterPublicKey,
noFees: r.noFees ?? false,
peerPaymentsDisabled: r.peerPaymentsDisabled ?? false,
currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN",
exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
tosStatus: getExchangeTosStatusFromRecord(r),
ageRestrictionOptions: exchangeDetails?.ageMask
? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
: [],
paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate),
lastUpdateErrorInfo,
scopeInfo: scopeInfo ?? {
type: ScopeType.Exchange,
currency: "UNKNOWN",
url: r.baseUrl,
},
};
}
export interface ExchangeWireDetails {
currency: string;
masterPublicKey: EddsaPublicKeyString;
wireInfo: WireInfo;
exchangeBaseUrl: string;
auditors: ExchangeAuditor[];
globalFees: ExchangeGlobalFees[];
}
export async function getExchangeWireDetailsInTx(
tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
exchangeBaseUrl: string,
): Promise {
const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
if (!det) {
return undefined;
}
return {
currency: det.currency,
masterPublicKey: det.masterPublicKey,
wireInfo: det.wireInfo,
exchangeBaseUrl: det.exchangeBaseUrl,
auditors: det.auditors,
globalFees: det.globalFees,
};
}
export async function lookupExchangeByUri(
wex: WalletExecutionContext,
req: GetExchangeEntryByUrlRequest,
): Promise {
return await wex.db.runReadOnlyTx(
{
storeNames: [
"exchanges",
"exchangeDetails",
"operationRetries",
"globalCurrencyAuditors",
"globalCurrencyExchanges",
],
},
async (tx) => {
const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
if (!exchangeRec) {
throw Error("exchange not found");
}
const exchangeDetails = await getExchangeRecordsInternal(
tx,
exchangeRec.baseUrl,
);
const opRetryRecord = await tx.operationRetries.get(
TaskIdentifiers.forExchangeUpdate(exchangeRec),
);
return await makeExchangeListItem(
tx,
exchangeRec,
exchangeDetails,
opRetryRecord?.lastError,
);
},
);
}
/**
* Mark the current ToS version as accepted by the user.
*/
export async function acceptExchangeTermsOfService(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise {
const notif = await wex.db.runReadWriteTx(
{ storeNames: ["exchangeDetails", "exchanges"] },
async (tx) => {
const exch = await tx.exchanges.get(exchangeBaseUrl);
if (exch && exch.tosCurrentEtag) {
const oldExchangeState = getExchangeState(exch);
exch.tosAcceptedEtag = exch.tosCurrentEtag;
exch.tosAcceptedTimestamp = timestampPreciseToDb(
TalerPreciseTimestamp.now(),
);
await tx.exchanges.put(exch);
const newExchangeState = getExchangeState(exch);
wex.ws.exchangeCache.clear();
return {
type: NotificationType.ExchangeStateTransition,
exchangeBaseUrl,
newExchangeState: newExchangeState,
oldExchangeState: oldExchangeState,
} satisfies WalletNotification;
}
return undefined;
},
);
if (notif) {
wex.ws.notify(notif);
}
}
/**
* Mark the current ToS version as accepted by the user.
*/
export async function forgetExchangeTermsOfService(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise {
const notif = await wex.db.runReadWriteTx(
{ storeNames: ["exchangeDetails", "exchanges"] },
async (tx) => {
const exch = await tx.exchanges.get(exchangeBaseUrl);
if (exch) {
const oldExchangeState = getExchangeState(exch);
exch.tosAcceptedEtag = undefined;
exch.tosAcceptedTimestamp = undefined;
await tx.exchanges.put(exch);
const newExchangeState = getExchangeState(exch);
wex.ws.exchangeCache.clear();
return {
type: NotificationType.ExchangeStateTransition,
exchangeBaseUrl,
newExchangeState: newExchangeState,
oldExchangeState: oldExchangeState,
} satisfies WalletNotification;
}
return undefined;
},
);
if (notif) {
wex.ws.notify(notif);
}
}
/**
* Validate wire fees and wire accounts.
*
* Throw an exception if they are invalid.
*/
async function validateWireInfo(
wex: WalletExecutionContext,
versionCurrent: number,
wireInfo: ExchangeKeysDownloadResult,
masterPublicKey: string,
): Promise {
for (const a of wireInfo.accounts) {
logger.trace("validating exchange acct");
let isValid = false;
if (wex.ws.config.testing.insecureTrustExchange) {
isValid = true;
} else {
const { valid: v } = await wex.ws.cryptoApi.isValidWireAccount({
masterPub: masterPublicKey,
paytoUri: a.payto_uri,
sig: a.master_sig,
versionCurrent,
conversionUrl: a.conversion_url,
creditRestrictions: a.credit_restrictions,
debitRestrictions: a.debit_restrictions,
});
isValid = v;
}
if (!isValid) {
throw Error("exchange acct signature invalid");
}
}
logger.trace("account validation done");
const feesForType: WireFeeMap = {};
for (const wireMethod of Object.keys(wireInfo.wireFees)) {
const feeList: WireFee[] = [];
for (const x of wireInfo.wireFees[wireMethod]) {
const startStamp = x.start_date;
const endStamp = x.end_date;
const fee: WireFee = {
closingFee: Amounts.stringify(x.closing_fee),
endStamp,
sig: x.sig,
startStamp,
wireFee: Amounts.stringify(x.wire_fee),
};
let isValid = false;
if (wex.ws.config.testing.insecureTrustExchange) {
isValid = true;
} else {
const { valid: v } = await wex.ws.cryptoApi.isValidWireFee({
masterPub: masterPublicKey,
type: wireMethod,
wf: fee,
});
isValid = v;
}
if (!isValid) {
throw Error("exchange wire fee signature invalid");
}
feeList.push(fee);
}
feesForType[wireMethod] = feeList;
}
return {
accounts: wireInfo.accounts,
feesForType,
};
}
/**
* Validate global fees.
*
* Throw an exception if they are invalid.
*/
async function validateGlobalFees(
wex: WalletExecutionContext,
fees: GlobalFees[],
masterPub: string,
): Promise {
const egf: ExchangeGlobalFees[] = [];
for (const gf of fees) {
logger.trace("validating exchange global fees");
let isValid = false;
if (wex.ws.config.testing.insecureTrustExchange) {
isValid = true;
} else {
const { valid: v } = await wex.cryptoApi.isValidGlobalFees({
masterPub,
gf,
});
isValid = v;
}
if (!isValid) {
throw Error("exchange global fees signature invalid: " + gf.master_sig);
}
egf.push({
accountFee: Amounts.stringify(gf.account_fee),
historyFee: Amounts.stringify(gf.history_fee),
purseFee: Amounts.stringify(gf.purse_fee),
startDate: gf.start_date,
endDate: gf.end_date,
signature: gf.master_sig,
historyTimeout: gf.history_expiration,
purseLimit: gf.purse_account_limit,
purseTimeout: gf.purse_timeout,
});
}
return egf;
}
/**
* Add an exchange entry to the wallet database in the
* entry state "preset".
*
* Returns the notification to the caller that should be emitted
* if the DB transaction succeeds.
*/
export async function addPresetExchangeEntry(
tx: WalletDbReadWriteTransaction<["exchanges"]>,
exchangeBaseUrl: string,
currencyHint?: string,
): Promise<{ notification?: WalletNotification }> {
let exchange = await tx.exchanges.get(exchangeBaseUrl);
if (!exchange) {
const r: ExchangeEntryRecord = {
entryStatus: ExchangeEntryDbRecordStatus.Preset,
updateStatus: ExchangeEntryDbUpdateStatus.Initial,
baseUrl: exchangeBaseUrl,
presetCurrencyHint: currencyHint,
detailsPointer: undefined,
lastUpdate: undefined,
lastKeysEtag: undefined,
nextRefreshCheckStamp: timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
),
nextUpdateStamp: timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
),
tosAcceptedEtag: undefined,
tosAcceptedTimestamp: undefined,
tosCurrentEtag: undefined,
};
await tx.exchanges.put(r);
return {
notification: {
type: NotificationType.ExchangeStateTransition,
exchangeBaseUrl: exchangeBaseUrl,
// Exchange did not exist yet
oldExchangeState: undefined,
newExchangeState: getExchangeState(r),
},
};
}
return {};
}
async function provideExchangeRecordInTx(
ws: InternalWalletState,
tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
baseUrl: string,
): Promise<{
exchange: ExchangeEntryRecord;
exchangeDetails: ExchangeDetailsRecord | undefined;
notification?: WalletNotification;
}> {
let notification: WalletNotification | undefined = undefined;
let exchange = await tx.exchanges.get(baseUrl);
if (!exchange) {
const r: ExchangeEntryRecord = {
entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
baseUrl: baseUrl,
detailsPointer: undefined,
lastUpdate: undefined,
nextUpdateStamp: timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
),
nextRefreshCheckStamp: timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
),
// The first update should always be done in a way that ignores the cache,
// so that removing and re-adding an exchange works properly, even
// if /keys is cached in the browser.
cachebreakNextUpdate: true,
lastKeysEtag: undefined,
tosAcceptedEtag: undefined,
tosAcceptedTimestamp: undefined,
tosCurrentEtag: undefined,
};
await tx.exchanges.put(r);
exchange = r;
notification = {
type: NotificationType.ExchangeStateTransition,
exchangeBaseUrl: r.baseUrl,
oldExchangeState: undefined,
newExchangeState: getExchangeState(r),
};
}
const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
return { exchange, exchangeDetails, notification };
}
export interface ExchangeKeysDownloadResult {
baseUrl: string;
masterPublicKey: string;
currency: string;
auditors: ExchangeAuditor[];
currentDenominations: DenominationRecord[];
protocolVersion: string;
signingKeys: ExchangeSignKeyJson[];
reserveClosingDelay: TalerProtocolDuration;
expiry: TalerProtocolTimestamp;
recoup: Recoup[];
listIssueDate: TalerProtocolTimestamp;
globalFees: GlobalFees[];
accounts: ExchangeWireAccount[];
wireFees: { [methodName: string]: WireFeesJson[] };
}
/**
* Download and validate an exchange's /keys data.
*/
async function downloadExchangeKeysInfo(
baseUrl: string,
http: HttpRequestLibrary,
timeout: Duration,
cancellationToken: CancellationToken,
noCache: boolean,
): Promise {
const keysUrl = new URL("keys", baseUrl);
const headers: Record = {};
if (noCache) {
headers["cache-control"] = "no-cache";
}
const resp = await http.fetch(keysUrl.href, {
timeout,
cancellationToken,
headers,
});
logger.info("got response to /keys request");
// We must make sure to parse out the protocol version
// before we validate the body.
// Otherwise the parser might complain with a hard to understand
// message about some other field, when it is just a version
// incompatibility.
const keysJson = await resp.json();
const protocolVersion = keysJson.version;
if (typeof protocolVersion !== "string") {
throw Error("bad exchange, does not even specify protocol version");
}
const versionRes = LibtoolVersion.compare(
WALLET_EXCHANGE_PROTOCOL_VERSION,
protocolVersion,
);
if (!versionRes) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
{
requestUrl: resp.requestUrl,
httpStatusCode: resp.status,
requestMethod: resp.requestMethod,
},
"exchange protocol version malformed",
);
}
if (!versionRes.compatible) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
{
exchangeProtocolVersion: protocolVersion,
walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
},
"exchange protocol version not compatible with wallet",
);
}
const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangeKeysJson(),
);
if (exchangeKeysJsonUnchecked.denominations.length === 0) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
{
exchangeBaseUrl: baseUrl,
},
"exchange doesn't offer any denominations",
);
}
const currency = exchangeKeysJsonUnchecked.currency;
const currentDenominations: DenominationRecord[] = [];
for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
switch (denomGroup.cipher) {
case "RSA":
case "RSA+age_restricted": {
let ageMask = 0;
if (denomGroup.cipher === "RSA+age_restricted") {
ageMask = denomGroup.age_mask;
}
for (const denomIn of denomGroup.denoms) {
const denomPub: DenominationPubKey = {
age_mask: ageMask,
cipher: DenomKeyType.Rsa,
rsa_public_key: denomIn.rsa_pub,
};
const denomPubHash = encodeCrock(hashDenomPub(denomPub));
const value = Amounts.parseOrThrow(denomGroup.value);
const rec: DenominationRecord = {
denomPub,
denomPubHash,
exchangeBaseUrl: baseUrl,
exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
isOffered: true,
isRevoked: false,
isLost: denomIn.lost ?? false,
value: Amounts.stringify(value),
currency: value.currency,
stampExpireDeposit: timestampProtocolToDb(
denomIn.stamp_expire_deposit,
),
stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
stampExpireWithdraw: timestampProtocolToDb(
denomIn.stamp_expire_withdraw,
),
stampStart: timestampProtocolToDb(denomIn.stamp_start),
verificationStatus: DenominationVerificationStatus.Unverified,
masterSig: denomIn.master_sig,
fees: {
feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
feeRefund: Amounts.stringify(denomGroup.fee_refund),
feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
},
};
currentDenominations.push(rec);
}
break;
}
case "CS+age_restricted":
case "CS":
logger.warn("Clause-Schnorr denominations not supported");
continue;
default:
logger.warn(
`denomination type ${(denomGroup as any).cipher} not supported`,
);
continue;
}
}
return {
masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
currency,
baseUrl: exchangeKeysJsonUnchecked.base_url,
auditors: exchangeKeysJsonUnchecked.auditors,
currentDenominations,
protocolVersion: exchangeKeysJsonUnchecked.version,
signingKeys: exchangeKeysJsonUnchecked.signkeys,
reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
expiry: AbsoluteTime.toProtocolTimestamp(
getExpiry(resp, {
minDuration: Duration.fromSpec({ hours: 1 }),
}),
),
recoup: exchangeKeysJsonUnchecked.recoup ?? [],
listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
globalFees: exchangeKeysJsonUnchecked.global_fees,
accounts: exchangeKeysJsonUnchecked.accounts,
wireFees: exchangeKeysJsonUnchecked.wire_fees,
};
}
type TosMetaResult = { type: "not-found" } | { type: "ok"; etag: string };
/**
* Download metadata about an exchange's terms of service.
*/
async function downloadTosMeta(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise {
logger.trace(`downloading exchange tos metadata for ${exchangeBaseUrl}`);
const reqUrl = new URL("terms", exchangeBaseUrl);
// FIXME: We can/should make a HEAD request here.
// Not sure if qtart supports it at the moment.
const resp = await wex.http.fetch(reqUrl.href, {
cancellationToken: wex.cancellationToken,
});
switch (resp.status) {
case HttpStatusCode.NotFound:
case HttpStatusCode.NotImplemented:
return { type: "not-found" };
case HttpStatusCode.Ok:
break;
default:
throwUnexpectedRequestError(resp, await readTalerErrorResponse(resp));
}
const etag = resp.headers.get("etag") || "unknown";
return {
type: "ok",
etag,
};
}
async function downloadTosFromAcceptedFormat(
wex: WalletExecutionContext,
baseUrl: string,
timeout: Duration,
acceptedFormat?: string[],
acceptLanguage?: string,
): Promise {
let tosFound: ExchangeTosDownloadResult | undefined;
// Remove this when exchange supports multiple content-type in accept header
if (acceptedFormat)
for (const format of acceptedFormat) {
const resp = await downloadExchangeWithTermsOfService(
wex,
baseUrl,
wex.http,
timeout,
format,
acceptLanguage,
);
if (resp.tosContentType === format) {
tosFound = resp;
break;
}
}
if (tosFound !== undefined) {
return tosFound;
}
// If none of the specified format was found try text/plain
return await downloadExchangeWithTermsOfService(
wex,
baseUrl,
wex.http,
timeout,
"text/plain",
acceptLanguage,
);
}
/**
* Transition an exchange into an updating state.
*
* If the update is forced, the exchange is put into an updating state
* even if the old information should still be up to date.
*
* If the exchange entry doesn't exist,
* a new ephemeral entry is created.
*/
async function startUpdateExchangeEntry(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
options: { forceUpdate?: boolean } = {},
): Promise {
logger.info(
`starting update of exchange entry ${exchangeBaseUrl}, forced=${
options.forceUpdate ?? false
}`,
);
const { notification } = await wex.db.runReadWriteTx(
{ storeNames: ["exchanges", "exchangeDetails"] },
async (tx) => {
wex.ws.exchangeCache.clear();
return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl);
},
);
logger.trace("created exchange record");
if (notification) {
wex.ws.notify(notification);
}
const { oldExchangeState, newExchangeState, taskId } =
await wex.db.runReadWriteTx(
{ storeNames: ["exchanges", "operationRetries"] },
async (tx) => {
const r = await tx.exchanges.get(exchangeBaseUrl);
if (!r) {
throw Error("exchange not found");
}
const oldExchangeState = getExchangeState(r);
switch (r.updateStatus) {
case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
r.cachebreakNextUpdate = options.forceUpdate;
break;
case ExchangeEntryDbUpdateStatus.Suspended:
r.cachebreakNextUpdate = options.forceUpdate;
break;
case ExchangeEntryDbUpdateStatus.ReadyUpdate:
r.cachebreakNextUpdate = options.forceUpdate;
break;
case ExchangeEntryDbUpdateStatus.Ready: {
const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
timestampPreciseFromDb(r.nextUpdateStamp),
);
// Only update if entry is outdated or update is forced.
if (
options.forceUpdate ||
AbsoluteTime.isExpired(nextUpdateTimestamp)
) {
r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
r.cachebreakNextUpdate = options.forceUpdate;
}
break;
}
case ExchangeEntryDbUpdateStatus.Initial:
r.cachebreakNextUpdate = options.forceUpdate;
r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
break;
case ExchangeEntryDbUpdateStatus.InitialUpdate:
r.cachebreakNextUpdate = options.forceUpdate;
break;
}
wex.ws.exchangeCache.clear();
await tx.exchanges.put(r);
const newExchangeState = getExchangeState(r);
const taskId = TaskIdentifiers.forExchangeUpdate(r);
return { oldExchangeState, newExchangeState, taskId };
},
);
wex.ws.notify({
type: NotificationType.ExchangeStateTransition,
exchangeBaseUrl,
newExchangeState: newExchangeState,
oldExchangeState: oldExchangeState,
});
logger.info(`start update ${exchangeBaseUrl} task ${taskId}`);
await wex.taskScheduler.resetTaskRetries(taskId);
}
/**
* Basic information about an exchange in a ready state.
*/
export interface ReadyExchangeSummary {
exchangeBaseUrl: string;
currency: string;
masterPub: string;
tosStatus: ExchangeTosStatus;
tosAcceptedEtag: string | undefined;
tosCurrentEtag: string | undefined;
wireInfo: WireInfo;
protocolVersionRange: string;
tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
scopeInfo: ScopeInfo;
}
/**
* Ensure that a fresh exchange entry exists for the given
* exchange base URL.
*
* The cancellation token can be used to abort waiting for the
* updated exchange entry.
*
* If an exchange entry for the database doesn't exist in the
* DB, it will be added ephemerally.
*
* If the expectedMasterPub is given and does not match the actual
* master pub, an exception will be thrown. However, the exchange
* will still have been added as an ephemeral exchange entry.
*/
export async function fetchFreshExchange(
wex: WalletExecutionContext,
baseUrl: string,
options: {
forceUpdate?: boolean;
} = {},
): Promise {
logger.info(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`);
if (!options.forceUpdate) {
const cachedResp = wex.ws.exchangeCache.get(baseUrl);
if (cachedResp) {
return cachedResp;
}
} else {
wex.ws.exchangeCache.clear();
}
await wex.taskScheduler.ensureRunning();
await startUpdateExchangeEntry(wex, baseUrl, {
forceUpdate: options.forceUpdate,
});
const resp = await waitReadyExchange(wex, baseUrl, options);
wex.ws.exchangeCache.put(baseUrl, resp);
return resp;
}
async function waitReadyExchange(
wex: WalletExecutionContext,
canonUrl: string,
options: {
forceUpdate?: boolean;
expectedMasterPub?: string;
} = {},
): Promise {
logger.trace(`waiting for exchange ${canonUrl} to become ready`);
const operationId = constructTaskIdentifier({
tag: PendingTaskType.ExchangeUpdate,
exchangeBaseUrl: canonUrl,
});
let res: ReadyExchangeSummary | undefined = undefined;
await genericWaitForState(wex, {
filterNotification(notif): boolean {
return (
notif.type === NotificationType.ExchangeStateTransition &&
notif.exchangeBaseUrl === canonUrl
);
},
async checkState(): Promise {
const { exchange, exchangeDetails, retryInfo, scopeInfo } =
await wex.db.runReadOnlyTx(
{
storeNames: [
"exchanges",
"exchangeDetails",
"operationRetries",
"globalCurrencyAuditors",
"globalCurrencyExchanges",
],
},
async (tx) => {
const exchange = await tx.exchanges.get(canonUrl);
const exchangeDetails = await getExchangeRecordsInternal(
tx,
canonUrl,
);
const retryInfo = await tx.operationRetries.get(operationId);
let scopeInfo: ScopeInfo | undefined = undefined;
if (exchange && exchangeDetails) {
scopeInfo = await internalGetExchangeScopeInfo(
tx,
exchangeDetails,
);
}
return { exchange, exchangeDetails, retryInfo, scopeInfo };
},
);
if (!exchange) {
throw Error("exchange entry does not exist anymore");
}
let ready = false;
switch (exchange.updateStatus) {
case ExchangeEntryDbUpdateStatus.Ready:
ready = true;
break;
case ExchangeEntryDbUpdateStatus.ReadyUpdate:
// If the update is forced,
// we wait until we're in a full "ready" state,
// as we're not happy with the stale information.
if (!options.forceUpdate) {
ready = true;
}
break;
case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
throw TalerError.fromDetail(
TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
{
exchangeBaseUrl: canonUrl,
innerError: retryInfo?.lastError,
},
);
default: {
if (retryInfo) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
{
exchangeBaseUrl: canonUrl,
innerError: retryInfo?.lastError,
},
);
}
}
}
if (!ready) {
return false;
}
if (!exchangeDetails) {
throw Error("invariant failed");
}
if (!scopeInfo) {
throw Error("invariant failed");
}
const mySummary: ReadyExchangeSummary = {
currency: exchangeDetails.currency,
exchangeBaseUrl: canonUrl,
masterPub: exchangeDetails.masterPublicKey,
tosStatus: getExchangeTosStatusFromRecord(exchange),
tosAcceptedEtag: exchange.tosAcceptedEtag,
wireInfo: exchangeDetails.wireInfo,
protocolVersionRange: exchangeDetails.protocolVersionRange,
tosCurrentEtag: exchange.tosCurrentEtag,
tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
exchange.tosAcceptedTimestamp,
),
scopeInfo,
};
if (options.expectedMasterPub) {
if (mySummary.masterPub !== options.expectedMasterPub) {
throw Error(
"public key of the exchange does not match expected public key",
);
}
}
res = mySummary;
return true;
},
});
checkLogicInvariant(!!res);
return res;
}
function checkPeerPaymentsDisabled(
keysInfo: ExchangeKeysDownloadResult,
): boolean {
const now = AbsoluteTime.now();
for (let gf of keysInfo.globalFees) {
const isActive = AbsoluteTime.isBetween(
now,
AbsoluteTime.fromProtocolTimestamp(gf.start_date),
AbsoluteTime.fromProtocolTimestamp(gf.end_date),
);
if (!isActive) {
continue;
}
return false;
}
// No global fees, we can't do p2p payments!
return true;
}
function checkNoFees(keysInfo: ExchangeKeysDownloadResult): boolean {
for (const gf of keysInfo.globalFees) {
if (!Amounts.isZero(gf.account_fee)) {
return false;
}
if (!Amounts.isZero(gf.history_fee)) {
return false;
}
if (!Amounts.isZero(gf.purse_fee)) {
return false;
}
}
for (const denom of keysInfo.currentDenominations) {
if (!Amounts.isZero(denom.fees.feeWithdraw)) {
return false;
}
if (!Amounts.isZero(denom.fees.feeDeposit)) {
return false;
}
if (!Amounts.isZero(denom.fees.feeRefund)) {
return false;
}
if (!Amounts.isZero(denom.fees.feeRefresh)) {
return false;
}
}
for (const wft of Object.values(keysInfo.wireFees)) {
for (const wf of wft) {
if (!Amounts.isZero(wf.wire_fee)) {
return false;
}
}
}
return true;
}
/**
* Update an exchange entry in the wallet's database
* by fetching the /keys and /wire information.
* Optionally link the reserve entry to the new or existing
* exchange entry in then DB.
*/
export async function updateExchangeFromUrlHandler(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise {
logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
const oldExchangeRec = await wex.db.runReadOnlyTx(
{ storeNames: ["exchanges"] },
async (tx) => {
return tx.exchanges.get(exchangeBaseUrl);
},
);
if (!oldExchangeRec) {
logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
return TaskRunResult.finished();
}
let updateRequestedExplicitly = false;
switch (oldExchangeRec.updateStatus) {
case ExchangeEntryDbUpdateStatus.Suspended:
logger.info(`not updating exchange in status "suspended"`);
return TaskRunResult.finished();
case ExchangeEntryDbUpdateStatus.Initial:
logger.info(`not updating exchange in status "initial"`);
return TaskRunResult.finished();
case ExchangeEntryDbUpdateStatus.InitialUpdate:
case ExchangeEntryDbUpdateStatus.ReadyUpdate:
updateRequestedExplicitly = true;
break;
case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
// Only retry when scheduled to respect backoff
break;
case ExchangeEntryDbUpdateStatus.Ready:
break;
default:
assertUnreachable(oldExchangeRec.updateStatus);
}
let refreshCheckNecessary = true;
if (!updateRequestedExplicitly) {
// If the update wasn't requested explicitly,
// check if we really need to update.
let nextUpdateStamp = timestampAbsoluteFromDb(
oldExchangeRec.nextUpdateStamp,
);
let nextRefreshCheckStamp = timestampAbsoluteFromDb(
oldExchangeRec.nextRefreshCheckStamp,
);
let updateNecessary = true;
if (
!AbsoluteTime.isNever(nextUpdateStamp) &&
!AbsoluteTime.isExpired(nextUpdateStamp)
) {
logger.info(
`exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
nextUpdateStamp,
)}`,
);
updateNecessary = false;
}
if (
!AbsoluteTime.isNever(nextRefreshCheckStamp) &&
!AbsoluteTime.isExpired(nextRefreshCheckStamp)
) {
logger.info(
`exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
nextRefreshCheckStamp,
)}`,
);
refreshCheckNecessary = false;
}
if (!(updateNecessary || refreshCheckNecessary)) {
logger.trace("update not necessary, running again later");
return TaskRunResult.runAgainAt(
AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
);
}
}
// When doing the auto-refresh check, we always update
// the key info before that.
logger.trace("updating exchange /keys info");
const timeout = getExchangeRequestTimeout();
const keysInfo = await downloadExchangeKeysInfo(
exchangeBaseUrl,
wex.http,
timeout,
wex.cancellationToken,
oldExchangeRec.cachebreakNextUpdate ?? false,
);
logger.trace("validating exchange wire info");
const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
if (!version) {
// Should have been validated earlier.
throw Error("unexpected invalid version");
}
const wireInfo = await validateWireInfo(
wex,
version.current,
keysInfo,
keysInfo.masterPublicKey,
);
const globalFees = await validateGlobalFees(
wex,
keysInfo.globalFees,
keysInfo.masterPublicKey,
);
if (keysInfo.baseUrl != exchangeBaseUrl) {
logger.warn("exchange base URL mismatch");
const errorDetail: TalerErrorDetail = makeErrorDetail(
TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
{
urlWallet: exchangeBaseUrl,
urlExchange: keysInfo.baseUrl,
},
);
return {
type: TaskRunResultType.Error,
errorDetail,
};
}
logger.trace("finished validating exchange /wire info");
const tosMeta = await downloadTosMeta(wex, exchangeBaseUrl);
logger.trace("updating exchange info in database");
let ageMask = 0;
for (const x of keysInfo.currentDenominations) {
if (
isWithdrawableDenom(x, wex.ws.config.testing.denomselAllowLate) &&
x.denomPub.age_mask != 0
) {
ageMask = x.denomPub.age_mask;
break;
}
}
let noFees = checkNoFees(keysInfo);
let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo);
const updated = await wex.db.runReadWriteTx(
{
storeNames: [
"exchanges",
"exchangeDetails",
"exchangeSignKeys",
"denominations",
"coins",
"refreshGroups",
"recoupGroups",
"coinAvailability",
"denomLossEvents",
],
},
async (tx) => {
const r = await tx.exchanges.get(exchangeBaseUrl);
if (!r) {
logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
return;
}
wex.ws.refreshCostCache.clear();
wex.ws.exchangeCache.clear();
wex.ws.denomInfoCache.clear();
const oldExchangeState = getExchangeState(r);
const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
let detailsPointerChanged = false;
if (!existingDetails) {
detailsPointerChanged = true;
}
let detailsIncompatible = false;
if (existingDetails) {
if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
detailsIncompatible = true;
detailsPointerChanged = true;
}
if (existingDetails.currency !== keysInfo.currency) {
detailsIncompatible = true;
detailsPointerChanged = true;
}
// FIXME: We need to do some consistency checks!
}
if (detailsIncompatible) {
logger.warn(
`exchange ${r.baseUrl} has incompatible data in /keys, not updating`,
);
// We don't support this gracefully right now.
// See https://bugs.taler.net/n/8576
r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1;
r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter);
r.nextRefreshCheckStamp = timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
);
r.cachebreakNextUpdate = true;
await tx.exchanges.put(r);
return {
oldExchangeState,
newExchangeState: getExchangeState(r),
};
}
r.updateRetryCounter = 0;
const newDetails: ExchangeDetailsRecord = {
auditors: keysInfo.auditors,
currency: keysInfo.currency,
masterPublicKey: keysInfo.masterPublicKey,
protocolVersionRange: keysInfo.protocolVersion,
reserveClosingDelay: keysInfo.reserveClosingDelay,
globalFees,
exchangeBaseUrl: r.baseUrl,
wireInfo,
ageMask,
};
r.noFees = noFees;
r.peerPaymentsDisabled = peerPaymentsDisabled;
switch (tosMeta.type) {
case "not-found":
r.tosCurrentEtag = undefined;
break;
case "ok":
r.tosCurrentEtag = tosMeta.etag;
break;
}
if (existingDetails?.rowId) {
newDetails.rowId = existingDetails.rowId;
}
r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
r.nextUpdateStamp = timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(
AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
),
);
// New denominations might be available.
r.nextRefreshCheckStamp = timestampPreciseToDb(
TalerPreciseTimestamp.now(),
);
if (detailsPointerChanged) {
r.detailsPointer = {
currency: newDetails.currency,
masterPublicKey: newDetails.masterPublicKey,
updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
};
}
r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
r.cachebreakNextUpdate = false;
await tx.exchanges.put(r);
const drRowId = await tx.exchangeDetails.put(newDetails);
checkDbInvariant(
typeof drRowId.key === "number",
"exchange details key is not a number",
);
for (const sk of keysInfo.signingKeys) {
// FIXME: validate signing keys before inserting them
await tx.exchangeSignKeys.put({
exchangeDetailsRowId: drRowId.key,
masterSig: sk.master_sig,
signkeyPub: sk.key,
stampEnd: timestampProtocolToDb(sk.stamp_end),
stampExpire: timestampProtocolToDb(sk.stamp_expire),
stampStart: timestampProtocolToDb(sk.stamp_start),
});
}
// In the future: Filter out old denominations by index
const allOldDenoms =
await tx.denominations.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
const oldDenomByDph = new Map();
for (const denom of allOldDenoms) {
oldDenomByDph.set(denom.denomPubHash, denom);
}
logger.trace("updating denominations in database");
const currentDenomSet = new Set(
keysInfo.currentDenominations.map((x) => x.denomPubHash),
);
for (const currentDenom of keysInfo.currentDenominations) {
const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash);
if (oldDenom) {
// FIXME: Do consistency check, report to auditor if necessary.
// See https://bugs.taler.net/n/8594
// Mark lost denominations as lost.
if (currentDenom.isLost && !oldDenom.isLost) {
logger.warn(
`marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`,
);
oldDenom.isLost = true;
await tx.denominations.put(currentDenom);
}
} else {
await tx.denominations.put(currentDenom);
}
}
// Update list issue date for all denominations,
// and mark non-offered denominations as such.
for (const x of allOldDenoms) {
if (!currentDenomSet.has(x.denomPubHash)) {
// FIXME: Here, an auditor report should be created, unless
// the denomination is really legally expired.
if (x.isOffered) {
x.isOffered = false;
logger.info(
`setting denomination ${x.denomPubHash} to offered=false`,
);
}
} else {
if (!x.isOffered) {
x.isOffered = true;
logger.info(
`setting denomination ${x.denomPubHash} to offered=true`,
);
}
}
await tx.denominations.put(x);
}
logger.trace("done updating denominations in database");
const denomLossResult = await handleDenomLoss(
wex,
tx,
newDetails.currency,
exchangeBaseUrl,
);
await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup);
const newExchangeState = getExchangeState(r);
return {
exchange: r,
exchangeDetails: newDetails,
oldExchangeState,
newExchangeState,
denomLossResult,
};
},
);
if (!updated) {
throw Error("something went wrong with updating the exchange");
}
if (updated.denomLossResult) {
for (const notif of updated.denomLossResult.notifications) {
wex.ws.notify(notif);
}
}
logger.trace("done updating exchange info in database");
logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
let minCheckThreshold = AbsoluteTime.addDuration(
AbsoluteTime.now(),
Duration.fromSpec({ days: 1 }),
);
if (refreshCheckNecessary) {
// Do auto-refresh.
await wex.db.runReadWriteTx(
{
storeNames: [
"coins",
"denominations",
"coinAvailability",
"refreshGroups",
"refreshSessions",
"exchanges",
],
},
async (tx) => {
const exchange = await tx.exchanges.get(exchangeBaseUrl);
if (!exchange || !exchange.detailsPointer) {
return;
}
const coins = await tx.coins.indexes.byBaseUrl
.iter(exchangeBaseUrl)
.toArray();
const refreshCoins: CoinRefreshRequest[] = [];
for (const coin of coins) {
if (coin.status !== CoinStatus.Fresh) {
continue;
}
const denom = await tx.denominations.get([
exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
logger.warn("denomination not in database");
continue;
}
const executeThreshold =
getAutoRefreshExecuteThresholdForDenom(denom);
if (AbsoluteTime.isExpired(executeThreshold)) {
refreshCoins.push({
coinPub: coin.coinPub,
amount: denom.value,
});
} else {
const checkThreshold = getAutoRefreshCheckThreshold(denom);
minCheckThreshold = AbsoluteTime.min(
minCheckThreshold,
checkThreshold,
);
}
}
if (refreshCoins.length > 0) {
const res = await createRefreshGroup(
wex,
tx,
exchange.detailsPointer?.currency,
refreshCoins,
RefreshReason.Scheduled,
undefined,
);
logger.trace(
`created refresh group for auto-refresh (${res.refreshGroupId})`,
);
}
logger.trace(
`next refresh check at ${AbsoluteTime.toIsoString(
minCheckThreshold,
)}`,
);
exchange.nextRefreshCheckStamp = timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
);
wex.ws.exchangeCache.clear();
await tx.exchanges.put(exchange);
},
);
}
wex.ws.notify({
type: NotificationType.ExchangeStateTransition,
exchangeBaseUrl,
newExchangeState: updated.newExchangeState,
oldExchangeState: updated.oldExchangeState,
});
// Next invocation will cause the task to be run again
// at the necessary time.
return TaskRunResult.progress();
}
interface DenomLossResult {
notifications: WalletNotification[];
}
async function handleDenomLoss(
wex: WalletExecutionContext,
tx: WalletDbReadWriteTransaction<
["coinAvailability", "denominations", "denomLossEvents", "coins"]
>,
currency: string,
exchangeBaseUrl: string,
): Promise {
const coinAvailabilityRecs =
await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
const denomsVanished: string[] = [];
const denomsUnoffered: string[] = [];
const denomsExpired: string[] = [];
let amountVanished = Amount.zeroOfCurrency(currency);
let amountExpired = Amount.zeroOfCurrency(currency);
let amountUnoffered = Amount.zeroOfCurrency(currency);
const result: DenomLossResult = {
notifications: [],
};
for (const coinAv of coinAvailabilityRecs) {
if (coinAv.freshCoinCount <= 0) {
continue;
}
const n = coinAv.freshCoinCount;
const denom = await tx.denominations.get([
coinAv.exchangeBaseUrl,
coinAv.denomPubHash,
]);
const timestampExpireDeposit = !denom
? undefined
: timestampAbsoluteFromDb(denom.stampExpireDeposit);
if (!denom) {
// Remove availability
coinAv.freshCoinCount = 0;
coinAv.visibleCoinCount = 0;
await tx.coinAvailability.put(coinAv);
denomsVanished.push(coinAv.denomPubHash);
const total = Amount.from(coinAv.value).mult(n);
amountVanished = amountVanished.add(total);
} else if (!denom.isOffered) {
// Remove availability
coinAv.freshCoinCount = 0;
coinAv.visibleCoinCount = 0;
await tx.coinAvailability.put(coinAv);
denomsUnoffered.push(coinAv.denomPubHash);
const total = Amount.from(coinAv.value).mult(n);
amountUnoffered = amountUnoffered.add(total);
} else if (
timestampExpireDeposit &&
AbsoluteTime.isExpired(timestampExpireDeposit)
) {
// Remove availability
coinAv.freshCoinCount = 0;
coinAv.visibleCoinCount = 0;
await tx.coinAvailability.put(coinAv);
denomsExpired.push(coinAv.denomPubHash);
const total = Amount.from(coinAv.value).mult(n);
amountExpired = amountExpired.add(total);
} else {
// Denomination is still fine!
continue;
}
logger.warn(`denomination ${coinAv.denomPubHash} is a loss`);
const coins = await tx.coins.indexes.byDenomPubHash.getAll(
coinAv.denomPubHash,
);
for (const coin of coins) {
switch (coin.status) {
case CoinStatus.Fresh:
case CoinStatus.FreshSuspended: {
coin.status = CoinStatus.DenomLoss;
await tx.coins.put(coin);
break;
}
}
}
}
if (denomsVanished.length > 0) {
const denomLossEventId = encodeCrock(getRandomBytes(32));
await tx.denomLossEvents.add({
denomLossEventId,
amount: amountVanished.toString(),
currency,
exchangeBaseUrl,
denomPubHashes: denomsVanished,
eventType: DenomLossEventType.DenomVanished,
status: DenomLossStatus.Done,
timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.DenomLoss,
denomLossEventId,
});
result.notifications.push({
type: NotificationType.TransactionStateTransition,
transactionId,
oldTxState: {
major: TransactionMajorState.None,
},
newTxState: {
major: TransactionMajorState.Done,
},
});
result.notifications.push({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
}
if (denomsUnoffered.length > 0) {
const denomLossEventId = encodeCrock(getRandomBytes(32));
await tx.denomLossEvents.add({
denomLossEventId,
amount: amountUnoffered.toString(),
currency,
exchangeBaseUrl,
denomPubHashes: denomsUnoffered,
eventType: DenomLossEventType.DenomUnoffered,
status: DenomLossStatus.Done,
timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.DenomLoss,
denomLossEventId,
});
result.notifications.push({
type: NotificationType.TransactionStateTransition,
transactionId,
oldTxState: {
major: TransactionMajorState.None,
},
newTxState: {
major: TransactionMajorState.Done,
},
});
result.notifications.push({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
}
if (denomsExpired.length > 0) {
const denomLossEventId = encodeCrock(getRandomBytes(32));
await tx.denomLossEvents.add({
denomLossEventId,
amount: amountExpired.toString(),
currency,
exchangeBaseUrl,
denomPubHashes: denomsUnoffered,
eventType: DenomLossEventType.DenomExpired,
status: DenomLossStatus.Done,
timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.DenomLoss,
denomLossEventId,
});
result.notifications.push({
type: NotificationType.TransactionStateTransition,
transactionId,
oldTxState: {
major: TransactionMajorState.None,
},
newTxState: {
major: TransactionMajorState.Done,
},
});
result.notifications.push({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
}
return result;
}
export function computeDenomLossTransactionStatus(
rec: DenomLossEventRecord,
): TransactionState {
switch (rec.status) {
case DenomLossStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
case DenomLossStatus.Done:
return {
major: TransactionMajorState.Done,
};
}
}
export class DenomLossTransactionContext implements TransactionContext {
get taskId(): TaskIdStr | undefined {
return undefined;
}
transactionId: TransactionIdStr;
abortTransaction(): Promise {
throw new Error("Method not implemented.");
}
suspendTransaction(): Promise {
throw new Error("Method not implemented.");
}
resumeTransaction(): Promise {
throw new Error("Method not implemented.");
}
failTransaction(): Promise {
throw new Error("Method not implemented.");
}
async deleteTransaction(): Promise {
const transitionInfo = await this.wex.db.runReadWriteTx(
{ storeNames: ["denomLossEvents"] },
async (tx) => {
const rec = await tx.denomLossEvents.get(this.denomLossEventId);
if (rec) {
const oldTxState = computeDenomLossTransactionStatus(rec);
await tx.denomLossEvents.delete(this.denomLossEventId);
return {
oldTxState,
newTxState: {
major: TransactionMajorState.Deleted,
},
};
}
return undefined;
},
);
notifyTransition(this.wex, this.transactionId, transitionInfo);
}
constructor(
private wex: WalletExecutionContext,
public denomLossEventId: string,
) {
this.transactionId = constructTransactionIdentifier({
tag: TransactionType.DenomLoss,
denomLossEventId,
});
}
}
async function handleRecoup(
wex: WalletExecutionContext,
tx: WalletDbReadWriteTransaction<
["denominations", "coins", "recoupGroups", "refreshGroups"]
>,
exchangeBaseUrl: string,
recoup: Recoup[],
): Promise {
// Handle recoup
const recoupDenomList = recoup;
const newlyRevokedCoinPubs: string[] = [];
logger.trace("recoup list from exchange", recoupDenomList);
for (const recoupInfo of recoupDenomList) {
const oldDenom = await tx.denominations.get([
exchangeBaseUrl,
recoupInfo.h_denom_pub,
]);
if (!oldDenom) {
// We never even knew about the revoked denomination, all good.
continue;
}
if (oldDenom.isRevoked) {
// We already marked the denomination as revoked,
// this implies we revoked all coins
logger.trace("denom already revoked");
continue;
}
logger.info("revoking denom", recoupInfo.h_denom_pub);
oldDenom.isRevoked = true;
await tx.denominations.put(oldDenom);
const affectedCoins = await tx.coins.indexes.byDenomPubHash.getAll(
recoupInfo.h_denom_pub,
);
for (const ac of affectedCoins) {
newlyRevokedCoinPubs.push(ac.coinPub);
}
}
if (newlyRevokedCoinPubs.length != 0) {
logger.info("recouping coins", newlyRevokedCoinPubs);
await createRecoupGroup(wex, tx, exchangeBaseUrl, newlyRevokedCoinPubs);
}
}
function getAutoRefreshExecuteThresholdForDenom(
d: DenominationRecord,
): AbsoluteTime {
return getAutoRefreshExecuteThreshold({
stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
});
}
/**
* Timestamp after which the wallet would do the next check for an auto-refresh.
*/
function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
timestampProtocolFromDb(d.stampExpireWithdraw),
);
const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
timestampProtocolFromDb(d.stampExpireDeposit),
);
const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
const deltaDiv = durationMul(delta, 0.75);
return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
}
/**
* Find a payto:// URI of the exchange that is of one
* of the given target types.
*
* Throws if no matching account was found.
*/
export async function getExchangePaytoUri(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
supportedTargetTypes: string[],
): Promise {
// We do the update here, since the exchange might not even exist
// yet in our database.
const details = await wex.db.runReadOnlyTx(
{ storeNames: ["exchanges", "exchangeDetails"] },
async (tx) => {
return getExchangeRecordsInternal(tx, exchangeBaseUrl);
},
);
const accounts = details?.wireInfo.accounts ?? [];
for (const account of accounts) {
const res = parsePaytoUri(account.payto_uri);
if (!res) {
continue;
}
if (supportedTargetTypes.includes(res.targetType)) {
return account.payto_uri;
}
}
throw Error(
`no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
supportedTargetTypes,
)}`,
);
}
/**
* Get the exchange ToS in the requested format.
* Try to download in the accepted format not cached.
*/
export async function getExchangeTos(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
acceptedFormat?: string[],
acceptLanguage?: string,
): Promise {
const exch = await fetchFreshExchange(wex, exchangeBaseUrl);
const tosDownload = await downloadTosFromAcceptedFormat(
wex,
exchangeBaseUrl,
getExchangeRequestTimeout(),
acceptedFormat,
acceptLanguage,
);
await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, async (tx) => {
const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
if (updateExchangeEntry) {
updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
wex.ws.exchangeCache.clear();
await tx.exchanges.put(updateExchangeEntry);
}
});
return {
acceptedEtag: exch.tosAcceptedEtag,
currentEtag: tosDownload.tosEtag,
content: tosDownload.tosText,
contentType: tosDownload.tosContentType,
contentLanguage: tosDownload.tosContentLanguage,
tosStatus: exch.tosStatus,
tosAvailableLanguages: tosDownload.tosAvailableLanguages,
};
}
/**
* Parsed information about an exchange,
* obtained by requesting /keys.
*/
export interface ExchangeInfo {
keys: ExchangeKeysDownloadResult;
}
/**
* Helper function to download the exchange /keys info.
*
* Only used for testing / dbless wallet.
*/
export async function downloadExchangeInfo(
exchangeBaseUrl: string,
http: HttpRequestLibrary,
): Promise {
const keysInfo = await downloadExchangeKeysInfo(
exchangeBaseUrl,
http,
Duration.getForever(),
CancellationToken.CONTINUE,
false,
);
return {
keys: keysInfo,
};
}
/**
* List all exchange entries known to the wallet.
*/
export async function listExchanges(
wex: WalletExecutionContext,
): Promise {
const exchanges: ExchangeListItem[] = [];
await wex.db.runReadOnlyTx(
{
storeNames: [
"exchanges",
"operationRetries",
"exchangeDetails",
"globalCurrencyAuditors",
"globalCurrencyExchanges",
],
},
async (tx) => {
const exchangeRecords = await tx.exchanges.iter().toArray();
for (const r of exchangeRecords) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.ExchangeUpdate,
exchangeBaseUrl: r.baseUrl,
});
const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
const opRetryRecord = await tx.operationRetries.get(taskId);
exchanges.push(
await makeExchangeListItem(
tx,
r,
exchangeDetails,
opRetryRecord?.lastError,
),
);
}
},
);
return { exchanges };
}
/**
* Transition an exchange to the "used" entry state if necessary.
*
* Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
*
* The caller should emit the returned notification iff the current transaction
* succeeded.
*/
export async function markExchangeUsed(
wex: WalletExecutionContext,
tx: WalletDbReadWriteTransaction<["exchanges"]>,
exchangeBaseUrl: string,
): Promise<{ notif: WalletNotification | undefined }> {
logger.info(`marking exchange ${exchangeBaseUrl} as used`);
const exch = await tx.exchanges.get(exchangeBaseUrl);
if (!exch) {
logger.info(`exchange ${exchangeBaseUrl} NOT found`);
return {
notif: undefined,
};
}
const oldExchangeState = getExchangeState(exch);
switch (exch.entryStatus) {
case ExchangeEntryDbRecordStatus.Ephemeral:
case ExchangeEntryDbRecordStatus.Preset: {
exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
await tx.exchanges.put(exch);
const newExchangeState = getExchangeState(exch);
return {
notif: {
type: NotificationType.ExchangeStateTransition,
exchangeBaseUrl,
newExchangeState: newExchangeState,
oldExchangeState: oldExchangeState,
} satisfies WalletNotification,
};
}
default:
return {
notif: undefined,
};
}
}
/**
* Get detailed information about the exchange including a timeline
* for the fees charged by the exchange.
*/
export async function getExchangeDetailedInfo(
wex: WalletExecutionContext,
exchangeBaseurl: string,
): Promise {
const exchange = await wex.db.runReadOnlyTx(
{ storeNames: ["exchanges", "exchangeDetails", "denominations"] },
async (tx) => {
const ex = await tx.exchanges.get(exchangeBaseurl);
const dp = ex?.detailsPointer;
if (!dp) {
return;
}
const { currency } = dp;
const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
if (!exchangeDetails) {
return;
}
const denominationRecords =
await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
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 internalGetExchangeResources(
wex: WalletExecutionContext,
tx: DbReadOnlyTransaction<
typeof WalletStoresV1,
["exchanges", "coins", "withdrawalGroups"]
>,
exchangeBaseUrl: string,
): Promise {
let numWithdrawals = 0;
let numCoins = 0;
numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
numWithdrawals =
await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
const total = numWithdrawals + numCoins;
return {
hasResources: total != 0,
};
}
/**
* Purge information in the database associated with the exchange.
*
* Deletes information specific to the exchange and withdrawals,
* but keeps some transactions (payments, p2p, refreshes) around.
*/
async function purgeExchange(
tx: WalletDbReadWriteTransaction<
[
"exchanges",
"exchangeDetails",
"transactions",
"coinAvailability",
"coins",
"denominations",
"exchangeSignKeys",
"withdrawalGroups",
"planchets",
]
>,
exchangeBaseUrl: string,
): Promise {
const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
for (const r of detRecs) {
if (r.rowId == null) {
// Should never happen, as rowId is the primary key.
continue;
}
await tx.exchangeDetails.delete(r.rowId);
const signkeyRecs =
await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId);
for (const rec of signkeyRecs) {
await tx.exchangeSignKeys.delete([r.rowId, rec.signkeyPub]);
}
}
// FIXME: Also remove records related to transactions?
await tx.exchanges.delete(exchangeBaseUrl);
{
const coinAvailabilityRecs =
await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
for (const rec of coinAvailabilityRecs) {
await tx.coinAvailability.delete([
exchangeBaseUrl,
rec.denomPubHash,
rec.maxAge,
]);
}
}
{
const coinRecs = await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl);
for (const rec of coinRecs) {
await tx.coins.delete(rec.coinPub);
}
}
{
const denomRecs =
await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
for (const rec of denomRecs) {
await tx.denominations.delete(rec.denomPubHash);
}
}
{
const withdrawalGroupRecs =
await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
for (const wg of withdrawalGroupRecs) {
await tx.withdrawalGroups.delete(wg.withdrawalGroupId);
const planchets = await tx.planchets.indexes.byGroup.getAll(
wg.withdrawalGroupId,
);
for (const p of planchets) {
await tx.planchets.delete(p.coinPub);
}
}
}
}
export async function deleteExchange(
wex: WalletExecutionContext,
req: DeleteExchangeRequest,
): Promise {
let inUse: boolean = false;
const exchangeBaseUrl = req.exchangeBaseUrl;
await wex.db.runReadWriteTx(
{
storeNames: [
"exchanges",
"exchangeDetails",
"transactions",
"coinAvailability",
"coins",
"denominations",
"exchangeSignKeys",
"withdrawalGroups",
"planchets",
],
},
async (tx) => {
const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
if (!exchangeRec) {
// Nothing to delete!
logger.info("no exchange found to delete");
return;
}
const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl);
if (res.hasResources && !req.purge) {
inUse = true;
return;
}
await purgeExchange(tx, exchangeBaseUrl);
wex.ws.exchangeCache.clear();
},
);
if (inUse) {
throw TalerError.fromUncheckedDetail({
code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
hint: "Exchange in use.",
});
}
}
export async function getExchangeResources(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise {
// Withdrawals include internal withdrawals from peer transactions
const res = await wex.db.runReadOnlyTx(
{ storeNames: ["exchanges", "withdrawalGroups", "coins"] },
async (tx) => {
const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
if (!exchangeRecord) {
return undefined;
}
return internalGetExchangeResources(wex, tx, exchangeBaseUrl);
},
);
if (!res) {
throw Error("exchange not found");
}
return res;
}