wallet-core/packages/taler-wallet-core/src/operations/exchanges.ts

557 lines
17 KiB
TypeScript
Raw Normal View History

/*
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 <http://www.gnu.org/licenses/>
*/
2021-03-17 17:56:37 +01:00
/**
* Imports.
*/
2020-03-09 09:49:22 +01:00
import {
2021-03-17 17:56:37 +01:00
Amounts,
2020-03-09 09:49:22 +01:00
codecForExchangeKeysJson,
codecForExchangeWireJson,
2021-03-17 17:56:37 +01:00
compare,
Denomination,
Duration,
durationFromSpec,
getTimestampNow,
isTimestampExpired,
NotificationType,
parsePaytoUri,
TalerErrorCode,
TalerErrorDetails,
} from "@gnu-taler/taler-util";
import {
DenominationRecord,
DenominationStatus,
2021-03-17 17:56:37 +01:00
Stores,
ExchangeRecord,
ExchangeUpdateStatus,
WireFee,
2019-12-16 12:53:22 +01:00
ExchangeUpdateReason,
2021-03-17 17:56:37 +01:00
} from "../db.js";
import {
Logger,
URL,
readSuccessResponseJsonOrThrow,
getExpiryTimestamp,
readSuccessResponseTextOrThrow,
} from "../index.js";
import { j2s, canonicalizeBaseUrl } from "@gnu-taler/taler-util";
2021-03-17 17:56:37 +01:00
import { checkDbInvariant } from "../util/invariants.js";
import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries.js";
import {
2021-03-17 17:56:37 +01:00
makeErrorDetails,
OperationFailedAndReportedError,
guardOperationException,
2021-03-17 17:56:37 +01:00
} from "./errors.js";
import { createRecoupGroup, processRecoupGroup } from "./recoup.js";
import { InternalWalletState } from "./state.js";
import {
WALLET_CACHE_BREAKER_CLIENT_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
2021-03-17 17:56:37 +01:00
} from "./versions.js";
const logger = new Logger("exchanges.ts");
async function denominationRecordFromKeys(
ws: InternalWalletState,
exchangeBaseUrl: string,
denomIn: Denomination,
): Promise<DenominationRecord> {
const denomPubHash = await ws.cryptoApi.hashEncoded(denomIn.denom_pub);
const d: DenominationRecord = {
denomPub: denomIn.denom_pub,
denomPubHash,
exchangeBaseUrl,
feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit),
feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh),
feeRefund: Amounts.parseOrThrow(denomIn.fee_refund),
feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw),
isOffered: true,
isRevoked: false,
masterSig: denomIn.master_sig,
stampExpireDeposit: denomIn.stamp_expire_deposit,
stampExpireLegal: denomIn.stamp_expire_legal,
stampExpireWithdraw: denomIn.stamp_expire_withdraw,
stampStart: denomIn.stamp_start,
status: DenominationStatus.Unverified,
value: Amounts.parseOrThrow(denomIn.value),
};
return d;
}
2020-09-02 11:14:36 +02:00
async function handleExchangeUpdateError(
ws: InternalWalletState,
baseUrl: string,
2020-09-01 14:57:22 +02:00
err: TalerErrorDetails,
): Promise<void> {
2020-09-02 11:14:36 +02:00
await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
const exchange = await tx.get(Stores.exchanges, baseUrl);
if (!exchange) {
return;
}
exchange.retryInfo.retryCounter++;
updateRetryInfoTimeout(exchange.retryInfo);
exchange.lastError = err;
2020-09-02 11:14:36 +02:00
});
if (err) {
ws.notify({ type: NotificationType.ExchangeOperationError, error: err });
}
}
function getExchangeRequestTimeout(e: ExchangeRecord): Duration {
return { d_ms: 5000 };
}
/**
* Fetch the exchange's /keys and update our database accordingly.
*
* Exceptions thrown in this method must be caught and reported
* in the pending operations.
*/
async function updateExchangeWithKeys(
ws: InternalWalletState,
baseUrl: string,
): Promise<void> {
2019-12-16 12:53:22 +01:00
const existingExchangeRecord = await ws.db.get(Stores.exchanges, baseUrl);
2019-12-16 12:53:22 +01:00
if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) {
return;
}
logger.info("updating exchange /keys info");
const keysUrl = new URL("keys", baseUrl);
keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
const resp = await ws.http.get(keysUrl.href, {
timeout: getExchangeRequestTimeout(existingExchangeRecord),
});
const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangeKeysJson(),
);
logger.info("received /keys response");
2021-01-14 18:00:00 +01:00
logger.trace(j2s(exchangeKeysJson));
if (exchangeKeysJson.denoms.length === 0) {
const opErr = makeErrorDetails(
TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
"exchange doesn't offer any denominations",
{
exchangeBaseUrl: baseUrl,
},
);
2020-09-02 11:14:36 +02:00
await handleExchangeUpdateError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
}
const protocolVersion = exchangeKeysJson.version;
const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion);
if (versionRes?.compatible != true) {
const opErr = makeErrorDetails(
TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
"exchange protocol version not compatible with wallet",
{
exchangeProtocolVersion: protocolVersion,
walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
},
);
2020-09-02 11:14:36 +02:00
await handleExchangeUpdateError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
}
const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
.currency;
logger.trace("processing denominations");
const newDenominations = await Promise.all(
2020-03-30 12:39:32 +02:00
exchangeKeysJson.denoms.map((d) =>
denominationRecordFromKeys(ws, baseUrl, d),
),
);
logger.trace("done with processing denominations");
const lastUpdateTimestamp = getTimestampNow();
2020-04-06 17:45:41 +02:00
const recoupGroupId: string | undefined = undefined;
2019-12-12 22:39:45 +01:00
await ws.db.runWithWriteTransaction(
[Stores.exchanges, Stores.denominations, Stores.recoupGroups, Stores.coins],
2020-03-30 12:39:32 +02:00
async (tx) => {
const r = await tx.get(Stores.exchanges, baseUrl);
if (!r) {
logger.warn(`exchange ${baseUrl} no longer present`);
return;
}
if (r.details) {
// FIXME: We need to do some consistency checks!
}
2020-03-13 14:34:16 +01:00
// FIXME: validate signing keys and merge with old set
r.details = {
auditors: exchangeKeysJson.auditors,
currency: currency,
lastUpdateTime: lastUpdateTimestamp,
masterPublicKey: exchangeKeysJson.master_public_key,
protocolVersion: protocolVersion,
2020-03-13 14:34:16 +01:00
signingKeys: exchangeKeysJson.signkeys,
2020-09-02 11:14:36 +02:00
nextUpdateTime: getExpiryTimestamp(resp, {
minDuration: durationFromSpec({ hours: 1 }),
}),
2021-01-10 23:57:06 +01:00
reserveClosingDelay: exchangeKeysJson.reserve_closing_delay,
};
2019-12-16 12:53:22 +01:00
r.updateStatus = ExchangeUpdateStatus.FetchWire;
r.lastError = undefined;
2020-09-02 11:14:36 +02:00
r.retryInfo = initRetryInfo(false);
await tx.put(Stores.exchanges, r);
for (const newDenom of newDenominations) {
const oldDenom = await tx.get(Stores.denominations, [
baseUrl,
2020-09-08 17:33:10 +02:00
newDenom.denomPubHash,
]);
if (oldDenom) {
// FIXME: Do consistency check
} else {
await tx.put(Stores.denominations, newDenom);
}
}
// Handle recoup
const recoupDenomList = exchangeKeysJson.recoup ?? [];
const newlyRevokedCoinPubs: string[] = [];
logger.trace("recoup list from exchange", recoupDenomList);
for (const recoupInfo of recoupDenomList) {
2020-09-08 17:33:10 +02:00
const oldDenom = await tx.get(Stores.denominations, [
r.baseUrl,
recoupInfo.h_denom_pub,
2020-09-08 17:33:10 +02:00
]);
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.trace("revoking denom", recoupInfo.h_denom_pub);
oldDenom.isRevoked = true;
await tx.put(Stores.denominations, oldDenom);
const affectedCoins = await tx
.iterIndexed(Stores.coins.denomPubHashIndex, recoupInfo.h_denom_pub)
.toArray();
for (const ac of affectedCoins) {
newlyRevokedCoinPubs.push(ac.coinPub);
}
}
if (newlyRevokedCoinPubs.length != 0) {
logger.trace("recouping coins", newlyRevokedCoinPubs);
await createRecoupGroup(ws, tx, newlyRevokedCoinPubs);
}
},
);
if (recoupGroupId) {
// Asynchronously start recoup. This doesn't need to finish
// for the exchange update to be considered finished.
2020-03-30 12:39:32 +02:00
processRecoupGroup(ws, recoupGroupId).catch((e) => {
logger.error("error while recouping coins:", e);
});
}
logger.trace("done updating exchange /keys");
}
2019-12-16 12:53:22 +01:00
async function updateExchangeFinalize(
ws: InternalWalletState,
exchangeBaseUrl: string,
2020-04-06 21:53:29 +02:00
): Promise<void> {
2019-12-16 12:53:22 +01:00
const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
if (!exchange) {
return;
}
if (exchange.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
return;
}
2020-12-14 16:45:15 +01:00
await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
if (!r) {
return;
}
if (r.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
return;
}
r.addComplete = true;
r.updateStatus = ExchangeUpdateStatus.Finished;
// Reset time to next auto refresh check,
// as now new denominations might be available.
r.nextRefreshCheck = undefined;
await tx.put(Stores.exchanges, r);
});
2019-12-16 12:53:22 +01:00
}
2019-12-09 19:59:08 +01:00
async function updateExchangeWithTermsOfService(
ws: InternalWalletState,
exchangeBaseUrl: string,
2020-04-06 21:53:29 +02:00
): Promise<void> {
2019-12-12 22:39:45 +01:00
const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
2019-12-09 19:59:08 +01:00
if (!exchange) {
return;
}
2019-12-16 12:53:22 +01:00
if (exchange.updateStatus != ExchangeUpdateStatus.FetchTerms) {
2019-12-09 19:59:08 +01:00
return;
}
const reqUrl = new URL("terms", exchangeBaseUrl);
reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
const headers = {
Accept: "text/plain",
};
const resp = await ws.http.get(reqUrl.href, {
headers,
timeout: getExchangeRequestTimeout(exchange),
});
const tosText = await readSuccessResponseTextOrThrow(resp);
2019-12-09 19:59:08 +01:00
const tosEtag = resp.headers.get("etag") || undefined;
2020-03-30 12:39:32 +02:00
await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
2019-12-09 19:59:08 +01:00
const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
if (!r) {
return;
}
2019-12-16 12:53:22 +01:00
if (r.updateStatus != ExchangeUpdateStatus.FetchTerms) {
2019-12-09 19:59:08 +01:00
return;
}
r.termsOfServiceText = tosText;
r.termsOfServiceLastEtag = tosEtag;
2019-12-16 12:53:22 +01:00
r.updateStatus = ExchangeUpdateStatus.FinalizeUpdate;
2019-12-09 19:59:08 +01:00
await tx.put(Stores.exchanges, r);
});
}
export async function acceptExchangeTermsOfService(
ws: InternalWalletState,
exchangeBaseUrl: string,
etag: string | undefined,
2020-04-06 21:53:29 +02:00
): Promise<void> {
2020-03-30 12:39:32 +02:00
await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
2019-12-09 19:59:08 +01:00
const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
if (!r) {
return;
}
r.termsOfServiceAcceptedEtag = etag;
await tx.put(Stores.exchanges, r);
});
}
/**
* Fetch wire information for an exchange and store it in the database.
*
* @param exchangeBaseUrl Exchange base URL, assumed to be already normalized.
*/
async function updateExchangeWithWireInfo(
ws: InternalWalletState,
exchangeBaseUrl: string,
2020-04-06 21:53:29 +02:00
): Promise<void> {
2019-12-12 22:39:45 +01:00
const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
if (!exchange) {
return;
}
2019-12-16 12:53:22 +01:00
if (exchange.updateStatus != ExchangeUpdateStatus.FetchWire) {
return;
}
2019-12-05 23:07:46 +01:00
const details = exchange.details;
if (!details) {
throw Error("invalid exchange state");
}
const reqUrl = new URL("wire", exchangeBaseUrl);
2019-12-05 23:07:46 +01:00
reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
const resp = await ws.http.get(reqUrl.href, {
timeout: getExchangeRequestTimeout(exchange),
});
const wireInfo = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangeWireJson(),
);
2019-12-05 23:07:46 +01:00
for (const a of wireInfo.accounts) {
logger.trace("validating exchange acct");
2019-12-05 23:07:46 +01:00
const isValid = await ws.cryptoApi.isValidWireAccount(
a.payto_uri,
2019-12-05 23:07:46 +01:00
a.master_sig,
details.masterPublicKey,
);
if (!isValid) {
throw Error("exchange acct signature invalid");
}
}
const feesForType: { [wireMethod: string]: WireFee[] } = {};
for (const wireMethod of Object.keys(wireInfo.fees)) {
const feeList: WireFee[] = [];
for (const x of wireInfo.fees[wireMethod]) {
const startStamp = x.start_date;
const endStamp = x.end_date;
2019-12-05 23:07:46 +01:00
const fee: WireFee = {
closingFee: Amounts.parseOrThrow(x.closing_fee),
endStamp,
sig: x.sig,
startStamp,
wireFee: Amounts.parseOrThrow(x.wire_fee),
2019-12-05 23:07:46 +01:00
};
const isValid = await ws.cryptoApi.isValidWireFee(
wireMethod,
fee,
details.masterPublicKey,
);
if (!isValid) {
throw Error("exchange wire fee signature invalid");
}
feeList.push(fee);
}
feesForType[wireMethod] = feeList;
}
2020-03-30 12:39:32 +02:00
await ws.db.runWithWriteTransaction([Stores.exchanges], async (tx) => {
const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
if (!r) {
return;
}
2019-12-16 12:53:22 +01:00
if (r.updateStatus != ExchangeUpdateStatus.FetchWire) {
return;
}
r.wireInfo = {
accounts: wireInfo.accounts,
feesForType: feesForType,
};
2019-12-16 12:53:22 +01:00
r.updateStatus = ExchangeUpdateStatus.FetchTerms;
r.lastError = undefined;
2020-09-02 11:14:36 +02:00
r.retryInfo = initRetryInfo(false);
await tx.put(Stores.exchanges, r);
});
}
export async function updateExchangeFromUrl(
ws: InternalWalletState,
baseUrl: string,
2020-04-06 17:45:41 +02:00
forceNow = false,
): Promise<ExchangeRecord> {
2020-09-01 14:57:22 +02:00
const onOpErr = (e: TalerErrorDetails): Promise<void> =>
2020-09-02 11:14:36 +02:00
handleExchangeUpdateError(ws, baseUrl, e);
return await guardOperationException(
() => updateExchangeFromUrlImpl(ws, baseUrl, forceNow),
onOpErr,
);
}
/**
* Update or add exchange DB entry by fetching the /keys and /wire information.
* Optionally link the reserve entry to the new or existing
* exchange entry in then DB.
*/
async function updateExchangeFromUrlImpl(
ws: InternalWalletState,
baseUrl: string,
2020-04-06 17:45:41 +02:00
forceNow = false,
): Promise<ExchangeRecord> {
2020-09-02 11:14:36 +02:00
logger.trace(`updating exchange info for ${baseUrl}`);
const now = getTimestampNow();
baseUrl = canonicalizeBaseUrl(baseUrl);
2020-09-02 08:53:11 +02:00
let r = await ws.db.get(Stores.exchanges, baseUrl);
if (!r) {
const newExchangeRecord: ExchangeRecord = {
2019-12-16 12:53:22 +01:00
builtIn: false,
addComplete: false,
permanent: true,
baseUrl: baseUrl,
details: undefined,
wireInfo: undefined,
2019-12-16 12:53:22 +01:00
updateStatus: ExchangeUpdateStatus.FetchKeys,
updateStarted: now,
2019-12-16 12:53:22 +01:00
updateReason: ExchangeUpdateReason.Initial,
2019-12-09 19:59:08 +01:00
termsOfServiceAcceptedEtag: undefined,
termsOfServiceLastEtag: undefined,
termsOfServiceText: undefined,
2020-09-02 11:14:36 +02:00
retryInfo: initRetryInfo(false),
};
2019-12-12 22:39:45 +01:00
await ws.db.put(Stores.exchanges, newExchangeRecord);
} else {
2020-03-30 12:39:32 +02:00
await ws.db.runWithWriteTransaction([Stores.exchanges], async (t) => {
const rec = await t.get(Stores.exchanges, baseUrl);
if (!rec) {
return;
}
2020-09-02 11:14:36 +02:00
if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys) {
const t = rec.details?.nextUpdateTime;
if (!forceNow && t && !isTimestampExpired(t)) {
return;
}
}
2019-12-16 12:53:22 +01:00
if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && forceNow) {
rec.updateReason = ExchangeUpdateReason.Forced;
}
rec.updateStarted = now;
2019-12-16 12:53:22 +01:00
rec.updateStatus = ExchangeUpdateStatus.FetchKeys;
rec.lastError = undefined;
2020-09-02 11:14:36 +02:00
rec.retryInfo = initRetryInfo(false);
t.put(Stores.exchanges, rec);
});
}
await updateExchangeWithKeys(ws, baseUrl);
await updateExchangeWithWireInfo(ws, baseUrl);
2019-12-09 19:59:08 +01:00
await updateExchangeWithTermsOfService(ws, baseUrl);
2019-12-16 12:53:22 +01:00
await updateExchangeFinalize(ws, baseUrl);
2019-12-12 22:39:45 +01:00
const updatedExchange = await ws.db.get(Stores.exchanges, baseUrl);
2020-09-02 11:14:36 +02:00
checkDbInvariant(!!updatedExchange);
return updatedExchange;
}
export async function getExchangePaytoUri(
ws: InternalWalletState,
exchangeBaseUrl: string,
supportedTargetTypes: string[],
): Promise<string> {
// We do the update here, since the exchange might not even exist
// yet in our database.
const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl);
if (!exchangeRecord) {
throw Error(`Exchange '${exchangeBaseUrl}' not found.`);
}
const exchangeWireInfo = exchangeRecord.wireInfo;
if (!exchangeWireInfo) {
throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`);
}
2020-04-06 17:45:41 +02:00
for (const account of exchangeWireInfo.accounts) {
2020-01-19 19:02:47 +01:00
const res = parsePaytoUri(account.payto_uri);
if (!res) {
continue;
}
if (supportedTargetTypes.includes(res.targetType)) {
2020-01-19 19:02:47 +01:00
return account.payto_uri;
}
}
throw Error("no matching exchange account found");
}