import BigNumber from "bignumber.js"; import { genericGetAccountShape } from "../getAccountShape"; const getSyncHashMock = jest.fn(); jest.mock("@ledgerhq/ledger-wallet-framework/account/index", () => ({ encodeAccountId: jest.fn(() => "accId"), getSyncHash: (...args: any[]) => getSyncHashMock(...args), })); const mergeOpsMock = jest.fn(); jest.mock("@ledgerhq/ledger-wallet-framework/bridge/jsHelpers", () => ({ mergeOps: (...args: any[]) => mergeOpsMock(...args), })); const listOperationsMock = jest.fn(); const getBalanceMock = jest.fn(); const lastBlockMock = jest.fn(); const getTokenFromAssetMock = jest.fn(); const chainSpecificGetAccountShapeMock = jest.fn(); const refreshOperationsMock = jest.fn(); jest.mock("../alpaca", () => ({ getAlpacaApi: () => ({ lastBlock: (...a: any[]) => lastBlockMock(...a), getBalance: (...a: any[]) => getBalanceMock(...a), listOperations: (...a: any[]) => listOperationsMock(...a), refreshOperations: (...a: any[]) => refreshOperationsMock(...a), }), })); jest.mock("../bridge", () => ({ getBridgeApi: () => ({ getTokenFromAsset: getTokenFromAssetMock, getChainSpecificRules: () => ({ getAccountShape: (...a: any[]) => chainSpecificGetAccountShapeMock(...a), }), }), })); const adaptCoreOperationToLiveOperationMock = jest.fn(); const extractBalanceMock = jest.fn(); const cleanedOperationMock = jest.fn(); jest.mock("../utils", () => ({ adaptCoreOperationToLiveOperation: (...a: any[]) => adaptCoreOperationToLiveOperationMock(...a), extractBalance: (...a: any[]) => extractBalanceMock(...a), cleanedOperation: (...a: any[]) => cleanedOperationMock(...a), })); const inferSubOperationsMock = jest.fn(); jest.mock("@ledgerhq/ledger-wallet-framework/serialization", () => ({ inferSubOperations: (...a: any[]) => inferSubOperationsMock(...a), })); const buildSubAccountsMock = jest.fn(); const mergeSubAccountsMock = jest.fn(); jest.mock("../buildSubAccounts", () => ({ buildSubAccounts: (...a: any[]) => buildSubAccountsMock(...a), mergeSubAccounts: (...a: any[]) => mergeSubAccountsMock(...a), })); // Test matrix for Stellar & XRP const chains = [ { currency: { id: "stellar", name: "Stellar" }, network: "testnet" }, { currency: { id: "ripple", name: "XRP" }, network: "mainnet" }, { currency: { id: "tezos", name: "Tezos" }, network: "mainnet" }, ]; describe("genericGetAccountShape", () => { beforeEach(() => { jest.clearAllMocks(); }); describe.each(chains)("$currency.id", ({ currency, network }) => { test.each([ [ "an up-to-date sync hash", "sync-hash", { minHeight: 11, order: "desc", cursor: "pt1", }, [ { hash: "h0", type: "OUT", blockHeight: 12, }, { hash: "h2", type: "OUT", blockHeight: 12, subOperations: [{ id: `${currency.id}_subOp1` }], extra: {}, }, { blockHeight: 16, hash: "h4", type: "IN", }, { hash: "h1", blockHeight: 10, type: "OPT_IN", extra: { pagingToken: "pt1", assetReference: "ar1", assetOwner: "ow1" }, }, ], ], [ "an outdated sync hash", "outdated-sync-hash", { minHeight: 0, order: "desc", }, [ { hash: "h0", type: "OUT", blockHeight: 12, }, { hash: "h2", type: "OUT", blockHeight: 12, subOperations: [{ id: `${currency.id}_subOp1` }], extra: {}, }, { blockHeight: 16, hash: "h4", type: "IN", }, ], ], ])( "builds account shape with existing operations, pagination and sub accounts from %s", async (_s, syncHash, expectedPagination, expectedOperations) => { const oldOp = { hash: "h1", blockHeight: 10, type: "OPT_IN", extra: { pagingToken: "pt1", assetReference: "ar1", assetOwner: "ow1" }, }; const pendingOp = { hash: "h0", blockHeight: 10, type: "OUT", }; const initialAccount = { operations: [oldOp], pendingOperations: [pendingOp], blockHeight: 10, syncHash, }; getSyncHashMock.mockReturnValue("sync-hash"); extractBalanceMock.mockReturnValue({ value: 1000n, locked: 300n }); getBalanceMock.mockResolvedValue([ { asset: { type: "native" }, value: 1000n, locked: 300n }, { asset: { type: "token", symbol: "TOK1" }, value: 42n }, { asset: { type: "token", symbol: "TOK_IGNORE" }, value: 5n }, ]); getTokenFromAssetMock.mockImplementation(asset => asset.symbol === "TOK1" ? { id: `${currency.id}_token1` } : null, ); listOperationsMock.mockResolvedValue({ items: [ { hash: "h2", type: "OUT", height: 12 }, { hash: "h2", type: "OUT", height: 12, _token: true }, { hash: "h3", type: "IN", tx: { failed: true }, height: 14 }, // won't appear in final shape { hash: "h4", type: "IN", tx: { failed: false }, height: 16 }, ], next: undefined, }); refreshOperationsMock.mockImplementation(ops => { const op = ops[0]; if (op?.hash === "h0") { return [{ ...op, blockHeight: 12 }]; } return []; }); adaptCoreOperationToLiveOperationMock.mockImplementation((_accId, op: any) => { const isTokenOp = op._token === true; return { hash: op.hash, type: op.type, blockHeight: op.height, extra: isTokenOp ? { assetReference: "ar2", assetOwner: "ow2" } : {}, }; }); mergeOpsMock.mockImplementation((oldOps, newOps) => [...newOps, ...oldOps]); cleanedOperationMock.mockImplementation(operation => operation); mergeSubAccountsMock.mockImplementation((oldSubAccounts, newSubAccounts) => [ ...newSubAccounts, ...oldSubAccounts, ]); buildSubAccountsMock.mockReturnValue([ { id: `${currency.id}_subAcc1`, type: "TokenAccount" }, ]); inferSubOperationsMock.mockImplementation(hash => hash === "h2" ? [{ id: `${currency.id}_subOp1` }] : [], ); lastBlockMock.mockResolvedValue({ height: 123 }); const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr1`, initialAccount, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); expect(chainSpecificGetAccountShapeMock).toHaveBeenCalledWith(`${currency.id}_addr1`); expect(listOperationsMock).toHaveBeenCalledWith(`${currency.id}_addr1`, { minHeight: expectedPagination.minHeight, order: expectedPagination.order, ...("cursor" in expectedPagination ? { cursor: expectedPagination.cursor } : {}), }); const assetsBalancePassed = buildSubAccountsMock.mock.calls[0][0].allTokenAssetsBalances; expect(assetsBalancePassed).toEqual([ { asset: { symbol: "TOK1", type: "token" }, value: 42n }, { asset: { symbol: "TOK_IGNORE", type: "token" }, value: 5n }, ]); const assetOpsPassed = buildSubAccountsMock.mock.calls[0][0].operations; expect(assetOpsPassed).toEqual([ { blockHeight: 12, extra: { assetOwner: "ow2", assetReference: "ar2", }, hash: "h2", type: "OUT", }, ]); expect(result).toMatchObject({ balance: new BigNumber(1000), spendableBalance: new BigNumber(700), blockHeight: 123, operationsCount: expectedOperations.length, subAccounts: [{ id: `${currency.id}_subAcc1`, type: "TokenAccount" }], operations: expectedOperations, }); }, ); test("handles empty operations (no old ops, no new ops) and blockHeight=0", async () => { getBalanceMock.mockResolvedValue([{ asset: { type: "native" }, value: 0n, locked: 0n }]); extractBalanceMock.mockReturnValue({ value: 0n, locked: 0n }); listOperationsMock.mockResolvedValue({ items: [], next: undefined }); buildSubAccountsMock.mockReturnValue([]); const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr2`, initialAccount: undefined, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); expect(result).toMatchObject({ operations: [], // Empty array check for `operations` blockHeight: 0, operationsCount: 0, subAccounts: [], // Empty array check for `subAccounts` balance: new BigNumber(0), spendableBalance: new BigNumber(0), }); }); test("existing operations object refs are preserved", async () => { const oldOps = [ { hash: "h1", blockHeight: 10, type: "OPT_IN", extra: { pagingToken: "pt1", assetReference: "ar1", assetOwner: "ow1" }, accountId: "accId", id: "accId_h1_OPT_IN", }, { hash: "h2", blockHeight: 12, type: "OUT", extra: { assetReference: "ar2", assetOwner: "ow2" }, accountId: "accId", id: "accId_h2_OUT", }, ]; const initialAccount = { operations: oldOps, pendingOperations: [], blockHeight: 10, syncHash: "sync-hash", }; const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr2`, initialAccount, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); expect(result.operations?.[0]).toStrictEqual(oldOps[0]); expect(result.operations?.[1]).toStrictEqual(oldOps[1]); }); test("LedgerOPTypes is handled correctly", async () => { const txWithLedgerOpTypes = { hash: "tx-hash3", type: "OUT", tx: { failed: false }, details: { assetReference: "usdc", assetOwner: "owner", ledgerOpType: "IN", assetSenders: ["other"], assetRecipients: ["owner"], }, }; getBalanceMock.mockResolvedValue([{ asset: { type: "native" }, value: 0n, locked: 0n }]); extractBalanceMock.mockReturnValue({ value: 0n, locked: 0n }); listOperationsMock.mockResolvedValue({ items: [txWithLedgerOpTypes], next: undefined }); buildSubAccountsMock.mockReturnValue([]); lastBlockMock.mockResolvedValue({ height: 1 }); adaptCoreOperationToLiveOperationMock.mockImplementation((_accId, op: any) => ({ hash: op.hash, type: op.type, blockHeight: 1, extra: { assetReference: op.details?.assetReference, assetOwner: op.details?.assetOwner, feePayer: `${currency.id}_addr2`, }, })); cleanedOperationMock.mockImplementation((o: any) => o); mergeOpsMock.mockImplementation((_old: any[], newOps: any[]) => newOps); inferSubOperationsMock.mockReturnValue([]); const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr2`, initialAccount: undefined, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); const operation = result.operations?.[0]; expect(operation?.hash).toBe(txWithLedgerOpTypes.hash); // Token-only op becomes a synthetic FEES parent (one parent per hash) expect(operation?.type).toBe("FEES"); }); test("buildOneParentOpPerHash: token-only hash produces one synthetic FEES parent with subOperations", async () => { const txHash = "pure-erc20-hash"; const tokenOpFromList = { hash: txHash, type: "OUT", height: 20, tx: { failed: false } }; getBalanceMock.mockResolvedValue([{ asset: { type: "native" }, value: 1000n, locked: 0n }]); extractBalanceMock.mockReturnValue({ value: 1000n, locked: 0n }); listOperationsMock.mockResolvedValue({ items: [tokenOpFromList] }); lastBlockMock.mockResolvedValue({ height: 100 }); adaptCoreOperationToLiveOperationMock.mockImplementation((_accId, op) => ({ hash: op.hash, type: op.type, blockHeight: op.height, blockHash: "0xblock", fee: new BigNumber(21000), value: new BigNumber("8000000"), senders: ["0xabc"], recipients: ["0xdef"], date: new Date("2024-01-15"), extra: { assetReference: "0xusdc", assetOwner: "accId", feePayer: `${currency.id}_addr_pure_token`, }, })); buildSubAccountsMock.mockReturnValue([ { id: `${currency.id}_tokenAcc1`, type: "TokenAccount", operations: [ { hash: txHash, type: "OUT", accountId: `${currency.id}_tokenAcc1`, id: `accId_${txHash}_OUT`, value: new BigNumber("8000000"), fee: new BigNumber(0), }, ], }, ]); inferSubOperationsMock.mockImplementation((hash: string) => hash === txHash ? [ { hash: txHash, type: "OUT", accountId: `${currency.id}_tokenAcc1`, id: `accId_${txHash}_OUT`, value: new BigNumber("8000000"), fee: new BigNumber(0), }, ] : [], ); cleanedOperationMock.mockImplementation((op: any) => op); mergeOpsMock.mockImplementation((_old: any[], newOps: any[]) => newOps); const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr_pure_token`, initialAccount: undefined, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); expect(result.operations).toHaveLength(1); const topOp = result.operations?.[0]; expect(topOp?.hash).toBe(txHash); expect(topOp?.type).toBe("FEES"); expect(topOp?.value?.toFixed()).toBe("21000"); expect(topOp?.fee?.toFixed()).toBe("21000"); expect(topOp?.subOperations).toHaveLength(1); expect(topOp?.subOperations?.[0]?.type).toBe("OUT"); expect(topOp?.subOperations?.[0]?.value?.toFixed()).toBe("8000000"); }); test("buildOneParentOpPerHash: native op with same hash as token uses native as parent with subOperations", async () => { const txHash = "native-and-token-hash"; const nativeOpFromList = { hash: txHash, type: "OUT", height: 18, tx: { failed: false } }; const tokenOpFromList = { hash: txHash, type: "OUT", height: 18, tx: { failed: false } }; getBalanceMock.mockResolvedValue([{ asset: { type: "native" }, value: 1000n, locked: 0n }]); extractBalanceMock.mockReturnValue({ value: 1000n, locked: 0n }); listOperationsMock.mockResolvedValue({ items: [nativeOpFromList, tokenOpFromList] }); lastBlockMock.mockResolvedValue({ height: 100 }); adaptCoreOperationToLiveOperationMock.mockImplementation((_accId, op) => { if (op === tokenOpFromList) { return { hash: op.hash, type: op.type, blockHeight: op.height, blockHash: "0xblock", fee: new BigNumber(21000), value: new BigNumber("1000000"), senders: ["0xabc"], recipients: ["0xdef"], date: new Date("2024-01-15"), extra: { assetReference: "0xusdc", assetOwner: "accId" }, }; } return { hash: op.hash, type: op.type, blockHeight: op.height, blockHash: "0xblock", fee: new BigNumber(21000), value: new BigNumber(1e18), senders: ["0xabc"], recipients: ["0xdef"], date: new Date("2024-01-15"), extra: {}, }; }); buildSubAccountsMock.mockReturnValue([ { id: `${currency.id}_tokenAcc1`, type: "TokenAccount", operations: [ { hash: txHash, type: "OUT", accountId: `${currency.id}_tokenAcc1`, id: `accId_${txHash}_OUT`, value: new BigNumber("1000000"), fee: new BigNumber(0), }, ], }, ]); inferSubOperationsMock.mockImplementation((hash: string) => hash === txHash ? [ { hash: txHash, type: "OUT", accountId: `${currency.id}_tokenAcc1`, id: `accId_${txHash}_OUT`, value: new BigNumber("1000000"), fee: new BigNumber(0), }, ] : [], ); cleanedOperationMock.mockImplementation((op: any) => op); mergeOpsMock.mockImplementation((_old: any[], newOps: any[]) => newOps); const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr_native_token`, initialAccount: undefined, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); expect(result.operations).toHaveLength(1); const topOp = result.operations?.[0]; expect(topOp?.hash).toBe(txHash); expect(topOp?.type).toBe("OUT"); expect(topOp?.value?.toFixed()).toBe((1e18).toString()); expect(topOp?.subOperations).toHaveLength(1); expect(topOp?.subOperations?.[0]?.value?.toFixed()).toBe("1000000"); }); test("buildOneParentOpPerHash: self-send (same hash IN + OUT) emits 2 parent operations", async () => { const txHash = "self-send-hash"; const inOpFromList = { hash: txHash, type: "IN", height: 20, tx: { failed: false } }; const outOpFromList = { hash: txHash, type: "OUT", height: 20, tx: { failed: false } }; getBalanceMock.mockResolvedValue([{ asset: { type: "native" }, value: 1000n, locked: 0n }]); extractBalanceMock.mockReturnValue({ value: 1000n, locked: 0n }); listOperationsMock.mockResolvedValue({ items: [inOpFromList, outOpFromList] }); lastBlockMock.mockResolvedValue({ height: 100 }); adaptCoreOperationToLiveOperationMock.mockImplementation((_accId, op: any) => { return { hash: op.hash, type: op.type, blockHeight: op.height, blockHash: "0xblock", fee: new BigNumber(21000), value: new BigNumber("1"), senders: ["0xabc"], recipients: ["0xabc"], date: new Date("2024-01-15"), extra: {}, }; }); buildSubAccountsMock.mockReturnValue([]); inferSubOperationsMock.mockReturnValue([]); cleanedOperationMock.mockImplementation((op: any) => op); mergeOpsMock.mockImplementation((_old: any[], newOps: any[]) => newOps); const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr_self_send`, initialAccount: undefined, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); expect(result.operations).toHaveLength(2); const types = result.operations?.map(o => o.type) ?? []; expect(types).toContain("IN"); expect(types).toContain("OUT"); expect(result.operations?.[0]?.hash).toBe(txHash); expect(result.operations?.[1]?.hash).toBe(txHash); }); test("internal vs non-internal ops are partitioned and internal attached to correct parent", async () => { const parentHash = "parent-h"; const nativeOp = { hash: parentHash, type: "OUT", height: 10, tx: { failed: false }, }; const internalOp = { hash: parentHash, type: "IN", height: 10, tx: { failed: false }, details: { ledgerOpType: "IN" }, }; getBalanceMock.mockResolvedValue([{ asset: { type: "native" }, value: 1000n, locked: 0n }]); extractBalanceMock.mockReturnValue({ value: 1000n, locked: 0n }); listOperationsMock.mockResolvedValue({ items: [nativeOp, internalOp] }); buildSubAccountsMock.mockReturnValue([]); lastBlockMock.mockResolvedValue({ height: 100 }); adaptCoreOperationToLiveOperationMock.mockImplementation((_accId, op: any) => { if (op.hash === parentHash && op.type === "IN") { return { hash: op.hash, type: op.type, blockHeight: op.height, extra: { internal: true }, }; } return { hash: op.hash, type: op.type, blockHeight: op.height, extra: {}, }; }); cleanedOperationMock.mockImplementation((op: any) => op); mergeOpsMock.mockImplementation((_old: any[], newOps: any[]) => newOps); inferSubOperationsMock.mockReturnValue([]); const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr_partition`, initialAccount: undefined, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); expect(result.operations).toHaveLength(1); expect(result.operations?.[0]?.internalOperations).toHaveLength(1); expect(result.operations?.[0]?.internalOperations?.[0]?.extra).toHaveProperty( "internal", true, ); }); test("internal operations are correctly attached to parent operations with matching hash", async () => { const parentOpHash = "parent-hash-123"; const parentOp = { hash: parentOpHash, type: "OUT", height: 15, tx: { failed: false }, }; const internalOp = { hash: parentOpHash, // Same hash as parent type: "IN", height: 15, tx: { failed: false }, details: { ledgerOpType: "IN", }, }; getBalanceMock.mockResolvedValue([{ asset: { type: "native" }, value: 1000n, locked: 0n }]); extractBalanceMock.mockReturnValue({ value: 1000n, locked: 0n }); listOperationsMock.mockResolvedValue({ items: [parentOp, internalOp], next: undefined }); buildSubAccountsMock.mockReturnValue([]); lastBlockMock.mockResolvedValue({ height: 123 }); adaptCoreOperationToLiveOperationMock.mockImplementation((_accId, op) => { if (op.hash === parentOpHash && op.type === "IN") { // This is the internal operation return { hash: op.hash, type: op.type, blockHeight: op.height, extra: { internal: true }, }; } // This is the parent operation return { hash: op.hash, type: op.type, blockHeight: op.height, extra: {}, }; }); cleanedOperationMock.mockImplementation(operation => operation); mergeOpsMock.mockImplementation((_oldOps, newOps) => newOps); inferSubOperationsMock.mockReturnValue([]); const getShape = genericGetAccountShape(network, currency.id); const result = await getShape( { address: `${currency.id}_addr3`, initialAccount: undefined, currency, derivationMode: "", } as any, { paginationConfig: {} as any }, ); expect(result.operations).toHaveLength(1); const operation = result.operations?.[0]; expect(operation?.hash).toBe(parentOpHash); expect(operation?.type).toBe("OUT"); expect(operation?.internalOperations).toBeDefined(); expect(operation?.internalOperations).toHaveLength(1); const attachedInternalOp = operation?.internalOperations?.[0]; expect(attachedInternalOp?.hash).toBe(parentOpHash); expect(attachedInternalOp?.type).toBe("IN"); expect((attachedInternalOp as any)?.extra?.internal).toBe(true); }); }); describe("evm", () => { const network = "mainnet"; const currency = { id: "evm", name: "EVM" }; const txHash = "0xspec"; const realAdaptCoreOp = ( jest.requireActual("../utils") as { adaptCoreOperationToLiveOperation: (a: string, o: unknown) => unknown; } ).adaptCoreOperationToLiveOperation; function toCoreOp(op: { type: string; senders: string[]; recipients: string[]; value: number | string; fee: number | string; asset?: { type: "native" } | { type: string; assetReference?: string; assetOwner?: string }; feesPayer?: string; internal?: boolean; }) { const details = op.asset?.type !== "native" && op.asset && "assetReference" in op.asset ? { assetAmount: String(op.value), ledgerOpType: op.type, assetSenders: op.senders, assetRecipients: op.recipients, ...(op.internal ? { internal: true } : {}), } : op.internal ? { internal: true } : undefined; return { type: op.type, senders: op.senders, recipients: op.recipients, value: BigInt(op.value), asset: op.asset ?? { type: "native" }, tx: { hash: txHash, fees: BigInt(op.fee), feesPayer: op.feesPayer, failed: false, block: { hash: "0xblock", height: 100 }, date: new Date("2025-02-20"), }, ...(details ? { details } : {}), }; } function setupSpecTest() { getSyncHashMock.mockReturnValue("sync-hash"); getBalanceMock.mockResolvedValue([{ asset: { type: "native" }, value: 0n, locked: 0n }]); extractBalanceMock.mockReturnValue({ value: 0n, locked: 0n }); lastBlockMock.mockResolvedValue({ height: 100 }); mergeOpsMock.mockImplementation((_old: any[], newOps: any[]) => newOps); cleanedOperationMock.mockImplementation((op: any) => op); mergeSubAccountsMock.mockImplementation((_old: any[], newSub: any[]) => newSub); chainSpecificGetAccountShapeMock.mockImplementation(() => {}); adaptCoreOperationToLiveOperationMock.mockImplementation( realAdaptCoreOp as typeof adaptCoreOperationToLiveOperationMock, ); } function createGetShape() { return genericGetAccountShape(network, currency.id); } async function runGetShape(address: string) { const getShape = createGetShape(); return getShape({ address, initialAccount: undefined, currency, derivationMode: "" } as any, { paginationConfig: {} as any, }); } function mockNoSubAccounts() { buildSubAccountsMock.mockReturnValue([]); } function mockNoInferSubOps() { inferSubOperationsMock.mockReturnValue([]); } function mockInferSubOpsByHash() { inferSubOperationsMock.mockImplementation((hash: string, subAccounts: any[]) => (subAccounts?.[0]?.operations ?? []).filter((o: any) => o.hash === hash), ); } function mockErc20SubAccounts( contractAddress: string, opMap?: (ops: any[], owner: string) => any[], ) { buildSubAccountsMock.mockImplementation((_ctx: any) => { const ops = _ctx.operations as any[]; if (!ops?.length) return []; const owner = ops[0].extra?.assetOwner ?? ""; const tokenAccId = `accId_${owner}_${contractAddress}`; const defaultOps = ops.map((o: any) => ({ ...o, accountId: tokenAccId, value: new BigNumber(o.value?.toString() ?? o.extra?.assetAmount ?? 0), type: o.extra?.ledgerOpType ?? o.type, })); return [ { id: tokenAccId, type: "TokenAccount", parentId: "accId", token: { contractAddress }, operations: opMap ? opMap(ops, owner) : defaultOps, } as any, ]; }); } test("Case 1: simple native transfer between EOAs", async () => { setupSpecTest(); const alpacaAddress1 = toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", }); const alpacaAddress2 = toCoreOp({ type: "IN", senders: ["address1"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", }); listOperationsMock.mockImplementation((addr: string) => { const items = addr === "address1" ? [alpacaAddress1] : [alpacaAddress2]; return Promise.resolve({ items, next: undefined }); }); mockNoSubAccounts(); mockNoInferSubOps(); const result1 = await runGetShape("address1"); expect(result1).toMatchObject({ operations: [ { type: "OUT", senders: ["address1"], recipients: ["address2"], value: new BigNumber(3), fee: new BigNumber(1), }, ], }); const result2 = await runGetShape("address2"); expect(result2).toMatchObject({ operations: [ { type: "IN", senders: ["address1"], recipients: ["address2"], value: new BigNumber(2), fee: new BigNumber(1), }, ], }); }); test("Case 2: native self send from EOA", async () => { setupSpecTest(); // AlpacaApi returns 2 ops (OUT, IN) for self-sends per spec. getAccountShape uses them as-is. const outOp = toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["address1"], value: 2, fee: 1, feesPayer: "address1", }); const inOp = toCoreOp({ type: "IN", senders: ["address1"], recipients: ["address1"], value: 2, fee: 1, feesPayer: "address1", }); listOperationsMock.mockResolvedValue({ items: [outOp, inOp], next: undefined }); mockNoSubAccounts(); mockNoInferSubOps(); const result = await runGetShape("address1"); expect(result.operations).toHaveLength(2); const out = result.operations?.find(o => o.type === "OUT"); const in_ = result.operations?.find(o => o.type === "IN"); expect(out).toMatchObject({ type: "OUT", senders: ["address1"], recipients: ["address1"], value: new BigNumber(3), fee: new BigNumber(1), }); expect(in_).toMatchObject({ type: "IN", senders: ["address1"], recipients: ["address1"], value: new BigNumber(2), fee: new BigNumber(1), }); }); test("Case 3: simple ERC20 transfer between EOAs", async () => { setupSpecTest(); const usdtContract = "0xUSDTContract"; const alpacaAddr1 = toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address1" }, }); const alpacaAddr2 = toCoreOp({ type: "IN", senders: ["address1"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address2" }, }); listOperationsMock.mockImplementation((addr: string) => { const items = addr === "address1" ? [alpacaAddr1] : [alpacaAddr2]; return Promise.resolve({ items, next: undefined }); }); mockErc20SubAccounts(usdtContract); mockInferSubOpsByHash(); const result1 = await runGetShape("address1"); expect(result1).toMatchObject({ operations: [ { type: "FEES", value: new BigNumber(1), senders: ["address1"], recipients: [usdtContract], }, ], subAccounts: [ { operations: [ { type: "OUT", senders: ["address1"], recipients: ["address2"], value: new BigNumber(2), }, ], }, ], }); const result2 = await runGetShape("address2"); expect(result2).toMatchObject({ operations: [ { type: "NONE", senders: ["address1"], recipients: [usdtContract], value: new BigNumber(0), fee: new BigNumber(1), }, ], subAccounts: [ { operations: [ { type: "IN", senders: ["address1"], recipients: ["address2"], value: new BigNumber(2), }, ], }, ], }); }); test("Case 4: ERC20 self send from EOA", async () => { setupSpecTest(); const usdtContract = "0xUSDTContract"; // AlpacaApi returns 2 ops (OUT, IN) for self-sends per spec. const outOp = toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["address1"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address1" }, }); const inOp = toCoreOp({ type: "IN", senders: ["address1"], recipients: ["address1"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address1" }, }); listOperationsMock.mockResolvedValue({ items: [outOp, inOp], next: undefined }); mockErc20SubAccounts(usdtContract); mockInferSubOpsByHash(); const result = await runGetShape("address1"); expect(result.operations).toHaveLength(1); expect(result.operations?.[0]).toMatchObject({ type: "FEES", value: new BigNumber(1) }); expect(result.subAccounts).toHaveLength(1); expect(result.subAccounts?.[0].operations).toHaveLength(2); const outSub = result.subAccounts?.[0].operations?.find( (o: { type: string }) => o.type === "OUT", ); const inSub = result.subAccounts?.[0].operations?.find( (o: { type: string }) => o.type === "IN", ); expect(outSub?.value?.toFixed()).toBe("2"); expect(inSub?.value?.toFixed()).toBe("2"); }); test("Case 5: ETH transfer from smart contract", async () => { setupSpecTest(); const alpacaAddr1 = toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["contract1"], value: 0, fee: 1, feesPayer: "address1", }); const alpacaAddr2Internal = toCoreOp({ type: "IN", senders: ["contract1"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", internal: true, }); listOperationsMock.mockImplementation((addr: string) => { const items = addr === "address1" ? [alpacaAddr1] : [alpacaAddr2Internal]; return Promise.resolve({ items, next: undefined }); }); mockNoSubAccounts(); mockNoInferSubOps(); const result1 = await runGetShape("address1"); expect(result1).toMatchObject({ operations: [ { type: "FEES", senders: ["address1"], recipients: ["contract1"], value: new BigNumber(1), fee: new BigNumber(1), }, ], }); const result2 = await runGetShape("address2"); expect(result2.operations).toHaveLength(2); const noneOp = result2.operations?.find(o => o.type === "NONE"); const inOp = result2.operations?.find(o => o.type === "IN"); expect(noneOp).toMatchObject({ type: "NONE", senders: ["address1"], recipients: ["contract1"], value: new BigNumber(0), fee: new BigNumber(1), }); expect(inOp).toMatchObject({ type: "IN", senders: ["contract1"], recipients: ["address2"], value: new BigNumber(2), fee: new BigNumber(1), }); }); test("Case 6: ERC20 transfer from smart contract", async () => { setupSpecTest(); const usdtContract = "0xUSDTContract"; listOperationsMock.mockImplementation((addr: string) => { let items: ReturnType[]; switch (addr) { case "address1": items = [ toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["contract1"], value: 0, fee: 1, feesPayer: "address1", }), ]; break; case "address2": items = [ toCoreOp({ type: "IN", senders: ["address3"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address2" }, }), ]; break; default: items = [ toCoreOp({ type: "OUT", senders: ["address3"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address3" }, }), ]; } return Promise.resolve({ items, next: undefined }); }); mockErc20SubAccounts(usdtContract); mockInferSubOpsByHash(); const result1 = await runGetShape("address1"); expect(result1).toMatchObject({ operations: [ { type: "FEES", value: new BigNumber(1), senders: ["address1"], recipients: ["contract1"], }, ], }); const result2 = await runGetShape("address2"); expect(result2).toMatchObject({ operations: [ { type: "NONE", senders: ["address3"], recipients: [usdtContract], value: new BigNumber(0), fee: new BigNumber(1), }, ], subAccounts: [ { operations: [ { type: "IN", senders: ["address3"], recipients: ["address2"], value: new BigNumber(2), }, ], }, ], }); const result3 = await runGetShape("address3"); expect(result3).toMatchObject({ operations: [ { type: "NONE", senders: ["address3"], recipients: [usdtContract], value: new BigNumber(0), fee: new BigNumber(1), }, ], subAccounts: [ { operations: [ { type: "OUT", senders: ["address3"], recipients: ["address2"], value: new BigNumber(2), }, ], }, ], }); }); test("Case 7: ETH transfer to smart contract", async () => { setupSpecTest(); listOperationsMock.mockResolvedValue({ items: [ toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["contract1"], value: 2, fee: 1, feesPayer: "address1", }), ], next: undefined, }); mockNoSubAccounts(); mockNoInferSubOps(); const result = await runGetShape("address1"); expect(result).toMatchObject({ operations: [ { type: "OUT", senders: ["address1"], recipients: ["contract1"], value: new BigNumber(3), fee: new BigNumber(1), }, ], }); }); test("Case 8: ERC20 transfer to smart contract", async () => { setupSpecTest(); const usdtContract = "0xUSDTContract"; listOperationsMock.mockResolvedValue({ items: [ toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["contract1"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address1" }, }), ], next: undefined, }); mockErc20SubAccounts(usdtContract, (ops, owner) => ops.map((o: any) => ({ ...o, accountId: `accId_${owner}_${usdtContract}`, value: new BigNumber(o.value?.toString() ?? 0), })), ); mockInferSubOpsByHash(); const result = await runGetShape("address1"); expect(result).toMatchObject({ operations: [ { type: "FEES", value: new BigNumber(1), senders: ["address1"], recipients: [usdtContract], }, ], subAccounts: [ { operations: [ { type: "OUT", senders: ["address1"], recipients: ["contract1"], value: new BigNumber(2), }, ], }, ], }); }); test("Case 9: ETH transfer through smart contract", async () => { setupSpecTest(); listOperationsMock.mockImplementation((addr: string) => { let items: ReturnType[]; switch (addr) { case "address1": items = [ toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["contract1"], value: 2, fee: 1, feesPayer: "address1", }), ]; break; default: items = [ toCoreOp({ type: "IN", senders: ["contract1"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", }), ]; } return Promise.resolve({ items, next: undefined }); }); mockNoSubAccounts(); mockNoInferSubOps(); const result1 = await runGetShape("address1"); expect(result1).toMatchObject({ operations: [ { type: "OUT", senders: ["address1"], recipients: ["contract1"], value: new BigNumber(3), fee: new BigNumber(1), }, ], }); const result2 = await runGetShape("address2"); expect(result2).toMatchObject({ operations: [ { type: "IN", senders: ["contract1"], recipients: ["address2"], value: new BigNumber(2), fee: new BigNumber(1), }, ], }); }); test("Case 10: mixed assets smart contract interaction", async () => { setupSpecTest(); const usdtContract = "0xUSDTContract"; listOperationsMock.mockImplementation((addr: string) => { let items: ReturnType[]; switch (addr) { case "address1": items = [ toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["contract1"], value: 1, fee: 1, feesPayer: "address1", }), toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address1" }, }), ]; break; default: items = [ toCoreOp({ type: "IN", senders: ["address1"], recipients: ["address2"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: usdtContract, assetOwner: "address2" }, }), ]; } return Promise.resolve({ items, next: undefined }); }); mockErc20SubAccounts(usdtContract); mockInferSubOpsByHash(); const result1 = await runGetShape("address1"); expect(result1).toMatchObject({ operations: [ { type: "OUT", value: new BigNumber(2), senders: ["address1"], recipients: ["contract1"], }, ], subAccounts: [ { operations: [ { type: "OUT", senders: ["address1"], recipients: ["address2"], value: new BigNumber(2), }, ], }, ], }); const result2 = await runGetShape("address2"); expect(result2).toMatchObject({ operations: [ { type: "NONE", senders: ["address1"], recipients: [usdtContract], value: new BigNumber(0), fee: new BigNumber(1), }, ], subAccounts: [ { operations: [ { type: "IN", senders: ["address1"], recipients: ["address2"], value: new BigNumber(2), }, ], }, ], }); }); test("Case 11: Spoofed ERC20 transfer through smart contract", async () => { setupSpecTest(); const scamContract = "0xSCAMCOINContract"; listOperationsMock.mockImplementation((addr: string) => { let items: ReturnType[]; switch (addr) { case "address1": items = [ toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["contract1"], value: 0, fee: 1, feesPayer: "address1", }), ]; break; case "address2": items = [ toCoreOp({ type: "OUT", senders: ["address2"], recipients: ["address3"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: scamContract, assetOwner: "address2" }, }), ]; break; default: items = [ toCoreOp({ type: "IN", senders: ["address2"], recipients: ["address3"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: scamContract, assetOwner: "address3" }, }), ]; } return Promise.resolve({ items, next: undefined }); }); mockErc20SubAccounts(scamContract); mockInferSubOpsByHash(); const result1 = await runGetShape("address1"); expect(result1).toMatchObject({ operations: [ { type: "FEES", value: new BigNumber(1), senders: ["address1"], recipients: ["contract1"], }, ], }); const result2 = await runGetShape("address2"); expect(result2).toMatchObject({ operations: [ { type: "NONE", senders: ["address2"], recipients: [scamContract], value: new BigNumber(0), fee: new BigNumber(1), }, ], subAccounts: [ { operations: [ { type: "OUT", senders: ["address2"], recipients: ["address3"], value: new BigNumber(2), }, ], }, ], }); const result3 = await runGetShape("address3"); expect(result3).toMatchObject({ operations: [ { type: "NONE", senders: ["address2"], recipients: [scamContract], value: new BigNumber(0), fee: new BigNumber(1), }, ], subAccounts: [ { operations: [ { type: "IN", senders: ["address2"], recipients: ["address3"], value: new BigNumber(2), }, ], }, ], }); }); test("Case 12: Smart contract token minting", async () => { setupSpecTest(); const stethContract = "0xSTETHContract"; const zeroAddress = "0x0000000000000000000000000000000000000000"; listOperationsMock.mockResolvedValue({ items: [ toCoreOp({ type: "OUT", senders: ["address1"], recipients: ["contract1"], value: 1, fee: 1, feesPayer: "address1", }), toCoreOp({ type: "IN", senders: [zeroAddress], recipients: ["address1"], value: 2, fee: 1, feesPayer: "address1", asset: { type: "erc20", assetReference: stethContract, assetOwner: "address1" }, }), ], next: undefined, }); mockErc20SubAccounts(stethContract); mockInferSubOpsByHash(); const result = await runGetShape("address1"); expect(result).toMatchObject({ operations: [ { type: "OUT", senders: ["address1"], recipients: ["contract1"], value: new BigNumber(2), fee: new BigNumber(1), }, ], subAccounts: [ { operations: [ { type: "IN", senders: [zeroAddress], recipients: ["address1"], value: new BigNumber(2), }, ], }, ], }); }); }); });