diff --git a/.changeset/two-hornets-push.md b/.changeset/two-hornets-push.md new file mode 100644 index 000000000000..a39e6152c137 --- /dev/null +++ b/.changeset/two-hornets-push.md @@ -0,0 +1,9 @@ +--- +"@ledgerhq/coin-polkadot": minor +"@ledgerhq/coin-stellar": minor +"@ledgerhq/coin-tezos": minor +"@ledgerhq/coin-xrp": minor +"@ledgerhq/coin-framework": minor +--- + +Add pagination to listOperations for Alpaca diff --git a/libs/coin-framework/src/api/types.ts b/libs/coin-framework/src/api/types.ts index cff36d366b16..8185bcd380f2 100644 --- a/libs/coin-framework/src/api/types.ts +++ b/libs/coin-framework/src/api/types.ts @@ -27,6 +27,7 @@ export type Transaction = { supplement?: unknown; }; +export type Pagination = { limit: number; start?: number }; export type Api = { broadcast: (tx: string) => Promise; combine: (tx: string, signature: string, pubkey?: string) => string; @@ -34,5 +35,13 @@ export type Api = { estimateFees: (addr: string, amount: bigint) => Promise; getBalance: (address: string) => Promise; lastBlock: () => Promise; - listOperations: (address: string, blockHeight: number) => Promise; + /** + * + * @param address + * @param pagination The max number of operation to receive and the "id" or "index" to start from (see returns value). + * @returns Operations found and the next "id" or "index" to use for pagination (i.e. `start` property).\ + * If `0` is returns, no pagination needed. + * This "id" or "index" value, thus it has functional meaning, is different for each blockchain. + */ + listOperations: (address: string, pagination: Pagination) => Promise<[Operation[], number]>; }; diff --git a/libs/coin-modules/coin-polkadot/package.json b/libs/coin-modules/coin-polkadot/package.json index a91ac01c74bf..7c13279ba43d 100644 --- a/libs/coin-modules/coin-polkadot/package.json +++ b/libs/coin-modules/coin-polkadot/package.json @@ -161,6 +161,6 @@ "msw": "^2.2.13", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.4.5" } } \ No newline at end of file diff --git a/libs/coin-modules/coin-polkadot/src/api/index.integ.test.ts b/libs/coin-modules/coin-polkadot/src/api/index.integ.test.ts index e399c8955baf..f94acc7f8900 100644 --- a/libs/coin-modules/coin-polkadot/src/api/index.integ.test.ts +++ b/libs/coin-modules/coin-polkadot/src/api/index.integ.test.ts @@ -42,17 +42,28 @@ describe("Polkadot Api", () => { describe("listOperations", () => { it("returns a list regarding address parameter", async () => { // When - const result = await module.listOperations(address, 21500219); + const [tx, _] = await module.listOperations(address, { limit: 100 }); // Then - expect(result.length).toBeGreaterThanOrEqual(1); - result.forEach(operation => { + expect(tx.length).toBeGreaterThanOrEqual(1); + tx.forEach(operation => { expect(operation.address).toEqual(address); const isSenderOrReceipt = operation.senders.includes(address) || operation.recipients.includes(address); expect(isSenderOrReceipt).toBeTruthy(); }); }, 20000); + + it("returns paginated operations", async () => { + // When + const [tx, idx] = await module.listOperations(address, { limit: 100 }); + const [tx2, _] = await module.listOperations(address, { limit: 100, start: idx }); + tx.push(...tx2); + + // Then + const checkSet = new Set(tx.map(elt => elt.hash)); + expect(checkSet.size).toEqual(tx.length); + }); }); describe("lastBlock", () => { diff --git a/libs/coin-modules/coin-polkadot/src/api/index.ts b/libs/coin-modules/coin-polkadot/src/api/index.ts index fcdc3d4d6f0f..3a38e8d4afae 100644 --- a/libs/coin-modules/coin-polkadot/src/api/index.ts +++ b/libs/coin-modules/coin-polkadot/src/api/index.ts @@ -1,4 +1,8 @@ -import type { Api, Transaction as ApiTransaction } from "@ledgerhq/coin-framework/api/index"; +import type { + Api, + Transaction as ApiTransaction, + Pagination, +} from "@ledgerhq/coin-framework/api/index"; import coinConfig, { type PolkadotConfig } from "../config"; import { broadcast, @@ -23,7 +27,7 @@ export function createApi(config: PolkadotConfig): Api { estimateFees: estimate, getBalance, lastBlock, - listOperations, + listOperations: operations, }; } @@ -42,3 +46,6 @@ async function estimate(addr: string, amount: bigint): Promise { const tx = await craftEstimationTransaction(addr, amount); return estimateFees(tx); } + +const operations = async (addr: string, { limit, start }: Pagination) => + listOperations(addr, { limit, startAt: start }); diff --git a/libs/coin-modules/coin-polkadot/src/common/address.ts b/libs/coin-modules/coin-polkadot/src/common/address.ts index 9fb9749619ed..f8eb7dc163f0 100644 --- a/libs/coin-modules/coin-polkadot/src/common/address.ts +++ b/libs/coin-modules/coin-polkadot/src/common/address.ts @@ -10,7 +10,7 @@ const POLKADOT_SS58_PREFIX = 0; */ // TODO Cache this to improve perf export const isValidAddress = ( - address: string, + address: string | undefined, ss58Format: number = POLKADOT_SS58_PREFIX, ): boolean => { if (!address) return false; diff --git a/libs/coin-modules/coin-polkadot/src/logic/listOperations.ts b/libs/coin-modules/coin-polkadot/src/logic/listOperations.ts index 5bb9dc4f0aad..1b6721583270 100644 --- a/libs/coin-modules/coin-polkadot/src/logic/listOperations.ts +++ b/libs/coin-modules/coin-polkadot/src/logic/listOperations.ts @@ -14,12 +14,15 @@ export type Operation = { transactionSequenceNumber: number; }; -export async function listOperations(addr: string, startAt?: number): Promise { +export async function listOperations( + addr: string, + { limit, startAt }: { limit: number; startAt?: number | undefined }, +): Promise<[Operation[], number]> { //The accountId is used to map Operations to Live types. const fakeAccountId = ""; - const operations = await network.getOperations(fakeAccountId, addr, startAt); + const operations = await network.getOperations(fakeAccountId, addr, startAt, limit); - return operations.map(convertToCoreOperation(addr)); + return [operations.map(convertToCoreOperation(addr)), operations.slice(-1)[0].blockHeight ?? 0]; } const convertToCoreOperation = (address: string) => (operation: PolkadotOperation) => { diff --git a/libs/coin-modules/coin-polkadot/src/network/bisontrails.ts b/libs/coin-modules/coin-polkadot/src/network/bisontrails.ts index e4155da41bf9..a50e834635a2 100644 --- a/libs/coin-modules/coin-polkadot/src/network/bisontrails.ts +++ b/libs/coin-modules/coin-polkadot/src/network/bisontrails.ts @@ -194,7 +194,7 @@ const getValue = (extrinsic: any, type: OperationType): BigNumber => { const extrinsicToOperation = ( addr: string, accountId: string, - extrinsic: any, + extrinsic: ExplorerExtrinsic, ): PolkadotOperation | null => { let type = getOperationType(extrinsic.section, extrinsic.method); @@ -219,7 +219,7 @@ const extrinsicToOperation = ( extra: getExtra(type, extrinsic), senders: [extrinsic.signer], recipients: [extrinsic.affectedAddress1, extrinsic.affectedAddress2] - .filter(Boolean) + .filter(addr => addr !== undefined) .filter(isValidAddress), transactionSequenceNumber: extrinsic.signer === addr ? extrinsic.nonce : undefined, hasFailed: !extrinsic.isSuccess, @@ -295,12 +295,13 @@ const fetchOperationList = async ( accountId: string, addr: string, startAt: number, + limit = LIMIT, offset = 0, prevOperations: PolkadotOperation[] = [], ): Promise => { const { data } = await network({ method: "GET", - url: getAccountOperationUrl(addr, offset, startAt), + url: getAccountOperationUrl(addr, offset, startAt, limit), }); const operations = data.extrinsics.map((extrinsic: any) => extrinsicToOperation(addr, accountId, extrinsic), @@ -313,7 +314,7 @@ const fetchOperationList = async ( return mergedOp.filter(Boolean).sort((a, b) => b.date - a.date); } - return await fetchOperationList(accountId, addr, startAt, offset + LIMIT, mergedOp); + return await fetchOperationList(accountId, addr, startAt, limit, offset + LIMIT, mergedOp); }; /** @@ -325,6 +326,11 @@ const fetchOperationList = async ( * * @return {PolkadotOperation[]} */ -export const getOperations = async (accountId: string, addr: string, startAt = 0) => { - return await fetchOperationList(accountId, addr, startAt); +export const getOperations = async ( + accountId: string, + addr: string, + startAt = 0, + limit = LIMIT, +) => { + return await fetchOperationList(accountId, addr, startAt, limit); }; diff --git a/libs/coin-modules/coin-stellar/src/api/index.integ.test.ts b/libs/coin-modules/coin-stellar/src/api/index.integ.test.ts index 87023840ae85..50328419d120 100644 --- a/libs/coin-modules/coin-stellar/src/api/index.integ.test.ts +++ b/libs/coin-modules/coin-stellar/src/api/index.integ.test.ts @@ -1,9 +1,12 @@ import type { Api } from "@ledgerhq/coin-framework/api/index"; import { createApi } from "."; +/** + * Testnet scan: https://testnet.lumenscan.io/ + */ describe("Stellar Api", () => { let module: Api; - const address = "GD6QELUZPSKPRWVXOQ3F6GBF4OBRMCHO5PHREXH4ZRTPJAG7V5MD7JGX"; + const address = "GBAUZBDXMVV7HII4JWBGFMLVKVJ6OLQAKOCGXM5E2FM4TAZB6C7JO2L7"; beforeAll(() => { module = createApi({ @@ -26,20 +29,31 @@ describe("Stellar Api", () => { }); }); - describe("listOperations", () => { + describe.only("listOperations", () => { it("returns a list regarding address parameter", async () => { // When - const result = await module.listOperations(address, 0); + const [tx, _] = await module.listOperations(address, { limit: 100 }); // Then - expect(result.length).toBeGreaterThanOrEqual(1); - result.forEach(operation => { + expect(tx.length).toBeGreaterThanOrEqual(100); + tx.forEach(operation => { expect(operation.address).toEqual(address); const isSenderOrReceipt = operation.senders.includes(address) || operation.recipients.includes(address); expect(isSenderOrReceipt).toBeTruthy(); }); }); + + it("returns paginated operations", async () => { + // When + const [tx, idx] = await module.listOperations(address, { limit: 200 }); + const [tx2, _] = await module.listOperations(address, { limit: 200, start: idx }); + tx.push(...tx2); + + // Then + const checkSet = new Set(tx.map(elt => elt.hash)); + expect(checkSet.size).toEqual(tx.length); + }); }); describe("lastBlock", () => { diff --git a/libs/coin-modules/coin-stellar/src/api/index.ts b/libs/coin-modules/coin-stellar/src/api/index.ts index f13df4107a22..2a9ca6f0f21e 100644 --- a/libs/coin-modules/coin-stellar/src/api/index.ts +++ b/libs/coin-modules/coin-stellar/src/api/index.ts @@ -1,4 +1,9 @@ -import type { Api, Transaction as ApiTransaction } from "@ledgerhq/coin-framework/api/index"; +import type { + Api, + Operation, + Pagination, + Transaction as ApiTransaction, +} from "@ledgerhq/coin-framework/api/index"; import coinConfig, { type StellarConfig } from "../config"; import { broadcast, @@ -20,7 +25,7 @@ export function createApi(config: StellarConfig): Api { estimateFees, getBalance, lastBlock, - listOperations, + listOperations: operations, }; } @@ -61,3 +66,8 @@ function compose(tx: string, signature: string, pubkey?: string): string { } return combine(tx, signature, pubkey); } + +const operations = async ( + address: string, + { limit, start }: Pagination, +): Promise<[Operation[], number]> => listOperations(address, { limit, cursor: start }); diff --git a/libs/coin-modules/coin-stellar/src/bridge/logic.ts b/libs/coin-modules/coin-stellar/src/bridge/logic.ts index 77ddccc0aef6..2488e2cd3780 100644 --- a/libs/coin-modules/coin-stellar/src/bridge/logic.ts +++ b/libs/coin-modules/coin-stellar/src/bridge/logic.ts @@ -1,36 +1,12 @@ import { BigNumber } from "bignumber.js"; -import type { Account, OperationType, TokenAccount } from "@ledgerhq/types-live"; -import { Horizon, StrKey } from "@stellar/stellar-sdk"; +import type { Account, TokenAccount } from "@ledgerhq/types-live"; +import { StrKey } from "@stellar/stellar-sdk"; import { findSubAccountById } from "@ledgerhq/coin-framework/account/helpers"; -import { encodeOperationId } from "@ledgerhq/coin-framework/operation"; -import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; -import { parseCurrencyUnit } from "@ledgerhq/coin-framework/currencies/parseCurrencyUnit"; -import { BASE_RESERVE, BASE_RESERVE_MIN_COUNT, fetchBaseFee, fetchSigners } from "../network"; -import type { - BalanceAsset, - RawOperation, - StellarOperation, - Transaction, - TransactionRaw, -} from "../types"; +import { fetchSigners } from "../network"; +import type { Transaction, TransactionRaw } from "../types"; export const STELLAR_BURN_ADDRESS = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; -const currency = getCryptoCurrencyById("stellar"); - -const getMinimumBalance = (account: Horizon.ServerApi.AccountRecord): BigNumber => { - return parseCurrencyUnit(currency.units[0], getReservedBalance(account).toString()); -}; - -export async function getAccountSpendableBalance( - balance: BigNumber, - account: Horizon.ServerApi.AccountRecord, -): Promise { - const minimumBalance = getMinimumBalance(account); - const { recommendedFee } = await fetchBaseFee(); - return BigNumber.max(balance.minus(minimumBalance).minus(recommendedFee), 0); -} - export function getAmountValue( account: Account, transaction: Transaction, @@ -48,58 +24,6 @@ export function getAmountValue( : transaction.amount; } -export function getReservedBalance(account: Horizon.ServerApi.AccountRecord): BigNumber { - const numOfSponsoringEntries = Number(account.num_sponsoring); - const numOfSponsoredEntries = Number(account.num_sponsored); - - const nativeAsset = account.balances?.find(b => b.asset_type === "native") as BalanceAsset; - - const amountInOffers = new BigNumber(nativeAsset?.selling_liabilities || 0); - const numOfEntries = new BigNumber(account.subentry_count || 0); - - return new BigNumber(BASE_RESERVE_MIN_COUNT) - .plus(numOfEntries) - .plus(numOfSponsoringEntries) - .minus(numOfSponsoredEntries) - .times(BASE_RESERVE) - .plus(amountInOffers); -} - -export function getOperationType(operation: RawOperation, addr: string): OperationType { - switch (operation.type) { - case "create_account": - return operation.funder === addr ? "OUT" : "IN"; - - case "payment": - if (operation.from === addr && operation.to !== addr) { - return "OUT"; - } - - return "IN"; - - case "path_payment_strict_send": - if (operation.to === addr) return "IN"; - return "OUT"; - - case "path_payment_strict_receive": - return "IN"; - - case "change_trust": - if (new BigNumber(operation.limit).eq(0)) { - return "OPT_OUT"; - } - - return "OPT_IN"; - - default: - if (operation.source_account === addr) { - return "OUT"; - } - - return "IN"; - } -} - export function getAssetCodeIssuer(tr: Transaction | TransactionRaw): string[] { if (tr.subAccountId) { const assetString = tr.subAccountId.split("+")[1]; @@ -109,106 +33,6 @@ export function getAssetCodeIssuer(tr: Transaction | TransactionRaw): string[] { return [tr.assetCode || "", tr.assetIssuer || ""]; } -function getRecipients(operation: RawOperation): string[] { - switch (operation.type) { - case "create_account": - return [operation.account]; - - case "payment": - return [operation.to_muxed || operation.to]; - - case "path_payment_strict_send": - return [operation.to]; - - default: - return []; - } -} - -export async function formatOperation( - rawOperation: RawOperation, - accountId: string, - addr: string, -): Promise { - const transaction = await rawOperation.transaction(); - const type = getOperationType(rawOperation, addr); - const value = getValue(rawOperation, transaction, type); - const recipients = getRecipients(rawOperation); - const memo = transaction.memo - ? transaction.memo_type === "hash" || transaction.memo_type === "return" - ? Buffer.from(transaction.memo, "base64").toString("hex") - : transaction.memo - : null; - - const operation: StellarOperation = { - id: encodeOperationId(accountId, rawOperation.transaction_hash, type), - accountId, - fee: new BigNumber(transaction.fee_charged), - value: rawOperation?.asset_code ? new BigNumber(transaction.fee_charged) : value, - // Using type NONE to hide asset operations from the main account (show them - // only on sub-account) - type: rawOperation?.asset_code && !["OPT_IN", "OPT_OUT"].includes(type) ? "NONE" : type, - hash: rawOperation.transaction_hash, - blockHeight: transaction.ledger_attr, - date: new Date(rawOperation.created_at), - senders: [rawOperation.source_account], - recipients, - transactionSequenceNumber: Number(transaction.source_account_sequence), - hasFailed: !rawOperation.transaction_successful, - blockHash: null, - extra: { - ledgerOpType: type, - }, - }; - - if (rawOperation.paging_token) { - operation.extra.pagingToken = rawOperation.paging_token; - } - if (rawOperation.asset_code) { - operation.extra.assetCode = rawOperation.asset_code; - operation.extra.assetAmount = rawOperation.asset_code ? value.toString() : undefined; - } - if (rawOperation.asset_issuer) { - operation.extra.assetIssuer = rawOperation.asset_issuer; - } - if (memo) { - operation.extra.memo = memo; - } - - return operation; -} - -function getValue( - operation: RawOperation, - transaction: Horizon.ServerApi.TransactionRecord, - type: OperationType, -): BigNumber { - let value = new BigNumber(0); - - if (!operation.transaction_successful) { - return type === "IN" ? value : new BigNumber(transaction.fee_charged || 0); - } - - switch (operation.type) { - case "create_account": - value = parseCurrencyUnit(currency.units[0], operation.starting_balance); - - if (type === "OUT") { - value = value.plus(transaction.fee_charged); - } - - return value; - - case "payment": - case "path_payment_strict_send": - case "path_payment_strict_receive": - return parseCurrencyUnit(currency.units[0], operation.amount); - - default: - return type !== "IN" ? new BigNumber(transaction.fee_charged) : value; - } -} - export function isMemoValid(memoType: string, memoValue: string): boolean { switch (memoType) { case "MEMO_TEXT": @@ -259,33 +83,3 @@ export function isAddressValid(address: string): boolean { return false; } } - -export function rawOperationsToOperations( - operations: RawOperation[], - addr: string, - accountId: string, -): Promise { - const supportedOperationTypes = [ - "create_account", - "payment", - "path_payment_strict_send", - "path_payment_strict_receive", - "change_trust", - ]; - - return Promise.all( - operations - .filter(operation => { - return ( - operation.from === addr || - operation.to === addr || - operation.funder === addr || - operation.account === addr || - operation.trustor === addr || - operation.source_account === addr - ); - }) - .filter(operation => supportedOperationTypes.includes(operation.type)) - .map(operation => formatOperation(operation, accountId, addr)), - ); -} diff --git a/libs/coin-modules/coin-stellar/src/bridge/synchronization.ts b/libs/coin-modules/coin-stellar/src/bridge/synchronization.ts index 1b931e141f90..bc6237d897d5 100644 --- a/libs/coin-modules/coin-stellar/src/bridge/synchronization.ts +++ b/libs/coin-modules/coin-stellar/src/bridge/synchronization.ts @@ -2,7 +2,7 @@ import { encodeAccountId } from "@ledgerhq/coin-framework/account/index"; import { inferSubOperations } from "@ledgerhq/coin-framework/serialization/index"; import { Account } from "@ledgerhq/types-live"; import { GetAccountShape, mergeOps } from "@ledgerhq/coin-framework/bridge/jsHelpers"; -import { fetchAccount, fetchOperations } from "../network"; +import { fetchAccount, fetchAllOperations } from "../network"; import { buildSubAccounts } from "./tokens"; import { StellarBurnAddressError, StellarOperation } from "../types"; import { STELLAR_BURN_ADDRESS } from "./logic"; @@ -24,14 +24,13 @@ export const getAccountShape: GetAccountShape = async (info, syncConfig const { blockHeight, balance, spendableBalance, assets } = await fetchAccount(address); const oldOperations = (initialAccount?.operations || []) as StellarOperation[]; - const lastPagingToken = oldOperations[0]?.extra.pagingToken || "0"; const newOperations = - (await fetchOperations({ + (await fetchAllOperations({ accountId, addr: address, order: "asc", - cursor: lastPagingToken, + cursor: oldOperations[0]?.extra.pagingToken, })) || []; const allOperations = mergeOps(oldOperations, newOperations) as StellarOperation[]; diff --git a/libs/coin-modules/coin-stellar/src/logic/listOperations.ts b/libs/coin-modules/coin-stellar/src/logic/listOperations.ts index 1799f453f142..e69218b27a5d 100644 --- a/libs/coin-modules/coin-stellar/src/logic/listOperations.ts +++ b/libs/coin-modules/coin-stellar/src/logic/listOperations.ts @@ -1,4 +1,4 @@ -import type { Operation as LiveOperation } from "@ledgerhq/types-live"; +import type { StellarOperation } from "../types/bridge"; import { fetchOperations } from "../network"; export type Operation = { @@ -14,19 +14,27 @@ export type Operation = { transactionSequenceNumber: number; }; -export async function listOperations(address: string, _blockHeight: number): Promise { +export async function listOperations( + address: string, + { limit, cursor }: { limit: number; cursor?: number | undefined }, +): Promise<[Operation[], number]> { // Fake accountId const accountId = ""; const operations = await fetchOperations({ accountId, addr: address, order: "asc", - cursor: "0", + limit, + cursor: cursor?.toString(), }); - return operations.map(convertToCoreOperation(address)); + + return [ + operations.map(convertToCoreOperation(address)), + parseInt(operations.slice(-1)[0].extra.pagingToken ?? "0"), + ]; } -const convertToCoreOperation = (address: string) => (operation: LiveOperation) => { +const convertToCoreOperation = (address: string) => (operation: StellarOperation) => { return { hash: operation.hash, address, diff --git a/libs/coin-modules/coin-stellar/src/network/horizon.ts b/libs/coin-modules/coin-stellar/src/network/horizon.ts index c80cfd0e7417..f11ac47546bd 100644 --- a/libs/coin-modules/coin-stellar/src/network/horizon.ts +++ b/libs/coin-modules/coin-stellar/src/network/horizon.ts @@ -4,25 +4,20 @@ import { LedgerAPI4xx, LedgerAPI5xx, NetworkDown } from "@ledgerhq/errors"; import type { CacheRes } from "@ledgerhq/live-network/cache"; import { makeLRUCache } from "@ledgerhq/live-network/cache"; import { log } from "@ledgerhq/logs"; -import type { Account, Operation } from "@ledgerhq/types-live"; +import type { Account } from "@ledgerhq/types-live"; import { // @ts-expect-error stellar-sdk ts definition missing? AccountRecord, BASE_FEE, Horizon, + MuxedAccount, NetworkError, Networks, NotFoundError, Transaction as StellarSdkTransaction, StrKey, - MuxedAccount, } from "@stellar/stellar-sdk"; import { BigNumber } from "bignumber.js"; -import { - getAccountSpendableBalance, - getReservedBalance, - rawOperationsToOperations, -} from "../bridge/logic"; import coinConfig from "../config"; import { type BalanceAsset, @@ -30,7 +25,13 @@ import { type RawOperation, type Signer, NetworkCongestionLevel, + StellarOperation, } from "../types"; +import { + getAccountSpendableBalance, + getReservedBalance, + rawOperationsToOperations, +} from "./serialization"; const FALLBACK_BASE_FEE = 100; const TRESHOLD_LOW = 0.5; @@ -179,22 +180,22 @@ export async function fetchAccount(addr: string): Promise<{ * * @return {Operation[]} */ -export async function fetchOperations({ +export async function fetchAllOperations({ accountId, addr, order, - cursor, + cursor = "0", }: { accountId: string; addr: string; order: "asc" | "desc"; - cursor: string; -}): Promise { + cursor: string | undefined; +}): Promise { if (!addr) { return []; } - let operations: Operation[] = []; + let operations: StellarOperation[] = []; try { let rawOperations = await getServer() @@ -252,6 +253,70 @@ export async function fetchOperations({ } } +export async function fetchOperations({ + accountId, + addr, + order, + cursor = "0", + limit, +}: { + accountId: string; + addr: string; + order: "asc" | "desc"; + cursor: string | undefined; + limit?: number | undefined; +}): Promise { + if (!addr) { + return []; + } + + const defaultFetchLimit = coinConfig.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT; + + try { + const rawOperations = await getServer() + .operations() + .forAccount(addr) + .limit(limit ?? defaultFetchLimit) + .order(order) + .cursor(cursor) + .includeFailed(true) + .join("transactions") + .call(); + + if (!rawOperations || !rawOperations.records.length) { + return []; + } + + return rawOperationsToOperations(rawOperations.records as RawOperation[], addr, accountId); + } catch (e: unknown) { + // FIXME: terrible hacks, because Stellar SDK fails to cast network failures to typed errors in react-native... + // (https://github.com/stellar/js-stellar-sdk/issues/638) + const errorMsg = e ? String(e) : ""; + + if (e instanceof NotFoundError || errorMsg.match(/status code 404/)) { + return []; + } + + if (errorMsg.match(/status code 4[0-9]{2}/)) { + throw new LedgerAPI4xx(); + } + + if (errorMsg.match(/status code 5[0-9]{2}/)) { + throw new LedgerAPI5xx(); + } + + if ( + e instanceof NetworkError || + errorMsg.match(/ECONNRESET|ECONNREFUSED|ENOTFOUND|EPIPE|ETIMEDOUT/) || + errorMsg.match(/undefined is not an object/) + ) { + throw new NetworkDown(); + } + + throw e; + } +} + export async function fetchAccountNetworkInfo(account: Account): Promise { try { const extendedAccount = await getServer().accounts().accountId(account.freshAddress).call(); diff --git a/libs/coin-modules/coin-stellar/src/network/index.ts b/libs/coin-modules/coin-stellar/src/network/index.ts index 3ca613ddc285..86e328bdabe1 100644 --- a/libs/coin-modules/coin-stellar/src/network/index.ts +++ b/libs/coin-modules/coin-stellar/src/network/index.ts @@ -1,6 +1,7 @@ export { broadcastTransaction, fetchAccount, + fetchAllOperations, fetchOperations, fetchBaseFee, fetchSequence, diff --git a/libs/coin-modules/coin-stellar/src/network/serialization.ts b/libs/coin-modules/coin-stellar/src/network/serialization.ts new file mode 100644 index 000000000000..29b67e904473 --- /dev/null +++ b/libs/coin-modules/coin-stellar/src/network/serialization.ts @@ -0,0 +1,209 @@ +/* + * Serialization functions from Horizon to Ledger Live types + */ + +import type { OperationType } from "@ledgerhq/types-live"; +import { encodeOperationId } from "@ledgerhq/coin-framework/operation"; +import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; +import { parseCurrencyUnit } from "@ledgerhq/coin-framework/currencies/parseCurrencyUnit"; +import { Horizon } from "@stellar/stellar-sdk"; +import { BASE_RESERVE, BASE_RESERVE_MIN_COUNT, fetchBaseFee } from "./horizon"; +import type { BalanceAsset, RawOperation, StellarOperation } from "../types"; +import BigNumber from "bignumber.js"; + +const currency = getCryptoCurrencyById("stellar"); + +export async function getAccountSpendableBalance( + balance: BigNumber, + account: Horizon.ServerApi.AccountRecord, +): Promise { + const minimumBalance = getMinimumBalance(account); + const { recommendedFee } = await fetchBaseFee(); + return BigNumber.max(balance.minus(minimumBalance).minus(recommendedFee), 0); +} + +const getMinimumBalance = (account: Horizon.ServerApi.AccountRecord): BigNumber => { + return parseCurrencyUnit(currency.units[0], getReservedBalance(account).toString()); +}; + +export function getReservedBalance(account: Horizon.ServerApi.AccountRecord): BigNumber { + const numOfSponsoringEntries = Number(account.num_sponsoring); + const numOfSponsoredEntries = Number(account.num_sponsored); + + const nativeAsset = account.balances?.find(b => b.asset_type === "native") as BalanceAsset; + + const amountInOffers = new BigNumber(nativeAsset?.selling_liabilities || 0); + const numOfEntries = new BigNumber(account.subentry_count || 0); + + return new BigNumber(BASE_RESERVE_MIN_COUNT) + .plus(numOfEntries) + .plus(numOfSponsoringEntries) + .minus(numOfSponsoredEntries) + .times(BASE_RESERVE) + .plus(amountInOffers); +} + +export function rawOperationsToOperations( + operations: RawOperation[], + addr: string, + accountId: string, +): Promise { + const supportedOperationTypes = [ + "create_account", + "payment", + "path_payment_strict_send", + "path_payment_strict_receive", + "change_trust", + ]; + + return Promise.all( + operations + .filter(operation => { + return ( + operation.from === addr || + operation.to === addr || + operation.funder === addr || + operation.account === addr || + operation.trustor === addr || + operation.source_account === addr + ); + }) + .filter(operation => supportedOperationTypes.includes(operation.type)) + .map(operation => formatOperation(operation, accountId, addr)), + ); +} + +async function formatOperation( + rawOperation: RawOperation, + accountId: string, + addr: string, +): Promise { + const transaction = await rawOperation.transaction(); + const type = getOperationType(rawOperation, addr); + const value = getValue(rawOperation, transaction, type); + const recipients = getRecipients(rawOperation); + const memo = transaction.memo + ? transaction.memo_type === "hash" || transaction.memo_type === "return" + ? Buffer.from(transaction.memo, "base64").toString("hex") + : transaction.memo + : null; + + const operation: StellarOperation = { + id: encodeOperationId(accountId, rawOperation.transaction_hash, type), + accountId, + fee: new BigNumber(transaction.fee_charged), + value: rawOperation?.asset_code ? new BigNumber(transaction.fee_charged) : value, + // Using type NONE to hide asset operations from the main account (show them + // only on sub-account) + type: rawOperation?.asset_code && !["OPT_IN", "OPT_OUT"].includes(type) ? "NONE" : type, + hash: rawOperation.transaction_hash, + blockHeight: transaction.ledger_attr, + date: new Date(rawOperation.created_at), + senders: [rawOperation.source_account], + recipients, + transactionSequenceNumber: Number(transaction.source_account_sequence), + hasFailed: !rawOperation.transaction_successful, + blockHash: null, + extra: { + ledgerOpType: type, + }, + }; + + if (rawOperation.paging_token) { + operation.extra.pagingToken = rawOperation.paging_token; + } + if (rawOperation.asset_code) { + operation.extra.assetCode = rawOperation.asset_code; + operation.extra.assetAmount = rawOperation.asset_code ? value.toString() : undefined; + } + if (rawOperation.asset_issuer) { + operation.extra.assetIssuer = rawOperation.asset_issuer; + } + if (memo) { + operation.extra.memo = memo; + } + + return operation; +} + +function getRecipients(operation: RawOperation): string[] { + switch (operation.type) { + case "create_account": + return [operation.account]; + + case "payment": + return [operation.to_muxed || operation.to]; + + case "path_payment_strict_send": + return [operation.to]; + + default: + return []; + } +} + +function getValue( + operation: RawOperation, + transaction: Horizon.ServerApi.TransactionRecord, + type: OperationType, +): BigNumber { + let value = new BigNumber(0); + + if (!operation.transaction_successful) { + return type === "IN" ? value : new BigNumber(transaction.fee_charged || 0); + } + + switch (operation.type) { + case "create_account": + value = parseCurrencyUnit(currency.units[0], operation.starting_balance); + + if (type === "OUT") { + value = value.plus(transaction.fee_charged); + } + + return value; + + case "payment": + case "path_payment_strict_send": + case "path_payment_strict_receive": + return parseCurrencyUnit(currency.units[0], operation.amount); + + default: + return type !== "IN" ? new BigNumber(transaction.fee_charged) : value; + } +} + +function getOperationType(operation: RawOperation, addr: string): OperationType { + switch (operation.type) { + case "create_account": + return operation.funder === addr ? "OUT" : "IN"; + + case "payment": + if (operation.from === addr && operation.to !== addr) { + return "OUT"; + } + + return "IN"; + + case "path_payment_strict_send": + if (operation.to === addr) return "IN"; + return "OUT"; + + case "path_payment_strict_receive": + return "IN"; + + case "change_trust": + if (new BigNumber(operation.limit).eq(0)) { + return "OPT_OUT"; + } + + return "OPT_IN"; + + default: + if (operation.source_account === addr) { + return "OUT"; + } + + return "IN"; + } +} diff --git a/libs/coin-modules/coin-tezos/package.json b/libs/coin-modules/coin-tezos/package.json index 4c436d7754ef..00f5774eaafc 100644 --- a/libs/coin-modules/coin-tezos/package.json +++ b/libs/coin-modules/coin-tezos/package.json @@ -94,7 +94,8 @@ "@types/jest": "^29.5.12", "@types/lodash": "^4.17.4", "jest": "^29.7.0", - "ts-jest": "^29.1.4" + "ts-jest": "^29.1.4", + "typescript": "^5.4.5" }, "scripts": { "clean": "rimraf lib lib-es", diff --git a/libs/coin-modules/coin-tezos/src/api/index.integ.test.ts b/libs/coin-modules/coin-tezos/src/api/index.integ.test.ts index 179cf21ebaf8..945aec677710 100644 --- a/libs/coin-modules/coin-tezos/src/api/index.integ.test.ts +++ b/libs/coin-modules/coin-tezos/src/api/index.integ.test.ts @@ -6,7 +6,7 @@ import { createApi } from "."; */ describe("Tezos Api", () => { let module: Api; - const address = "tz1THUNARo58aD5YdNGYPnWNnvd8yHPrMdsF"; + const address = "tz1heMGVHQnx7ALDcDKqez8fan64Eyicw4DJ"; beforeAll(() => { module = createApi({ @@ -43,20 +43,32 @@ describe("Tezos Api", () => { }); }); - describe("listOperations", () => { + describe.only("listOperations", () => { it("returns a list regarding address parameter", async () => { // When - const result = await module.listOperations(address, 0); + const [tx, _] = await module.listOperations(address, { limit: 100 }); // Then - expect(result.length).toBeGreaterThanOrEqual(1); - result.forEach(operation => { + expect(tx.length).toBeGreaterThanOrEqual(1); + tx.forEach(operation => { expect(operation.address).toEqual(address); const isSenderOrReceipt = operation.senders.includes(address) || operation.recipients.includes(address); expect(isSenderOrReceipt).toBeTruthy(); }); }); + + it("returns paginated operations", async () => { + // When + const [tx, idx] = await module.listOperations(address, { limit: 100 }); + const [tx2, _] = await module.listOperations(address, { limit: 100, start: idx }); + tx.push(...tx2); + + // Then + // Find a way to create a unique id. In Tezos, the same hash may represent different operations in case of delegation. + const checkSet = new Set(tx.map(elt => `${elt.hash}${elt.type}${elt.senders[0]}`)); + expect(checkSet.size).toEqual(tx.length); + }); }); describe("lastBlock", () => { diff --git a/libs/coin-modules/coin-tezos/src/api/index.ts b/libs/coin-modules/coin-tezos/src/api/index.ts index 4d5a5805af14..dcd8234655f8 100644 --- a/libs/coin-modules/coin-tezos/src/api/index.ts +++ b/libs/coin-modules/coin-tezos/src/api/index.ts @@ -1,5 +1,6 @@ import { IncorrectTypeError, + Pagination, type Api, type Transaction as ApiTransaction, } from "@ledgerhq/coin-framework/api/index"; @@ -26,7 +27,7 @@ export function createApi(config: TezosConfig): Api { estimateFees: estimate, getBalance, lastBlock, - listOperations, + listOperations: operations, }; } @@ -63,3 +64,6 @@ async function estimate(addr: string, amount: bigint): Promise { }); return estimatedFees.estimatedFees; } + +const operations = (address: string, { limit, start }: Pagination) => + listOperations(address, { limit, lastId: start }); diff --git a/libs/coin-modules/coin-tezos/src/logic/listOperations.ts b/libs/coin-modules/coin-tezos/src/logic/listOperations.ts index e400557a715d..f2d201773213 100644 --- a/libs/coin-modules/coin-tezos/src/logic/listOperations.ts +++ b/libs/coin-modules/coin-tezos/src/logic/listOperations.ts @@ -1,5 +1,10 @@ -import { fetchAllTransactions } from "../network"; -import type { APIOperation } from "../network/types"; +import { tzkt } from "../network"; +import { + type APIDelegationType, + type APITransactionType, + isAPIDelegationType, + isAPITransactionType, +} from "../network/types"; export type Operation = { hash: string; @@ -14,30 +19,38 @@ export type Operation = { transactionSequenceNumber: number; }; -export async function listOperations(address: string, _blockHeight: number): Promise { - const operations = await fetchAllTransactions(address); - return operations.reduce( - (acc, op) => acc.concat(convertOperation(address, op) ?? acc), - [] as Operation[], - ); +export async function listOperations( + address: string, + { lastId, limit }: { lastId?: number; limit?: number }, +): Promise<[Operation[], number]> { + const operations = await tzkt.getAccountOperations(address, { lastId, limit }); + return [ + operations + .filter(op => isAPITransactionType(op) || isAPIDelegationType(op)) + .reduce((acc, op) => acc.concat(convertOperation(address, op)), [] as Operation[]), + operations.slice(-1)[0].id, + ]; } -function convertOperation(address: string, operation: APIOperation): Operation | null { - if (operation.type !== "transaction") { - return null; +function convertOperation( + address: string, + operation: APITransactionType | APIDelegationType, +): Operation { + const { amount, hash, storageFee, sender, timestamp, type, counter } = operation; + let targetAddress = ""; + if (isAPITransactionType(operation) && operation.target) { + targetAddress = operation.target.address; } - - const { amount, hash, storageFee, sender, target, timestamp, type, counter } = operation; return { hash: hash ?? "", address, type: type, value: BigInt(amount), // storageFee for transaction is always present - fee: BigInt(storageFee!), + fee: BigInt(storageFee ?? 0), blockHeight: 0, // operation.block is a string senders: [sender?.address ?? ""], - recipients: [target?.address ?? ""], + recipients: [targetAddress], date: new Date(timestamp), transactionSequenceNumber: counter, }; diff --git a/libs/coin-modules/coin-tezos/src/network/index.ts b/libs/coin-modules/coin-tezos/src/network/index.ts index 9403ae922a0d..f9804867bf15 100644 --- a/libs/coin-modules/coin-tezos/src/network/index.ts +++ b/libs/coin-modules/coin-tezos/src/network/index.ts @@ -1,2 +1,4 @@ -export * from "./tzkt"; -export * from "./bakers"; +import tzkt, { fetchAllTransactions } from "./tzkt"; +import * as bakers from "./bakers"; + +export { bakers, tzkt, fetchAllTransactions }; diff --git a/libs/coin-modules/coin-tezos/src/network/types.ts b/libs/coin-modules/coin-tezos/src/network/types.ts index 831cd95be481..826faff46105 100644 --- a/libs/coin-modules/coin-tezos/src/network/types.ts +++ b/libs/coin-modules/coin-tezos/src/network/types.ts @@ -39,23 +39,34 @@ type CommonOperationType = { /** * Source: https://api.tzkt.io/#operation/Accounts_GetOperations */ +export type APITransactionType = CommonOperationType & { + type: "transaction"; + amount: number; + initiator: { address: string } | undefined | null; + sender: { address: string } | undefined | null; + target: { address: string } | undefined | null; + counter: number; +}; +export function isAPITransactionType(op: APIOperation): op is APITransactionType { + return op.type === "transaction"; +} +export type APIDelegationType = CommonOperationType & { + type: "delegation"; + amount: number; + sender: { address: string } | undefined | null; + counter: number; + prevDelegate: { address: string } | undefined | null; + newDelegate: { address: string } | undefined | null; +}; +export function isAPIDelegationType(op: APIOperation): op is APIDelegationType { + return op.type === "delegation"; +} export type APIOperation = - | (CommonOperationType & { - type: "transaction"; - amount: number; - initiator: { address: string } | undefined | null; - sender: { address: string } | undefined | null; - target: { address: string } | undefined | null; - counter: number; - }) + | APITransactionType | (CommonOperationType & { type: "reveal"; }) - | (CommonOperationType & { - type: "delegation"; - prevDelegate: { address: string } | undefined | null; - newDelegate: { address: string } | undefined | null; - }) + | APIDelegationType | (CommonOperationType & { type: "activation"; balance: number; diff --git a/libs/coin-modules/coin-tezos/src/network/tzkt.ts b/libs/coin-modules/coin-tezos/src/network/tzkt.ts index a6d726b3de48..919e80621198 100644 --- a/libs/coin-modules/coin-tezos/src/network/tzkt.ts +++ b/libs/coin-modules/coin-tezos/src/network/tzkt.ts @@ -33,13 +33,19 @@ const api = { }); return data; }, + // https://api.tzkt.io/#operation/Accounts_GetOperations async getAccountOperations( address: string, query: { lastId?: number; sort?: number; + limit?: number; }, ): Promise { + // Remove undefined from query + Object.entries(query).forEach( + ([key, value]) => value === undefined && delete query[key as keyof typeof query], + ); const { data } = await network({ url: URL.format({ pathname: `${getExplorerUrl()}/v1/accounts/${address}/operations`, @@ -50,6 +56,10 @@ const api = { }, }; +const sortOperation = { + ascending: 0, + descending: 1, +}; export const fetchAllTransactions = async ( address: string, lastId?: number, @@ -57,7 +67,10 @@ export const fetchAllTransactions = async ( let ops: APIOperation[] = []; let maxIteration = coinConfig.getCoinConfig().explorer.maxTxQuery; do { - const newOps = await api.getAccountOperations(address, { lastId, sort: 0 }); + const newOps = await api.getAccountOperations(address, { + lastId, + sort: sortOperation.ascending, + }); if (newOps.length === 0) return ops; ops = ops.concat(newOps); const last = ops[ops.length - 1]; diff --git a/libs/coin-modules/coin-xrp/src/api/index.integ.test.ts b/libs/coin-modules/coin-xrp/src/api/index.integ.test.ts index 883f6d0e3b13..b81629bf0ace 100644 --- a/libs/coin-modules/coin-xrp/src/api/index.integ.test.ts +++ b/libs/coin-modules/coin-xrp/src/api/index.integ.test.ts @@ -30,17 +30,28 @@ describe("Xrp Api", () => { describe("listOperations", () => { it("returns a list regarding address parameter", async () => { // When - const result = await module.listOperations(address, 0); + const [tx, _] = await module.listOperations(address, { limit: 200 }); // Then - expect(result.length).toBeGreaterThanOrEqual(1); - result.forEach(operation => { + expect(tx.length).toBe(200); + tx.forEach(operation => { expect(operation.address).toEqual(address); const isSenderOrReceipt = operation.senders.includes(address) || operation.recipients.includes(address); expect(isSenderOrReceipt).toBeTruthy(); }); }); + + it("returns paginated operations", async () => { + // When + const [tx, idx] = await module.listOperations(address, { limit: 200 }); + const [tx2, _] = await module.listOperations(address, { limit: 200, start: idx }); + tx.push(...tx2); + + // Then + const checkSet = new Set(tx.map(elt => elt.hash)); + expect(checkSet.size).toEqual(tx.length); + }); }); describe("lastBlock", () => { @@ -84,7 +95,7 @@ describe("Xrp Api", () => { }); // Then - expect(result.slice(0, 34)).toEqual("1200002280000000240002588F201B001D"); + expect(result.slice(0, 34)).toEqual("12000022800000002400025899201B002D"); expect(result.slice(38)).toEqual( "61400000000000000A68400000000000000181142A6ADC782DAFDDB464E434B684F01416B8A33B208314CA26FB6B0EF6859436C2037BA0A9913208A59B98", ); diff --git a/libs/coin-modules/coin-xrp/src/api/index.test.ts b/libs/coin-modules/coin-xrp/src/api/index.test.ts index e9690f3d2ef0..d87aefdf17c9 100644 --- a/libs/coin-modules/coin-xrp/src/api/index.test.ts +++ b/libs/coin-modules/coin-xrp/src/api/index.test.ts @@ -91,7 +91,7 @@ describe("listOperations", () => { ]); // When - const results = await api.listOperations(address, 0); + const [results, _] = await api.listOperations(address, { limit: 100 }); // Then expect(mockGetServerInfos).toHaveBeenCalledTimes(1); diff --git a/libs/coin-modules/coin-xrp/src/api/index.ts b/libs/coin-modules/coin-xrp/src/api/index.ts index 37d0d58be6b5..6fddc72a292a 100644 --- a/libs/coin-modules/coin-xrp/src/api/index.ts +++ b/libs/coin-modules/coin-xrp/src/api/index.ts @@ -2,6 +2,7 @@ import type { Api, Operation, Transaction as ApiTransaction, + Pagination, } from "@ledgerhq/coin-framework/api/index"; import coinConfig, { type XrpConfig } from "../config"; import { @@ -40,10 +41,16 @@ async function estimate(_addr: string, _amount: bigint): Promise { return fees.fee; } -async function operations(address: string, blockHeight: number): Promise { - const ops = await listOperations(address, blockHeight); - return ops.map(op => { - const { simpleType, ...rest } = op; - return { ...rest } satisfies Operation; - }); +async function operations( + address: string, + { limit, start }: Pagination, +): Promise<[Operation[], number]> { + const [ops, index] = await listOperations(address, { limit, mostRecentIndex: start }); + return [ + ops.map(op => { + const { simpleType, ...rest } = op; + return { ...rest } satisfies Operation; + }), + index, + ]; } diff --git a/libs/coin-modules/coin-xrp/src/bridge/synchronization.ts b/libs/coin-modules/coin-xrp/src/bridge/synchronization.ts index 7deef8c64c6f..04021f10d150 100644 --- a/libs/coin-modules/coin-xrp/src/bridge/synchronization.ts +++ b/libs/coin-modules/coin-xrp/src/bridge/synchronization.ts @@ -32,7 +32,7 @@ export const getAccountShape: GetAccountShape = async info => { } const oldOperations = initialAccount?.operations || []; - const startAt = oldOperations.length ? (oldOperations[0].blockHeight || 0) + 1 : 0; + const blockHeight = oldOperations.length ? (oldOperations[0].blockHeight || 0) + 1 : 0; const serverInfo = await getServerInfos(); const ledgers = serverInfo.info.complete_ledgers.split("-"); @@ -41,7 +41,7 @@ export const getAccountShape: GetAccountShape = async info => { const balance = new BigNumber(accountInfo.balance); const spendableBalance = await calculateSpendableBalance(accountInfo, serverInfo); - const newOperations = await filterOperations(accountId, address, startAt); + const newOperations = await filterOperations(accountId, address, blockHeight); const operations = mergeOps(oldOperations, newOperations); @@ -61,9 +61,9 @@ export const getAccountShape: GetAccountShape = async info => { async function filterOperations( accountId: string, address: string, - startAt: number, + blockHeight: number, ): Promise { - const operations = await listOperations(address, startAt); + const [operations, _] = await listOperations(address, { startAt: blockHeight }); return operations.map( op => diff --git a/libs/coin-modules/coin-xrp/src/logic/listOperations.test.ts b/libs/coin-modules/coin-xrp/src/logic/listOperations.test.ts index 6b9e766b8428..4d796784c548 100644 --- a/libs/coin-modules/coin-xrp/src/logic/listOperations.test.ts +++ b/libs/coin-modules/coin-xrp/src/logic/listOperations.test.ts @@ -89,7 +89,7 @@ describe("listOperations", () => { ]); // When - const results = await listOperations(address, 0); + const [results, _] = await listOperations(address, { startAt: 0 }); // Then expect(mockGetServerInfos).toHaveBeenCalledTimes(1); diff --git a/libs/coin-modules/coin-xrp/src/logic/listOperations.ts b/libs/coin-modules/coin-xrp/src/logic/listOperations.ts index 9b7a570ac271..da4e2d9ac770 100644 --- a/libs/coin-modules/coin-xrp/src/logic/listOperations.ts +++ b/libs/coin-modules/coin-xrp/src/logic/listOperations.ts @@ -11,24 +11,46 @@ import { RIPPLE_EPOCH } from "./utils"; */ export async function listOperations( address: string, - blockHeight: number, -): Promise { + { + limit, + mostRecentIndex, + startAt, + }: { + limit?: number; + mostRecentIndex?: number | undefined; + startAt?: number; + }, +): Promise<[XrpOperation[], number]> { const serverInfo = await getServerInfos(); const ledgers = serverInfo.info.complete_ledgers.split("-"); const minLedgerVersion = Number(ledgers[0]); const maxLedgerVersion = Number(ledgers[1]); - // if there is no ops, it might be after a clear and we prefer to pull from the oldest possible history - const startAt = Math.max(blockHeight, minLedgerVersion); + let options: { ledger_index_min?: number; ledger_index_max?: number; limit?: number } = { + ledger_index_max: mostRecentIndex ?? maxLedgerVersion, + }; + if (limit) { + options = { + ...options, + limit, + }; + } + if (startAt) { + options = { + ...options, + // if there is no ops, it might be after a clear and we prefer to pull from the oldest possible history + ledger_index_min: Math.max(startAt ?? 0, minLedgerVersion), + }; + } - const transactions = await getTransactions(address, { - ledger_index_min: startAt, - ledger_index_max: maxLedgerVersion, - }); + const transactions = await getTransactions(address, options); - return transactions - .filter(op => op.tx.TransactionType === "Payment") - .map(convertToCoreOperation(address)); + return [ + transactions + .filter(op => op.tx.TransactionType === "Payment") + .map(convertToCoreOperation(address)), + transactions.slice(-1)[0].tx.ledger_index - 1, // Returns the next index to start from for pagination + ]; } const convertToCoreOperation = diff --git a/libs/coin-modules/coin-xrp/src/network/index.ts b/libs/coin-modules/coin-xrp/src/network/index.ts index 9e89e30a42b7..29e4250d23f8 100644 --- a/libs/coin-modules/coin-xrp/src/network/index.ts +++ b/libs/coin-modules/coin-xrp/src/network/index.ts @@ -68,7 +68,7 @@ export const getServerInfos = async (): Promise => { export const getTransactions = async ( address: string, - options: { ledger_index_min?: number; ledger_index_max?: number } | undefined, + options: { ledger_index_min?: number; ledger_index_max?: number; limit?: number } | undefined, ): Promise => { const result = await rpcCall("account_tx", { account: address, diff --git a/libs/ledger-live-common/src/families/tezos/react.ts b/libs/ledger-live-common/src/families/tezos/react.ts index 840928b6461e..9e118edac3a1 100644 --- a/libs/ledger-live-common/src/families/tezos/react.ts +++ b/libs/ledger-live-common/src/families/tezos/react.ts @@ -1,29 +1,24 @@ import type { AccountLike } from "@ledgerhq/types-live"; import { useEffect, useMemo, useState } from "react"; import { Baker, Delegation } from "@ledgerhq/coin-tezos/types/index"; -import { - getAccountDelegationSync, - getBakerSync, - listBakers, - listBakersWithDefault, - loadAccountDelegation, - loadBaker, -} from "@ledgerhq/coin-tezos/network/index"; +import { bakers } from "@ledgerhq/coin-tezos/network/index"; export function useBakers(whitelistAddresses: string[]): Baker[] { - const [bakers, setBakers] = useState(() => listBakersWithDefault(whitelistAddresses)); + const [bakers, setBakers] = useState(() => + bakers.listBakersWithDefault(whitelistAddresses), + ); useEffect(() => { - listBakers(whitelistAddresses).then(setBakers); - }, [whitelistAddresses]); + bakers.listBakers(whitelistAddresses).then(setBakers); + }, [bakers, whitelistAddresses]); return bakers; } export function useDelegation(account: AccountLike): Delegation | null | undefined { - const [delegation, setDelegation] = useState(() => getAccountDelegationSync(account)); + const [delegation, setDelegation] = useState(() => bakers.getAccountDelegationSync(account)); useEffect(() => { let cancelled = false; - loadAccountDelegation(account).then(delegation => { + bakers.loadAccountDelegation(account).then(delegation => { if (cancelled) return; setDelegation(delegation); }); @@ -36,9 +31,9 @@ export function useDelegation(account: AccountLike): Delegation | null | undefin } export function useBaker(addr: string): Baker | undefined { - const [baker, setBaker] = useState(() => getBakerSync(addr)); + const [baker, setBaker] = useState(() => bakers.getBakerSync(addr)); - loadBaker(addr).then(setBaker); + bakers.loadBaker(addr).then(setBaker); return baker; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a1fe559d530..4ac95d178e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2753,7 +2753,7 @@ importers: specifier: ^10.9.2 version: 10.9.2(typescript@5.6.3) typescript: - specifier: ^5.3.3 + specifier: ^5.4.5 version: 5.6.3 libs/coin-modules/coin-solana: @@ -3076,7 +3076,10 @@ importers: version: 29.7.0 ts-jest: specifier: ^29.1.4 - version: 29.1.5(jest@29.7.0)(typescript@5.4.3) + version: 29.1.5(jest@29.7.0)(typescript@5.6.3) + typescript: + specifier: ^5.4.5 + version: 5.6.3 libs/coin-modules/coin-ton: dependencies: