@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
535 lines • 24.1 kB
JavaScript
/* eslint-disable no-console */
import fs from "fs";
import path from "path";
import { BigNumber } from "bignumber.js";
import uniq from "lodash/uniq";
import groupBy from "lodash/groupBy";
import { log } from "@ledgerhq/logs";
import invariant from "invariant";
import flatMap from "lodash/flatMap";
import { getEnv } from "@ledgerhq/live-env";
import allSpecs from "../generated/specs";
import { promiseAllBatched } from "../promise";
import { isCurrencySupported, formatCurrencyUnit, getFiatCurrencyByTicker } from "../currencies";
import { formatAccount, isAccountEmpty, toAccountRaw } from "../account";
import { runWithAppSpec } from "./engine";
import { formatReportForConsole, formatError, formatTime } from "./formatters";
import { initialState, loadCountervalues, inferTrackingPairForAccounts, } from "@ledgerhq/live-countervalues/logic";
import { getPortfolio } from "@ledgerhq/live-countervalues/portfolio";
import { getContext } from "@ledgerhq/ledger-wallet-framework/bot/bot-test-context";
import { sha256 } from "../crypto";
import { getDefaultAccountName } from "@ledgerhq/live-wallet/accountName";
const usd = getFiatCurrencyByTicker("USD");
function convertMutation(report) {
const { appCandidate, mutation, account, destination, error, operation } = report;
return {
appCandidate,
mutationName: mutation?.name,
accountId: account?.id,
destinationId: destination?.id,
operationId: operation?.id,
error: error ? formatError(error) : undefined,
};
}
function convertSpecReport(result) {
const accounts = result.accountsAfter?.map(a => {
// remove the "expensive" data fields
const raw = toAccountRaw(a);
raw.operations = [];
delete raw.balanceHistoryCache;
if (raw.subAccounts) {
raw.subAccounts.forEach(a => {
a.operations = [];
delete a.balanceHistoryCache;
});
}
const unsafe = raw;
if (unsafe.bitcoinResources) {
delete unsafe.bitcoinResources.walletAccount;
}
return raw;
});
const mutations = result.mutations?.map(convertMutation);
return {
specName: result.spec.name,
fatalError: result.fatalError ? formatError(result.fatalError) : undefined,
accounts,
mutations,
existingMutationNames: result.spec.mutations.map(m => m.name),
hintWarnings: result.hintWarnings,
};
}
function makeAppJSON(accounts) {
const jsondata = {
data: {
settings: {
hasCompletedOnboarding: true,
},
accounts: accounts.map(account => ({
data: toAccountRaw(account),
version: 1,
})),
},
};
return JSON.stringify(jsondata);
}
export function getSpecs({ disabled, filter }) {
const specs = [];
const filteredCurrencies = filter?.currencies || [];
const filteredFamilies = filter?.families || [];
const filteredMutation = filter?.mutation;
const filteredFeatures = filter.features || [];
let disabledCurrencies = disabled?.currencies || [];
let disabledFamilies = disabled?.families || [];
if (filteredFamilies.length > 0) {
// We want to ignore disabled families when user filters on a family
disabledFamilies = [];
}
if (filteredCurrencies.length > 0) {
// We want to ignore disabled currencies when user filters on a currency
disabledCurrencies = [];
}
for (const family in allSpecs) {
if (filteredFamilies.length > 0 && !filteredFamilies.includes(family)) {
// We only want to test specific families when we use a filter
continue;
}
if (disabledFamilies.includes(family)) {
// We don't want to test disabled families
continue;
}
const familySpecs = allSpecs[family];
for (const key in familySpecs) {
let spec = familySpecs[key];
if (filteredCurrencies.length > 0 && !filteredCurrencies.includes(spec.currency.id)) {
// We only want to test specific currencies when we use a filter
continue;
}
if (disabledCurrencies.includes(spec.currency.id)) {
// We don't want to test disabled currencies
continue;
}
if (!isCurrencySupported(spec.currency) || spec.disabled) {
// We do not want to add the spec if currency isn't supported or is disabled
continue;
}
if (filteredMutation) {
spec = {
...spec,
mutations: spec.mutations.filter(m => new RegExp(filteredMutation).test(m.name)),
};
}
if (filteredFeatures.length > 0) {
spec = {
...spec,
mutations: spec.mutations.filter(m => filteredFeatures.includes(m.feature)),
};
}
specs.push(spec);
}
}
return specs;
}
export async function bot({ disabled, filter } = {}) {
const SEED = getEnv("SEED");
invariant(SEED, "SEED required");
const specsLogs = [];
const specs = getSpecs({ disabled, filter });
const timeBefore = Date.now();
const results = await promiseAllBatched(getEnv("BOT_MAX_CONCURRENT"), specs, (spec) => {
const logs = [];
specsLogs.push(logs);
return runWithAppSpec(spec, message => {
log("bot", message);
if (process.env.CI)
console.log(message);
logs.push(message);
}).catch((fatalError) => ({
spec,
fatalError,
mutations: [],
accountsBefore: [],
accountsAfter: [],
hintWarnings: [],
skipMutationsTimeoutReached: false,
}));
});
const totalDuration = Date.now() - timeBefore;
const allAppPaths = uniq(results.map(r => r.appPath || "").sort());
const allAccountsAfter = flatMap(results, r => r.accountsAfter || []);
let countervaluesError;
const countervaluesState = await loadCountervalues(initialState, {
trackingPairs: inferTrackingPairForAccounts(allAccountsAfter, usd),
autofillGaps: true,
refreshRate: 60000,
marketCapBatchingAfterRank: 20,
}).catch(e => {
if (process.env.CI)
console.error(e);
countervaluesError = e;
return null;
});
const period = "month";
const portfolio = countervaluesState
? getPortfolio(allAccountsAfter, period, countervaluesState, usd)
: null;
const totalUSD = portfolio
? formatCurrencyUnit(usd.units[0], new BigNumber(portfolio.balanceHistory[portfolio.balanceHistory.length - 1].value), {
showCode: true,
})
: "";
const allMutationReports = flatMap(results, r => r.mutations || []);
const mutationReports = allMutationReports.filter(r => r.mutation || r.error);
const errorCases = allMutationReports.filter(r => r.error);
const specFatals = results.filter(r => r.fatalError);
const botHaveFailed = specFatals.length > 0 || errorCases.length > 0;
const specsWithUncoveredMutations = results
.map(r => ({
spec: r.spec,
unavailableMutations: r.spec.mutations
.map(mutation => {
if (r.mutations && r.mutations.some(mr => mr.mutation === mutation)) {
return;
}
const errors = (r.mutations || [])
.map(mr => !mr.mutation && mr.unavailableMutationReasons
? mr.unavailableMutationReasons.find(r => r.mutation === mutation)
: null)
.filter(Boolean)
.map(({ error }) => error);
return {
mutation,
errors,
};
})
.filter(Boolean),
}))
.filter(r => r.unavailableMutations.length > 0);
const uncoveredMutations = flatMap(specsWithUncoveredMutations, s => s.unavailableMutations);
if (specFatals.length && process.env.CI) {
console.error(`================== SPEC ERRORS =====================\n`);
specFatals.forEach(c => {
console.error(c.fatalError);
console.error("");
});
}
if (errorCases.length && process.env.CI) {
console.error(`================== MUTATION ERRORS =====================\n`);
errorCases.forEach(c => {
console.error(formatReportForConsole(c));
console.error(c.error);
console.error("");
});
console.error(`/!\\ ${errorCases.length} failures out of ${mutationReports.length} mutations. Check above!\n`);
}
const specsWithoutFunds = results.filter(s => !s.fatalError &&
((s.accountsBefore && s.accountsBefore.every(isAccountEmpty)) ||
(s.mutations && s.mutations.every(r => !r.mutation))));
const fullySuccessfulSpecs = results.filter(s => !s.fatalError &&
s.mutations &&
!specsWithoutFunds.includes(s) &&
s.mutations.every(r => !r.mutation || r.operation));
const specsWithErrors = results.filter(s => !s.fatalError &&
s.mutations &&
!specsWithoutFunds.includes(s) &&
s.mutations.some(r => r.error || (r.mutation && !r.operation)));
const specsWithoutOperations = results.filter(s => !s.fatalError &&
!specsWithoutFunds.includes(s) &&
!specsWithErrors.includes(s) &&
s.mutations &&
s.mutations.every(r => !r.operation));
const withoutFunds = specsWithoutFunds
.filter(s =>
// ignore coin that are backed by testnet that have funds
!results.some(o => o.spec.currency.isTestnetFor === s.spec.currency.id && !specsWithoutFunds.includes(o)))
.map(s => s.spec.name);
const { GITHUB_RUN_ID, GITHUB_WORKFLOW } = process.env;
let body = "";
let githubBody = "";
function appendBody(content) {
body += content;
githubBody += content;
}
function appendBodyFullOnly(content) {
body += content;
}
let title = "";
const runURL = `https://github.com/LedgerHQ/ledger-live/actions/runs/${String(GITHUB_RUN_ID)}`;
const success = mutationReports.length - errorCases.length;
if (success > 0) {
title += `✅ ${success} txs `;
}
if (errorCases.length) {
title += `❌ ${errorCases.length} txs `;
}
if (withoutFunds.length) {
const msg = `💰 ${withoutFunds.length} miss funds `;
title += msg;
}
if (countervaluesError) {
title += `❌ countervalues `;
}
else {
title += `(${totalUSD}) `;
}
title += `⏲ ${formatTime(totalDuration)} `;
let subtitle = "";
if (countervaluesError) {
subtitle += `> ${formatError(countervaluesError)}`;
}
let slackBody = "";
appendBody(`## `);
if (GITHUB_RUN_ID && GITHUB_WORKFLOW) {
appendBody(`[**${GITHUB_WORKFLOW}**](${runURL}) `);
}
appendBody(`${title}\n\n`);
appendBody("\n\n");
appendBody(subtitle);
if (fullySuccessfulSpecs.length) {
const msg = `> ✅ ${fullySuccessfulSpecs.length} specs are successful: _${fullySuccessfulSpecs
.map(o => o.spec.name)
.join(", ")}_\n`;
appendBody(msg);
}
// slack unified message
const slackUnified = uniq(specFatals.concat(specsWithErrors).concat(specsWithoutOperations));
if (slackUnified.length) {
const msg = `> ❌ ${slackUnified.length} specs have problems: _${slackUnified
.map(o => o.spec.name)
.join(", ")}_\n`;
slackBody += msg;
}
// PR report detailed
if (specsWithErrors.length) {
const msg = `> ❌ ${specsWithErrors.length} specs have problems: _${specsWithErrors
.map(o => o.spec.name)
.join(", ")}_\n`;
appendBody(msg);
}
if (withoutFunds.length) {
const missingFundsWarn = `> 💰 ${withoutFunds.length} specs may miss funds: _${withoutFunds.join(", ")}_\n`;
appendBody(missingFundsWarn);
}
if (specsWithoutOperations.length) {
const warn = `> ⚠️ ${specsWithoutOperations.length} specs may have issues: *${specsWithoutOperations.map(o => o.spec.name).join(", ")}*\n`;
appendBody(warn);
}
appendBody("\n> What is the bot and how does it work? [Everything is documented here!](https://github.com/LedgerHQ/ledger-live/wiki/LLC:bot)\n\n");
appendBody("\n\n");
if (specFatals.length) {
appendBody("<details>\n");
appendBody(`<summary>${specFatals.length} critical spec errors</summary>\n\n`);
specFatals.forEach(({ spec, fatalError }) => {
appendBody(`**Spec ${spec.name} failed!**\n`);
appendBody("```\n" + formatError(fatalError, true) + "\n```\n\n");
});
appendBody("</details>\n\n");
}
// summarize the error causes
const dedupedErrorCauses = [];
errorCases.forEach(m => {
if (!m.error)
return;
const ctx = getContext(m.error);
if (!ctx)
return;
const cause = m.spec.name + " > " + ctx;
if (!dedupedErrorCauses.includes(cause)) {
dedupedErrorCauses.push(cause);
}
});
if (errorCases.length) {
appendBody("<details>\n");
appendBody(`<summary>❌ ${errorCases.length} mutation errors</summary>\n\n`);
errorCases.forEach(c => {
appendBody("```\n" + formatReportForConsole(c) + "\n```\n\n");
});
appendBody("</details>\n\n");
}
const specWithWarnings = results.filter(s => s.hintWarnings.length > 0);
if (specWithWarnings.length > 0) {
appendBody("<details>\n");
appendBody(`<summary>⚠️ ${specWithWarnings.reduce((sum, s) => s.hintWarnings.length + sum, 0)} spec hints</summary>\n\n`);
specWithWarnings.forEach(s => {
appendBody(`- Spec ${s.spec.name}:\n`);
s.hintWarnings.forEach(txt => appendBody(` - ${txt}\n`));
});
appendBody("</details>\n\n");
}
appendBodyFullOnly("<details>\n");
appendBodyFullOnly(`<summary>Details of the ${mutationReports.length} mutations</summary>\n\n`);
results.forEach((r, i) => {
const spec = specs[i];
const logs = specsLogs[i];
appendBodyFullOnly(`#### Spec ${spec.name} (${r.mutations ? r.mutations.length : "failed"})\n`);
appendBodyFullOnly("\n```\n");
appendBodyFullOnly(logs.join("\n"));
if (r.mutations) {
r.mutations.forEach(m => {
if (m.error || m.mutation) {
appendBodyFullOnly(formatReportForConsole(m) + "\n");
}
});
}
appendBodyFullOnly("\n```\n");
});
appendBodyFullOnly("</details>\n\n");
if (uncoveredMutations.length > 0) {
appendBodyFullOnly("<details>\n");
appendBodyFullOnly(`<summary>Details of the ${uncoveredMutations.length} uncovered mutations</summary>\n\n`);
specsWithUncoveredMutations.forEach(({ spec, unavailableMutations }) => {
appendBodyFullOnly(`#### Spec ${spec.name} (${unavailableMutations.length})\n`);
unavailableMutations.forEach(m => {
// FIXME: we definitely got to stop using Maybe types or | undefined | null
if (!m)
return;
const msgs = groupBy(m.errors.map(e => e.message));
appendBodyFullOnly("- **" +
m.mutation.name +
"**: " +
Object.keys(msgs)
.map(msg => `${msg} (${msgs[msg].length})`)
.join(", ") +
"\n");
});
});
appendBodyFullOnly("</details>\n\n");
}
appendBody("<details>\n");
appendBody(`<summary>Portfolio ${totalUSD ? " (" + totalUSD + ")" : ""} – Details of the ${results.length} currencies</summary>\n\n`);
appendBody("| Spec (accounts) | State | Remaining Runs (est) | funds? |\n");
appendBody("|-----------------|-------|----------------------|--------|\n");
results.forEach(r => {
function sumAccounts(all) {
if (!all || all.length === 0)
return;
return all.reduce((sum, a) => sum.plus(a.spendableBalance), new BigNumber(0));
}
const { accountsBefore } = r;
const accountsBeforeBalance = sumAccounts(accountsBefore);
let balance = !accountsBeforeBalance
? "🤷♂️"
: "**" +
formatCurrencyUnit(r.spec.currency.units[0], accountsBeforeBalance, {
showCode: true,
}) +
"**";
let eta = 0;
let etaEmoji = "❌";
const accounts = r.accountsAfter || r.accountsBefore || [];
const operations = flatMap(accounts, a => a.operations).sort((a, b) => a.fee.minus(b.fee).toNumber());
const avgOperationFee = operations
.reduce((sum, o) => sum.plus(o.fee || 0), new BigNumber(0))
.div(operations.length);
// const medianOperation = operations[Math.floor(operations.length / 2)];
const maxRuns = r.spec.mutations.reduce((sum, m) => sum + m.maxRun || 1, 0);
if (avgOperationFee.gt(0) && maxRuns > 0) {
const spendableBalanceSum = accounts.reduce((sum, a) => sum.plus(BigNumber.max(a.spendableBalance.minus(r.spec.minViableAmount || 0), 0)), new BigNumber(0));
eta = spendableBalanceSum.div(avgOperationFee).div(maxRuns).toNumber();
etaEmoji = eta < 50 ? "⚠️" : eta < 500 ? "👍" : "💪";
}
if (countervaluesState && r.accountsAfter) {
const portfolio = getPortfolio(r.accountsAfter, period, countervaluesState, usd);
const totalUSD = portfolio
? formatCurrencyUnit(usd.units[0], new BigNumber(portfolio.balanceHistory[portfolio.balanceHistory.length - 1].value), {
showCode: true,
})
: "";
balance += " (" + totalUSD + ")";
}
function countOps(all) {
if (!all)
return 0;
return all.reduce((sum, a) => sum + a.operations.length, 0);
}
const beforeOps = countOps(r.accountsBefore);
const afterOps = countOps(r.accountsAfter);
const firstAccount = accounts[0];
appendBody(`| ${r.spec.name} (${accounts.length}) `);
appendBody(`| ${afterOps || beforeOps} ops ${afterOps > beforeOps ? ` (+${afterOps - beforeOps})` : ""}, ${balance} `);
appendBody(`| ${etaEmoji} ${!eta ? "" : eta > 999 ? "999+" : Math.round(eta)} `);
appendBody(`| \`${(firstAccount && firstAccount.freshAddress) || ""}\` `);
appendBody("|\n");
});
appendBody("\n```\n");
appendBody(allAccountsAfter.map(a => formatAccount(a, "head")).join("\n"));
appendBody("\n```\n");
appendBody("\n</details>\n\n");
// Add performance details
appendBody("<details>\n");
appendBody(`<summary>Performance ⏲ ${formatTime(totalDuration)}</summary>\n\n`);
appendBody("**Time spent for each spec:** (total across mutations)\n");
function sumMutation(mutations, f) {
return mutations?.reduce((sum, m) => sum + (f(m) || 0), 0) || 0;
}
function sumResults(f) {
return results.reduce((sum, r) => sum + (f(r) || 0), 0);
}
function sumResultsMutation(f) {
return sumResults(r => sumMutation(r.mutations, f));
}
appendBody("| Spec (accounts) | preload | scan | re-sync | tx status | sign op | broadcast | test | destination test |\n");
appendBody("|---|---|---|---|---|---|---|---|---|\n");
appendBody("| **TOTAL** |");
appendBody(`**${formatTime(sumResults(r => r.preloadDuration))}** |`);
appendBody(`**${formatTime(sumResults(r => r.scanDuration))}** |`);
appendBody(`**${formatTime(sumResultsMutation(m => m.resyncAccountsDuration || 0))}** |`);
appendBody(`**${formatTime(sumResultsMutation(m => (m.mutationTime && m.statusTime ? m.statusTime - m.mutationTime : 0)))}** |`);
appendBody(`**${formatTime(sumResultsMutation(m => (m.statusTime && m.signedTime ? m.signedTime - m.statusTime : 0)))}** |`);
appendBody(`**${formatTime(sumResultsMutation(m => m.signedTime && m.broadcastedTime ? m.broadcastedTime - m.signedTime : 0))}** |`);
appendBody(`**${formatTime(sumResultsMutation(m => m.broadcastedTime && m.confirmedTime ? m.confirmedTime - m.broadcastedTime : 0))}** |`);
appendBody(`**${formatTime(sumResultsMutation(m => m.testDestinationDuration || 0))}** |\n`);
results.forEach(r => {
const accounts = r.accountsAfter || r.accountsBefore || [];
appendBody(`| ${r.spec.name} (${accounts.filter(a => a.used).length}) |`);
appendBody(`${formatTime(r.preloadDuration || 0)} |`);
appendBody(`${formatTime(r.scanDuration || 0)} |`);
appendBody(`${formatTime(sumMutation(r.mutations, m => m.resyncAccountsDuration || 0))} |`);
appendBody(`${formatTime(sumMutation(r.mutations, m => m.mutationTime && m.statusTime ? m.statusTime - m.mutationTime : 0))} |`);
appendBody(`${formatTime(sumMutation(r.mutations, m => m.statusTime && m.signedTime ? m.signedTime - m.statusTime : 0))} |`);
appendBody(`${formatTime(sumMutation(r.mutations, m => m.signedTime && m.broadcastedTime ? m.broadcastedTime - m.signedTime : 0))} |`);
appendBody(`${formatTime(sumMutation(r.mutations, m => m.broadcastedTime && m.confirmedTime ? m.confirmedTime - m.broadcastedTime : 0))} |`);
appendBody(`${formatTime(sumMutation(r.mutations, m => m.testDestinationDuration || 0))} |\n`);
});
appendBody("\n</details>\n\n");
appendBody("\n> What is the bot and how does it work? [Everything is documented here!](https://github.com/LedgerHQ/ledger-live/wiki/LLC:bot)\n\n");
const { BOT_REPORT_FOLDER, BOT_ENVIRONMENT } = process.env;
let complementary = "";
const { GITHUB_REF_NAME, GITHUB_ACTOR } = process.env;
if (GITHUB_REF_NAME !== "develop") {
complementary = `:pr: by *${GITHUB_ACTOR}* on \`${GITHUB_REF_NAME}\` `;
}
const slackCommentTemplate = `${String(GITHUB_WORKFLOW)} ${complementary}(<{{url}}|details> – <${runURL}|logs>)\n${title}\n${slackBody}`;
if (BOT_REPORT_FOLDER) {
const serializedReport = {
results: results.map(convertSpecReport),
environment: BOT_ENVIRONMENT,
seedHash: sha256(getEnv("SEED")).toString("hex"),
};
await Promise.all([
fs.promises.writeFile(path.join(BOT_REPORT_FOLDER, "github-report.md"), githubBody, "utf-8"),
fs.promises.writeFile(path.join(BOT_REPORT_FOLDER, "full-report.md"), body, "utf-8"),
fs.promises.writeFile(path.join(BOT_REPORT_FOLDER, "slack-comment-template.md"), slackCommentTemplate, "utf-8"),
fs.promises.writeFile(path.join(BOT_REPORT_FOLDER, "app.json"), makeAppJSON(allAccountsAfter), "utf-8"),
fs.promises.writeFile(path.join(BOT_REPORT_FOLDER, "coin-apps.json"), JSON.stringify(allAppPaths), "utf-8"),
fs.promises.writeFile(path.join(BOT_REPORT_FOLDER, "report.json"), JSON.stringify(serializedReport), "utf-8"),
]);
}
if (botHaveFailed) {
let txt = "";
specFatals.forEach(({ spec, fatalError }) => {
txt += `${spec.name} got ${String(fatalError)}\n`;
});
errorCases.forEach((c) => {
txt += `in ${c.spec.name}`;
if (c.account)
txt += `/${getDefaultAccountName(c.account)}`;
if (c.mutation)
txt += `/${c.mutation.name}`;
txt += ` got ${String(c.error)}\n`;
});
// throw new Error(txt);
console.error(txt);
}
}
//# sourceMappingURL=index.js.map