taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 761e65b5a1da6fc694f8beb1c5784631a9dd545b
parent 3bf07a819c805b2c1df8afa995c2de4fd02b4257
Author: Florian Dold <florian@dold.me>
Date:   Wed,  2 Oct 2024 16:57:20 +0200

wallet-core: implement getTransactionsV2

Diffstat:
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db.ts | 3+++
Mpackages/taler-wallet-core/src/transactions.ts | 248++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 9+++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 6++++++
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,