UNPKG

@ledgerhq/live-common

Version:
357 lines (325 loc) • 9.1 kB
import network from "@ledgerhq/live-network/network"; import { log } from "@ledgerhq/logs"; import { Observable, from, interval } from "rxjs"; import { filter, share, switchMap } from "rxjs/operators"; import semver from "semver"; import { getCryptoCurrencyById } from "../../currencies"; import { getEnv } from "@ledgerhq/live-env"; import { RPCHostInvalid, RPCHostRequired, RPCPassRequired, RPCUserRequired, SatStackAccessDown, SatStackStillSyncing, SatStackVersionTooOld, } from "../../errors"; import { getCurrencyExplorer } from "@ledgerhq/coin-bitcoin/explorer"; import type { AccountDescriptor } from "@ledgerhq/coin-bitcoin/descriptor"; const minVersionMatch = ">=0.11.1"; function isAcceptedVersion(version: string | null | undefined) { return !!version && semver.satisfies(semver.coerce(version) || "", minVersionMatch); } let mockStatus: SatStackStatus = { type: "ready", }; export function setMockStatus(s: SatStackStatus): void { mockStatus = s; } export type RPCNodeConfig = { host: string; username: string; password: string; tls?: boolean; notls?: boolean; }; export function isValidHost(host: string): boolean { const pattern = new RegExp( "^" + // beginning of url "(((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,})|^[a-z\\d-]{2,}|" + // domain name "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*$", ); // port and path return !!pattern.test(host); } // we would call this only during the user validation of the rpc node configuration export function validateRPCNodeConfig(config: RPCNodeConfig): Array<{ field: string; error: Error; }> { const errors: Array<{ field: string; error: Error }> = []; if (!config.host) { errors.push({ field: "host", error: new RPCHostRequired(), }); } else if (!isValidHost(config.host)) { errors.push({ field: "host", error: new RPCHostInvalid(), }); } if (!config.username) { errors.push({ field: "username", error: new RPCUserRequired(), }); } if (!config.password) { errors.push({ field: "password", error: new RPCPassRequired(), }); } return errors; } // we would call this only during the "Testing node connection" step // Check if the node is accessible by RPC. promise fails if not. export async function checkRPCNodeConfig(config: RPCNodeConfig): Promise<void> { const errors = validateRPCNodeConfig(config); if (errors.length) { throw errors[0].error; } if (getEnv("MOCK")) { if (mockStatus.type === "node-disconnected") { throw new Error("mock disconnected"); } return; } const { host, username, password, tls } = config; await network({ url: `http${tls ? "s" : ""}://${host}`, method: "POST", data: { jsonrpc: "1.0", id: "ledger-live-full-node-check", method: "getblockchaininfo", params: [], }, auth: { username, password, }, }); } export type SatStackConfig = { node: RPCNodeConfig; accounts: Array<{ descriptor: AccountDescriptor; extra?: Record<string, any>; // user's unknown fields are preserved in this }>; extra?: Record<string, any>; // user's unknown fields are preserved in this }; // we would need to call this any time we would "Edit" the flow export function parseSatStackConfig(json: string): SatStackConfig | null | undefined { const obj = JSON.parse(json); if (obj && typeof obj === "object") { const { accounts, rpcurl, rpcuser, rpcpass, notls, ...extra } = obj; if (!rpcurl || typeof rpcurl !== "string") return; if (!rpcuser || typeof rpcuser !== "string") return; if (!rpcpass || typeof rpcpass !== "string") return; const result: SatStackConfig = { node: { host: rpcurl, username: rpcuser, password: rpcpass, tls: !notls, }, accounts: [], extra, }; if (accounts && typeof accounts === "object" && Array.isArray(accounts)) { result.accounts = accounts .map(a => { const { external, internal, ...extra } = a; if (!external || typeof external !== "string") return; if (!internal || typeof internal !== "string") return; return { descriptor: { external, internal, }, extra, }; }) .filter(Boolean) as Array<{ descriptor: AccountDescriptor; extra: Record<string, any> | undefined; }>; } return result; } } // we would need this at the end of the setup flow, when we save to a jss.json configuration file export function stringifySatStackConfig(config: SatStackConfig): string { return JSON.stringify( { accounts: config.accounts.map(a => ({ external: a.descriptor.external, internal: a.descriptor.internal, ...a.extra, })), rpcurl: config.node.host, rpcuser: config.node.username, rpcpass: config.node.password, notls: !config.node.tls, ...config.extra, }, null, 2, ); } // We would need it to apply an edition over an existing sats stack configuration (before saving it over) export function editSatStackConfig( existing: SatStackConfig, edit: Partial<SatStackConfig>, ): SatStackConfig { const accounts = existing.accounts.concat( // append accounts that would not already exist (edit.accounts || []).filter( a => !existing.accounts.some(existing => a.descriptor.internal === existing.descriptor.internal), ), ); return { ...existing, ...edit, // edit is patching existing fields accounts, }; } export type SatStackStatus = | { type: "satstack-outdated"; progress?: undefined; } | { type: "satstack-disconnected"; progress?: undefined; } | { type: "node-disconnected"; progress?: undefined; } | { type: "invalid-chain"; found: string; progress?: undefined; } | { type: "initializing"; progress?: undefined; } | { type: "syncing"; progress: number; } // progress percentage from 0 to 1 | { type: "scanning"; progress: number; } // progress percentage from 0 to 1 | { type: "ready"; progress?: undefined; }; export function isSatStackEnabled(): boolean { return Boolean(getEnv("SATSTACK")); } // We would need it any time we want to check if the Sats Stack is up and what status is it at currently // - during the configuration flow (on last step) (actively polling) // - on the settings screen (actively polling) // - before doing a sync // - before doing an add account // NB the promise is never rejected export async function fetchSatStackStatus(): Promise<SatStackStatus> { if (!isSatStackEnabled()) { return { type: "satstack-disconnected", }; } if (getEnv("MOCK")) { return mockStatus; } const ce = getCurrencyExplorer(getCryptoCurrencyById("bitcoin")); const r = await network({ method: "GET", url: `${ce.endpoint}/blockchain/${ce.version}/explorer/status`, }).catch(() => null); if (!r || !r.data) { return { type: "satstack-disconnected", }; } const { chain, status, sync_progress, scan_progress, version } = r.data; if (!isAcceptedVersion(version)) { return { type: "satstack-outdated", }; } if (chain !== "main") { return { type: "invalid-chain", found: chain, }; } if (status === "initializing" || status === "pending-scan") { return { type: "initializing", }; } if (status === "node-disconnected") { return { type: "node-disconnected", }; } if (status === "syncing") { return { type: "syncing", progress: (sync_progress || 0) / 100, }; } if (status === "scanning") { return { type: "scanning", progress: (scan_progress || 0) / 100, }; } return { type: "ready", }; } export async function checkDescriptorExists(descriptor: string): Promise<boolean> { if (getEnv("MOCK")) { return true; } const r = await network({ method: "POST", url: `${getEnv("EXPLORER_SATSTACK")}/control/descriptors/has`, data: { descriptor, }, }); log("satstack", "checkDescriptorExists " + descriptor + " is " + r.data.exists); return Boolean(r.data.exists); } export async function requiresSatStackReady(): Promise<void> { if (isSatStackEnabled()) { const status = await fetchSatStackStatus(); switch (status.type) { case "ready": return; case "syncing": case "scanning": throw new SatStackStillSyncing(); case "satstack-outdated": throw new SatStackVersionTooOld(); default: throw new SatStackAccessDown(); } } } export const statusObservable: Observable<SatStackStatus> = interval(1000).pipe( switchMap(() => from(fetchSatStackStatus())), filter((e, i) => i > 4 || e.type !== "satstack-disconnected"), share(), );