@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
210 lines (167 loc) • 7.1 kB
text/typescript
import { from } from "rxjs";
import calService, { convertCertificateToDeviceData } from "@ledgerhq/ledger-cal-service";
import { DmkSignerHyperliquid } from "@ledgerhq/live-signer-hyperliquid";
import { handlers, type PerpsSignParams, type PerpsSignResult } from "./server";
import { getMainAccount, getParentAccount } from "../../account";
import { withDevice } from "../../hw/deviceAccess";
import { isDmkTransport } from "../../hw/dmkUtils";
import { getAccountIdFromWalletAccountId } from "../converters";
import { createFixtureAccount } from "../../mock/fixtures/cryptoCurrencies";
jest.mock("@ledgerhq/wallet-api-server", () => ({
customWrapper: jest.fn(handler => handler),
}));
jest.mock("@ledgerhq/wallet-api-core", () => ({
...jest.requireActual("@ledgerhq/wallet-api-core"),
createAccountNotFound: jest.fn(id => ({ code: "AccountNotFound", id })),
createUnknownError: jest.fn(opts => ({ code: "UnknownError", ...opts })),
ServerError: class ServerError extends Error {
constructor(public error: unknown) {
super("ServerError");
}
},
}));
jest.mock("@ledgerhq/ledger-cal-service", () => ({
__esModule: true,
default: { getCertificate: jest.fn() },
convertCertificateToDeviceData: jest.fn(),
}));
jest.mock("@ledgerhq/live-signer-hyperliquid", () => ({
DmkSignerHyperliquid: jest.fn(),
}));
jest.mock("../converters", () => ({
getAccountIdFromWalletAccountId: jest.fn(),
}));
jest.mock("../../account", () => ({
getMainAccount: jest.fn(),
getParentAccount: jest.fn(),
}));
jest.mock("../../hw/deviceAccess", () => ({
withDevice: jest.fn(),
}));
jest.mock("../../hw/dmkUtils", () => ({
isDmkTransport: jest.fn(),
}));
// ─── Fixtures ────────────────────────────────────────────────────────────────
const mockAccount = createFixtureAccount("abcd");
const ACCOUNT_ID = mockAccount.id;
const WALLET_ACCOUNT_ID = `wallet:${ACCOUNT_ID}`;
const DERIVATION_PATH = mockAccount.freshAddressPath;
const DEVICE_ID = "mock-device-id";
const mockCertificate = { descriptor: "aabbcc", signature: "ddeeff" };
const mockDeviceData = new Uint8Array([0xaa, 0xbb, 0xcc, 0x15, 0x03, 0xdd, 0xee, 0xff]);
const mockDevice = { modelId: "stax", deviceId: DEVICE_ID, deviceName: undefined };
const baseParams = {
accountId: WALLET_ACCOUNT_ID,
metadataWithSignature: "0102030405",
actions: [{ action: { type: "cancel" as const, cancels: [{ a: 1, o: 42 }] }, nonce: 1 }],
};
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("Perps handlers", () => {
type MockedHandlers = {
"custom.perps.signActions": (params?: PerpsSignParams) => Promise<PerpsSignResult>;
};
let mockSignActions: jest.Mock;
let mockUiDeviceSelect: jest.Mock;
let serverHandlers: MockedHandlers;
beforeEach(() => {
jest.clearAllMocks();
// Converters
jest.mocked(getAccountIdFromWalletAccountId).mockReturnValue(ACCOUNT_ID);
// Coin-framework
jest.mocked(getParentAccount).mockReturnValue(mockAccount);
jest.mocked(getMainAccount).mockReturnValue(mockAccount as never);
// CAL service
jest.mocked(calService.getCertificate).mockResolvedValue(mockCertificate);
jest.mocked(convertCertificateToDeviceData).mockReturnValue(mockDeviceData);
// DMK signer
mockSignActions = jest.fn();
jest
.mocked(DmkSignerHyperliquid)
.mockImplementation(() => ({ signActions: mockSignActions }) as never);
// deviceAccess + dmkUtils
jest.mocked(isDmkTransport).mockReturnValue(true);
jest
.mocked(withDevice)
.mockReturnValue(job => from(job({ dmk: {}, sessionId: "session-1" } as never)));
// UI hook
mockUiDeviceSelect = jest
.fn()
.mockImplementation(({ onSuccess }) => onSuccess({ device: mockDevice }));
serverHandlers = handlers({
accounts: [mockAccount],
uiHooks: { "device.select": mockUiDeviceSelect },
}) as unknown as MockedHandlers;
});
describe("custom.perps.signActions", () => {
it("should return signatures on success", async () => {
// GIVEN
const expectedSignatures = [{ r: "0xr", s: "0xs", v: 27 }];
mockSignActions.mockResolvedValue(expectedSignatures);
// WHEN
const result = await serverHandlers["custom.perps.signActions"](baseParams);
// THEN
expect(result).toEqual({ signatures: expectedSignatures });
});
it("should call calService.getCertificate with the device modelId", async () => {
// GIVEN
mockSignActions.mockResolvedValue([]);
// WHEN
await serverHandlers["custom.perps.signActions"](baseParams);
// THEN
expect(calService.getCertificate).toHaveBeenCalledWith(mockDevice.modelId, "perps_data");
});
it("should call signActions with the correct parameters", async () => {
// GIVEN
mockSignActions.mockResolvedValue([]);
// WHEN
await serverHandlers["custom.perps.signActions"](baseParams);
// THEN
expect(mockSignActions).toHaveBeenCalledWith(
DERIVATION_PATH,
mockDeviceData,
new Uint8Array(Buffer.from(baseParams.metadataWithSignature, "hex")),
[{ type: "cancel", cancels: [{ a: 1, o: 42 }], nonce: 1 }],
);
});
it("should throw a ServerError when params is undefined", async () => {
// WHEN & THEN
await expect(serverHandlers["custom.perps.signActions"](undefined)).rejects.toThrow(
"ServerError",
);
});
it("should throw a ServerError when account is not found", async () => {
// GIVEN
jest.mocked(getAccountIdFromWalletAccountId).mockReturnValue("unknown-id");
// WHEN & THEN
await expect(serverHandlers["custom.perps.signActions"](baseParams)).rejects.toThrow(
"ServerError",
);
});
it("should throw a ServerError when the derivation path is missing", async () => {
// GIVEN
jest
.mocked(getMainAccount)
.mockReturnValue({ ...mockAccount, freshAddressPath: "" } as never);
// WHEN & THEN
await expect(serverHandlers["custom.perps.signActions"](baseParams)).rejects.toThrow(
"ServerError",
);
});
it("should reject when the user cancels the device selection", async () => {
// GIVEN
mockUiDeviceSelect.mockImplementation(({ onCancel }) => onCancel());
// WHEN & THEN
await expect(serverHandlers["custom.perps.signActions"](baseParams)).rejects.toThrow(
"User cancelled device selection",
);
});
it("should throw when the transport is not a DMK transport", async () => {
// GIVEN
jest.mocked(isDmkTransport).mockReturnValue(false);
// WHEN & THEN
await expect(serverHandlers["custom.perps.signActions"](baseParams)).rejects.toThrow(
"Not DMK transport",
);
});
});
});