@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
588 lines (519 loc) • 20.3 kB
text/typescript
import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies";
import type { CurrencyConfig } from "@ledgerhq/coin-framework/config";
import { BigNumber } from "bignumber.js";
import { getDescriptor, getSendDescriptor } from "./descriptor/registry";
import { sendFeatures } from "./descriptor/send/features";
import { applyMemoToTransaction } from "./descriptor/send/memo";
import * as configModule from "../config/index";
jest.mock("../config/index");
describe("getDescriptor", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("should return null for undefined currency", () => {
const descriptor = getDescriptor(undefined);
expect(descriptor).toBeNull();
});
it("should return descriptor for bitcoin", () => {
const currency = getCryptoCurrencyById("bitcoin");
jest.spyOn(configModule, "getCurrencyConfiguration").mockReturnValue({
status: {
type: "active",
features: [{ id: "blockchain_txs", status: "active" }],
},
});
const descriptor = getDescriptor(currency);
expect(descriptor).toMatchObject({
send: {
inputs: {},
fees: {
hasPresets: true,
hasCustom: true,
hasCoinControl: true,
presets: {
legend: { type: "feeRate", unit: "sat/vbyte", valueFrom: "presetAmount" },
strategyLabelInAmount: "legend",
},
},
selfTransfer: "free",
},
});
expect(typeof descriptor?.send.fees.presets?.getOptions).toBe("function");
expect(typeof descriptor?.send.fees.presets?.shouldEstimateWithBridge).toBe("function");
});
it("should return descriptor for ethereum", () => {
const currency = getCryptoCurrencyById("ethereum");
jest.spyOn(configModule, "getCurrencyConfiguration").mockReturnValue({
status: {
type: "active",
features: [{ id: "blockchain_txs", status: "active" }],
},
});
const descriptor = getDescriptor(currency);
expect(descriptor).toMatchObject({
send: {
inputs: { recipientSupportsDomain: true },
fees: {
hasPresets: true,
hasCustom: true,
presets: {},
},
selfTransfer: "free",
errors: { userRefusedTransaction: "UserRefusedOnDevice" },
amount: {},
},
});
expect(typeof descriptor?.send.fees.presets?.getOptions).toBe("function");
expect(typeof descriptor?.send.amount?.getPlugins).toBe("function");
});
it("should return descriptor for solana", () => {
const currency = getCryptoCurrencyById("solana");
jest.spyOn(configModule, "getCurrencyConfiguration").mockReturnValue({
status: {
type: "active",
features: [{ id: "blockchain_txs", status: "active" }],
},
});
const descriptor = getDescriptor(currency);
expect(descriptor).toMatchObject({
send: {
inputs: {
memo: {
type: "text",
maxLength: 32,
},
},
fees: {
hasPresets: false,
hasCustom: false,
},
},
});
});
const configCases: ReadonlyArray<readonly [string, CurrencyConfig]> = [
[
"feature is inactive",
{
status: {
type: "active",
features: [{ id: "blockchain_txs", status: "inactive" }],
},
},
],
[
"currency status is not active",
{
status: {
type: "under_maintenance",
message: "Maintenance",
},
},
],
];
it.each(configCases)("should not be affected by config when %s", (_, mockConfig) => {
const bitcoin = getCryptoCurrencyById("bitcoin");
jest.spyOn(configModule, "getCurrencyConfiguration").mockReturnValue(mockConfig);
const descriptor = getDescriptor(bitcoin);
expect(descriptor).not.toBeNull();
});
it("should not be affected when no features array", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
jest.spyOn(configModule, "getCurrencyConfiguration").mockReturnValue({
status: {
type: "active",
},
});
const descriptor = getDescriptor(bitcoin);
expect(descriptor).not.toBeNull();
});
it("should not be affected when config throws error", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
jest.spyOn(configModule, "getCurrencyConfiguration").mockImplementation(() => {
throw new Error("Config not found");
});
const descriptor = getDescriptor(bitcoin);
expect(descriptor).not.toBeNull();
});
});
describe("getSendDescriptor", () => {
it("should return send descriptor", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
jest.spyOn(configModule, "getCurrencyConfiguration").mockReturnValue({
status: {
type: "active",
features: [{ id: "blockchain_txs", status: "active" }],
},
});
const sendDescriptor = getSendDescriptor(bitcoin);
expect(sendDescriptor).toMatchObject({
inputs: {},
fees: {
hasPresets: true,
hasCustom: true,
hasCoinControl: true,
presets: {
legend: { type: "feeRate", unit: "sat/vbyte", valueFrom: "presetAmount" },
strategyLabelInAmount: "legend",
},
},
selfTransfer: "free",
});
expect(typeof sendDescriptor?.fees.presets?.getOptions).toBe("function");
expect(typeof sendDescriptor?.fees.presets?.shouldEstimateWithBridge).toBe("function");
});
it("should not be affected when feature is not active", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
jest.spyOn(configModule, "getCurrencyConfiguration").mockReturnValue({
status: {
type: "active",
features: [{ id: "blockchain_txs", status: "inactive" }],
},
});
const sendDescriptor = getSendDescriptor(bitcoin);
expect(sendDescriptor).not.toBeNull();
});
});
describe("sendFeatures", () => {
beforeEach(() => {
jest.spyOn(configModule, "getCurrencyConfiguration").mockReturnValue({
status: {
type: "active",
features: [{ id: "blockchain_txs", status: "active" }],
},
});
});
it.each([
["solana", true],
["bitcoin", false],
])("should check memo support for %s", (currencyId, expected) => {
const currency = getCryptoCurrencyById(currencyId);
expect(sendFeatures.hasMemo(currency)).toBe(expected);
});
it("should check fee presets support", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
expect(sendFeatures.hasFeePresets(bitcoin)).toBe(true);
});
it("should check custom fees support", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
expect(sendFeatures.hasCustomFees(bitcoin)).toBe(true);
});
it("should check coin control support", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
expect(sendFeatures.hasCoinControl(bitcoin)).toBe(true);
});
it("should return true for canSendMax when not explicitly disabled (bitcoin, ethereum)", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
const ethereum = getCryptoCurrencyById("ethereum");
expect(sendFeatures.canSendMax(bitcoin)).toBe(true);
expect(sendFeatures.canSendMax(ethereum)).toBe(true);
});
it("should return false for canSendMax when descriptor sets canSendMax to false (xrp)", () => {
const xrp = getCryptoCurrencyById("ripple");
expect(sendFeatures.canSendMax(xrp)).toBe(false);
});
it("should return true for canSendMax when currency is undefined (default)", () => {
expect(sendFeatures.canSendMax(undefined)).toBe(true);
});
it("should return null for getCustomFeeConfig when currency has no custom fees (solana)", () => {
const solana = getCryptoCurrencyById("solana");
expect(sendFeatures.getCustomFeeConfig(solana)).toBeNull();
});
it("should return null for getCustomFeeConfig when currency is undefined", () => {
expect(sendFeatures.getCustomFeeConfig(undefined)).toBeNull();
});
it("should return custom fee config for bitcoin with inputs and builders", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
const config = sendFeatures.getCustomFeeConfig(bitcoin);
expect(config).not.toBeNull();
expect(config?.inputs).toHaveLength(1);
expect(config?.inputs[0]).toMatchObject({
key: "feePerByte",
type: "number",
unitLabel: "sat/vbyte",
});
expect(typeof config?.getInitialValues).toBe("function");
expect(typeof config?.buildTransactionPatch).toBe("function");
});
it("should return custom fee config for ethereum with EIP-1559 inputs", () => {
const ethereum = getCryptoCurrencyById("ethereum");
const config = sendFeatures.getCustomFeeConfig(ethereum);
expect(config).not.toBeNull();
expect(config?.inputs.length).toBeGreaterThanOrEqual(2);
expect(config?.inputs.map(i => i.key)).toContain("maxFeePerGas");
expect(typeof config?.getInitialValues).toBe("function");
expect(typeof config?.buildTransactionPatch).toBe("function");
});
it("should return custom fee config for stellar", () => {
const stellar = getCryptoCurrencyById("stellar");
const config = sendFeatures.getCustomFeeConfig(stellar);
expect(config).not.toBeNull();
expect(config?.inputs).toHaveLength(1);
expect(config?.inputs[0]).toMatchObject({
key: "fees",
type: "number",
unitLabel: "stroop",
});
expect(config?.buildTransactionPatch({ fees: "100" })).toMatchObject({
fees: new BigNumber(100),
customFees: { parameters: { fees: new BigNumber(100) } },
});
});
it("should return custom fee config for kaspa", () => {
const kaspa = getCryptoCurrencyById("kaspa");
const config = sendFeatures.getCustomFeeConfig(kaspa);
expect(config).not.toBeNull();
expect(config?.inputs).toHaveLength(1);
expect(config?.inputs[0]).toMatchObject({
key: "feePerByte",
type: "number",
unitLabel: "Sompi/byte",
});
});
it("should return false for custom fees when coin does not expose custom config", () => {
const stacks = getCryptoCurrencyById("stacks");
const filecoin = getCryptoCurrencyById("filecoin");
expect(sendFeatures.hasCustomFees(stacks)).toBe(false);
expect(sendFeatures.hasCustomFees(filecoin)).toBe(false);
expect(sendFeatures.getCustomFeeConfig(stacks)).toBeNull();
expect(sendFeatures.getCustomFeeConfig(filecoin)).toBeNull();
});
it("should return empty fee preset options when not implemented", () => {
const solana = getCryptoCurrencyById("solana");
expect(sendFeatures.getFeePresetOptions(solana, {})).toEqual([]);
});
it("should expose fee preset options for bitcoin from descriptor", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
const options = sendFeatures.getFeePresetOptions(bitcoin, {
networkInfo: {
feeItems: {
items: [
{ speed: "slow", feePerByte: new BigNumber(1) },
{ speed: "medium", feePerByte: new BigNumber(2) },
],
},
},
});
expect(options).toEqual([
{ id: "medium", amount: new BigNumber(2) },
{ id: "slow", amount: new BigNumber(1) },
]);
});
it("should expose fee preset options for evm from descriptor", () => {
const ethereum = getCryptoCurrencyById("ethereum");
const options = sendFeatures.getFeePresetOptions(ethereum, {
gasLimit: new BigNumber(21_000),
gasOptions: {
slow: { maxFeePerGas: new BigNumber(10), gasPrice: null },
medium: { maxFeePerGas: new BigNumber(20), gasPrice: null },
fast: { gasPrice: new BigNumber(30) },
},
});
expect(options.map(o => o.id)).toEqual(["slow", "medium", "fast"]);
expect(options.find(o => o.id === "slow")?.amount).toEqual(new BigNumber(10).times(21_000));
expect(options.find(o => o.id === "medium")?.amount).toEqual(new BigNumber(20).times(21_000));
expect(options.find(o => o.id === "fast")?.amount).toEqual(new BigNumber(30).times(21_000));
});
it("should expose fee preset options for kaspa from descriptor", () => {
const kaspa = getCryptoCurrencyById("kaspa");
const options = sendFeatures.getFeePresetOptions(kaspa, {
networkInfo: [
{ label: "slow", amount: new BigNumber(1), estimatedSeconds: 10 },
{ label: "medium", amount: new BigNumber(2), estimatedSeconds: 10 },
{ label: "fast", amount: new BigNumber(3), estimatedSeconds: 10 },
],
});
expect(options).toEqual([
{ id: "slow", amount: new BigNumber(1), estimatedMs: 10_000, disabled: true },
{ id: "medium", amount: new BigNumber(2), estimatedMs: 10_000, disabled: true },
{ id: "fast", amount: new BigNumber(3), estimatedMs: 10_000, disabled: false },
]);
});
it("should return false when bridge estimation is not specified", () => {
const solana = getCryptoCurrencyById("solana");
expect(sendFeatures.shouldEstimateFeePresetsWithBridge(solana, {})).toBe(false);
});
it("should require bridge estimation for bitcoin presets", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
expect(sendFeatures.shouldEstimateFeePresetsWithBridge(bitcoin, {})).toBe(true);
});
it("should return empty plugins when not specified", () => {
const bitcoin = getCryptoCurrencyById("bitcoin");
expect(sendFeatures.getAmountPlugins(bitcoin)).toEqual([]);
});
it("should expose amount plugins for evm", () => {
const ethereum = getCryptoCurrencyById("ethereum");
expect(sendFeatures.getAmountPlugins(ethereum)).toEqual(["evmGasOptionsSync"]);
});
it("should get memo type", () => {
const solana = getCryptoCurrencyById("solana");
expect(sendFeatures.getMemoType(solana)).toBe("text");
});
it("should get memo max length", () => {
const solana = getCryptoCurrencyById("solana");
expect(sendFeatures.getMemoMaxLength(solana)).toBe(32);
});
it("should get memo default option", () => {
const stellar = getCryptoCurrencyById("stellar");
expect(sendFeatures.getMemoDefaultOption(stellar)).toBe("MEMO_TEXT");
});
it("should return undefined when memo has no default option", () => {
const solana = getCryptoCurrencyById("solana");
expect(sendFeatures.getMemoDefaultOption(solana)).toBeUndefined();
});
it.each([
["ethereum", true],
["bitcoin", false],
])("should check domain support for %s", (currencyId, expected) => {
const currency = getCryptoCurrencyById(currencyId);
expect(sendFeatures.supportsDomain(currency)).toBe(expected);
});
describe("applyMemoToTransaction", () => {
describe("fallback behavior", () => {
it.each(["algorand", "cosmos", "hedera", "stacks", "internet_computer", "mina"])(
"should use default memo field for %s",
family => {
const result = applyMemoToTransaction(family, "test memo");
expect(result).toEqual({ memo: "test memo" });
},
);
it("should handle undefined memo with fallback", () => {
const result = applyMemoToTransaction("unknown_chain", undefined);
expect(result).toEqual({ memo: undefined });
});
});
describe("nested structures", () => {
it("should apply memo for solana with empty transaction", () => {
const result = applyMemoToTransaction("solana", "test memo", {});
expect(result).toEqual({
model: {
uiState: {
memo: "test memo",
},
},
});
});
it("should apply memo for solana preserving existing data", () => {
const transaction = {
model: {
kind: "transfer",
uiState: {
amount: "100",
},
},
};
const result = applyMemoToTransaction("solana", "test memo", transaction);
expect(result).toEqual({
model: {
kind: "transfer",
uiState: {
amount: "100",
memo: "test memo",
},
},
});
});
it("should apply memo for ton with empty transaction", () => {
const result = applyMemoToTransaction("ton", "test comment", {});
expect(result).toEqual({
comment: {
text: "test comment",
},
});
});
it("should apply memo for ton preserving existing data", () => {
const transaction = {
comment: {
isEncrypted: false,
},
};
const result = applyMemoToTransaction("ton", "test comment", transaction);
expect(result).toEqual({
comment: {
isEncrypted: false,
text: "test comment",
},
});
});
});
describe("special field names", () => {
it("should apply transferId for casper", () => {
const result = applyMemoToTransaction("casper", "12345");
expect(result).toEqual({ transferId: "12345" });
});
it("should apply numeric tag for xrp", () => {
const result = applyMemoToTransaction("xrp", 12345);
expect(result).toEqual({ tag: 12345 });
});
it("should convert string to number for xrp", () => {
const result = applyMemoToTransaction("xrp", "67890");
expect(result).toEqual({ tag: 67890 });
});
it("should handle undefined tag for xrp", () => {
const result = applyMemoToTransaction("xrp", undefined);
expect(result).toEqual({ tag: undefined });
});
it("should apply memoValue for stellar", () => {
const result = applyMemoToTransaction("stellar", "stellar memo");
expect(result).toEqual({ memoValue: "stellar memo" });
});
});
});
it.each([
["bitcoin", "free"],
["ethereum", "free"],
["filecoin", "free"],
["cardano", "free"],
["solana", "impossible"],
["cosmos", "impossible"],
["near", "warning"],
["vechain", "warning"],
])("should get self transfer policy for %s", (currencyId, expected) => {
const currency = getCryptoCurrencyById(currencyId);
expect(sendFeatures.getSelfTransferPolicy(currency)).toBe(expected);
});
it("should return impossible as default self transfer policy", () => {
expect(sendFeatures.getSelfTransferPolicy(undefined)).toBe("impossible");
});
it.each([
["stellar", "StellarUserRefusedError"],
["ethereum", "UserRefusedOnDevice"],
["cosmos", "UserRefusedOnDevice"],
["bitcoin", "TransactionRefusedOnDevice"],
["solana", "TransactionRefusedOnDevice"],
["tron", "TransactionRefusedOnDevice"],
["cardano", "TransactionRefusedOnDevice"],
["filecoin", "TransactionRefusedOnDevice"],
])("should get user refused transaction error name for %s", (currencyId, expected) => {
const currency = getCryptoCurrencyById(currencyId);
expect(sendFeatures.getUserRefusedTransactionErrorName(currency)).toBe(expected);
});
it("should return TransactionRefusedOnDevice as default when currency is undefined", () => {
expect(sendFeatures.getUserRefusedTransactionErrorName(undefined)).toBe(
"TransactionRefusedOnDevice",
);
});
it.each([
["stellar", { name: "StellarUserRefusedError" }, true],
["ethereum", { name: "UserRefusedOnDevice" }, true],
["cosmos", { name: "UserRefusedOnDevice" }, true],
["bitcoin", { name: "TransactionRefusedOnDevice" }, true],
["solana", { name: "TransactionRefusedOnDevice" }, true],
["tron", { name: "TransactionRefusedOnDevice" }, true],
["bitcoin", { name: "UserRefusedOnDevice" }, false],
["ethereum", { name: "TransactionRefusedOnDevice" }, false],
["bitcoin", null, false],
["bitcoin", undefined, false],
["bitcoin", {}, false],
])(
"should check if error is user refused transaction error for %s",
(currencyId, error, expected) => {
const currency = getCryptoCurrencyById(currencyId);
expect(sendFeatures.isUserRefusedTransactionError(currency, error)).toBe(expected);
},
);
it("should return false when currency is undefined", () => {
expect(
sendFeatures.isUserRefusedTransactionError(undefined, { name: "TransactionRefusedOnDevice" }),
).toBe(false);
});
});