diff --git a/core/api/src/app/accounts/get-account-transactions-for-contact.ts b/core/api/src/app/accounts/get-account-transactions-for-contact.ts index 9aac2e5964b..f8422456f18 100644 --- a/core/api/src/app/accounts/get-account-transactions-for-contact.ts +++ b/core/api/src/app/accounts/get-account-transactions-for-contact.ts @@ -1,5 +1,6 @@ -import { memoSharingConfig } from "@/config" +import { MAX_PAGINATION_PAGE_SIZE, memoSharingConfig } from "@/config" import { LedgerError } from "@/domain/ledger" +import { checkedToPaginatedQueryArgs } from "@/domain/primitives" import { WalletTransactionHistory } from "@/domain/wallets" import { getNonEndUserWalletIds, LedgerService } from "@/services/ledger" @@ -8,29 +9,50 @@ import { WalletsRepository } from "@/services/mongoose" export const getAccountTransactionsForContact = async ({ account, contactUsername, - paginationArgs, + rawPaginationArgs, }: { account: Account contactUsername: Username - paginationArgs?: PaginationArgs -}): Promise | ApplicationError> => { + rawPaginationArgs: { + first?: number | null + last?: number | null + before?: string | null + after?: string | null + } +}): Promise | ApplicationError> => { + const paginationArgs = checkedToPaginatedQueryArgs({ + paginationArgs: rawPaginationArgs, + maxPageSize: MAX_PAGINATION_PAGE_SIZE, + }) + + if (paginationArgs instanceof Error) return paginationArgs + const ledger = LedgerService() const wallets = await WalletsRepository().listByAccountId(account.id) if (wallets instanceof Error) return wallets - const resp = await ledger.getTransactionsByWalletIdAndContactUsername({ + const ledgerTxs = await ledger.getTransactionsByWalletIdAndContactUsername({ walletIds: wallets.map((wallet) => wallet.id), contactUsername, paginationArgs, }) - if (resp instanceof LedgerError) return resp + if (ledgerTxs instanceof LedgerError) return ledgerTxs + + const nonEndUserWalletIds = Object.values(await getNonEndUserWalletIds()) + + const txEdges = ledgerTxs.edges.map((edge) => { + const { transactions } = WalletTransactionHistory.fromLedger({ + ledgerTransactions: [edge.node], + nonEndUserWalletIds, + memoSharingConfig, + }) - const confirmedHistory = WalletTransactionHistory.fromLedger({ - ledgerTransactions: resp.slice, - nonEndUserWalletIds: Object.values(await getNonEndUserWalletIds()), - memoSharingConfig, + return { + cursor: edge.cursor, + node: transactions[0], + } }) - return { slice: confirmedHistory.transactions, total: resp.total } + return { ...ledgerTxs, edges: txEdges } } diff --git a/core/api/src/app/accounts/get-transactions-for-account.ts b/core/api/src/app/accounts/get-transactions-for-account.ts index 28b5902fb86..54c625463d8 100644 --- a/core/api/src/app/accounts/get-transactions-for-account.ts +++ b/core/api/src/app/accounts/get-transactions-for-account.ts @@ -1,7 +1,5 @@ import { getTransactionsForWallets } from "../wallets" -import { PartialResult } from "../partial-result" - import { AccountValidator } from "@/domain/accounts" import { RepositoryError } from "@/domain/errors" import { WalletsRepository } from "@/services/mongoose" @@ -9,26 +7,38 @@ import { WalletsRepository } from "@/services/mongoose" export const getTransactionsForAccountByWalletIds = async ({ account, walletIds, - paginationArgs, + rawPaginationArgs, }: { account: Account - walletIds: WalletId[] - paginationArgs?: PaginationArgs -}): Promise>> => { + walletIds?: WalletId[] + rawPaginationArgs: { + first?: number | null + last?: number | null + before?: string | null + after?: string | null + } +}): Promise | ApplicationError> => { const walletsRepo = WalletsRepository() const wallets: Wallet[] = [] - for (const walletId of walletIds) { - const wallet = await walletsRepo.findById(walletId) - if (wallet instanceof RepositoryError) return PartialResult.err(wallet) - const accountValidator = AccountValidator(account) - if (accountValidator instanceof Error) return PartialResult.err(accountValidator) - const validateWallet = accountValidator.validateWalletForAccount(wallet) - if (validateWallet instanceof Error) return PartialResult.err(validateWallet) + if (walletIds) { + for (const walletId of walletIds) { + const wallet = await walletsRepo.findById(walletId) + if (wallet instanceof RepositoryError) return wallet + + const accountValidator = AccountValidator(account) + if (accountValidator instanceof Error) return accountValidator + const validateWallet = accountValidator.validateWalletForAccount(wallet) + if (validateWallet instanceof Error) return validateWallet - wallets.push(wallet) + wallets.push(wallet) + } + } else { + const accountWallets = await walletsRepo.listByAccountId(account.id) + if (accountWallets instanceof RepositoryError) return accountWallets + wallets.push(...accountWallets) } - return getTransactionsForWallets({ wallets, paginationArgs }) + return getTransactionsForWallets({ wallets, rawPaginationArgs }) } diff --git a/core/api/src/app/wallets/get-transactions-by-addresses.ts b/core/api/src/app/wallets/get-transactions-by-addresses.ts index fc89221ac6d..6ea8e8be613 100644 --- a/core/api/src/app/wallets/get-transactions-by-addresses.ts +++ b/core/api/src/app/wallets/get-transactions-by-addresses.ts @@ -1,61 +1,60 @@ -import { memoSharingConfig } from "@/config" -import { PartialResult } from "@/app/partial-result" +import { MAX_PAGINATION_PAGE_SIZE, memoSharingConfig } from "@/config" import { LedgerError } from "@/domain/ledger" import { WalletTransactionHistory } from "@/domain/wallets" -import { CouldNotFindError } from "@/domain/errors" import { getNonEndUserWalletIds, LedgerService } from "@/services/ledger" -import { WalletOnChainPendingReceiveRepository } from "@/services/mongoose" +import { checkedToPaginatedQueryArgs } from "@/domain/primitives" export const getTransactionsForWalletsByAddresses = async ({ wallets, addresses, - paginationArgs, + rawPaginationArgs, }: { wallets: Wallet[] addresses: OnChainAddress[] - paginationArgs?: PaginationArgs -}): Promise>> => { - const walletIds = wallets.map((wallet) => wallet.id) + rawPaginationArgs: { + first?: number | null + last?: number | null + before?: string | null + after?: string | null + } +}): Promise | ApplicationError> => { + const paginationArgs = checkedToPaginatedQueryArgs({ + paginationArgs: rawPaginationArgs, + maxPageSize: MAX_PAGINATION_PAGE_SIZE, + }) - let pendingHistory = - await WalletOnChainPendingReceiveRepository().listByWalletIdsAndAddresses({ - walletIds, - addresses, - }) - if (pendingHistory instanceof Error) { - if (pendingHistory instanceof CouldNotFindError) { - pendingHistory = [] - } else { - return PartialResult.err(pendingHistory) - } + if (paginationArgs instanceof Error) { + return paginationArgs } - const confirmedLedgerTxns = await LedgerService().getTransactionsByWalletIds({ + const walletIds = wallets.map((wallet) => wallet.id) + + const ledgerTxs = await LedgerService().getTransactionsByWalletIdsAndAddresses({ walletIds, paginationArgs, + addresses, }) - if (confirmedLedgerTxns instanceof LedgerError) { - return PartialResult.partial( - { slice: pendingHistory, total: pendingHistory.length }, - confirmedLedgerTxns, - ) + + if (ledgerTxs instanceof LedgerError) { + return ledgerTxs } - const ledgerTransactions = confirmedLedgerTxns.slice.filter( - (tx) => tx.address && addresses.includes(tx.address), - ) - - const confirmedHistory = WalletTransactionHistory.fromLedger({ - ledgerTransactions, - nonEndUserWalletIds: Object.values(await getNonEndUserWalletIds()), - memoSharingConfig, - }) - const transactions = [...pendingHistory, ...confirmedHistory.transactions] + const nonEndUserWalletIds = Object.values(await getNonEndUserWalletIds()) + + const txEdges = ledgerTxs.edges.map((edge) => { + const { transactions } = WalletTransactionHistory.fromLedger({ + ledgerTransactions: [edge.node], + nonEndUserWalletIds, + memoSharingConfig, + }) - return PartialResult.ok({ - slice: transactions, - total: transactions.length, + return { + cursor: edge.cursor, + node: transactions[0], + } }) + + return { ...ledgerTxs, edges: txEdges } } diff --git a/core/api/src/app/wallets/get-transactions-for-wallet.ts b/core/api/src/app/wallets/get-transactions-for-wallet.ts index 56b87991454..f1105b5f8ae 100644 --- a/core/api/src/app/wallets/get-transactions-for-wallet.ts +++ b/core/api/src/app/wallets/get-transactions-for-wallet.ts @@ -1,55 +1,53 @@ -import { memoSharingConfig } from "@/config" -import { PartialResult } from "@/app/partial-result" +import { MAX_PAGINATION_PAGE_SIZE, memoSharingConfig } from "@/config" import { LedgerError } from "@/domain/ledger" import { WalletTransactionHistory } from "@/domain/wallets" -import { CouldNotFindError } from "@/domain/errors" import { getNonEndUserWalletIds, LedgerService } from "@/services/ledger" -import { WalletOnChainPendingReceiveRepository } from "@/services/mongoose" +import { checkedToPaginatedQueryArgs } from "@/domain/primitives" export const getTransactionsForWallets = async ({ wallets, - paginationArgs, + rawPaginationArgs, }: { wallets: Wallet[] - paginationArgs?: PaginationArgs -}): Promise>> => { - const walletIds = wallets.map((wallet) => wallet.id) - - let pendingHistory = await WalletOnChainPendingReceiveRepository().listByWalletIds({ - walletIds, - }) - if (pendingHistory instanceof Error) { - if (pendingHistory instanceof CouldNotFindError) { - pendingHistory = [] - } else { - return PartialResult.err(pendingHistory) - } + rawPaginationArgs: { + first?: number | null + last?: number | null + before?: string | null + after?: string | null } +}): Promise | ApplicationError> => { + const paginationArgs = checkedToPaginatedQueryArgs({ + paginationArgs: rawPaginationArgs, + maxPageSize: MAX_PAGINATION_PAGE_SIZE, + }) + + if (paginationArgs instanceof Error) return paginationArgs - const confirmedLedgerTxns = await LedgerService().getTransactionsByWalletIds({ + const walletIds = wallets.map((wallet) => wallet.id) + + const ledgerTxs = await LedgerService().getTransactionsByWalletIds({ walletIds, paginationArgs, }) - if (confirmedLedgerTxns instanceof LedgerError) { - return PartialResult.partial( - { slice: pendingHistory, total: pendingHistory.length }, - confirmedLedgerTxns, - ) - } + if (ledgerTxs instanceof LedgerError) return ledgerTxs - const confirmedHistory = WalletTransactionHistory.fromLedger({ - ledgerTransactions: confirmedLedgerTxns.slice, - nonEndUserWalletIds: Object.values(await getNonEndUserWalletIds()), - memoSharingConfig, - }) + const nonEndUserWalletIds = Object.values(await getNonEndUserWalletIds()) - const transactions = [...pendingHistory, ...confirmedHistory.transactions] + const txEdges = ledgerTxs.edges.map((edge) => { + const { transactions } = WalletTransactionHistory.fromLedger({ + ledgerTransactions: [edge.node], + nonEndUserWalletIds, + memoSharingConfig, + }) - return PartialResult.ok({ - slice: transactions, - total: confirmedLedgerTxns.total + pendingHistory.length, + return { + cursor: edge.cursor, + node: transactions[0], + } }) + + return { ...ledgerTxs, edges: txEdges } } diff --git a/core/api/src/domain/ledger/index.types.d.ts b/core/api/src/domain/ledger/index.types.d.ts index f837301d524..91b6fd0973f 100644 --- a/core/api/src/domain/ledger/index.types.d.ts +++ b/core/api/src/domain/ledger/index.types.d.ts @@ -2,8 +2,6 @@ type LedgerError = import("./errors").LedgerError type FeeDifferenceError = import("./errors").FeeDifferenceError type LedgerServiceError = import("./errors").LedgerServiceError -type PaginationArgs = import("graphql-relay").ConnectionArguments - declare const liabilitiesWalletId: unique symbol type LiabilitiesWalletId = string & { [liabilitiesWalletId]: never } @@ -250,20 +248,32 @@ interface ILedgerService { paymentHash: PaymentHash }): Promise[] | LedgerServiceError> + getTransactionsByWalletIdsAndAddresses(args: { + walletIds: WalletId[] + addresses: OnChainAddress[] + paginationArgs: PaginatedQueryArgs + }): Promise< + PaginatedQueryResult> | LedgerServiceError + > + getTransactionsByWalletId( walletId: WalletId, ): Promise[] | LedgerServiceError> getTransactionsByWalletIds(args: { walletIds: WalletId[] - paginationArgs?: PaginationArgs - }): Promise> | LedgerServiceError> + paginationArgs: PaginatedQueryArgs + }): Promise< + PaginatedQueryResult> | LedgerServiceError + > getTransactionsByWalletIdAndContactUsername(args: { walletIds: WalletId[] contactUsername: Username - paginationArgs?: PaginationArgs - }): Promise> | LedgerServiceError> + paginationArgs: PaginatedQueryArgs + }): Promise< + PaginatedQueryResult> | LedgerServiceError + > listPendingPayments( walletId: WalletId, diff --git a/core/api/src/domain/wallet-on-chain/index.types.d.ts b/core/api/src/domain/wallet-on-chain/index.types.d.ts index ec0dabd19ee..80361641da2 100644 --- a/core/api/src/domain/wallet-on-chain/index.types.d.ts +++ b/core/api/src/domain/wallet-on-chain/index.types.d.ts @@ -30,13 +30,13 @@ interface IWalletOnChainAddressesRepository { type ListWalletOnChainPendingReceiveArgs = { walletIds: WalletId[] - paginationArgs?: PaginationArgs + paginationArgs?: PaginatedQueryArgs } type ListWalletOnChainPendingReceiveByAddressesArgs = { walletIds: WalletId[] addresses: OnChainAddress[] - paginationArgs?: PaginationArgs + paginationArgs?: PaginatedQueryArgs } type PersistWalletOnChainPendingReceiveArgs = WalletOnChainPendingTransaction diff --git a/core/api/src/graphql/connections.ts b/core/api/src/graphql/connections.ts index 23287d1402a..fa2f5c069d3 100644 --- a/core/api/src/graphql/connections.ts +++ b/core/api/src/graphql/connections.ts @@ -3,73 +3,6 @@ import { ConnectionConfig, GraphQLConnectionDefinitions } from "graphql-relay" import { GT } from "." -import { InputValidationError } from "@/graphql/error" -import { MAX_PAGINATION_PAGE_SIZE } from "@/config" - -const CURSOR_REGEX = /^[A-Fa-f0-9]{24}$/ - -// The following function is temporary. It should be replaced by db pagination. - -// It's a slightly modified version of the same function in graphql-relay. -// The original function uses offset-based cursors which means -// reusing the function on a different array of data reuses cursor values. -// That's a problem for client's merging of paginated sets. - -// This modified version uses the identity of the objects in array -// for cursor values instead. - -export const connectionFromPaginatedArray = ( - array: ReadonlyArray, - totalLength: number, - args: PaginationArgs, -) => { - const { after, before, first, last } = args - - const sliceStart = 0 - const sliceEnd = sliceStart + array.length - - let startOffset = Math.max(sliceStart, 0) - let endOffset = Math.min(sliceEnd, totalLength) - - const afterOffset = after ? array.findIndex((obj) => obj.id === after) : -1 - if (0 <= afterOffset && afterOffset < totalLength) { - startOffset = Math.max(startOffset, afterOffset + 1) - } - - const beforeOffset = before ? array.findIndex((obj) => obj.id === before) : endOffset - if (0 <= beforeOffset && beforeOffset < totalLength) { - endOffset = Math.min(endOffset, beforeOffset) - } - - if (first) { - endOffset = Math.min(endOffset, startOffset + first) - } - - if (last) { - startOffset = Math.max(startOffset, endOffset - last) - } - - // If supplied slice is too large, trim it down before mapping over it. - const slice = array.slice(startOffset - sliceStart, endOffset - sliceStart) - - const edges = slice.map((obj) => ({ cursor: obj.id, node: obj })) - - const firstEdge = edges[0] - const lastEdge = edges[edges.length - 1] - const lowerBound = after != null ? afterOffset + 1 : 0 - const upperBound = before != null ? beforeOffset : totalLength - - return { - edges, - pageInfo: { - startCursor: firstEdge ? firstEdge.cursor : null, - endCursor: lastEdge ? lastEdge.cursor : null, - hasPreviousPage: typeof last === "number" ? startOffset > lowerBound : false, - hasNextPage: typeof first === "number" ? endOffset < upperBound : false, - }, - } -} - const pageInfoType = GT.Object({ name: "PageInfo", description: "Information about pagination in a connection.", @@ -139,46 +72,3 @@ export const connectionDefinitions = ( } export { connectionArgs } from "graphql-relay" - -export const checkedConnectionArgs = (args: PaginationArgs): PaginationArgs | Error => { - if (typeof args.first === "number" && args.first > MAX_PAGINATION_PAGE_SIZE) { - return new InputValidationError({ - message: `Requesting ${args.first} records on this connection exceeds the "first" limit of ${MAX_PAGINATION_PAGE_SIZE} records.`, - }) - } - - if (typeof args.last === "number" && args.last > MAX_PAGINATION_PAGE_SIZE) { - return new InputValidationError({ - message: `Requesting ${args.last} records on this connection exceeds the "last" limit of ${MAX_PAGINATION_PAGE_SIZE} records.`, - }) - } - - if (typeof args.first === "number" && args.first <= 0) { - return new InputValidationError({ - message: 'Argument "first" must be greater than 0', - }) - } - - if (typeof args.last === "number" && args.last <= 0) { - return new InputValidationError({ message: 'Argument "last" must be greater than 0' }) - } - - // FIXME: make first or last required (after making sure no one is using them as optional) - if (args.first === undefined && args.last === undefined) { - args.first = MAX_PAGINATION_PAGE_SIZE - } - - if (args.after && typeof args.after === "string" && !CURSOR_REGEX.test(args.after)) { - return new InputValidationError({ - message: 'Argument "after" must be a valid cursor', - }) - } - - if (args.before && typeof args.before === "string" && !CURSOR_REGEX.test(args.before)) { - return new InputValidationError({ - message: 'Argument "before" must be a valid cursor', - }) - } - - return args -} diff --git a/core/api/src/graphql/public/types/object/account-contact.ts b/core/api/src/graphql/public/types/object/account-contact.ts index 0f4144f6184..94ac31fbdef 100644 --- a/core/api/src/graphql/public/types/object/account-contact.ts +++ b/core/api/src/graphql/public/types/object/account-contact.ts @@ -9,11 +9,7 @@ import { TransactionConnection } from "../../../shared/types/object/transaction" import { Accounts } from "@/app" import { checkedToUsername } from "@/domain/accounts" import { GT } from "@/graphql/index" -import { - checkedConnectionArgs, - connectionArgs, - connectionFromPaginatedArray, -} from "@/graphql/connections" +import { connectionArgs } from "@/graphql/connections" import { mapError } from "@/graphql/error-map" const AccountContact = GT.Object({ @@ -36,11 +32,6 @@ const AccountContact = GT.Object({ type: TransactionConnection, args: connectionArgs, resolve: async (source, args, { domainAccount }) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - if (!source.username) { throw new Error("Missing username for contact") } @@ -59,18 +50,14 @@ const AccountContact = GT.Object({ const resp = await Accounts.getAccountTransactionsForContact({ account, contactUsername, - paginationArgs, + rawPaginationArgs: args, }) if (resp instanceof Error) { throw mapError(resp) } - return connectionFromPaginatedArray( - resp.slice, - resp.total, - paginationArgs, - ) + return resp }, description: "Paginated list of transactions sent to/from this contact.", }, diff --git a/core/api/src/graphql/public/types/object/business-account.ts b/core/api/src/graphql/public/types/object/business-account.ts index d7506ca6b7d..4a8e748357b 100644 --- a/core/api/src/graphql/public/types/object/business-account.ts +++ b/core/api/src/graphql/public/types/object/business-account.ts @@ -13,13 +13,7 @@ import Transaction, { import RealtimePrice from "./realtime-price" import { NotificationSettings } from "./notification-settings" -import { WalletsRepository } from "@/services/mongoose" - -import { - connectionArgs, - connectionFromPaginatedArray, - checkedConnectionArgs, -} from "@/graphql/connections" +import { connectionArgs } from "@/graphql/connections" import { GT } from "@/graphql/index" import { mapError } from "@/graphql/error-map" import { @@ -27,7 +21,6 @@ import { SAT_PRICE_PRECISION_OFFSET, USD_PRICE_PRECISION_OFFSET, } from "@/domain/fiat" -import { CouldNotFindTransactionsForAccountError } from "@/domain/errors" import { Accounts, Prices, Wallets } from "@/app" import { IInvoiceConnection } from "@/graphql/shared/types/abstract/invoice" @@ -125,40 +118,19 @@ const BusinessAccount = GT.Object({ }, }, resolve: async (source, args) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - let { walletIds } = args - - if (!walletIds) { - const wallets = await WalletsRepository().listByAccountId(source.id) - if (wallets instanceof Error) { - throw mapError(wallets) - } - walletIds = wallets.map((wallet) => wallet.id) - } + const { walletIds } = args - const { result, error } = await Accounts.getTransactionsForAccountByWalletIds({ + const result = await Accounts.getTransactionsForAccountByWalletIds({ account: source, walletIds, - paginationArgs, + rawPaginationArgs: args, }) - if (error instanceof Error) { - throw mapError(error) - } - if (!result?.slice) { - const nullError = new CouldNotFindTransactionsForAccountError() - throw mapError(nullError) + if (result instanceof Error) { + throw mapError(result) } - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) + return result }, }, pendingTransactions: { diff --git a/core/api/src/graphql/public/types/object/consumer-account.ts b/core/api/src/graphql/public/types/object/consumer-account.ts index 47428567c2f..b9fdb11e50b 100644 --- a/core/api/src/graphql/public/types/object/consumer-account.ts +++ b/core/api/src/graphql/public/types/object/consumer-account.ts @@ -19,15 +19,10 @@ import { SAT_PRICE_PRECISION_OFFSET, USD_PRICE_PRECISION_OFFSET, } from "@/domain/fiat" -import { CouldNotFindTransactionsForAccountError } from "@/domain/errors" import { GT } from "@/graphql/index" import { mapError } from "@/graphql/error-map" -import { - connectionArgs, - connectionFromPaginatedArray, - checkedConnectionArgs, -} from "@/graphql/connections" +import { connectionArgs } from "@/graphql/connections" import Wallet from "@/graphql/shared/types/abstract/wallet" import IAccount from "@/graphql/public/types/abstract/account" @@ -35,8 +30,6 @@ import WalletId from "@/graphql/shared/types/scalar/wallet-id" import RealtimePrice from "@/graphql/public/types/object/realtime-price" import DisplayCurrency from "@/graphql/shared/types/scalar/display-currency" -import { WalletsRepository } from "@/services/mongoose" - import { listEndpoints } from "@/app/callback" import { IInvoiceConnection } from "@/graphql/shared/types/abstract/invoice" @@ -172,41 +165,19 @@ const ConsumerAccount = GT.Object({ }, }, resolve: async (source, args) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - let { walletIds } = args - - if (!walletIds) { - const wallets = await WalletsRepository().listByAccountId(source.id) - if (wallets instanceof Error) { - throw mapError(wallets) - } - walletIds = wallets.map((wallet) => wallet.id) - } + const { walletIds } = args - const { result, error } = await Accounts.getTransactionsForAccountByWalletIds({ + const result = await Accounts.getTransactionsForAccountByWalletIds({ account: source, walletIds, - paginationArgs, + rawPaginationArgs: args, }) - if (error instanceof Error) { - throw mapError(error) - } - - if (!result?.slice) { - const nullError = new CouldNotFindTransactionsForAccountError() - throw mapError(nullError) + if (result instanceof Error) { + throw mapError(result) } - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) + return result }, }, pendingTransactions: { diff --git a/core/api/src/graphql/shared/types/object/btc-wallet.ts b/core/api/src/graphql/shared/types/object/btc-wallet.ts index 5fca6fca9ef..ee3b6a84986 100644 --- a/core/api/src/graphql/shared/types/object/btc-wallet.ts +++ b/core/api/src/graphql/shared/types/object/btc-wallet.ts @@ -14,11 +14,7 @@ import Transaction, { TransactionConnection } from "./transaction" import { GT } from "@/graphql/index" import { normalizePaymentAmount } from "@/graphql/shared/root/mutation" -import { - connectionArgs, - connectionFromPaginatedArray, - checkedConnectionArgs, -} from "@/graphql/connections" +import { connectionArgs } from "@/graphql/connections" import { mapError } from "@/graphql/error-map" import { Wallets } from "@/app" @@ -68,27 +64,16 @@ const BtcWallet = GT.Object({ type: TransactionConnection, args: connectionArgs, resolve: async (source, args) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - const { result, error } = await Wallets.getTransactionsForWallets({ + const result = await Wallets.getTransactionsForWallets({ wallets: [source], - paginationArgs, + rawPaginationArgs: args, }) - if (error instanceof Error) { - throw mapError(error) - } - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error + if (result instanceof Error) { + throw mapError(result) + } - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) + return result }, description: "A list of BTC transactions associated with this wallet.", }, @@ -155,31 +140,20 @@ const BtcWallet = GT.Object({ }, }, resolve: async (source, args) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - const { address } = args if (address instanceof Error) throw address - const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ + const result = await Wallets.getTransactionsForWalletsByAddresses({ wallets: [source], addresses: [address], - paginationArgs, + rawPaginationArgs: args, }) - if (error instanceof Error) { - throw mapError(error) - } - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error + if (result instanceof Error) { + throw mapError(result) + } - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) + return result }, }, invoiceByPaymentHash: { diff --git a/core/api/src/graphql/shared/types/object/usd-wallet.ts b/core/api/src/graphql/shared/types/object/usd-wallet.ts index d493d5d31bb..049a2113c70 100644 --- a/core/api/src/graphql/shared/types/object/usd-wallet.ts +++ b/core/api/src/graphql/shared/types/object/usd-wallet.ts @@ -13,11 +13,7 @@ import IInvoice, { IInvoiceConnection } from "../abstract/invoice" import Transaction, { TransactionConnection } from "./transaction" import { GT } from "@/graphql/index" -import { - connectionArgs, - connectionFromPaginatedArray, - checkedConnectionArgs, -} from "@/graphql/connections" +import { connectionArgs } from "@/graphql/connections" import { normalizePaymentAmount } from "@/graphql/shared/root/mutation" import { mapError } from "@/graphql/error-map" @@ -67,27 +63,16 @@ const UsdWallet = GT.Object({ type: TransactionConnection, args: connectionArgs, resolve: async (source, args) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - - const { result, error } = await Wallets.getTransactionsForWallets({ + const result = await Wallets.getTransactionsForWallets({ wallets: [source], - paginationArgs, + rawPaginationArgs: args, }) - if (error instanceof Error) { - throw mapError(error) - } - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error + if (result instanceof Error) { + throw mapError(result) + } - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) + return result }, }, pendingTransactions: { @@ -153,31 +138,20 @@ const UsdWallet = GT.Object({ }, }, resolve: async (source, args) => { - const paginationArgs = checkedConnectionArgs(args) - if (paginationArgs instanceof Error) { - throw paginationArgs - } - const { address } = args if (address instanceof Error) throw address - const { result, error } = await Wallets.getTransactionsForWalletsByAddresses({ + const result = await Wallets.getTransactionsForWalletsByAddresses({ wallets: [source], addresses: [address], - paginationArgs, + rawPaginationArgs: args, }) - if (error instanceof Error) { - throw mapError(error) - } - // Non-null signal to type checker; consider fixing in PartialResult type - if (!result?.slice) throw error + if (result instanceof Error) { + throw mapError(result) + } - return connectionFromPaginatedArray( - result.slice, - result.total, - paginationArgs, - ) + return result }, }, invoiceByPaymentHash: { diff --git a/core/api/src/services/ledger/index.ts b/core/api/src/services/ledger/index.ts index c194934e9f7..4ae18c9ca21 100644 --- a/core/api/src/services/ledger/index.ts +++ b/core/api/src/services/ledger/index.ts @@ -166,28 +166,43 @@ export const LedgerService = (): ILedgerService => { const getTransactionsByWalletIds = async ({ walletIds, - paginationArgs = {} as PaginationArgs, + paginationArgs, }: { walletIds: WalletId[] - paginationArgs: PaginationArgs - }): Promise> | LedgerError> => { + paginationArgs: PaginatedQueryArgs + }): Promise> | LedgerError> => { const liabilitiesWalletIds = walletIds.map(toLiabilitiesWalletId) try { const ledgerResp = await paginatedLedger({ - query: { account: liabilitiesWalletIds }, + filters: { + mediciFilters: { account: liabilitiesWalletIds }, + }, paginationArgs, }) - if (ledgerResp instanceof Error) { - return ledgerResp - } + return ledgerResp + } catch (err) { + return new UnknownLedgerError(err) + } + } - const { slice, total } = ledgerResp + const getTransactionsByWalletIdsAndAddresses = async ({ + walletIds, + paginationArgs, + addresses, + }: { + walletIds: WalletId[] + paginationArgs: PaginatedQueryArgs + addresses: OnChainAddress[] + }): Promise> | LedgerError> => { + const liabilitiesWalletIds = walletIds.map(toLiabilitiesWalletId) + try { + const ledgerResp = await paginatedLedger({ + filters: { mediciFilters: { account: liabilitiesWalletIds }, addresses }, + paginationArgs, + }) - return { - slice: slice.map((tx) => translateToLedgerTx(tx)), - total, - } + return ledgerResp } catch (err) { return new UnknownLedgerError(err) } @@ -200,25 +215,19 @@ export const LedgerService = (): ILedgerService => { }: { walletIds: WalletId[] contactUsername: Username - paginationArgs?: PaginationArgs - }): Promise> | LedgerError> => { + paginationArgs: PaginatedQueryArgs + }): Promise> | LedgerError> => { const liabilitiesWalletIds = walletIds.map(toLiabilitiesWalletId) try { const ledgerResp = await paginatedLedger({ - query: { account: liabilitiesWalletIds, username: contactUsername }, + filters: { + mediciFilters: { account: liabilitiesWalletIds }, + username: contactUsername, + }, paginationArgs, }) - if (ledgerResp instanceof Error) { - return ledgerResp - } - - const { slice, total } = ledgerResp - - return { - slice: slice.map((tx) => translateToLedgerTx(tx)), - total, - } + return ledgerResp } catch (err) { return new UnknownLedgerError(err) } @@ -464,6 +473,7 @@ export const LedgerService = (): ILedgerService => { getTransactionsForWalletByPaymentHash, getTransactionsByWalletId, getTransactionsByWalletIds, + getTransactionsByWalletIdsAndAddresses, getTransactionsByWalletIdAndContactUsername, listPendingPayments, listAllPaymentHashes, diff --git a/core/api/src/services/ledger/index.types.d.ts b/core/api/src/services/ledger/index.types.d.ts index 20c1e4843f6..63813280348 100644 --- a/core/api/src/services/ledger/index.types.d.ts +++ b/core/api/src/services/ledger/index.types.d.ts @@ -212,7 +212,5 @@ type DisplayTxnAmountsArg = { displayCurrency: DisplayCurrency } -type PaginatedArray = { slice: T[]; total: number } - // The following is needed for src/services/ledger/paginated-ledger.ts declare module "medici/build/helper/parse/parseFilterQuery" diff --git a/core/api/src/services/ledger/paginated-ledger.ts b/core/api/src/services/ledger/paginated-ledger.ts index fa87a04dfcb..28ae019f958 100644 --- a/core/api/src/services/ledger/paginated-ledger.ts +++ b/core/api/src/services/ledger/paginated-ledger.ts @@ -9,34 +9,34 @@ import { parseFilterQuery } from "medici/build/helper/parse/parseFilterQuery" import { MainBook } from "./books" -import { InvalidPaginationArgumentsError } from "@/domain/ledger" +import { translateToLedgerTx } from "." + import { Transaction } from "@/services/ledger/schema" -import { MAX_PAGINATION_PAGE_SIZE } from "@/config" +import { checkedToPaginatedQueryCursor } from "@/domain/primitives" -type IFilterQuery = { - account?: string | string[] - _journal?: Types.ObjectId | string - start_date?: Date | string | number - end_date?: Date | string | number -} & Partial +type LedgerQueryFilter = { + mediciFilters: { + account?: string | string[] + _journal?: Types.ObjectId | string + start_date?: Date | string | number + end_date?: Date | string | number + } + username?: string + addresses?: OnChainAddress[] +} export const paginatedLedger = async ({ - query, + filters, paginationArgs, }: { - query: IFilterQuery - paginationArgs?: PaginationArgs -}): Promise> => { - const filterQuery = parseFilterQuery(query, MainBook) - - const { first, after, last, before } = paginationArgs || {} - - if ( - (first !== undefined && last !== undefined) || - (after !== undefined && before !== undefined) - ) { - return new InvalidPaginationArgumentsError() - } + filters: LedgerQueryFilter + paginationArgs: PaginatedQueryArgs +}): Promise>> => { + const filterQuery = parseFilterQuery(filters.mediciFilters, MainBook) + + const { first, last, before, after } = paginationArgs + + filterQuery["_id"] = { $exists: true } if (after) { filterQuery["_id"] = { $lt: new Types.ObjectId(after) } @@ -46,27 +46,59 @@ export const paginatedLedger = async ({ filterQuery["_id"] = { $gt: new Types.ObjectId(before) } } - let limit = first ?? MAX_PAGINATION_PAGE_SIZE - let skip = 0 + if (filters.username) { + filterQuery["username"] = filters.username + } - const total = await Transaction.countDocuments(filterQuery) + if (filters.addresses) { + filterQuery["payee_addresses"] = { $in: filters.addresses } + } + + const documentCount = await Transaction.countDocuments(filterQuery) + + // hasPreviousPage and hasNextPage can default to false for the opposite pagination direction per the Connection spec + let hasPreviousPage = false + let hasNextPage = false + let transactionRecords: ILedgerTransaction[] = [] - if (last) { - limit = last - if (total > last) { - skip = total - last + if (first !== undefined) { + if (documentCount > first) { + hasNextPage = true } + + transactionRecords = await Transaction.collection + .find(filterQuery) + .sort({ _id: -1 }) + .limit(first) + .toArray() + } else { + let skipAmount = 0 + if (documentCount > last) { + hasPreviousPage = true + skipAmount = documentCount - last + } + + transactionRecords = await Transaction.collection + .find(filterQuery) + .sort({ _id: 1 }) + .skip(skipAmount) + .toArray() } - const slice = await Transaction.collection - .find(filterQuery) - .sort({ datetime: -1, timestamp: -1, _id: -1 }) - .limit(limit) - .skip(skip) - .toArray() + const txs = transactionRecords.map((tx) => translateToLedgerTx(tx)) return { - slice, - total, + edges: txs.map((tx) => ({ + cursor: checkedToPaginatedQueryCursor(tx.id), + node: tx, + })), + pageInfo: { + startCursor: txs[0]?.id ? checkedToPaginatedQueryCursor(txs[0].id) : undefined, + endCursor: txs[txs.length - 1]?.id + ? checkedToPaginatedQueryCursor(txs[txs.length - 1].id) + : undefined, + hasPreviousPage, + hasNextPage, + }, } } diff --git a/core/api/test/helpers/wallet.ts b/core/api/test/helpers/wallet.ts index 82fc01def84..d5707982e3e 100644 --- a/core/api/test/helpers/wallet.ts +++ b/core/api/test/helpers/wallet.ts @@ -1,7 +1,10 @@ import { createChainAddress } from "lightning" -import { PartialResult } from "@/app/partial-result" -import { getBalanceForWallet, getTransactionsForWallets } from "@/app/wallets" +import { + getBalanceForWallet, + getPendingOnChainTransactionsForWallets, + getTransactionsForWallets, +} from "@/app/wallets" import { RepositoryError } from "@/domain/errors" import { WalletsRepository, WalletOnChainAddressesRepository } from "@/services/mongoose" import { getActiveLnd } from "@/services/lnd/config" @@ -16,11 +19,22 @@ export const getBalanceHelper = async ( export const getTransactionsForWalletId = async ( walletId: WalletId, -): Promise>> => { +): Promise | ApplicationError> => { const wallets = WalletsRepository() const wallet = await wallets.findById(walletId) - if (wallet instanceof RepositoryError) return PartialResult.err(wallet) - return getTransactionsForWallets({ wallets: [wallet] }) + if (wallet instanceof RepositoryError) return wallet + return getTransactionsForWallets({ wallets: [wallet], rawPaginationArgs: {} }) +} + +export const getPendingTransactionsForWalletId = async ( + walletId: WalletId, +): Promise => { + const wallets = WalletsRepository() + const wallet = await wallets.findById(walletId) + if (wallet instanceof RepositoryError) return wallet + return getPendingOnChainTransactionsForWallets({ + wallets: [wallet], + }) } // This is to test detection of funds coming in on legacy addresses diff --git a/core/api/test/integration/app/accounts/get-transactions-for-accounts.spec.ts b/core/api/test/integration/app/accounts/get-transactions-for-accounts.spec.ts index 8d557ace25b..f188b56f872 100644 --- a/core/api/test/integration/app/accounts/get-transactions-for-accounts.spec.ts +++ b/core/api/test/integration/app/accounts/get-transactions-for-accounts.spec.ts @@ -74,9 +74,10 @@ describe("getTransactionsForAccountByWalletIds", () => { const txns = await Accounts.getTransactionsForAccountByWalletIds({ account: senderAccount, walletIds: [otherWalletDescriptor.id], + rawPaginationArgs: {}, }) if (txns instanceof Error) throw txns - expect(txns.error).toBeInstanceOf(InvalidWalletId) + expect(txns).toBeInstanceOf(InvalidWalletId) // Restore system state await Transaction.deleteMany({ memo }) diff --git a/core/api/test/integration/services/ledger-service.spec.ts b/core/api/test/integration/services/ledger-service.spec.ts index c16c7f11aee..24b163b3f09 100644 --- a/core/api/test/integration/services/ledger-service.spec.ts +++ b/core/api/test/integration/services/ledger-service.spec.ts @@ -1,17 +1,18 @@ import { UnknownLedgerError } from "@/domain/ledger" +import { checkedToPaginatedQueryCursor } from "@/domain/primitives" import { BtcWalletDescriptor, UsdWalletDescriptor, WalletCurrency } from "@/domain/shared" import { LedgerService } from "@/services/ledger" import { createMandatoryUsers, recordWalletIdIntraLedgerPayment } from "test/helpers" -let walletDescriptor: WalletDescriptor<"BTC"> -let walletDescriptorOther: WalletDescriptor<"USD"> +let walletDescriptorA: WalletDescriptor<"BTC"> +let walletDescriptorB: WalletDescriptor<"USD"> beforeAll(async () => { await createMandatoryUsers() - walletDescriptor = BtcWalletDescriptor(crypto.randomUUID() as WalletId) - walletDescriptorOther = UsdWalletDescriptor(crypto.randomUUID() as WalletId) + walletDescriptorA = BtcWalletDescriptor(crypto.randomUUID() as WalletId) + walletDescriptorB = UsdWalletDescriptor(crypto.randomUUID() as WalletId) const paymentAmount = { usd: { amount: 200n, currency: WalletCurrency.Usd }, @@ -37,16 +38,16 @@ beforeAll(async () => { } await recordWalletIdIntraLedgerPayment({ - senderWalletDescriptor: walletDescriptor, - recipientWalletDescriptor: walletDescriptorOther, + senderWalletDescriptor: walletDescriptorA, + recipientWalletDescriptor: walletDescriptorB, paymentAmount, senderDisplayAmounts, recipientDisplayAmounts, }) await recordWalletIdIntraLedgerPayment({ - senderWalletDescriptor: walletDescriptorOther, - recipientWalletDescriptor: walletDescriptor, + senderWalletDescriptor: walletDescriptorB, + recipientWalletDescriptor: walletDescriptorA, paymentAmount, senderDisplayAmounts, recipientDisplayAmounts, @@ -58,18 +59,21 @@ describe("LedgerService", () => { const ledger = LedgerService() it("returns valid data for walletIds passed", async () => { - const txns = await ledger.getTransactionsByWalletIds({ - walletIds: [walletDescriptor.id, walletDescriptorOther.id], + const paginatedResult = await ledger.getTransactionsByWalletIds({ + walletIds: [walletDescriptorA.id, walletDescriptorB.id], + paginationArgs: { first: 10 }, }) - if (txns instanceof Error) throw txns + if (paginatedResult instanceof Error) throw paginatedResult + + const txs = paginatedResult.edges.map((edge) => edge.node) - expect(txns.slice).toEqual( + expect(txs).toEqual( expect.arrayContaining([ expect.objectContaining({ - walletId: walletDescriptor.id, + walletId: walletDescriptorA.id, }), expect.objectContaining({ - walletId: walletDescriptorOther.id, + walletId: walletDescriptorB.id, }), ]), ) @@ -77,24 +81,28 @@ describe("LedgerService", () => { it("returns valid data using after cursor", async () => { const allTxns = await ledger.getTransactionsByWalletIds({ - walletIds: [walletDescriptor.id, walletDescriptorOther.id], + walletIds: [walletDescriptorA.id, walletDescriptorB.id], + paginationArgs: { first: 100 }, }) if (allTxns instanceof Error) throw allTxns - const firstTxnId = allTxns.slice[0].id + const firstTxnId = allTxns.edges[0].node.id const txns = await ledger.getTransactionsByWalletIds({ - walletIds: [walletDescriptor.id, walletDescriptorOther.id], - paginationArgs: { after: firstTxnId }, + walletIds: [walletDescriptorA.id, walletDescriptorB.id], + paginationArgs: { first: 100, after: checkedToPaginatedQueryCursor(firstTxnId) }, }) if (txns instanceof Error) throw txns - expect(txns.total).toEqual(allTxns.total - 1) + expect(txns.edges.length).toEqual(allTxns.edges.length - 1) }) it("returns error for invalid after cursor", async () => { const txns = await ledger.getTransactionsByWalletIds({ - walletIds: [walletDescriptor.id, walletDescriptorOther.id], - paginationArgs: { after: "invalid-cursor" }, + walletIds: [walletDescriptorA.id, walletDescriptorB.id], + paginationArgs: { + first: 100, + after: checkedToPaginatedQueryCursor("invalid-cursor"), + }, }) expect(txns).toBeInstanceOf(UnknownLedgerError) }) diff --git a/core/api/test/legacy-integration/02-user-wallet/02-receive-rewards.spec.ts b/core/api/test/legacy-integration/02-user-wallet/02-receive-rewards.spec.ts index 8a5e2bd93d3..00cf5505e9d 100644 --- a/core/api/test/legacy-integration/02-user-wallet/02-receive-rewards.spec.ts +++ b/core/api/test/legacy-integration/02-user-wallet/02-receive-rewards.spec.ts @@ -108,12 +108,15 @@ describe("UserWallet - addEarn", () => { const OnboardingEarnIds = Object.keys(OnboardingEarn) expect(OnboardingEarnIds.length).toBeGreaterThanOrEqual(1) - const { result: transactionsBefore } = await getTransactionsForWalletId(walletIdB) + const transactionsBefore = await getTransactionsForWalletId(walletIdB) + if (transactionsBefore instanceof Error) throw transactionsBefore let OnboardingEarnId = "" let txCheck: WalletTransaction | undefined for (OnboardingEarnId of OnboardingEarnIds) { - txCheck = transactionsBefore?.slice.find((tx) => tx.memo === OnboardingEarnId) + txCheck = transactionsBefore.edges.find( + ({ node: tx }) => tx.memo === OnboardingEarnId, + )?.node if (!txCheck) break } expect(txCheck).toBeUndefined() @@ -136,8 +139,11 @@ describe("UserWallet - addEarn", () => { }) if (payment instanceof Error) return payment - const { result: transactionsAfter } = await getTransactionsForWalletId(walletIdB) - const rewardTx = transactionsAfter?.slice.find((tx) => tx.memo === OnboardingEarnId) + const transactionsAfter = await getTransactionsForWalletId(walletIdB) + if (transactionsAfter instanceof Error) throw transactionsAfter + const rewardTx = transactionsAfter.edges.find( + ({ node: tx }) => tx.memo === OnboardingEarnId, + ) expect(rewardTx).not.toBeUndefined() }) }) diff --git a/core/api/test/legacy-integration/02-user-wallet/02-tx-display.spec.ts b/core/api/test/legacy-integration/02-user-wallet/02-tx-display.spec.ts index 0d55fa96a89..177b0379ce7 100644 --- a/core/api/test/legacy-integration/02-user-wallet/02-tx-display.spec.ts +++ b/core/api/test/legacy-integration/02-user-wallet/02-tx-display.spec.ts @@ -9,7 +9,6 @@ import { sat2btc, toSats } from "@/domain/bitcoin" import { LedgerTransactionType, UnknownLedgerError } from "@/domain/ledger" import * as LnFeesImpl from "@/domain/payments" import { paymentAmountFromNumber, WalletCurrency } from "@/domain/shared" -import { TxStatus } from "@/domain/wallets" import { UsdDisplayCurrency, displayAmountFromNumber } from "@/domain/fiat" import { updateDisplayCurrency } from "@/app/accounts" @@ -39,7 +38,7 @@ import { createUserAndWalletFromPhone, getAccountByPhone, getDefaultWalletIdByPhone, - getTransactionsForWalletId, + getPendingTransactionsForWalletId, getUsdWalletIdByPhone, lndOutside1, onceBriaSubscribe, @@ -772,11 +771,9 @@ describe("Display properties on transactions", () => { } // Check entries - const { result: txs, error } = await getTransactionsForWalletId(recipientWalletId) - if (error instanceof Error || txs === null) { - throw error - } - const pendingTxs = txs.slice.filter(({ status }) => status === TxStatus.Pending) + const pendingTxs = await getPendingTransactionsForWalletId(recipientWalletId) + if (pendingTxs instanceof Error) throw pendingTxs + expect(pendingTxs.length).toBe(1) const recipientTxn = pendingTxs[0] diff --git a/core/api/test/legacy-integration/app/trigger/trigger.fn.spec.ts b/core/api/test/legacy-integration/app/trigger/trigger.fn.spec.ts index 821571d826e..01212815be8 100644 --- a/core/api/test/legacy-integration/app/trigger/trigger.fn.spec.ts +++ b/core/api/test/legacy-integration/app/trigger/trigger.fn.spec.ts @@ -69,9 +69,9 @@ type WalletState = { const getWalletState = async (walletId: WalletId): Promise => { const balance = await getBalanceHelper(walletId) - const { result, error } = await getTransactionsForWalletId(walletId) - if (error instanceof Error || !result?.slice) { - throw error + const result = await getTransactionsForWalletId(walletId) + if (result instanceof Error) { + throw result } const onchainAddress = await Wallets.getLastOnChainAddress(walletId) if (onchainAddress instanceof Error) { @@ -79,7 +79,7 @@ const getWalletState = async (walletId: WalletId): Promise => { } return { balance, - transactions: result.slice, + transactions: result.edges.map((edge) => edge.node), onchainAddress, } }