diff --git a/.changeset/new-plums-shout.md b/.changeset/new-plums-shout.md new file mode 100644 index 000000000000..77c94bf9f92c --- /dev/null +++ b/.changeset/new-plums-shout.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/coin-xrp": patch +--- + +Fix Cardano getAccountInfo 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 b9ad99455218..eeb6853d6409 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 @@ -5,7 +5,8 @@ import { sign } from "ripple-keypairs"; describe("Xrp Api", () => { let module: Api; - const address = "rKtXXTVno77jhu6tto1MAXjepyuaKaLcqB"; + const address = "rh1HPuRVsYYvThxG2Bs1MfjmrVC73S16Fb"; + const emptyAddress = "rKtXXTVno77jhu6tto1MAXjepyuaKaLcqB"; // Account with no transaction (at the time of this writing) const xrpPubKey = process.env["PUB_KEY"]!; const xrpSecretKey = process.env["SECRET_KEY"]!; @@ -16,7 +17,6 @@ describe("Xrp Api", () => { describe("estimateFees", () => { it("returns a default value", async () => { // Given - const address = "rDCyjRD2TcSSGUQpEcEhJGmDWfjPJpuGxu"; const amount = BigInt(100); // When @@ -56,12 +56,20 @@ describe("Xrp Api", () => { }); describe("getBalance", () => { - it("returns a list regarding address parameter", async () => { + it("returns an amount above 0 when address has transactions", async () => { // When const result = await module.getBalance(address); // Then - expect(result).toBeGreaterThan(0); + expect(result).toBeGreaterThan(BigInt(0)); + }); + + it("returns 0 when address has no transaction", async () => { + // When + const result = await module.getBalance(emptyAddress); + + // Then + expect(result).toBe(BigInt(0)); }); }); @@ -76,14 +84,16 @@ describe("Xrp Api", () => { }); // Then - expect(result.slice(0, 34)).toEqual("120000228000000024001BCDA6201B001F"); + expect(result.slice(0, 34)).toEqual("1200002280000000240002588F201B001D"); expect(result.slice(38)).toEqual( - "61400000000000000A6840000000000000018114CF30F590D7A9067B2604D80D46090FBF342EBE988314CA26FB6B0EF6859436C2037BA0A9913208A59B98", + "61400000000000000A68400000000000000181142A6ADC782DAFDDB464E434B684F01416B8A33B208314CA26FB6B0EF6859436C2037BA0A9913208A59B98", ); }); }); - describe("combine", () => { + // To enable this test, you need to fill an `.env` file at the root of this package. Example can be found in `.env.integ.test.example`. + // The value hardcoded here depends on the value filled in the `.env` file. + describe.skip("combine", () => { it("returns a signed raw transaction", async () => { // Given const rawTx = diff --git a/libs/coin-modules/coin-xrp/src/bridge/synchronization.ts b/libs/coin-modules/coin-xrp/src/bridge/synchronization.ts index 215394b51fd0..39d09e749fd0 100644 --- a/libs/coin-modules/coin-xrp/src/bridge/synchronization.ts +++ b/libs/coin-modules/coin-xrp/src/bridge/synchronization.ts @@ -3,7 +3,7 @@ import { Operation } from "@ledgerhq/types-live"; import { encodeAccountId } from "@ledgerhq/coin-framework/account/index"; import { GetAccountShape, mergeOps } from "@ledgerhq/coin-framework/bridge/jsHelpers"; import { getAccountInfo, getServerInfos, getTransactions } from "../network"; -import { NEW_ACCOUNT_ERROR_MESSAGE, parseAPIValue } from "../logic"; +import { parseAPIValue } from "../logic"; import { filterOperations } from "./logic"; export const getAccountShape: GetAccountShape = async info => { @@ -17,7 +17,7 @@ export const getAccountShape: GetAccountShape = async info => { }); const accountInfo = await getAccountInfo(address); - if (!accountInfo || accountInfo.error === NEW_ACCOUNT_ERROR_MESSAGE) { + if (accountInfo.isNewAccount) { return { id: accountId, xpub: address, @@ -42,10 +42,10 @@ export const getAccountShape: GetAccountShape = async info => { const minLedgerVersion = Number(ledgers[0]); const maxLedgerVersion = Number(ledgers[1]); - const trustlines = accountInfo.account_data.OwnerCount; + const trustlines = accountInfo.ownerCount; - const balance = new BigNumber(accountInfo.account_data.Balance); - const spendableBalance = new BigNumber(accountInfo.account_data.Balance) + const balance = new BigNumber(accountInfo.balance); + const spendableBalance = new BigNumber(accountInfo.balance) .minus(reserveMinXRP) .minus(reservePerTrustline.times(trustlines)); diff --git a/libs/coin-modules/coin-xrp/src/logic/getBalance.test.ts b/libs/coin-modules/coin-xrp/src/logic/getBalance.test.ts index 45eb26491265..95c74aafc207 100644 --- a/libs/coin-modules/coin-xrp/src/logic/getBalance.test.ts +++ b/libs/coin-modules/coin-xrp/src/logic/getBalance.test.ts @@ -3,7 +3,7 @@ import { getBalance } from "./getBalance"; const mockGetAccountInfo = jest.fn(); jest.mock("../network", () => ({ - getAccountInfo: (arg: unknown) => mockGetAccountInfo(arg), + getAccountInfo: (address: string) => mockGetAccountInfo(address), })); describe("getBalance", () => { @@ -16,9 +16,7 @@ describe("getBalance", () => { const balance = faker.number.bigInt(100_000_000); const address = "ACCOUNT_ADDRESS"; mockGetAccountInfo.mockResolvedValue({ - account_data: { - Balance: balance.toString(), - }, + balance, }); // When diff --git a/libs/coin-modules/coin-xrp/src/logic/getBalance.ts b/libs/coin-modules/coin-xrp/src/logic/getBalance.ts index ffc6d3925061..43906cc87d67 100644 --- a/libs/coin-modules/coin-xrp/src/logic/getBalance.ts +++ b/libs/coin-modules/coin-xrp/src/logic/getBalance.ts @@ -2,5 +2,5 @@ import { getAccountInfo } from "../network"; export async function getBalance(address: string): Promise { const accountInfo = await getAccountInfo(address); - return BigInt(accountInfo.account_data.Balance); + return BigInt(accountInfo.balance); } diff --git a/libs/coin-modules/coin-xrp/src/logic/index.ts b/libs/coin-modules/coin-xrp/src/logic/index.ts index 67681c8d3fbb..034fa644dc3b 100644 --- a/libs/coin-modules/coin-xrp/src/logic/index.ts +++ b/libs/coin-modules/coin-xrp/src/logic/index.ts @@ -13,6 +13,3 @@ export { } from "./utils"; export { parseAPIValue } from "./common"; - -//FIXME -export { NEW_ACCOUNT_ERROR_MESSAGE } from "../network"; diff --git a/libs/coin-modules/coin-xrp/src/logic/utils.test.ts b/libs/coin-modules/coin-xrp/src/logic/utils.test.ts new file mode 100644 index 000000000000..1c76d6a024c7 --- /dev/null +++ b/libs/coin-modules/coin-xrp/src/logic/utils.test.ts @@ -0,0 +1,60 @@ +import { cachedRecipientIsNew } from "./utils"; + +jest.mock("ripple-address-codec", () => ({ + isValidClassicAddress: () => true, +})); +const mockGetAccountInfo = jest.fn(); +jest.mock("../network", () => ({ + getAccountInfo: (address: string) => mockGetAccountInfo(address), +})); + +describe("cachedRecipientIsNew", () => { + afterEach(() => { + mockGetAccountInfo.mockClear(); + }); + + it("returns true when network returns a new empty account", async () => { + // Given + mockGetAccountInfo.mockResolvedValueOnce({ + isNewAccount: true, + balance: "0", + ownerCount: 0, + sequence: 0, + }); + + // When + const result = await cachedRecipientIsNew("address1"); + + // Then + expect(mockGetAccountInfo).toHaveBeenCalledTimes(1); + expect(result).toBeTruthy(); + }); + + it("returns false when network a valid AccountInfo", async () => { + // Given + mockGetAccountInfo.mockResolvedValueOnce({ + isNewAccount: false, + balance: "999441667919804", + ownerCount: 0, + sequence: 999441667919804, + }); + + // When + const result = await cachedRecipientIsNew("address2"); + + // Then + expect(mockGetAccountInfo).toHaveBeenCalledTimes(1); + expect(result).toBeFalsy(); + }); + + it("throws an error when network throws an error", async () => { + // Given + mockGetAccountInfo.mockImplementationOnce(() => { + throw new Error("Malformed address"); + }); + + // When & Then + await expect(cachedRecipientIsNew("address3")).rejects.toThrow("Malformed address"); + expect(mockGetAccountInfo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/coin-modules/coin-xrp/src/logic/utils.ts b/libs/coin-modules/coin-xrp/src/logic/utils.ts index ff4bdaa91b85..2afeb46e5961 100644 --- a/libs/coin-modules/coin-xrp/src/logic/utils.ts +++ b/libs/coin-modules/coin-xrp/src/logic/utils.ts @@ -1,6 +1,6 @@ import BigNumber from "bignumber.js"; import { isValidClassicAddress } from "ripple-address-codec"; -import { getAccountInfo, NEW_ACCOUNT_ERROR_MESSAGE } from "../network"; +import { getAccountInfo } from "../network"; export const UINT32_MAX = new BigNumber(2).pow(32).minus(1); @@ -15,7 +15,7 @@ export const validateTag = (tag: BigNumber) => { export const getNextValidSequence = async (address: string) => { const accInfo = await getAccountInfo(address, true); - return accInfo.account_data.Sequence; + return accInfo.sequence; }; function isRecipientValid(recipient: string): boolean { @@ -26,10 +26,7 @@ const recipientIsNew = async (recipient: string): Promise => { if (!isRecipientValid(recipient)) return false; const info = await getAccountInfo(recipient); - if (info.error === NEW_ACCOUNT_ERROR_MESSAGE) { - return true; - } - return false; + return info.isNewAccount; }; const cacheRecipientsNew: Record = {}; diff --git a/libs/coin-modules/coin-xrp/src/network/index.test.ts b/libs/coin-modules/coin-xrp/src/network/index.test.ts new file mode 100644 index 000000000000..3934cf1f4f3e --- /dev/null +++ b/libs/coin-modules/coin-xrp/src/network/index.test.ts @@ -0,0 +1,114 @@ +import network from "@ledgerhq/live-network"; +import { getAccountInfo } from "."; +import coinConfig, { type XrpCoinConfig } from "../config"; + +jest.mock("@ledgerhq/live-network"); + +describe("getAccountInfo", () => { + beforeAll(() => { + coinConfig.setCoinConfig( + () => + ({ + node: "", + }) as XrpCoinConfig, + ); + }); + + it("returns an empty AccountInfo when returns an error 'actNotFound'", async () => { + // Given + const emptyAddress = "rNCgVpHinUDjXP2vHDFDMjm7ssBwpveHya"; + (network as jest.Mock).mockResolvedValue({ + data: { + result: { + account: emptyAddress, + error: "actNotFound", + error_code: 19, + error_message: "Account not found.", + ledger_hash: "F2E6EFD279C3663B62D9DC9977106EC25BA8F89DA551C2D7AB3AE5D75B146258", + ledger_index: 91772714, + request: { + account: emptyAddress, + command: "account_info", + ledger_index: "validated", + }, + status: "error", + validated: true, + }, + }, + }); + + // When + const result = await getAccountInfo(emptyAddress); + + // Then + expect(result).toEqual({ + isNewAccount: true, + balance: "0", + ownerCount: 0, + sequence: 0, + }); + }); + + it("returns a valid AccountInfo when return a correct AccountInfo", async () => { + // Given + const address = "rh1HPuRVsYYvThxG2Bs1MfjmrVC73S16Fb"; + (network as jest.Mock).mockResolvedValue({ + data: { + result: { + account_data: { + Account: address, + Balance: "999441667919804", + Flags: 0, + LedgerEntryType: "AccountRoot", + OwnerCount: 0, + PreviousTxnID: "947F03794C982FE4C7C9FECC4C33C543BB25B82938895EBA8F9B6021CC27A571", + PreviousTxnLgrSeq: 725208, + Sequence: 153743, + index: "BC0DAE09C0BFBC4A49AA94B849266588BFD6E1F554B184B5788AC55D6E07EB95", + }, + ledger_hash: "93E952B2770233B0ABFBFBBFBC3E2E2159DCABD07FEB5F4C49174027935D9FBB", + ledger_index: 1908009, + status: "success", + validated: true, + }, + }, + }); + + // When + const result = await getAccountInfo(address); + + // Then + expect(result).toEqual({ + isNewAccount: false, + balance: "999441667919804", + ownerCount: 0, + sequence: 153743, + }); + }); + + it("throws an error when backend returns any other error", async () => { + // Given + const invalidAddress = "rNCgVpHinUDjXP2vHDFDMjm7ssBwpveHyaa"; + (network as jest.Mock).mockResolvedValue({ + result: { + error: "actMalformed", + error_code: 35, + error_message: "Account malformed.", + ledger_hash: "87DE2DD287BCAD6E81720BC6E6361EF01A66EE70A37B6BDF1EFF2E719D9410AE", + ledger_index: 91772741, + request: { + account: invalidAddress, + command: "account_info", + ledger_index: "validated", + }, + status: "error", + validated: true, + }, + }); + + // When & Then + await expect(getAccountInfo(invalidAddress)).rejects.toThrow( + "Cannot read properties of undefined (reading 'result')", + ); + }); +}); diff --git a/libs/coin-modules/coin-xrp/src/network/index.ts b/libs/coin-modules/coin-xrp/src/network/index.ts index c8e3897952e4..dace2a441708 100644 --- a/libs/coin-modules/coin-xrp/src/network/index.ts +++ b/libs/coin-modules/coin-xrp/src/network/index.ts @@ -1,9 +1,12 @@ import network from "@ledgerhq/live-network"; import coinConfig from "../config"; +import type { AccountInfo } from "../types/model"; import { + isErrorResponse, isResponseStatus, type AccountInfoResponse, type AccountTxResponse, + type ErrorResponse, type LedgerResponse, type ServerInfoResponse, type SubmitReponse, @@ -20,10 +23,10 @@ export const submit = async (signature: string): Promise => { export const getAccountInfo = async ( recipient: string, current?: boolean, -): Promise => { +): Promise => { const { data: { result }, - } = await network<{ result: AccountInfoResponse }>({ + } = await network<{ result: AccountInfoResponse | ErrorResponse }>({ method: "POST", url: getNodeUrl(), data: { @@ -41,7 +44,21 @@ export const getAccountInfo = async ( throw new Error(`couldn't fetch account info ${recipient}`); } - return result; + if (isErrorResponse(result)) { + return { + isNewAccount: true, + balance: "0", + ownerCount: 0, + sequence: 0, + }; + } else { + return { + isNewAccount: false, + balance: result.account_data.Balance, + ownerCount: result.account_data.OwnerCount, + sequence: result.account_data.Sequence, + }; + } }; export const getServerInfos = async (): Promise => { diff --git a/libs/coin-modules/coin-xrp/src/network/types.ts b/libs/coin-modules/coin-xrp/src/network/types.ts index 998d0e8cc6d9..b7fe56c3939c 100644 --- a/libs/coin-modules/coin-xrp/src/network/types.ts +++ b/libs/coin-modules/coin-xrp/src/network/types.ts @@ -193,3 +193,22 @@ export type LedgerResponse = { ledger_index: number; validated: boolean; } & ResponseStatus; + +export type ErrorResponse = { + account: string; + error: string; + error_code: number; + error_message: string; + ledger_hash: string; + ledger_index: number; + request: { + account: string; + command: string; + ledger_index: string; + }; + status: string; + validated: boolean; +}; +export function isErrorResponse(obj: object): obj is ErrorResponse { + return "status" in obj && obj.status === "error" && "error" in obj; +} diff --git a/libs/coin-modules/coin-xrp/src/types/index.ts b/libs/coin-modules/coin-xrp/src/types/index.ts index 2aa9dd916aa1..34dbbe906c79 100644 --- a/libs/coin-modules/coin-xrp/src/types/index.ts +++ b/libs/coin-modules/coin-xrp/src/types/index.ts @@ -1,2 +1,3 @@ export * from "./bridge"; +export * from "./model"; export * from "./signer"; diff --git a/libs/coin-modules/coin-xrp/src/types/model.ts b/libs/coin-modules/coin-xrp/src/types/model.ts new file mode 100644 index 000000000000..0ce2da76f469 --- /dev/null +++ b/libs/coin-modules/coin-xrp/src/types/model.ts @@ -0,0 +1,6 @@ +export type AccountInfo = { + isNewAccount: boolean; + balance: string; + ownerCount: number; + sequence: number; +};