import { adaptCoreOperationToLiveOperation, applyMemoToIntent, bigNumberToBigIntDeep, buildOptimisticOperation, cleanedOperation, extractBalance, extractBalances, findCryptoCurrencyByNetwork, transactionToIntent, } from "./utils"; import BigNumber from "bignumber.js"; import { Operation as CoreOperation, TransactionIntent } from "@ledgerhq/coin-module-framework/api/types"; import { Account } from "@ledgerhq/types-live"; import { GenericTransaction, OperationCommon } from "./types"; describe("Alpaca utils", () => { describe("applyMemoToIntent", () => { it("does not apply any memo", () => { const intent = applyMemoToIntent( {} as unknown as TransactionIntent, {} as GenericTransaction, ); expect(intent).toEqual({}); }); it.each([ [0, "0"], [1, "1"], ])("applies '%s' as the destination tag", (tag, expectedTag) => { const intent = applyMemoToIntent( {} as unknown as TransactionIntent, { tag } as GenericTransaction, ); expect(intent).toEqual({ memo: { type: "map", memos: new Map([["destinationTag", expectedTag]]) }, }); }); it("applies a custom memo type", () => { const intent = applyMemoToIntent( {} as unknown as TransactionIntent, { memoType: "memo-type", memoValue: "memo-value" } as GenericTransaction, ); expect(intent).toEqual({ memo: { type: "memo-type", value: "memo-value" }, }); }); }); describe("bigNumberToBigIntDeep", () => { it.each([ [undefined, undefined], [null, null], ["", ""], ["str", "str"], [0, 0], [1, 1], [true, true], [false, false], [new BigNumber(0), 0n], [new BigNumber(1), 1n], [[], []], [ ["str", 1], ["str", 1], ], [ ["str", BigNumber(1)], ["str", 1n], ], [ [new BigNumber(0), new BigNumber(1)], [0n, 1n], ], [{}, {}], [ { a: "str", b: 0, c: true }, { a: "str", b: 0, c: true }, ], [ { a: "str", b: new BigNumber(1), c: true }, { a: "str", b: 1n, c: true }, ], [ { a: "str", b: new BigNumber(1), c: { ca: new BigNumber(2), cb: 4 } }, { a: "str", b: 1n, c: { ca: 2n, cb: 4 } }, ], [ { a: "str", b: new BigNumber(1), c: { ca: new BigNumber(2), cb: null } }, { a: "str", b: 1n, c: { ca: 2n, cb: null } }, ], [ { a: "str", b: new BigNumber(1), c: { ca: new BigNumber(2), cb: undefined } }, { a: "str", b: 1n, c: { ca: 2n } }, ], ])("replaces BigNumbers with BigInts (%j)", (input, output) => { expect(bigNumberToBigIntDeep(input)).toStrictEqual(output); }); }); describe("buildOptimisticOperation", () => { it.each([ [ "coin", "changeTrust", {}, { parentType: "OPT_IN", subType: undefined, parentValue: new BigNumber(50), parentRecipient: "recipient-address", }, ], [ "coin", "delegate", {}, { parentType: "DELEGATE", subType: undefined, parentValue: new BigNumber(50), parentRecipient: "recipient-address", }, ], [ "coin", "stake", {}, { parentType: "DELEGATE", subType: undefined, parentValue: new BigNumber(50), parentRecipient: "recipient-address", }, ], [ "coin", "undelegate", {}, { parentType: "UNDELEGATE", subType: undefined, parentValue: new BigNumber(50), parentRecipient: "recipient-address", }, ], [ "coin", "unstake", {}, { parentType: "UNDELEGATE", subType: undefined, parentValue: new BigNumber(50), parentRecipient: "recipient-address", }, ], [ "coin", "send", {}, { parentType: "OUT", subType: undefined, parentValue: new BigNumber(50), parentRecipient: "recipient-address", }, ], [ "token", "changeTrust", { subAccountId: "sub-account-id" }, { parentType: "FEES", subType: "OPT_IN", parentValue: new BigNumber(12), parentRecipient: "contract-address", }, ], [ "token", "delegate", { subAccountId: "sub-account-id" }, { parentType: "FEES", subType: "DELEGATE", parentValue: new BigNumber(12), parentRecipient: "contract-address", }, ], [ "token", "stake", { subAccountId: "sub-account-id" }, { parentType: "FEES", subType: "DELEGATE", parentValue: new BigNumber(12), parentRecipient: "contract-address", }, ], [ "token", "undelegate", { subAccountId: "sub-account-id" }, { parentType: "FEES", subType: "UNDELEGATE", parentValue: new BigNumber(12), parentRecipient: "contract-address", }, ], [ "token", "unstake", { subAccountId: "sub-account-id" }, { parentType: "FEES", subType: "UNDELEGATE", parentValue: new BigNumber(12), parentRecipient: "contract-address", }, ], [ "token", "send", { subAccountId: "sub-account-id" }, { parentType: "FEES", subType: "OUT", parentValue: new BigNumber(12), parentRecipient: "contract-address", }, ], ])("builds an optimistic %s operation with %s mode", (_s, mode, params, expected) => { const operation = buildOptimisticOperation( { id: "parent-account-id", freshAddress: "account-address", subAccounts: [{ id: "sub-account-id", token: { contractAddress: "contract-address" } }], } as Account, { mode, amount: new BigNumber(50), fees: new BigNumber(12), recipient: "recipient-address", recipientDomain: { registry: "ens", domain: "recipient.eth", address: "recipient-address", type: "forward", }, ...params, } as GenericTransaction, 3n, ); expect(operation).toMatchObject({ id: `parent-account-id--${expected.parentType}`, transactionSequenceNumber: new BigNumber(3), type: expected.parentType, value: expected.parentValue, accountId: "parent-account-id", senders: ["account-address"], recipients: ["recipient-address"], fee: new BigNumber(12), blockHash: null, blockHeight: null, transactionRaw: { amount: expected.subType ? "0" : expected.parentValue.toFixed(), fees: "12", recipient: expected.parentRecipient, recipientDomain: { registry: "ens", domain: "recipient.eth", address: "recipient-address", type: "forward", }, }, ...(expected.subType ? { subOperations: [ { id: `sub-account-id--${expected.subType}`, transactionSequenceNumber: new BigNumber(3), accountId: "sub-account-id", type: expected.subType, senders: ["account-address"], recipients: ["recipient-address"], fee: new BigNumber(12), value: new BigNumber(50), blockHash: null, blockHeight: null, transactionRaw: { amount: "50", fees: "12", recipient: "recipient-address", }, }, ], } : {}), }); }); }); describe("cleanedOperation", () => { it("creates a cleaned version of an operation without mutating it", () => { const dirty = { id: "id", hash: "hash", senders: ["sender"], recipients: ["recipient"], extra: { assetAmount: 5, assetReference: "USDC", paginationToken: "pagination" }, } as unknown as OperationCommon; const clean = cleanedOperation(dirty); expect(clean).toEqual({ id: "id", hash: "hash", senders: ["sender"], recipients: ["recipient"], extra: { paginationToken: "pagination" }, }); expect(dirty).toEqual({ id: "id", hash: "hash", senders: ["sender"], recipients: ["recipient"], extra: { assetAmount: 5, assetReference: "USDC", paginationToken: "pagination" }, }); }); }); describe("transactionToIntent", () => { describe("type", () => { it("fallbacks to 'Payment' without a transaction mode", () => { expect( transactionToIntent( { currency: { name: "ethereum", units: [{}] } } as Account, { mode: undefined } as GenericTransaction, ), ).toMatchObject({ type: "Payment", }); }); it.each([ ["changeTrust", "changeTrust"], ["send", "send"], ["send-legacy", "send-legacy"], ["send-eip1559", "send-eip1559"], ["stake", "stake"], ["unstake", "unstake"], ["delegate", "stake"], ["undelegate", "unstake"], ])( "by default, associates '%s' transaction mode to '%s' intent type", (mode, expectedType) => { expect( transactionToIntent( { currency: { name: "ethereum", units: [{}] } } as Account, { mode } as GenericTransaction, ), ).toMatchObject({ type: expectedType, }); }, ); it("rejects other modes", () => { expect(() => transactionToIntent( { currency: { name: "ethereum", units: [{}] } } as Account, { mode: "any" as unknown } as GenericTransaction, ), ).toThrow("Unsupported transaction mode: any"); }); it("supersedes the logic with a custom function", () => { const computeIntentType = (transaction: GenericTransaction) => transaction.mode === "send" && transaction.type === 2 ? "send-eip1559" : "send-legacy"; expect( transactionToIntent( { currency: { name: "ethereum", units: [{}] } } as Account, { mode: "send", type: 2 } as GenericTransaction, computeIntentType, ), ).toMatchObject({ type: "send-eip1559", }); }); }); }); describe("findCryptoCurrencyByNetwork", () => { it("finds a crypto currency by id", () => { expect(findCryptoCurrencyByNetwork("ethereum")).toMatchObject({ id: "ethereum", family: "evm", }); }); it("takes currency remapping into account", () => { expect(findCryptoCurrencyByNetwork("ripple")).toMatchObject({ id: "ripple", family: "xrp", }); expect(findCryptoCurrencyByNetwork("xrp")).toMatchObject({ id: "ripple", family: "xrp", }); }); it("does not find non existing currencies", () => { expect(findCryptoCurrencyByNetwork("non_existing_currency")).toBeUndefined(); }); }); describe("extractBalances", () => { it("extracts native balance only", () => { expect( extractBalances({ spendableBalance: BigNumber(10), balance: BigNumber(10), } as unknown as Account), ).toEqual([{ value: 10n, locked: 0n, asset: { type: "native" } }]); expect( extractBalances({ spendableBalance: BigNumber(8), balance: BigNumber(10), } as unknown as Account), ).toEqual([{ value: 10n, locked: 2n, asset: { type: "native" } }]); }); it("extracts native and token balances", () => { expect( extractBalances( { spendableBalance: BigNumber(10), balance: BigNumber(10), subAccounts: [ { spendableBalance: BigNumber(11), balance: BigNumber(20), token: { tokenType: "erc20", contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", }, }, ], } as unknown as Account, token => ({ type: token.tokenType, assetReference: token.contractAddress, }), ), ).toEqual([ { value: 10n, locked: 0n, asset: { type: "native" } }, { asset: { assetReference: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", type: "erc20", }, locked: 9n, value: 20n, }, ]); }); }); describe("extractBalance", () => { it("extracts an existing balance", () => { expect(extractBalance([{ value: 4n, asset: { type: "type1" } }], "type1")).toEqual({ value: 4n, asset: { type: "type1" }, }); }); it("generates an empty balance for a missing type", () => { expect(extractBalance([{ value: 4n, asset: { type: "type1" } }], "type2")).toEqual({ value: 0n, asset: { type: "type2" }, }); }); }); jest.mock("@ledgerhq/ledger-wallet-framework/operation", () => ({ encodeOperationId: jest.fn((accountId, txHash, opType) => `${accountId}-${txHash}-${opType}`), })); describe("adaptCoreOperationToLiveOperation", () => { const accountId = "acc_123"; const baseOp: CoreOperation = { id: "op_123", asset: { type: "native" }, type: "OUT", value: BigInt(100), tx: { hash: "txhash123", fees: BigInt(10), block: { hash: "blockhash123", height: 123456, time: new Date("2025-08-29T12:00:00Z"), }, date: new Date("2025-08-29T12:00:00Z"), failed: false, }, senders: ["sender1"], recipients: ["recipient1"], }; it("does not include fees in non native asset value", () => { expect( adaptCoreOperationToLiveOperation("account", { id: "operation", asset: { type: "token", assetOwner: "owner", assetReference: "reference" }, type: "OUT", value: BigInt(100), tx: { hash: "hash", fees: BigInt(10), block: { hash: "block_hash", height: 123456, time: new Date("2025-08-29T12:00:00Z"), }, date: new Date("2025-08-29T12:00:00Z"), failed: false, }, senders: ["sender"], recipients: ["recipient"], }), ).toEqual({ id: "account-hash-OUT", hash: "hash", accountId: "account", type: "OUT", value: new BigNumber(100), // value only fee: new BigNumber(10), extra: { assetOwner: "owner", assetReference: "reference", }, blockHash: "block_hash", blockHeight: 123456, senders: ["sender"], recipients: ["recipient"], date: new Date("2025-08-29T12:00:00Z"), transactionSequenceNumber: undefined, hasFailed: false, }); }); it("adapts a basic OUT operation", () => { const result = adaptCoreOperationToLiveOperation(accountId, baseOp); expect(result).toEqual({ id: "acc_123-txhash123-OUT", hash: "txhash123", accountId, type: "OUT", value: new BigNumber(110), // value + fee fee: new BigNumber(10), blockHash: "blockhash123", blockHeight: 123456, senders: ["sender1"], recipients: ["recipient1"], date: new Date("2025-08-29T12:00:00Z"), transactionSequenceNumber: undefined, hasFailed: false, extra: {}, }); }); it.each([["FEES"], ["DELEGATE"], ["UNDELEGATE"]])( "handles %s operation where value = value + fees", operationType => { const op = { ...baseOp, type: operationType, value: BigInt(5), tx: { ...baseOp.tx, fees: BigInt(2) }, }; const result = adaptCoreOperationToLiveOperation(accountId, op); expect(result.value.toString()).toEqual("7"); }, ); it("handles non-FEES/OUT operation where value = value only", () => { const op = { ...baseOp, type: "IN", value: BigInt(50), tx: { ...baseOp.tx, fees: BigInt(2) }, }; const result = adaptCoreOperationToLiveOperation(accountId, op); expect(result.value.toString()).toEqual("50"); }); it("shows fees in value when transaction has failed", () => { const failedOp = { ...baseOp, type: "OUT", value: BigInt(100), tx: { ...baseOp.tx, fees: BigInt(25), failed: true }, }; const result = adaptCoreOperationToLiveOperation(accountId, failedOp); expect(result).toMatchObject({ hasFailed: true, value: new BigNumber(25), fee: new BigNumber(25), }); }); }); });