@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
822 lines (750 loc) • 32.1 kB
text/typescript
import invariant from "invariant";
import { BigNumber } from "bignumber.js";
import { reduce, filter, map } from "rxjs/operators";
import flatMap from "lodash/flatMap";
import omit from "lodash/omit";
import { InvalidAddress, RecipientRequired, AmountRequired } from "@ledgerhq/errors";
import {
fromAccountRaw,
toAccountRaw,
decodeAccountId,
encodeAccountId,
flattenAccounts,
isAccountBalanceUnconfirmed,
} from "../../account";
import { getCryptoCurrencyById } from "../../currencies";
import { getOperationAmountNumber } from "../../operation";
import { fromTransactionRaw, toTransactionRaw, toTransactionStatusRaw } from "../../transaction";
import { getAccountBridge, getCurrencyBridge } from "../../bridge";
import { mockDeviceWithAPDUs, releaseMockDevice } from "./mockDevice";
import type {
Account,
AccountBridge,
AccountLike,
AccountRawLike,
SyncConfig,
DatasetTest,
CurrenciesData,
TokenAccount,
TransactionCommon,
TransactionStatusCommon,
} from "@ledgerhq/types-live";
import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
import { firstValueFrom } from "rxjs";
const warnDev = process.env.CI ? (..._args) => {} : (...msg) => console.warn(...msg);
// FIXME move out into DatasetTest to be defined in
const blacklistOpsSumEq = {
currencies: ["ripple", "ethereum", "tezos", "assethub_polkadot"],
impls: ["mock"],
};
function expectBalanceIsOpsSum(a) {
expect(a.balance).toEqual(
a.operations.reduce((sum, op) => sum.plus(getOperationAmountNumber(op)), new BigNumber(0)),
);
}
const defaultSyncConfig = {
paginationConfig: {},
blacklistedTokenIds: ["ethereum/erc20/ampleforth", "ethereum/erc20/steth"],
};
export function syncAccount<
T extends TransactionCommon,
A extends Account = Account,
U extends TransactionStatusCommon = TransactionStatusCommon,
>(
bridge: AccountBridge<T, A, U>,
account: A,
syncConfig: SyncConfig = defaultSyncConfig,
): Promise<A> {
return firstValueFrom(
bridge.sync(account, syncConfig).pipe(reduce((a, f: (arg0: A) => A) => f(a), account)),
);
}
export function testBridge<T extends TransactionCommon>(data: DatasetTest<T>): void {
// covers all bridges through many different accounts
// to test the common shared properties of bridges.
const accountsRelated: Array<{
accountRaw: any;
currencyData: CurrenciesData<T>;
accountData: any;
impl: string;
currency: CryptoCurrency;
}> = [];
const currenciesRelated: Array<{
currencyData: CurrenciesData<T>;
currency: CryptoCurrency;
}> = [];
const { implementations, currencies } = data;
for (const currencyId of Object.keys(currencies)) {
const currencyData = currencies[currencyId];
const currency = getCryptoCurrencyById(currencyId);
currenciesRelated.push({
currencyData,
currency,
});
const accounts = currencyData.accounts || [];
for (const accountData of accounts) {
for (const impl of implementations) {
if (accountData.implementations && !accountData.implementations.includes(impl)) {
continue;
}
const accountRaw = {
...accountData.raw,
id: encodeAccountId({
...decodeAccountId(accountData.raw.id),
type: impl,
}),
};
accountsRelated.push({
currencyData,
accountData,
accountRaw,
impl,
currency,
});
}
}
}
const accountsFoundInScanAccountsMap = {};
currenciesRelated.forEach(({ currencyData, currency }) => {
const bridge = getCurrencyBridge(currency);
const scanAccounts = async apdus => {
const deviceId = await mockDeviceWithAPDUs(apdus, currencyData.mockDeviceOptions);
try {
const accounts = await firstValueFrom(
bridge
.scanAccounts({
currency,
deviceId,
syncConfig: defaultSyncConfig,
})
.pipe(
filter(e => e.type === "discovered"),
map(e => e.account),
reduce((all, a) => all.concat(a), [] as Account[]),
),
);
return accounts;
} catch (e: any) {
console.error(e.message);
throw e;
} finally {
releaseMockDevice(deviceId);
}
};
const scanAccountsCaches = {};
const scanAccountsCached = apdus =>
scanAccountsCaches[apdus] || (scanAccountsCaches[apdus] = scanAccounts(apdus));
describe(currency.id + " currency bridge", () => {
const {
scanAccounts,
FIXME_ignoreAccountFields,
FIXME_ignoreOperationFields,
FIXME_ignorePreloadFields,
} = currencyData;
test("functions are defined", () => {
expect(typeof bridge.scanAccounts).toBe("function");
expect(typeof bridge.preload).toBe("function");
expect(typeof bridge.hydrate).toBe("function");
});
if (FIXME_ignorePreloadFields !== true) {
test("preload and rehydrate", async () => {
const data1 = (await bridge.preload(currency)) || {};
const data1filtered = omit(data1, FIXME_ignorePreloadFields || []);
bridge.hydrate(data1filtered, currency);
if (data1filtered) {
const serialized1 = JSON.parse(JSON.stringify(data1filtered));
bridge.hydrate(serialized1, currency);
expect(serialized1).toBeDefined();
const data2 = (await bridge.preload(currency)) || {};
const data2filtered = omit(data2, FIXME_ignorePreloadFields || []);
if (data2filtered) {
bridge.hydrate(data2filtered, currency);
expect(data1filtered).toMatchObject(data2filtered);
const serialized2 = JSON.parse(JSON.stringify(data2filtered));
expect(serialized1).toMatchObject(serialized2);
bridge.hydrate(serialized2, currency);
}
}
});
}
if (scanAccounts) {
if (FIXME_ignoreOperationFields && FIXME_ignoreOperationFields.length) {
warnDev(
currency.id +
" is ignoring operation fields: " +
FIXME_ignoreOperationFields.join(", "),
);
}
if (FIXME_ignoreAccountFields && FIXME_ignoreAccountFields.length) {
warnDev(
currency.id + " is ignoring account fields: " + FIXME_ignoreAccountFields.join(", "),
);
}
describe("scanAccounts", () => {
scanAccounts.forEach(sa => {
// we start running the scan accounts in parallel!
test(sa.name, async () => {
const accounts = await scanAccountsCached(sa.apdus);
accounts.forEach(a => {
accountsFoundInScanAccountsMap[a.id] = a;
});
if (!sa.unstableAccounts) {
const raws: AccountRawLike[] = flatMap(accounts, a => {
const main = toAccountRaw(a);
if (!main.subAccounts) return [main];
return [{ ...main, subAccounts: [] }, ...main.subAccounts] as AccountRawLike[];
});
const heads = raws.map(a => {
const copy = omit(
a,
[
"operations",
"lastSyncDate",
"creationDate",
"blockHeight",
"balanceHistory",
"balanceHistoryCache",
].concat(FIXME_ignoreAccountFields || []),
);
return copy;
});
const ops = raws.map(({ operations }) =>
operations
.slice(0)
.sort((a, b) => a.id.localeCompare(b.id))
.map(op => {
const copy = omit(op, ["date"].concat(FIXME_ignoreOperationFields || []));
return copy;
}),
);
expect(heads).toMatchSnapshot();
expect(ops).toMatchSnapshot();
}
const testFn = sa.test;
if (testFn) {
await testFn(expect, accounts, bridge);
}
});
test("estimateMaxSpendable is between 0 and account balance", async () => {
const accounts = await scanAccountsCached(sa.apdus);
for (const account of accounts) {
const accountBridge = getAccountBridge(account);
const estimation = await accountBridge.estimateMaxSpendable({
account,
});
expect(estimation.gte(0)).toBe(true);
if (!(account.spendableBalance.lt(0) && estimation.eq(0))) {
expect(estimation.lte(account.spendableBalance)).toBe(true);
}
for (const sub of account.subAccounts || []) {
const estimation = await accountBridge.estimateMaxSpendable({
parentAccount: account,
account: sub,
});
expect(estimation.gte(0)).toBe(true);
expect(estimation.lte(sub.balance)).toBe(true);
}
}
});
test("no unconfirmed account", async () => {
const accounts = await scanAccountsCached(sa.apdus);
for (const account of flattenAccounts(accounts)) {
expect({
id: account.id,
unconfirmed: isAccountBalanceUnconfirmed(account),
}).toEqual({
id: account.id,
unconfirmed: false,
});
}
});
test("creationDate is correct", async () => {
const accounts = await scanAccountsCached(sa.apdus);
for (const account of flattenAccounts(accounts)) {
if (account.operations.length) {
const op = account.operations[account.operations.length - 1];
if (account.creationDate.getTime() > op.date.getTime()) {
warnDev(
`OP ${
op.id
} have date=${op.date.toISOString()} older than account.creationDate=${account.creationDate.toISOString()}`,
);
}
expect(account.creationDate.getTime()).not.toBeGreaterThan(op.date.getTime());
}
}
});
});
});
}
const currencyDataTest = currencyData.test;
if (currencyDataTest) {
test(currency.id + " specific test", () => currencyDataTest(expect, bridge));
}
});
const accounts = currencyData.accounts || [];
if (accounts.length) {
const accountsInScan: string[] = [];
const accountsNotInScan: string[] = [];
accounts.forEach(({ raw }) => {
if (accountsFoundInScanAccountsMap[raw.id]) {
accountsInScan.push(raw.id);
} else {
accountsNotInScan.push(raw.id);
}
});
if (accountsInScan.length === 0) {
warnDev(
`/!\\ CURRENCY '${currency.id}' define accounts that are NOT in scanAccounts. please add at least one account that is from scanAccounts. This helps testing scanned accounts are fine and it also help performance.`,
);
}
if (accountsNotInScan.length === 0) {
warnDev(
`/!\\ CURRENCY '${currency.id}' define accounts that are ONLY in scanAccounts. please add one account that is NOT from scanAccounts. This helps covering the "recovering from xpub" mecanism.`,
);
}
}
});
accountsRelated
.map(({ accountRaw, ...rest }) => {
let accountPromise;
let bridgePromise;
let accountSyncedPromise;
// lazy eval so we don't run this yet
const getAccount = () => accountPromise || (accountPromise = fromAccountRaw(accountRaw));
const getBridge = async () => {
if (!bridgePromise) {
bridgePromise = getAccount().then(account => {
const bridge = getAccountBridge(account, null);
if (!bridge) throw new Error("no bridge for " + account.id);
return bridge;
});
}
return bridgePromise;
};
const getSynced = async () => {
if (!accountSyncedPromise) {
const account = await getAccount();
const bridge = await getBridge();
accountSyncedPromise = syncAccount(bridge, account);
}
return accountSyncedPromise;
};
const currency = rest.currency;
return {
getSynced,
getBridge,
getAccount,
initialAccountRaw: accountRaw,
initialAccountId: accountRaw.id,
initialAccountCurrency: currency,
...rest,
};
})
.forEach(arg => {
const {
getSynced,
getBridge,
getAccount,
initialAccountId,
initialAccountCurrency,
accountData,
impl,
} = arg;
const makeTest = (name, fn) => {
if (accountData.FIXME_tests && accountData.FIXME_tests.some(r => name.match(r))) {
warnDev(
"FIXME test was skipped. " +
name +
" for " +
initialAccountCurrency.name +
" (" +
initialAccountId +
")",
);
return;
}
test(name, fn);
};
describe(
impl + " bridge on account " + initialAccountCurrency.name + " (" + initialAccountId + ")",
() => {
describe("sync", () => {
makeTest("succeed", async () => {
const account = await getSynced();
expect(fromAccountRaw(toAccountRaw(account))).toBeDefined();
});
if (impl !== "mock") {
const accFromScanAccounts = accountsFoundInScanAccountsMap[initialAccountId];
if (accFromScanAccounts) {
makeTest("matches the same account from scanAccounts", async () => {
const acc = await getSynced();
expect(acc).toMatchObject(accFromScanAccounts);
});
}
}
makeTest("account have no NaN values", async () => {
const account = await getSynced();
[account, ...(account.subAccounts || [])].forEach(a => {
expect(a.balance.isNaN()).toBe(false);
expect(a.operations.find(op => op.value.isNaN())).toBe(undefined);
expect(a.operations.find(op => op.fee.isNaN())).toBe(undefined);
});
});
if (
!blacklistOpsSumEq.currencies.includes(initialAccountCurrency.id) &&
!blacklistOpsSumEq.impls.includes(impl)
) {
makeTest("balance is sum of ops", async () => {
const account = await getSynced();
expectBalanceIsOpsSum(account);
if (account.subAccounts) {
account.subAccounts.forEach(expectBalanceIsOpsSum);
}
});
makeTest("balance and spendableBalance boundaries", async () => {
const account = await getSynced();
expect(account.balance).toBeInstanceOf(BigNumber);
expect(account.spendableBalance).toBeInstanceOf(BigNumber);
expect(account.balance.lt(0)).toBe(false);
expect(account.spendableBalance.lt(0)).toBe(false);
expect(account.spendableBalance.lte(account.balance)).toBe(true);
});
}
makeTest("existing operations object refs are preserved", async () => {
const account = await getSynced();
const count = Math.floor(account.operations.length / 2);
const operations = account.operations.slice(count);
const copy = {
...account,
operations,
blockHeight: 0,
};
const bridge = await getBridge();
const synced = await syncAccount(bridge, copy);
expect(synced.operations.length).toBe(account.operations.length);
// same ops are restored
expect(synced.operations).toEqual(account.operations);
if (initialAccountId.startsWith("ethereumjs")) return; // ethereumjs seems to have a bug on this, we ignore because the impl will be dropped.
// existing ops are keeping refs
synced.operations.slice(count).forEach((op, i) => {
expect(op).toStrictEqual(operations[i]);
});
});
makeTest("pendingOperations are cleaned up", async () => {
const account = await getSynced();
if (account.operations.length) {
const operations = account.operations.slice(1);
const pendingOperations = [account.operations[0]];
const copy = {
...account,
operations,
pendingOperations,
blockHeight: 0,
};
const bridge = await getBridge();
const synced = await syncAccount(bridge, copy);
// same ops are restored
expect(synced.operations).toEqual(account.operations);
// pendingOperations is empty
expect(synced.pendingOperations).toEqual([]);
}
});
makeTest("there are no Operation dups (by id)", async () => {
const account = await getSynced();
const seen = {};
account.operations.forEach(op => {
expect(seen[op.id]).toBeUndefined();
seen[op.id] = op.id;
});
});
});
describe("createTransaction", () => {
makeTest(
"empty transaction is an object with empty recipient and zero amount",
async () => {
const account = await getAccount();
const bridge = await getBridge();
expect(bridge.createTransaction(account)).toMatchObject({
amount: new BigNumber(0),
recipient: "",
});
},
);
makeTest("empty transaction is equals to itself", async () => {
const account = await getAccount();
const bridge = await getBridge();
expect(bridge.createTransaction(account)).toEqual(bridge.createTransaction(account));
});
makeTest("empty transaction correctly serialize", async () => {
const account = await getAccount();
const bridge = await getBridge();
const t = bridge.createTransaction(account);
expect(fromTransactionRaw(toTransactionRaw(t))).toEqual(t);
});
makeTest("transaction with amount and recipient correctly serialize", async () => {
const account = await getSynced();
const bridge = await getBridge();
const t = {
...bridge.createTransaction(account),
amount: new BigNumber(1000),
recipient: account.freshAddress,
};
expect(fromTransactionRaw(toTransactionRaw(t))).toEqual(t);
});
});
describe("updateTransaction", () => {
// stability: function called twice will return the same object reference
// (=== convergence so we can stop looping, typically because transaction will be a hook effect dependency of prepareTransaction)
async function expectStability(t, patch) {
const bridge = await getBridge();
const t2 = bridge.updateTransaction(t, patch);
const t3 = bridge.updateTransaction(t2, patch);
expect(t2).toBe(t3);
}
makeTest("ref stability on empty transaction", async () => {
const account = await getSynced();
const bridge = await getBridge();
const tx = bridge.createTransaction(account);
await expectStability(tx, {});
});
makeTest("ref stability on self transaction", async () => {
const account = await getSynced();
const bridge = await getBridge();
const tx = bridge.createTransaction(account);
await expectStability(tx, {
amount: new BigNumber(1000),
recipient: account.freshAddress,
});
});
});
describe("prepareTransaction", () => {
// stability: function called twice will return the same object reference
// (=== convergence so we can stop looping, typically because transaction will be a hook effect dependency of prepareTransaction)
async function expectStability(account, t) {
const bridge = await getBridge();
let t2 = await bridge.prepareTransaction(account, t);
let t3 = await bridge.prepareTransaction(account, t2);
t2 = omit(t2, arg.currencyData.IgnorePrepareTransactionFields || []);
t3 = omit(t3, arg.currencyData.IgnorePrepareTransactionFields || []);
expect(t2).toStrictEqual(t3);
}
makeTest("ref stability on empty transaction", async () => {
const account = await getSynced();
const bridge = await getBridge();
await expectStability(account, bridge.createTransaction(account));
});
makeTest("ref stability on self transaction", async () => {
const account = await getSynced();
const bridge = await getBridge();
await expectStability(account, {
...bridge.createTransaction(account),
amount: new BigNumber(1000),
recipient: account.freshAddress,
});
});
makeTest("can be run in parallel and all yield same results", async () => {
const account = await getSynced();
const bridge = await getBridge();
const t = {
...bridge.createTransaction(account),
amount: new BigNumber(1000),
recipient: account.freshAddress,
};
const stable = await bridge.prepareTransaction(account, t);
const first = omit(
await bridge.prepareTransaction(account, stable),
arg.currencyData.IgnorePrepareTransactionFields || [],
);
const concur = await Promise.all(
Array(3)
.fill(null)
.map(() => bridge.prepareTransaction(account, stable)),
);
concur.forEach(r => {
r = omit(r, arg.currencyData.IgnorePrepareTransactionFields || []);
expect(r).toEqual(first);
});
});
});
describe("getTransactionStatus", () => {
makeTest("can be called on an empty transaction", async () => {
const account = await getSynced();
const bridge = await getBridge();
const t = {
...bridge.createTransaction(account),
feePerByte: new BigNumber(0.0001),
};
const s = await bridge.getTransactionStatus(account, t);
expect(s).toBeDefined();
expect(s.errors).toHaveProperty("recipient");
expect(s).toHaveProperty("totalSpent");
expect(s.totalSpent).toBeInstanceOf(BigNumber);
expect(s).toHaveProperty("estimatedFees");
expect(s.estimatedFees).toBeInstanceOf(BigNumber);
expect(s).toHaveProperty("amount");
expect(s.amount).toBeInstanceOf(BigNumber);
expect(s.amount).toEqual(new BigNumber(0));
});
makeTest("can be called on an empty prepared transaction", async () => {
const bridge = await getBridge();
const account = await getSynced();
const t = await bridge.prepareTransaction(account, {
...bridge.createTransaction(account),
feePerByte: new BigNumber(0.0001),
});
const s = await bridge.getTransactionStatus(account, t);
expect(s).toBeDefined(); // FIXME i'm not sure if we can establish more shared properties
});
makeTest("Default empty recipient have a recipientError", async () => {
const bridge = await getBridge();
const account = await getSynced();
const t = {
...bridge.createTransaction(account),
feePerByte: new BigNumber(0.0001),
};
const status = await bridge.getTransactionStatus(account, t);
expect(status.errors.recipient).toBeInstanceOf(RecipientRequired);
});
makeTest("invalid recipient have a recipientError", async () => {
const bridge = await getBridge();
const account = await getSynced();
const t = {
...bridge.createTransaction(account),
feePerByte: new BigNumber(0.0001),
recipient: "invalidADDRESS",
};
const status = await bridge.getTransactionStatus(account, t);
expect(status.errors.recipient).toBeInstanceOf(InvalidAddress);
});
makeTest("Default empty amount has an amount error", async () => {
const bridge = await getBridge();
const account = await getSynced();
const t = await bridge.prepareTransaction(account, {
...bridge.createTransaction(account),
feePerByte: new BigNumber(0.0001),
});
const status = await bridge.getTransactionStatus(account, t);
expect(status.errors.amount).toBeInstanceOf(AmountRequired);
});
const accountDataTest = accountData.test;
if (accountDataTest) {
makeTest("account specific test", async () => {
const bridge = await getBridge();
return accountDataTest(expect, await getSynced(), bridge);
});
}
(accountData.transactions || []).forEach(
({ name, transaction, expectedStatus, apdus, testSignedOperation, test: testFn }) => {
makeTest("transaction " + name, async () => {
const bridge = await getBridge();
const account: Account = await getSynced();
let t =
typeof transaction === "function"
? transaction(bridge.createTransaction(account), account, bridge)
: transaction;
t = await bridge.prepareTransaction(account, {
feePerByte: new BigNumber(0.0001),
...t,
});
const s = await bridge.getTransactionStatus(account, t);
if (expectedStatus) {
const es =
typeof expectedStatus === "function"
? expectedStatus(account, t, s)
: expectedStatus;
const { errors, warnings } = es;
// we match errors and warnings
errors && expect(s.errors).toMatchObject(errors);
warnings && expect(s.warnings).toMatchObject(warnings);
// now we match rest of fields but using the raw version for better readability
const restRaw: Record<string, any> = toTransactionStatusRaw(
{
...s,
...es,
},
account.currency.family,
);
delete restRaw.errors;
delete restRaw.warnings;
for (const k in restRaw) {
if (!(k in es)) {
delete restRaw[k];
}
}
expect(
toTransactionStatusRaw(s as TransactionStatusCommon, account.currency.family),
).toMatchObject(restRaw);
}
if (testFn) {
await testFn(expect, t, s, bridge);
}
if (Object.keys(s.errors).length === 0) {
const { subAccountId } = t;
const { subAccounts } = account;
const inferSubAccount = () => {
invariant(subAccounts, "sub accounts available");
const a = (subAccounts as TokenAccount[]).find(a => a.id === subAccountId);
invariant(a, "sub account not found");
return a;
};
const obj = subAccountId
? {
transaction: t as TransactionCommon,
account: inferSubAccount() as AccountLike,
parentAccount: account,
}
: {
transaction: t as TransactionCommon,
account: account as AccountLike,
};
if (
(typeof t.mode !== "string" || t.mode === "send") &&
t.model &&
t.model.kind !== "stake.createAccount"
) {
const estimation = await bridge.estimateMaxSpendable(obj);
expect(estimation.gte(0)).toBe(true);
expect(estimation.lte(obj.account.balance)).toBe(true);
if (t.useAllAmount) {
expect(estimation.toString()).toBe(s.amount.toString());
}
}
}
if (apdus && impl !== "mock") {
const deviceId = await mockDeviceWithAPDUs(apdus);
try {
const signedOperation = await firstValueFrom(
bridge
.signOperation({
account,
deviceId,
transaction: t,
})
.pipe(
filter((e: any) => e.type === "signed"),
map((e: any) => e.signedOperation),
),
);
if (testSignedOperation) {
await testSignedOperation(expect, signedOperation, account, t, s, bridge);
}
} finally {
releaseMockDevice(deviceId);
}
}
});
},
);
});
describe("signOperation and broadcast", () => {
makeTest("method is available on bridge", async () => {
const bridge = await getBridge();
expect(typeof bridge.signOperation).toBe("function");
expect(typeof bridge.broadcast).toBe("function");
}); // NB for now we are not going farther because most is covered by bash tests
});
},
);
});
}