@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
605 lines • 33.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.testBridge = exports.syncAccount = void 0;
const invariant_1 = __importDefault(require("invariant"));
const bignumber_js_1 = require("bignumber.js");
const operators_1 = require("rxjs/operators");
const flatMap_1 = __importDefault(require("lodash/flatMap"));
const omit_1 = __importDefault(require("lodash/omit"));
const errors_1 = require("@ledgerhq/errors");
const account_1 = require("../../account");
const currencies_1 = require("../../currencies");
const operation_1 = require("../../operation");
const transaction_1 = require("../../transaction");
const bridge_1 = require("../../bridge");
const mockDevice_1 = require("./mockDevice");
const rxjs_1 = require("rxjs");
const accountName_1 = require("@ledgerhq/live-wallet/accountName");
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"],
impls: ["mock"],
};
function expectBalanceIsOpsSum(a) {
expect(a.balance).toEqual(a.operations.reduce((sum, op) => sum.plus((0, operation_1.getOperationAmountNumber)(op)), new bignumber_js_1.BigNumber(0)));
}
const defaultSyncConfig = {
paginationConfig: {},
blacklistedTokenIds: ["ethereum/erc20/ampleforth", "ethereum/erc20/steth"],
};
function syncAccount(bridge, account, syncConfig = defaultSyncConfig) {
return (0, rxjs_1.firstValueFrom)(bridge.sync(account, syncConfig).pipe((0, operators_1.reduce)((a, f) => f(a), account)));
}
exports.syncAccount = syncAccount;
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;
Object.keys(currencies).forEach(currencyId => {
const currencyData = currencies[currencyId];
const currency = (0, currencies_1.getCryptoCurrencyById)(currencyId);
currenciesRelated.push({
currencyData,
currency,
});
const accounts = currencyData.accounts || [];
accounts.forEach(accountData => implementations.forEach(impl => {
if (accountData.implementations && !accountData.implementations.includes(impl)) {
return;
}
const account = (0, account_1.fromAccountRaw)({
...accountData.raw,
id: (0, account_1.encodeAccountId)({
...(0, account_1.decodeAccountId)(accountData.raw.id),
type: impl,
}),
});
accountsRelated.push({
currencyData,
accountData,
account,
impl,
});
}));
});
const accountsFoundInScanAccountsMap = {};
currenciesRelated.map(({ currencyData, currency }) => {
const bridge = (0, bridge_1.getCurrencyBridge)(currency);
const scanAccounts = async (apdus) => {
const deviceId = await (0, mockDevice_1.mockDeviceWithAPDUs)(apdus, currencyData.mockDeviceOptions);
try {
const accounts = await (0, rxjs_1.firstValueFrom)(bridge
.scanAccounts({
currency,
deviceId,
syncConfig: defaultSyncConfig,
})
.pipe((0, operators_1.filter)(e => e.type === "discovered"), (0, operators_1.map)(e => e.account), (0, operators_1.reduce)((all, a) => all.concat(a), [])));
return accounts;
}
catch (e) {
console.error(e.message);
throw e;
}
finally {
(0, mockDevice_1.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 = (0, omit_1.default)(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 = (0, omit_1.default)(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 = (0, flatMap_1.default)(accounts, a => {
const main = (0, account_1.toAccountRaw)(a);
if (!main.subAccounts)
return [main];
return [{ ...main, subAccounts: [] }, ...main.subAccounts];
});
const heads = raws.map(a => {
const copy = (0, omit_1.default)(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 = (0, omit_1.default)(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 = (0, bridge_1.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 (0, account_1.flattenAccounts)(accounts)) {
expect({
id: account.id,
unconfirmed: (0, account_1.isAccountBalanceUnconfirmed)(account),
}).toEqual({
id: account.id,
unconfirmed: false,
});
}
});
test("creationDate is correct", async () => {
const accounts = await scanAccountsCached(sa.apdus);
for (const account of (0, account_1.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(({ account, ...rest }) => {
const bridge = (0, bridge_1.getAccountBridge)(account, null);
if (!bridge)
throw new Error("no bridge for " + account.id);
let accountSyncedPromise;
// lazy eval so we don't run this yet
const getSynced = () => accountSyncedPromise || (accountSyncedPromise = syncAccount(bridge, account));
return {
getSynced,
bridge,
initialAccount: account,
...rest,
};
})
.forEach(arg => {
const { getSynced, bridge, initialAccount, 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 " + (0, accountName_1.getDefaultAccountName)(initialAccount));
return;
}
test(name, fn);
};
describe(impl + " bridge on account " + (0, accountName_1.getDefaultAccountName)(initialAccount), () => {
describe("sync", () => {
makeTest("succeed", async () => {
const account = await getSynced();
expect((0, account_1.fromAccountRaw)((0, account_1.toAccountRaw)(account))).toBeDefined();
});
if (impl !== "mock") {
const accFromScanAccounts = accountsFoundInScanAccountsMap[initialAccount.id];
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(initialAccount.currency.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_js_1.BigNumber);
expect(account.spendableBalance).toBeInstanceOf(bignumber_js_1.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 synced = await syncAccount(bridge, copy);
if (initialAccount.id.includes("ripple"))
return; // ripple wont work because of the current implementation of pagination
expect(synced.operations.length).toBe(account.operations.length);
// same ops are restored
expect(synced.operations).toEqual(account.operations);
if (initialAccount.id.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 (initialAccount.id.includes("ripple"))
return; // ripple wont work because of the current implementation of pagination
if (account.operations.length) {
const operations = account.operations.slice(1);
const pendingOperations = [account.operations[0]];
const copy = {
...account,
operations,
pendingOperations,
blockHeight: 0,
};
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", () => {
expect(bridge.createTransaction(initialAccount)).toMatchObject({
amount: new bignumber_js_1.BigNumber(0),
recipient: "",
});
});
makeTest("empty transaction is equals to itself", () => {
expect(bridge.createTransaction(initialAccount)).toEqual(bridge.createTransaction(initialAccount));
});
makeTest("empty transaction correctly serialize", () => {
const t = bridge.createTransaction(initialAccount);
expect((0, transaction_1.fromTransactionRaw)((0, transaction_1.toTransactionRaw)(t))).toEqual(t);
});
makeTest("transaction with amount and recipient correctly serialize", async () => {
const account = await getSynced();
const t = {
...bridge.createTransaction(account),
amount: new bignumber_js_1.BigNumber(1000),
recipient: account.freshAddress,
};
expect((0, transaction_1.fromTransactionRaw)((0, transaction_1.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)
function expectStability(t, patch) {
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 tx = bridge.createTransaction(account);
expectStability(tx, {});
});
makeTest("ref stability on self transaction", async () => {
const account = await getSynced();
const tx = bridge.createTransaction(account);
expectStability(tx, {
amount: new bignumber_js_1.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) {
let t2 = await bridge.prepareTransaction(account, t);
let t3 = await bridge.prepareTransaction(account, t2);
t2 = (0, omit_1.default)(t2, arg.currencyData.IgnorePrepareTransactionFields || []);
t3 = (0, omit_1.default)(t3, arg.currencyData.IgnorePrepareTransactionFields || []);
expect(t2).toStrictEqual(t3);
}
makeTest("ref stability on empty transaction", async () => {
const account = await getSynced();
await expectStability(account, bridge.createTransaction(account));
});
makeTest("ref stability on self transaction", async () => {
const account = await getSynced();
await expectStability(account, {
...bridge.createTransaction(account),
amount: new bignumber_js_1.BigNumber(1000),
recipient: account.freshAddress,
});
});
makeTest("can be run in parallel and all yield same results", async () => {
const account = await getSynced();
const t = {
...bridge.createTransaction(account),
amount: new bignumber_js_1.BigNumber(1000),
recipient: account.freshAddress,
};
const stable = await bridge.prepareTransaction(account, t);
const first = (0, omit_1.default)(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 = (0, omit_1.default)(r, arg.currencyData.IgnorePrepareTransactionFields || []);
expect(r).toEqual(first);
});
});
});
describe("getTransactionStatus", () => {
makeTest("can be called on an empty transaction", async () => {
const account = await getSynced();
const t = {
...bridge.createTransaction(account),
feePerByte: new bignumber_js_1.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_js_1.BigNumber);
expect(s).toHaveProperty("estimatedFees");
expect(s.estimatedFees).toBeInstanceOf(bignumber_js_1.BigNumber);
expect(s).toHaveProperty("amount");
expect(s.amount).toBeInstanceOf(bignumber_js_1.BigNumber);
expect(s.amount).toEqual(new bignumber_js_1.BigNumber(0));
});
makeTest("can be called on an empty prepared transaction", async () => {
const account = await getSynced();
const t = await bridge.prepareTransaction(account, {
...bridge.createTransaction(account),
feePerByte: new bignumber_js_1.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 account = await getSynced();
const t = {
...bridge.createTransaction(account),
feePerByte: new bignumber_js_1.BigNumber(0.0001),
};
const status = await bridge.getTransactionStatus(account, t);
expect(status.errors.recipient).toBeInstanceOf(errors_1.RecipientRequired);
});
makeTest("invalid recipient have a recipientError", async () => {
const account = await getSynced();
const t = {
...bridge.createTransaction(account),
feePerByte: new bignumber_js_1.BigNumber(0.0001),
recipient: "invalidADDRESS",
};
const status = await bridge.getTransactionStatus(account, t);
expect(status.errors.recipient).toBeInstanceOf(errors_1.InvalidAddress);
});
makeTest("Default empty amount has an amount error", async () => {
const account = await getSynced();
const t = await bridge.prepareTransaction(account, {
...bridge.createTransaction(account),
feePerByte: new bignumber_js_1.BigNumber(0.0001),
});
const status = await bridge.getTransactionStatus(account, t);
expect(status.errors.amount).toBeInstanceOf(errors_1.AmountRequired);
});
const accountDataTest = accountData.test;
if (accountDataTest) {
makeTest("account specific test", async () => accountDataTest(expect, await getSynced(), bridge));
}
(accountData.transactions || []).forEach(({ name, transaction, expectedStatus, apdus, testSignedOperation, test: testFn }) => {
makeTest("transaction " + name, async () => {
const account = await getSynced();
let t = typeof transaction === "function"
? transaction(bridge.createTransaction(account), account, bridge)
: transaction;
t = await bridge.prepareTransaction(account, {
feePerByte: new bignumber_js_1.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 = (0, transaction_1.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((0, transaction_1.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 = () => {
(0, invariant_1.default)(subAccounts, "sub accounts available");
const a = subAccounts.find(a => a.id === subAccountId);
(0, invariant_1.default)(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 (0, mockDevice_1.mockDeviceWithAPDUs)(apdus);
try {
const signedOperation = await (0, rxjs_1.firstValueFrom)(bridge
.signOperation({
account,
deviceId,
transaction: t,
})
.pipe((0, operators_1.filter)(e => e.type === "signed"), (0, operators_1.map)((e) => e.signedOperation)));
if (testSignedOperation) {
await testSignedOperation(expect, signedOperation, account, t, s, bridge);
}
}
finally {
(0, mockDevice_1.releaseMockDevice)(deviceId);
}
}
});
});
});
describe("signOperation and broadcast", () => {
makeTest("method is available on bridge", async () => {
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
});
});
});
}
exports.testBridge = testBridge;
//# sourceMappingURL=bridge.js.map