@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
649 lines • 34.8 kB
JavaScript
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 { 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(bridge, account, syncConfig = defaultSyncConfig) {
return firstValueFrom(bridge.sync(account, syncConfig).pipe(reduce((a, f) => f(a), account)));
}
export function testBridge(data) {
// covers all bridges through many different accounts
// to test the common shared properties of bridges.
const accountsRelated = [];
const currenciesRelated = [];
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), [])));
return accounts;
}
catch (e) {
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 = flatMap(accounts, a => {
const main = toAccountRaw(a);
if (!main.subAccounts)
return [main];
return [{ ...main, subAccounts: [] }, ...main.subAccounts];
});
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 = [];
const accountsNotInScan = [];
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 = 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 = 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, 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.find(a => a.id === subAccountId);
invariant(a, "sub account not found");
return a;
};
const obj = subAccountId
? {
transaction: t,
account: inferSubAccount(),
parentAccount: account,
}
: {
transaction: t,
account: account,
};
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) => e.type === "signed"), map((e) => 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
});
});
});
}
//# sourceMappingURL=bridge.js.map