commit 761e65b5a1da6fc694f8beb1c5784631a9dd545b
parent 3bf07a819c805b2c1df8afa995c2de4fd02b4257
Author: Florian Dold <florian@dold.me>
Date: Wed, 2 Oct 2024 16:57:20 +0200
wallet-core: implement getTransactionsV2
Diffstat:
5 files changed, 328 insertions(+), 1 deletion(-)
diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts
@@ -34,6 +34,7 @@ import {
codecForConstString,
codecForEither,
codecForList,
+ codecForNumber,
codecForString,
codecOptional,
} from "./codec.js";
@@ -41,6 +42,7 @@ import {
TalerPreciseTimestamp,
TalerProtocolDuration,
TalerProtocolTimestamp,
+ codecForPreciseTimestamp,
} from "./time.js";
import {
AmountString,
@@ -98,6 +100,43 @@ export interface TransactionsRequest {
filterByState?: TransactionStateFilter;
}
+export interface GetTransactionsV2Request {
+ /**
+ * Return only transactions in the given currency.
+ */
+ currency?: string;
+
+ /**
+ * Return only transactions in the given scopeInfo
+ */
+ scopeInfo?: ScopeInfo;
+
+ /**
+ * If true, include all refreshes in the transactions list.
+ */
+ includeRefreshes?: boolean;
+
+ /**
+ * Only return transactions before/after this offset.
+ */
+ offsetTransactionId?: TransactionIdStr;
+
+ /**
+ * Only return transactions before/after the transaction with this
+ * timestamp.
+ *
+ * Used as a fallback if the offsetTransactionId was deleted.
+ */
+ offsetTimestamp?: TalerPreciseTimestamp;
+
+ /**
+ * Number of transactions to return.
+ */
+ limit?: number;
+
+ filterState?: "final" | "nonfinal" | "done";
+}
+
export interface TransactionState {
major: TransactionMajorState;
minor?: TransactionMinorState;
@@ -793,6 +832,30 @@ export const codecForTransactionByIdRequest =
.property("transactionId", codecForString())
.build("TransactionByIdRequest");
+export const codecForGetTransactionsV2Request =
+ (): Codec<GetTransactionsV2Request> =>
+ buildCodecForObject<GetTransactionsV2Request>()
+ .property("currency", codecOptional(codecForString()))
+ .property("scopeInfo", codecOptional(codecForScopeInfo()))
+ .property(
+ "offsetTransactionId",
+ codecOptional(codecForString() as Codec<TransactionIdStr>),
+ )
+ .property("offsetTimestamp", codecOptional(codecForPreciseTimestamp))
+ .property("limit", codecOptional(codecForNumber()))
+ .property(
+ "filterState",
+ codecOptional(
+ codecForEither(
+ codecForConstString("final"),
+ codecForConstString("nonfinal"),
+ codecForConstString("done"),
+ ),
+ ),
+ )
+ .property("includeRefreshes", codecOptional(codecForBoolean()))
+ .build("GetTransactionsV2Request");
+
export const codecForTransactionsRequest = (): Codec<TransactionsRequest> =>
buildCodecForObject<TransactionsRequest>()
.property("currency", codecOptional(codecForString()))
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -275,6 +275,9 @@ export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000;
*/
export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff;
+export const OPERATION_STATUS_DONE_FIRST = 0x0500_0000;
+export const OPERATION_STATUS_DONE_LAST = 0x0500_ffff;
+
/**
* Status of a withdrawal.
*/
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
@@ -17,11 +17,17 @@
/**
* Imports.
*/
-import { GlobalIDB, IDBKeyRange } from "@gnu-taler/idb-bridge";
+import {
+ BridgeIDBKeyRange,
+ GlobalIDB,
+ IDBKeyRange,
+} from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
Amounts,
assertUnreachable,
+ checkLogicInvariant,
+ GetTransactionsV2Request,
j2s,
Logger,
makeTalerErrorDetail,
@@ -44,8 +50,14 @@ import {
TransactionContext,
} from "./common.js";
import {
+ DbPreciseTimestamp,
+ OPERATION_STATUS_DONE_FIRST,
+ OPERATION_STATUS_DONE_LAST,
OPERATION_STATUS_NONFINAL_FIRST,
OPERATION_STATUS_NONFINAL_LAST,
+ timestampPreciseToDb,
+ TransactionMetaRecord,
+ WalletDbAllStoresReadOnlyTransaction,
WalletDbAllStoresReadWriteTransaction,
} from "./db.js";
import { DepositTransactionContext } from "./deposits.js";
@@ -165,6 +177,240 @@ export function isUnsuccessfulTransaction(state: TransactionState): boolean {
);
}
+function checkFilterIncludes(
+ req: GetTransactionsV2Request | undefined,
+ mtx: TransactionMetaRecord,
+): boolean {
+ if (shouldSkipCurrency(req, mtx.currency, mtx.exchanges)) {
+ return false;
+ }
+
+ const parsedTx = parseTransactionIdentifier(mtx.transactionId);
+ if (parsedTx?.tag === TransactionType.Refresh && !req?.includeRefreshes) {
+ return false;
+ }
+
+ let included: boolean;
+
+ const filter = req?.filterState;
+ switch (filter) {
+ case "done":
+ included =
+ mtx.status >= OPERATION_STATUS_DONE_FIRST &&
+ mtx.status <= OPERATION_STATUS_DONE_LAST;
+ break;
+ case "final":
+ included = !(
+ mtx.status >= OPERATION_STATUS_NONFINAL_FIRST &&
+ mtx.status <= OPERATION_STATUS_NONFINAL_LAST
+ );
+ break;
+ case "nonfinal":
+ included =
+ mtx.status >= OPERATION_STATUS_NONFINAL_FIRST &&
+ mtx.status <= OPERATION_STATUS_NONFINAL_LAST;
+ break;
+ case undefined:
+ included = true;
+ break;
+ default:
+ assertUnreachable(filter);
+ }
+ return included;
+}
+
+function addFiltered(
+ req: GetTransactionsV2Request | undefined,
+ target: TransactionMetaRecord[],
+ source: TransactionMetaRecord[],
+): void {
+ for (const mtx of source) {
+ if (checkFilterIncludes(req, mtx)) {
+ target.push(mtx);
+ }
+ }
+}
+
+/**
+ * Sort transactions in-place according to the request.
+ */
+function sortTransactions(
+ req: GetTransactionsV2Request | undefined,
+ transactions: Transaction[],
+): void {
+ let sortSign: number;
+ if (req?.limit != null && req.limit < 0) {
+ sortSign = -1;
+ } else {
+ sortSign = 1;
+ }
+ const txCmp = (h1: Transaction, h2: Transaction) => {
+ // Order transactions by timestamp. Newest transactions come first.
+ const tsCmp = AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
+ AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
+ );
+ // If the timestamp is exactly the same, order by transaction type.
+ if (tsCmp === 0) {
+ return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
+ }
+ return sortSign * tsCmp;
+ };
+ transactions.sort(txCmp);
+}
+
+async function findOffsetTransaction(
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ req?: GetTransactionsV2Request,
+): Promise<TransactionMetaRecord | undefined> {
+ let forwards = req?.limit == null || req.limit >= 0;
+ let closestTimestamp: DbPreciseTimestamp | undefined = undefined;
+ if (req?.offsetTransactionId) {
+ const res = await tx.transactionsMeta.get(req.offsetTransactionId);
+ if (res) {
+ return res;
+ }
+ if (req.offsetTimestamp) {
+ closestTimestamp = timestampPreciseToDb(req.offsetTimestamp);
+ } else {
+ throw Error(
+ "offset transaction not found and no offset timestamp specified",
+ );
+ }
+ } else if (req?.offsetTimestamp) {
+ const dbStamp = timestampPreciseToDb(req.offsetTimestamp);
+ const res = await tx.transactionsMeta.indexes.byTimestamp.get(dbStamp);
+ if (res) {
+ return res;
+ }
+ closestTimestamp = timestampPreciseToDb(req.offsetTimestamp);
+ } else {
+ return undefined;
+ }
+
+ // We didn't find a precise offset transaction.
+ // This must mean that it was deleted.
+ // Depending on the direction, find the prev/next
+ // transaction and use it as an offset.
+
+ if (forwards) {
+ // We don't want to skip transactions in pagination,
+ // so get the transaction before the timestamp
+
+ // Slow query, but should not happen often!
+ const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll(
+ BridgeIDBKeyRange.upperBound(closestTimestamp, false),
+ );
+ if (recs.length > 0) {
+ return recs[recs.length - 1];
+ }
+ return undefined;
+ } else {
+ // Likewise, get the transaction after the timestamp
+ const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll(
+ BridgeIDBKeyRange.lowerBound(closestTimestamp, false),
+ 1,
+ );
+ return recs[0];
+ }
+}
+
+export async function getTransactionsV2(
+ wex: WalletExecutionContext,
+ transactionsRequest?: GetTransactionsV2Request,
+): Promise<TransactionsResponse> {
+ // The implementation of this function is optimized for
+ // the common fast path of requesting transactions
+ // in an ascending order.
+ // Other requests are more difficult to implement
+ // in a performant way due to IndexedDB limitations.
+
+ const resultTransactions: Transaction[] = [];
+
+ await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
+ let mtxs: TransactionMetaRecord[] = [];
+ let forwards =
+ transactionsRequest?.limit == null || transactionsRequest.limit >= 0;
+ let limit =
+ transactionsRequest?.limit != null
+ ? Math.abs(transactionsRequest.limit)
+ : undefined;
+ let offsetMtx = await findOffsetTransaction(tx, transactionsRequest);
+
+ if (limit == null && offsetMtx == null) {
+ // Fast path for returning *everything* that matches the filter.
+ // FIXME: We could use the DB for filtering here
+ const res = await tx.transactionsMeta.indexes.byStatus.getAll();
+ addFiltered(transactionsRequest, mtxs, res);
+ } else if (!forwards) {
+ // Descending, backwards request.
+ // Slow implementation. Doing it properly would require using cursors,
+ // which are also slow in IndexedDB.
+ const res = await tx.transactionsMeta.indexes.byTimestamp.getAll();
+ res.reverse();
+ let start: number;
+ if (offsetMtx != null) {
+ const needleTxId = offsetMtx.transactionId;
+ start = res.findIndex((x) => x.transactionId === needleTxId);
+ if (start < 0) {
+ throw Error("offset transaction not found");
+ }
+ } else {
+ start = 0;
+ }
+ let numAdded = 0;
+ for (let i = start; i < res.length; i++) {
+ if (limit != null && numAdded >= limit) {
+ break;
+ }
+ if (checkFilterIncludes(transactionsRequest, res[i])) {
+ mtxs.push(res[i]);
+ numAdded += 1;
+ }
+ }
+ } else {
+ // Forward request
+ let query: BridgeIDBKeyRange | undefined = undefined;
+ if (offsetMtx != null) {
+ query = GlobalIDB.KeyRange.lowerBound(offsetMtx.timestamp, true);
+ }
+ while (true) {
+ const res = await tx.transactionsMeta.indexes.byTimestamp.getAll(
+ query,
+ limit,
+ );
+ addFiltered(transactionsRequest, mtxs, res);
+ if (res.length === 0 || (limit != null && mtxs.length >= limit)) {
+ break;
+ }
+ // Continue after last result
+ query = BridgeIDBKeyRange.lowerBound(
+ res[res.length - 1].timestamp,
+ true,
+ );
+ }
+ }
+
+ // Lookup full transactions
+ for (const mtx of mtxs) {
+ const ctx = await getContextForTransaction(wex, mtx.transactionId);
+ const txDetails = await ctx.lookupFullTransaction(tx);
+ // FIXME: This means that in some cases we can return fewer transactions
+ // than requested.
+ if (!txDetails) {
+ continue;
+ }
+ resultTransactions.push(txDetails);
+ }
+ });
+
+ sortTransactions(transactionsRequest, resultTransactions);
+
+ return {
+ transactions: resultTransactions,
+ };
+}
+
/**
* Retrieve the full event history for this wallet.
*/
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -91,6 +91,7 @@ import {
GetMaxPeerPushDebitAmountResponse,
GetQrCodesForPaytoRequest,
GetQrCodesForPaytoResponse,
+ GetTransactionsV2Request,
GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest,
HintNetworkAvailabilityRequest,
@@ -187,6 +188,7 @@ export enum WalletApiOperation {
TestPay = "testPay",
AddExchange = "addExchange",
GetTransactions = "getTransactions",
+ GetTransactionsV2 = "getTransactionsV2",
GetTransactionById = "getTransactionById",
TestingGetSampleTransactions = "testingGetSampleTransactions",
ListExchanges = "listExchanges",
@@ -393,6 +395,12 @@ export type GetTransactionsOp = {
response: TransactionsResponse;
};
+export type GetTransactionsV2Op = {
+ op: WalletApiOperation.GetTransactionsV2;
+ request: GetTransactionsV2Request;
+ response: TransactionsResponse;
+};
+
/**
* List refresh transactions associated with another transaction.
*/
@@ -1309,6 +1317,7 @@ export type WalletOperations = {
[WalletApiOperation.GetMaxPeerPushDebitAmount]: GetMaxPeerPushDebitAmountOp;
[WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp;
[WalletApiOperation.GetTransactions]: GetTransactionsOp;
+ [WalletApiOperation.GetTransactionsV2]: GetTransactionsV2Op;
[WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp;
[WalletApiOperation.GetTransactionById]: GetTransactionByIdOp;
[WalletApiOperation.RetryPendingNow]: RetryPendingNowOp;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -160,6 +160,7 @@ import {
codecForGetMaxDepositAmountRequest,
codecForGetMaxPeerPushDebitAmountRequest,
codecForGetQrCodesForPaytoRequest,
+ codecForGetTransactionsV2Request,
codecForGetWithdrawalDetailsForAmountRequest,
codecForGetWithdrawalDetailsForUri,
codecForHintNetworkAvailabilityRequest,
@@ -343,6 +344,7 @@ import {
failTransaction,
getTransactionById,
getTransactions,
+ getTransactionsV2,
parseTransactionIdentifier,
rematerializeTransactions,
restartAll as restartAllRunningTasks,
@@ -1651,6 +1653,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForTransactionsRequest(),
handler: getTransactions,
},
+ [WalletApiOperation.GetTransactionsV2]: {
+ codec: codecForGetTransactionsV2Request(),
+ handler: getTransactionsV2,
+ },
[WalletApiOperation.GetTransactionById]: {
codec: codecForTransactionByIdRequest(),
handler: getTransactionById,