@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
1,576 lines (1,471 loc) • 51.7 kB
text/typescript
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<typeof toCoreOp>[];
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<typeof toCoreOp>[];
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<typeof toCoreOp>[];
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<typeof toCoreOp>[];
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",