diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..2048f1d1ec --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*.md] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = false +max_line_length = 80 +insert_final_newline = true +charset = utf-8 +end_of_line = lf diff --git a/.github/actions/export-env-vars/action.yml b/.github/actions/export-env-vars/action.yml new file mode 100644 index 0000000000..91c7db028b --- /dev/null +++ b/.github/actions/export-env-vars/action.yml @@ -0,0 +1,52 @@ +name: Export Repository Variables +description: Export all repository/environment variables from a JSON map into GITHUB_ENV. + +inputs: + vars_json: + description: JSON object map of repository/environment variables. + required: true + +runs: + using: composite + steps: + - name: Export variables to GITHUB_ENV + shell: bash + env: + VARS_JSON: ${{ inputs.vars_json }} + run: | + python3 - <<'PY' + import json + import os + import uuid + + vars_map = json.loads(os.environ.get("VARS_JSON", "{}")) + env_path = os.environ["GITHUB_ENV"] + alias_prefixes = ( + "BB_DEV_RPC_URL_HTTP_", + "BB_DEV_RPC_URL_WS_", + "BB_DEV_MQ_URL_", + "BB_PROD_RPC_URL_HTTP_", + "BB_PROD_RPC_URL_WS_", + "BB_PROD_MQ_URL_", + "BB_RPC_BIND_HOST_", + "BB_RPC_ALLOW_IP_", + "BB_DEV_API_URL_HTTP_", + "BB_DEV_API_URL_WS_", + ) + + def write_env_var(env_file, key, value): + delimiter = f"__{uuid.uuid4().hex}__" + env_file.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n") + + with open(env_path, "a", encoding="utf-8") as env_file: + for key, value in vars_map.items(): + text = "" if value is None else str(value) + normalized = key + for prefix in alias_prefixes: + if key.startswith(prefix): + alias = key[len(prefix):] + # Blockbook env lookups use lowercase coin aliases from configs/coins. + normalized = prefix + alias.lower().replace("-", "_") + break + write_env_var(env_file, normalized, text) + PY diff --git a/.github/bin/bbcli b/.github/bin/bbcli new file mode 100755 index 0000000000..f8e4d663ad --- /dev/null +++ b/.github/bin/bbcli @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/../.." && pwd)" + +exec "${repo_root}/.github/scripts/run.py" "$@" diff --git a/.github/scripts/backend_decision.py b/.github/scripts/backend_decision.py new file mode 100644 index 0000000000..642eed559d --- /dev/null +++ b/.github/scripts/backend_decision.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +import shlex +import sys +from pathlib import Path + +import backend_policy +from coin_rpc import CoinRPCError, load_config, resolve_build_env + + +def format_shell(decision: dict, build_env: str) -> str: + pairs = { + "BACKEND_SHOULD_BUILD": "1" if decision["should_build_backend"] else "0", + "BACKEND_REASON": decision["reason"], + "BACKEND_RPC_ENV": decision["rpc_env"], + "BACKEND_RPC_HOST": decision["rpc_host"], + "BACKEND_COIN_ALIAS": decision["coin_alias"], + "BACKEND_BUILD_ENV": build_env, + } + return "\n".join(f"{key}={shlex.quote(str(value))}" for key, value in pairs.items()) + + +def main(argv: list[str] | None = None) -> None: + args = list(sys.argv[1:] if argv is None else argv) + if len(args) != 1: + raise CoinRPCError(f"usage: {Path(sys.argv[0]).name} ") + coin = args[0] + config_path = Path("configs") / "coins" / f"{coin}.json" + if not config_path.is_file(): + raise CoinRPCError(f"missing coin config {config_path}") + build_env = resolve_build_env() + decision = backend_policy.compute_backend_decision( + coin=coin, + config=load_config(config_path), + build_env=build_env, + backend_mode=backend_policy.BACKEND_MODE_AUTO, + ) + print(format_shell(decision, build_env)) + + +if __name__ == "__main__": + try: + main() + except CoinRPCError as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) diff --git a/.github/scripts/backend_policy.py b/.github/scripts/backend_policy.py new file mode 100644 index 0000000000..650134bfd1 --- /dev/null +++ b/.github/scripts/backend_policy.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +from typing import Mapping + +from coin_rpc import get_coin_alias, rpc_hostname, rpc_url_env_name + +BACKEND_MODE_AUTO = "auto" +BACKEND_MODE_ALWAYS = "always" +BACKEND_MODE_NEVER = "never" + + +def should_build_backend( + *, + backend_mode: str, + rpc_url: str, +) -> tuple[bool, str]: + if backend_mode == BACKEND_MODE_NEVER: + return False, "backend-mode-never" + if backend_mode == BACKEND_MODE_ALWAYS: + return True, "backend-mode-always" + if not rpc_url: + return True, "rpc-url-env-missing-or-empty" + rpc_host = rpc_hostname(rpc_url) + if not rpc_host: + return False, "rpc-host-missing" + if rpc_host in {"localhost", "127.0.0.1", "::1"}: + return True, f"rpc-host-is-local-{rpc_host}" + return False, f"rpc-host-is-remote-{rpc_host}" + + +def compute_backend_decision( + *, + coin: str, + config: dict, + build_env: str, + backend_mode: str, + env: Mapping[str, str] | None = None, +) -> dict: + if env is None: + env = os.environ + coin_alias = get_coin_alias(config, coin) + rpc_env = rpc_url_env_name(coin_alias, build_env) + rpc_url = env.get(rpc_env, "").strip() + should_build, reason = should_build_backend( + backend_mode=backend_mode, + rpc_url=rpc_url, + ) + return { + "coin_alias": coin_alias, + "rpc_env": rpc_env, + "rpc_host": rpc_hostname(rpc_url), + "should_build_backend": should_build, + "reason": reason, + } diff --git a/.github/scripts/build_packages.py b/.github/scripts/build_packages.py new file mode 100644 index 0000000000..4ad330883d --- /dev/null +++ b/.github/scripts/build_packages.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import backend_policy +from coin_rpc import ( + BUILD_ENV_DEV, + BUILD_ENV_PROD, + BUILD_ENV_VAR, + CoinRPCError, + load_config, + resolve_build_env as resolve_build_env_common, +) + + +LOG_PREFIX = "CI/CD Pipeline:" +SCRIPT_NAME = "[build-packages]" +DEFAULT_PACKAGE_ROOT = "/opt/blockbook-builds" +def log(message: str) -> None: + print(f"{LOG_PREFIX} {SCRIPT_NAME} {message}", file=sys.stderr, flush=True) + + +def fail(message: str) -> None: + print(f"{LOG_PREFIX} error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def get_optional_package_name(config: dict, section: str, coin: str) -> str | None: + value = config.get(section, {}).get("package_name", "") + if value in (None, ""): + return None + if not isinstance(value, str) or not value.strip(): + fail(f"coin '{coin}' does not define a valid {section}.package_name") + return value.strip() + + +def resolve_build_env() -> str: + try: + return resolve_build_env_common() + except CoinRPCError as exc: + fail(str(exc)) + return "" + + +def resolve_branch_or_tag() -> str: + configured = os.environ.get("BRANCH_OR_TAG", "").strip() + if configured: + return configured + + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + check=True, + capture_output=True, + text=True, + ) + current_branch = result.stdout.strip() + except (FileNotFoundError, subprocess.CalledProcessError): + current_branch = "" + if current_branch: + return current_branch + + try: + result = subprocess.run( + ["git", "describe", "--tags", "--exact-match"], + check=True, + capture_output=True, + text=True, + ) + current_tag = result.stdout.strip() + except (FileNotFoundError, subprocess.CalledProcessError): + current_tag = "" + if current_tag: + return current_tag + + fail("BRANCH_OR_TAG is not set and the current checkout is neither a branch nor an exact tag") + + +def latest_package(pattern: str) -> Path: + matches = sorted(Path("build").glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) + if not matches: + fail(f"built package was not found (pattern build/{pattern})") + return matches[0] + + +def ensure_writable_dir(path: Path) -> None: + root_dir = path.parent + if not root_dir.exists(): + fail(f"writable root directory {root_dir} does not exist; pre-create it for the runner user") + if not root_dir.is_dir(): + fail(f"writable root path {root_dir} is not a directory") + if root_dir.stat().st_uid != os.getuid(): + fail( + f"writable root directory {root_dir} must be owned by the runner user " + f"(uid {os.getuid()})" + ) + + try: + path.mkdir(parents=True, exist_ok=True) + return + except PermissionError: + fail(f"cannot write to {path}; ensure {root_dir} is writable by the runner user") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument( + "--backend-mode", + choices=( + backend_policy.BACKEND_MODE_AUTO, + backend_policy.BACKEND_MODE_ALWAYS, + backend_policy.BACKEND_MODE_NEVER, + ), + default=backend_policy.BACKEND_MODE_AUTO, + ) + parser.add_argument("coins", nargs="+") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + raw_args = list(sys.argv[1:] if argv is None else argv) + if not raw_args: + fail(f"usage: {Path(sys.argv[0]).name} [ ...]") + parsed = parse_args(raw_args) + args = parsed.coins + + backend_mode = parsed.backend_mode + build_env = resolve_build_env() + + package_root = os.environ.get("BB_PACKAGE_ROOT", "").strip() or DEFAULT_PACKAGE_ROOT + if not os.path.isabs(package_root): + fail(f"BB_PACKAGE_ROOT must be an absolute path (got '{package_root}')") + branch_or_tag = resolve_branch_or_tag() + branch_or_tag_path = branch_or_tag.replace("/", "-") + branch_root = Path(package_root) / branch_or_tag_path + + log("requested coins: " + " ".join(args)) + log(f"backend_mode={backend_mode}") + log(f"{BUILD_ENV_VAR}={build_env}") + if backend_mode == backend_policy.BACKEND_MODE_AUTO: + log( + "backend build rule: auto mode builds backend unless the selected " + "BB_{DEV|PROD}_RPC_URL_HTTP is non-empty and non-local" + ) + elif backend_mode == backend_policy.BACKEND_MODE_ALWAYS: + log("backend build rule: always mode builds backend for coins that define a backend package") + else: + log( + "backend build rule: never mode skips backend for coins that also build " + "blockbook, but still builds backend-only coins" + ) + log(f"branch_or_tag={branch_or_tag} -> path={branch_or_tag_path}") + log(f"package_root={package_root}") + + ensure_writable_dir(branch_root) + + coins: list[str] = [] + blockbook_package_names: list[str | None] = [] + backend_package_names: list[str | None] = [] + build_backend_flags: list[bool] = [] + make_targets: list[str] = [] + + for coin in args: + config_path = Path("configs") / "coins" / f"{coin}.json" + if not config_path.is_file(): + fail(f"missing coin config {config_path}") + + config = load_config(config_path) + blockbook_package_name = get_optional_package_name(config, "blockbook", coin) + backend_package_name = get_optional_package_name(config, "backend", coin) + if blockbook_package_name is None and backend_package_name is None: + fail(f"coin '{coin}' does not define blockbook.package_name or backend.package_name") + try: + decision = backend_policy.compute_backend_decision( + coin=coin, + config=config, + build_env=build_env, + backend_mode=backend_mode, + ) + except CoinRPCError as exc: + fail(str(exc)) + build_backend = decision["should_build_backend"] + reason = decision["reason"] + if backend_package_name is None: + build_backend = False + reason = "backend-missing" + elif blockbook_package_name is None: + build_backend = True + reason = "blockbook-missing" + + coins.append(coin) + blockbook_package_names.append(blockbook_package_name) + backend_package_names.append(backend_package_name) + build_backend_flags.append(build_backend) + + if blockbook_package_name is not None and backend_package_name is not None: + target = f"deb-{coin}" if build_backend else f"deb-blockbook-{coin}" + elif backend_package_name is not None: + target = f"deb-backend-{coin}" + else: + target = f"deb-blockbook-{coin}" + log( + f"validated {coin}: alias={decision['coin_alias']}, blockbook={blockbook_package_name or ''}, " + f"backend={backend_package_name or ''}, target={target}, build_backend={str(build_backend).lower()}, " + f"reason={reason}, rpc_env={decision['rpc_env']}, rpc_host={decision['rpc_host'] or ''}" + ) + make_targets.append(target) + + if blockbook_package_name is not None: + log(f"removing previous packages matching build/{blockbook_package_name}_*.deb") + for path in Path("build").glob(f"{blockbook_package_name}_*.deb"): + path.unlink() + if build_backend and backend_package_name is not None: + log(f"removing previous packages matching build/{backend_package_name}_*.deb") + for path in Path("build").glob(f"{backend_package_name}_*.deb"): + path.unlink() + shutil.rmtree(branch_root / coin, ignore_errors=True) + + log("starting build: make PORTABLE=1 " + " ".join(make_targets)) + try: + subprocess.run(["make", "PORTABLE=1", *make_targets], check=True) + except subprocess.CalledProcessError as exc: + raise SystemExit(exc.returncode) from exc + log("build finished") + + for coin, blockbook_package_name, backend_package_name, build_backend in zip( + coins, blockbook_package_names, backend_package_names, build_backend_flags + ): + blockbook_package_file: Path | None = None + backend_package_file: Path | None = None + if blockbook_package_name is not None: + blockbook_package_file = latest_package(f"{blockbook_package_name}_*.deb") + if build_backend and backend_package_name is not None: + backend_package_file = latest_package(f"{backend_package_name}_*.deb") + + target_dir = branch_root / coin + target_dir.mkdir(parents=True, exist_ok=True) + + if blockbook_package_file is not None: + staged_blockbook = target_dir / blockbook_package_file.name + shutil.copy2(blockbook_package_file, staged_blockbook) + log(f"staged {coin} blockbook to {staged_blockbook}") + + if build_backend and backend_package_file is not None: + staged_backend = target_dir / backend_package_file.name + shutil.copy2(backend_package_file, staged_backend) + log(f"staged {coin} backend to {staged_backend}") + + if blockbook_package_file is not None: + log(f"built {coin} blockbook via {blockbook_package_file}") + if backend_package_file is not None: + log(f"built {coin} backend via {backend_package_file}") + if blockbook_package_file is not None: + print(blockbook_package_file) + elif backend_package_file is not None: + print(backend_package_file) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/build_packages_test.py b/.github/scripts/build_packages_test.py new file mode 100644 index 0000000000..7229e606e2 --- /dev/null +++ b/.github/scripts/build_packages_test.py @@ -0,0 +1,349 @@ +import contextlib +import io +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import build_packages + + +def write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + + +class BuildPackagesTest(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.workspace = Path(self.tempdir.name) + self.package_root = self.workspace / "packages" + self.build_dir = self.workspace / "build" + self.package_root.mkdir(parents=True, exist_ok=True) + self.build_dir.mkdir(parents=True, exist_ok=True) + + write_json( + self.workspace / "configs" / "coins" / "base_archive.json", + { + "coin": {"alias": "base_archive"}, + "blockbook": {"package_name": "blockbook-base"}, + "backend": {"package_name": "backend-base"}, + }, + ) + write_json( + self.workspace / "configs" / "coins" / "polygon_archive.json", + { + "coin": {"alias": "polygon_archive_bor"}, + "blockbook": {"package_name": "blockbook-polygon"}, + "backend": {"package_name": "backend-polygon"}, + }, + ) + write_json( + self.workspace / "configs" / "coins" / "ethereum_testnet_sepolia_consensus.json", + { + "coin": {"alias": "ethereum_testnet_sepolia_consensus"}, + "backend": {"package_name": "backend-eth-sepolia-consensus"}, + }, + ) + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def run_build( + self, + *, + coin: str, + build_env: str | None = None, + rpc_env: str | None = None, + rpc_url: str | None = None, + backend_mode: str = "auto", + ) -> tuple[list[str], str]: + commands: list[list[str]] = [] + outputs = { + "deb-base_archive": ("blockbook-base_1.0_amd64.deb", "backend-base_1.0_amd64.deb"), + "deb-blockbook-base_archive": ("blockbook-base_1.0_amd64.deb", None), + "deb-polygon_archive": ( + "blockbook-polygon_1.0_amd64.deb", + "backend-polygon_1.0_amd64.deb", + ), + "deb-blockbook-polygon_archive": ("blockbook-polygon_1.0_amd64.deb", None), + "deb-backend-ethereum_testnet_sepolia_consensus": ( + None, + "backend-eth-sepolia-consensus_1.0_amd64.deb", + ), + } + + def fake_run(cmd, check, **kwargs): + commands.append(list(cmd)) + if cmd[:1] == ["make"]: + target = next(part for part in cmd[1:] if not part.startswith("PORTABLE=")) + blockbook_name, backend_name = outputs[target] + if blockbook_name: + (self.build_dir / blockbook_name).write_text("blockbook", encoding="utf-8") + if backend_name: + (self.build_dir / backend_name).write_text("backend", encoding="utf-8") + return None + raise AssertionError(f"unexpected subprocess call: {cmd}") + + env = { + "BRANCH_OR_TAG": "feature/test-branch", + "BB_PACKAGE_ROOT": str(self.package_root), + } + if build_env is not None: + env["BB_BUILD_ENV"] = build_env + if rpc_env is not None and rpc_url is not None: + env[rpc_env] = rpc_url + stdout = io.StringIO() + old_cwd = Path.cwd() + try: + os.chdir(self.workspace) + with patch.dict(os.environ, env, clear=True), patch("build_packages.subprocess.run", side_effect=fake_run): + with contextlib.redirect_stdout(stdout): + argv = ["--backend-mode", backend_mode, coin] + build_packages.main(argv) + finally: + os.chdir(old_cwd) + + return commands[-1], stdout.getvalue().strip() + + def test_builds_backend_when_rpc_url_uses_localhost(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="http://localhost:18026", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertTrue((staged_dir / "backend-base_1.0_amd64.deb").is_file()) + + def test_builds_backend_when_rpc_url_uses_loopback_ip(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="http://127.0.0.1:18026", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertTrue((staged_dir / "backend-base_1.0_amd64.deb").is_file()) + + def test_skips_backend_when_rpc_url_host_is_remote(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="https://rpc.example.invalid/", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-blockbook-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertFalse((staged_dir / "backend-base_1.0_amd64.deb").exists()) + + def test_skips_backend_when_localhost_only_appears_in_rpc_path(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="https://rpc.example.invalid/localhost", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-blockbook-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertFalse((staged_dir / "backend-base_1.0_amd64.deb").exists()) + + def test_builds_backend_when_rpc_url_env_is_missing(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertTrue((staged_dir / "backend-base_1.0_amd64.deb").is_file()) + + def test_builds_backend_when_rpc_url_env_is_empty(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertTrue((staged_dir / "backend-base_1.0_amd64.deb").is_file()) + + def test_skips_backend_when_rpc_url_env_is_non_empty_but_invalid(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="not-a-loopback-url", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-blockbook-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertFalse((staged_dir / "backend-base_1.0_amd64.deb").exists()) + + def test_backend_mode_always_overrides_localhost_detection(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="https://rpc.example.invalid/", + backend_mode="always", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "backend-base_1.0_amd64.deb").is_file()) + + def test_backend_mode_never_forces_blockbook_only(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="http://localhost:18026", + backend_mode="never", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-blockbook-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertFalse((staged_dir / "backend-base_1.0_amd64.deb").exists()) + + def test_staging_uses_config_name_while_rpc_env_uses_alias(self) -> None: + make_cmd, output = self.run_build( + coin="polygon_archive", + rpc_env="BB_DEV_RPC_URL_HTTP_polygon_archive_bor", + rpc_url="http://localhost:8545", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-polygon_archive"]) + self.assertEqual(output, "build/blockbook-polygon_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "polygon_archive" + alias_dir = self.package_root / "feature-test-branch" / "polygon_archive_bor" + self.assertTrue((staged_dir / "blockbook-polygon_1.0_amd64.deb").is_file()) + self.assertTrue((staged_dir / "backend-polygon_1.0_amd64.deb").is_file()) + self.assertFalse(alias_dir.exists()) + + def test_prod_build_env_uses_prod_rpc_url_prefix(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + build_env="prod", + rpc_env="BB_PROD_RPC_URL_HTTP_base_archive", + rpc_url="https://rpc.example.invalid/", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-blockbook-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertFalse((staged_dir / "backend-base_1.0_amd64.deb").exists()) + + def test_prod_build_env_ignores_dev_rpc_url_prefix(self) -> None: + make_cmd, output = self.run_build( + coin="base_archive", + build_env="prod", + rpc_env="BB_DEV_RPC_URL_HTTP_base_archive", + rpc_url="https://rpc.example.invalid/", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-base_archive"]) + self.assertEqual(output, "build/blockbook-base_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "base_archive" + self.assertTrue((staged_dir / "blockbook-base_1.0_amd64.deb").is_file()) + self.assertTrue((staged_dir / "backend-base_1.0_amd64.deb").is_file()) + + def test_backend_only_coin_builds_backend_target(self) -> None: + make_cmd, output = self.run_build( + coin="ethereum_testnet_sepolia_consensus", + backend_mode="auto", + ) + + self.assertEqual(make_cmd, ["make", "PORTABLE=1", "deb-backend-ethereum_testnet_sepolia_consensus"]) + self.assertEqual(output, "build/backend-eth-sepolia-consensus_1.0_amd64.deb") + staged_dir = self.package_root / "feature-test-branch" / "ethereum_testnet_sepolia_consensus" + self.assertTrue((staged_dir / "backend-eth-sepolia-consensus_1.0_amd64.deb").is_file()) + + def test_fails_on_invalid_build_env(self) -> None: + env = { + "BRANCH_OR_TAG": "feature/test-branch", + "BB_PACKAGE_ROOT": str(self.package_root), + "BB_BUILD_ENV": "staging", + } + old_cwd = Path.cwd() + try: + os.chdir(self.workspace) + with patch.dict(os.environ, env, clear=True), patch("build_packages.subprocess.run"): + with self.assertRaises(SystemExit): + build_packages.main(["base_archive"]) + finally: + os.chdir(old_cwd) + + def test_fails_when_package_root_is_missing(self) -> None: + env = { + "BRANCH_OR_TAG": "feature/test-branch", + "BB_PACKAGE_ROOT": str(self.workspace / "missing-packages"), + } + old_cwd = Path.cwd() + try: + os.chdir(self.workspace) + with patch.dict(os.environ, env, clear=True), patch("build_packages.subprocess.run"): + with self.assertRaises(SystemExit): + build_packages.main(["base_archive"]) + finally: + os.chdir(old_cwd) + + def test_fails_when_package_root_is_not_runner_owned(self) -> None: + branch_root = self.package_root / "feature-test-branch" + original_stat = build_packages.Path.stat + + def fake_stat(path_obj: Path, *args, **kwargs): + result = original_stat(path_obj, *args, **kwargs) + if path_obj == self.package_root: + return os.stat_result( + ( + result.st_mode, + result.st_ino, + result.st_dev, + result.st_nlink, + result.st_uid + 1, + result.st_gid, + result.st_size, + result.st_atime, + result.st_mtime, + result.st_ctime, + ) + ) + return result + + with patch("build_packages.Path.stat", autospec=True, side_effect=fake_stat): + with self.assertRaises(SystemExit): + build_packages.ensure_writable_dir(branch_root) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/build_plan.py b/.github/scripts/build_plan.py new file mode 100644 index 0000000000..22ceb38e6b --- /dev/null +++ b/.github/scripts/build_plan.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path + +from runner import ( + PRODUCTION_RUNNER, + ValidationError, + build_runner_labels, + fail, + load_coin_context, + log, + parse_json_object, + resolve_build_selection, +) + + +def main() -> None: + workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() + try: + vars_map = parse_json_object(os.environ.get("VARS_JSON", "{}"), "VARS_JSON") + except ValidationError as exc: + fail(str(exc)) + coins_input = os.environ.get("COINS_INPUT", "") + build_env = os.environ.get("BUILD_ENV", "dev").strip().lower() + + try: + context = load_coin_context(workspace, vars_map) + selection = resolve_build_selection(context, coins_input, build_env) + except ValidationError as exc: + fail(str(exc)) + + grouped_by_runner = {} + for coin in selection.coins: + configured_runner = context.runner_map[coin] + runner = PRODUCTION_RUNNER if build_env == "prod" else configured_runner + grouped_by_runner.setdefault(runner, []).append(coin) + + runner_matrix = [] + for runner in sorted(grouped_by_runner): + coins = grouped_by_runner[runner] + runner_matrix.append( + { + "runner": runner, + "coins": coins, + "coins_csv": ",".join(coins), + "labels_json": json.dumps(build_runner_labels(runner, build_env), separators=(",", ":")), + } + ) + + output_file = os.environ.get("GITHUB_OUTPUT") + if not output_file: + fail("GITHUB_OUTPUT is not set") + + with open(output_file, "a", encoding="utf-8") as out: + out.write(f"runner_matrix={json.dumps(runner_matrix, separators=(',', ':'))}\n") + out.write(f"coins_csv={','.join(selection.coins)}\n") + + log(f"Build env: {build_env}") + if selection.skipped_prod_only and selection.requested_all: + log("Skipped prod-only coins for env=dev: " + ", ".join(selection.skipped_prod_only)) + log("Selected coins: " + ", ".join(selection.coins)) + for item in runner_matrix: + log( + f"Runner {item['runner']} labels={item['labels_json']}: " + + ", ".join(item["coins"]) + ) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/coin_rpc.py b/.github/scripts/coin_rpc.py new file mode 100644 index 0000000000..4f11a95814 --- /dev/null +++ b/.github/scripts/coin_rpc.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import os +from pathlib import Path +from urllib.parse import urlparse + +BUILD_ENV_VAR = "BB_BUILD_ENV" +BUILD_ENV_DEV = "dev" +BUILD_ENV_PROD = "prod" + + +class CoinRPCError(ValueError): + pass + + +def load_config(path: Path) -> dict: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + raise CoinRPCError(f"cannot read {path}: {exc}") from exc + if not isinstance(payload, dict): + raise CoinRPCError(f"invalid config {path}: expected a JSON object") + return payload + + +def get_coin_alias(config: dict, coin: str) -> str: + value = config.get("coin", {}).get("alias", coin) + if not isinstance(value, str) or not value.strip(): + raise CoinRPCError(f"coin '{coin}' does not define coin.alias") + return value.strip().lower() + + +def resolve_build_env(raw: str | None = None) -> str: + build_env = raw or os.environ.get(BUILD_ENV_VAR, "") + build_env = build_env.strip().lower() + if not build_env: + return BUILD_ENV_DEV + if build_env in {BUILD_ENV_DEV, BUILD_ENV_PROD}: + return build_env + raise CoinRPCError( + f"invalid {BUILD_ENV_VAR} value '{build_env}', expected 'dev' or 'prod'" + ) + + +def rpc_url_env_name(alias: str, build_env: str) -> str: + prefix = "BB_DEV_RPC_URL_HTTP_" if build_env == BUILD_ENV_DEV else "BB_PROD_RPC_URL_HTTP_" + return f"{prefix}{alias.replace('-', '_')}" + + +def rpc_hostname(url: str) -> str: + if not url: + return "" + try: + return urlparse(url).hostname or "" + except ValueError: + return "" diff --git a/.github/scripts/deploy_plan.py b/.github/scripts/deploy_plan.py new file mode 100644 index 0000000000..f917571488 --- /dev/null +++ b/.github/scripts/deploy_plan.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +import json +import os +import re +from pathlib import Path + +from runner import ( + ValidationError, + fail, + load_coin_context, + load_test_coin_name, + log, + parse_json_object, + require_coin_config, + resolve_deploy_selection, +) + + +def matchable_name(coin: str) -> str: + marker = "_testnet" + idx = coin.find(marker) + if idx != -1: + return coin[:idx] + "=test" + return coin + "=main" + + +def main() -> None: + workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() + try: + vars_map = parse_json_object(os.environ.get("VARS_JSON", "{}"), "VARS_JSON") + except ValidationError as exc: + fail(str(exc)) + coins_input = os.environ.get("COINS_INPUT", "") + + try: + context = load_coin_context(workspace, vars_map, include_deployability=True) + requested = resolve_deploy_selection(context, coins_input) + except ValidationError as exc: + fail(str(exc)) + + runner_matrix = [] + e2e_names = [] + test_coins = [] + + try: + for coin in requested: + configured_runner = context.runner_map[coin] + coin_cfg_path = require_coin_config(workspace, coin) + lookup_coin = load_test_coin_name(coin_cfg_path) + runner_matrix.append({"coin": coin, "runner": configured_runner}) + e2e_names.append(matchable_name(lookup_coin)) + test_coins.append(lookup_coin) + except ValidationError as exc: + fail(str(exc)) + + unique_names = sorted(set(e2e_names)) + if not unique_names: + fail("no coins selected after validation") + unique_test_coins = sorted(set(test_coins)) + escaped = [re.escape(name) for name in unique_names] + e2e_regex = "TestIntegration/(" + "|".join(escaped) + ")/api" + + output_file = os.environ.get("GITHUB_OUTPUT") + if not output_file: + fail("GITHUB_OUTPUT is not set") + + with open(output_file, "a", encoding="utf-8") as out: + out.write(f"runner_matrix={json.dumps(runner_matrix, separators=(',', ':'))}\n") + out.write(f"e2e_regex={e2e_regex}\n") + out.write(f"coins_csv={','.join(requested)}\n") + out.write(f"test_coins_csv={','.join(unique_test_coins)}\n") + + log("Selected coins: " + ", ".join(requested)) + log("E2E regex: " + e2e_regex) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/list_coins.py b/.github/scripts/list_coins.py new file mode 100644 index 0000000000..384f788721 --- /dev/null +++ b/.github/scripts/list_coins.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +import argparse +import os +from pathlib import Path + +from runner import ValidationError, fail, load_coin_context_from_repo + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="List selectable or dev-buildable coins from BB_RUNNER_* repository variables." + ) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument( + "--all", + action="store_true", + help="print all selectable coins (runner-mapped coins with existing configs)", + ) + mode.add_argument( + "--dev", + action="store_true", + help="print dev-buildable coins (selectable coins not mapped to production_builder)", + ) + parser.add_argument( + "--repo", + default="trezor/blockbook", + help="repository to query when VARS_JSON is not set (default: trezor/blockbook)", + ) + parser.add_argument( + "--format", + choices=("csv", "lines"), + default="csv", + help="output format (default: csv)", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() + + try: + context = load_coin_context_from_repo( + workspace, + args.repo, + os.environ.get("VARS_JSON"), + include_deployability=False, + ) + except ValidationError as exc: + fail(str(exc)) + + coins = context.all_coins if args.all else context.dev_buildable_coins + if args.format == "lines": + for coin in coins: + print(coin) + else: + print(",".join(coins)) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/run.py b/.github/scripts/run.py new file mode 100755 index 0000000000..fa41f80a91 --- /dev/null +++ b/.github/scripts/run.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +from pathlib import Path + +from runner import ( + ValidationError, + load_coin_context_from_repo, + resolve_build_selection, + resolve_deploy_selection, +) + + +SCRIPT_PATH = Path(__file__).resolve() +SCRIPT_NAME = SCRIPT_PATH.name +CLI_NAME = "bbcli" +REPO_ROOT = SCRIPT_PATH.parents[2] +DEFAULT_REPO = "trezor/blockbook" + + +class Formatter(argparse.RawTextHelpFormatter): + pass + + +def die(message: str) -> None: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def current_branch() -> str: + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return "" + return result.stdout.strip() + + +def workflow_ref_default() -> str: + return current_branch() + + +def workflow_ref_display() -> str: + return workflow_ref_default() or "" + + +def load_context(repo: str): + try: + return load_coin_context_from_repo( + REPO_ROOT, + repo, + os.environ.get("VARS_JSON"), + include_deployability=False, + ) + except ValidationError as exc: + die(str(exc)) + + +def load_deploy_context(repo: str): + try: + return load_coin_context_from_repo( + REPO_ROOT, + repo, + os.environ.get("VARS_JSON"), + include_deployability=True, + ) + except ValidationError as exc: + die(str(exc)) + + +def build_command( + repo: str, + workflow_ref: str, + branch_or_tag: str, + build_env: str, + coins: str, + backend_mode: str, +) -> list[str]: + cmd = [ + "gh", + "workflow", + "run", + "deploy.yml", + "-R", + repo, + "--ref", + workflow_ref, + "-f", + "mode=build", + "-f", + f"env={build_env}", + "-f", + f"coins={coins}", + ] + if backend_mode != "auto": + cmd += ["-f", f"backend_mode={backend_mode}"] + if branch_or_tag: + cmd += ["-f", f"branch_or_tag={branch_or_tag}"] + return cmd + + +def deploy_command( + repo: str, + workflow_ref: str, + branch_or_tag: str, + coins: str, +) -> list[str]: + cmd = [ + "gh", + "workflow", + "run", + "deploy.yml", + "-R", + repo, + "--ref", + workflow_ref, + "-f", + "mode=deploy", + "-f", + "env=dev", + "-f", + f"coins={coins}", + ] + if branch_or_tag: + cmd += ["-f", f"branch_or_tag={branch_or_tag}"] + return cmd + + +def print_or_run(cmd: list[str], execute: bool) -> None: + if execute: + subprocess.run(cmd, check=True) + return + print(shlex.join(cmd)) + + +def add_common_workflow_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--repo", + default=DEFAULT_REPO, + help=f"GitHub repository (default: {DEFAULT_REPO})", + ) + parser.add_argument( + "--workflow-ref", + default=workflow_ref_default(), + help="Branch/tag/commit that contains deploy.yml (default: current git branch)", + ) + parser.add_argument( + "--branch-or-tag", + default=workflow_ref_default(), + help="Branch or tag to run the workflow on (default: current git branch)", + ) + parser.add_argument( + "--run", + action="store_true", + help="Execute the generated gh command instead of printing it", + ) + + +def handle_help(args: argparse.Namespace) -> None: + parser = args.parser_map[args.topic] if args.topic else args.parser + parser.print_help() + + +def handle_list(args: argparse.Namespace) -> None: + context = load_context(args.repo) + coins = context.dev_buildable_coins if args.env == "dev" else context.all_coins + + if args.format == "csv": + print(",".join(coins)) + return + for coin in coins: + print(coin) + + +def handle_build(args: argparse.Namespace) -> None: + workflow_ref = args.workflow_ref or current_branch() + if not workflow_ref: + die("could not determine current git branch; pass --workflow-ref") + + context = load_context(args.repo) + try: + selection = resolve_build_selection(context, args.coins, args.env) + except ValidationError as exc: + die(str(exc)) + + print_or_run( + build_command( + args.repo, + workflow_ref, + args.branch_or_tag, + args.env, + "ALL" if selection.requested_all else ",".join(selection.coins), + args.backend_mode, + ), + args.run, + ) + + +def handle_deploy(args: argparse.Namespace) -> None: + workflow_ref = args.workflow_ref or current_branch() + if not workflow_ref: + die("could not determine current git branch; pass --workflow-ref") + + context = load_deploy_context(args.repo) + try: + coins = resolve_deploy_selection(context, args.coins) + except ValidationError as exc: + die(str(exc)) + + print_or_run( + deploy_command(args.repo, workflow_ref, args.branch_or_tag, ",".join(coins)), + args.run, + ) + + +def latest_run_id(repo: str) -> str: + try: + result = subprocess.run( + [ + "gh", + "run", + "list", + "-R", + repo, + "--workflow", + "deploy.yml", + "--limit", + "1", + "--json", + "databaseId", + "--jq", + ".[0].databaseId", + ], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError: + die("gh CLI not found") + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or str(exc)).strip() + die(f"failed to fetch latest Build / Deploy run: {details}") + return result.stdout.strip() + + +def run_metadata(repo: str, run_id: str) -> dict: + try: + result = subprocess.run( + [ + "gh", + "run", + "view", + "-R", + repo, + run_id, + "--json", + "status,conclusion", + ], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError: + die("gh CLI not found") + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or str(exc)).strip() + die(f"failed to fetch Build / Deploy run metadata: {details}") + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + die(f"failed to decode Build / Deploy run metadata: {exc}") + if not isinstance(payload, dict): + die("Build / Deploy run metadata must be a JSON object") + return payload + + +def show_run_logs(repo: str, run_id: str) -> None: + subprocess.run(["gh", "run", "view", "-R", repo, run_id, "--log"], check=True) + + +def handle_watch(args: argparse.Namespace) -> None: + run_id = args.run_id or latest_run_id(args.repo) + if not run_id or run_id == "null": + die("no Build / Deploy workflow runs found") + metadata = run_metadata(args.repo, run_id) + status = str(metadata.get("status") or "").strip().lower() + if status == "completed": + show_run_logs(args.repo, run_id) + return + subprocess.run(["gh", "run", "watch", "-R", args.repo, run_id], check=True) + + +def create_parser() -> tuple[argparse.ArgumentParser, dict[str, argparse.ArgumentParser]]: + workflow_ref = workflow_ref_display() + parser = argparse.ArgumentParser( + prog=CLI_NAME, + formatter_class=Formatter, + description="Helper for the Build / Deploy GitHub workflow.", + epilog=( + "Defaults:\n" + f" --repo: {DEFAULT_REPO}\n" + f" --workflow-ref: {workflow_ref}\n" + f" --branch-or-tag: {workflow_ref}\n" + " --env: dev\n\n" + "Use ' --help' for command-specific options." + ), + ) + + subparsers = parser.add_subparsers(dest="command") + parser_map: dict[str, argparse.ArgumentParser] = {} + + help_parser = subparsers.add_parser( + "help", + formatter_class=Formatter, + help="Show top-level or subcommand help.", + description="Show top-level help or help for a specific subcommand.", + ) + help_parser.add_argument("topic", nargs="?", choices=["list", "build", "deploy", "watch"]) + help_parser.set_defaults(func=handle_help) + parser_map["help"] = help_parser + + list_parser = subparsers.add_parser( + "list", + formatter_class=Formatter, + help="List coins available for dev or prod builds.", + description="List available coins for a build environment.", + ) + list_parser.add_argument( + "--repo", + default=DEFAULT_REPO, + help=f"GitHub repository (default: {DEFAULT_REPO})", + ) + list_parser.add_argument( + "--env", + choices=("dev", "prod"), + default="dev", + help="Build environment to list coins for (default: dev)", + ) + list_parser.add_argument( + "--format", + choices=("csv", "lines"), + default="lines", + help="Output format (default: lines)", + ) + list_parser.set_defaults(func=handle_list) + parser_map["list"] = list_parser + + build_parser = subparsers.add_parser( + "build", + formatter_class=Formatter, + help="Print or run the Build / Deploy workflow in build mode.", + description=( + "Build Debian packages only.\n" + "- env=dev uses BB_RUNNER_* mapping and ALL skips prod-only coins\n" + "- env=prod builds selected coins on the production-builder runner" + ), + ) + add_common_workflow_args(build_parser) + build_parser.add_argument( + "--coins", + required=True, + help="Required. Coin list, e.g. bitcoin,bsc_archive or ALL", + ) + build_parser.add_argument( + "--env", + choices=("dev", "prod"), + default="dev", + help="Build environment (default: dev)", + ) + build_parser.add_argument( + "--backend-mode", + choices=("auto", "always", "never"), + default="auto", + help=( + "Backend package build mode (default: auto). " + "auto derives from BB_BUILD_ENV plus BB_{DEV|PROD}_RPC_URL_HTTP_; " + "always forces backend builds; never skips backend for coins that also " + "build blockbook, but backend-only coins still build backend." + ), + ) + build_parser.set_defaults(func=handle_build) + parser_map["build"] = build_parser + + deploy_parser = subparsers.add_parser( + "deploy", + formatter_class=Formatter, + help="Print or run the Build / Deploy workflow in deploy mode.", + description=( + "Build, install, restart, wait for sync, then run e2e tests.\n" + "- env is fixed to dev\n" + "- ALL is not accepted\n" + "- coins mapped to production_builder are rejected" + ), + ) + add_common_workflow_args(deploy_parser) + deploy_parser.add_argument( + "--coins", + required=True, + help="Required. Coin list, e.g. bitcoin,bsc_archive", + ) + deploy_parser.set_defaults(func=handle_deploy) + parser_map["deploy"] = deploy_parser + + watch_parser = subparsers.add_parser( + "watch", + formatter_class=Formatter, + help="Watch the latest Build / Deploy workflow run or a specific run ID.", + description="Watch the latest Build / Deploy workflow run or a specific run ID.", + ) + watch_parser.add_argument("run_id", nargs="?", help="Optional workflow run ID to watch") + watch_parser.add_argument( + "--repo", + default=DEFAULT_REPO, + help=f"GitHub repository (default: {DEFAULT_REPO})", + ) + watch_parser.set_defaults(func=handle_watch) + parser_map["watch"] = watch_parser + + return parser, parser_map + + +def main(argv: list[str] | None = None) -> None: + parser, parser_map = create_parser() + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + if not getattr(args, "command", None): + parser.print_help() + return + args.parser = parser + args.parser_map = parser_map + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/run_test.py b/.github/scripts/run_test.py new file mode 100644 index 0000000000..b44788c2e4 --- /dev/null +++ b/.github/scripts/run_test.py @@ -0,0 +1,91 @@ +import importlib.util +import subprocess +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + + +SCRIPT = Path(__file__).with_name("run.py") +SCRIPT_DIR = SCRIPT.parent + + +def load_run_module(): + sys.path.insert(0, str(SCRIPT_DIR)) + try: + spec = importlib.util.spec_from_file_location("run_under_test", SCRIPT) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + check=False, + capture_output=True, + text=True, + ) + + +class RunCliHelpTest(unittest.TestCase): + def test_top_level_help_mentions_subcommand_help(self) -> None: + result = run_cli("--help") + self.assertEqual(result.returncode, 0) + self.assertIn("usage: bbcli", result.stdout) + self.assertIn("Use ' --help' for command-specific options.", result.stdout) + + def test_build_help_is_subcommand_specific(self) -> None: + result = run_cli("build", "--help") + self.assertEqual(result.returncode, 0) + self.assertIn("--backend-mode", result.stdout) + self.assertIn("--coins", result.stdout) + self.assertNotIn("--format", result.stdout) + + def test_list_help_is_subcommand_specific(self) -> None: + result = run_cli("list", "--help") + self.assertEqual(result.returncode, 0) + self.assertIn("--format", result.stdout) + self.assertNotIn("--backend-mode", result.stdout) + + def test_help_subcommand_can_show_build_help(self) -> None: + result = run_cli("help", "build") + self.assertEqual(result.returncode, 0) + self.assertIn("--backend-mode", result.stdout) + self.assertIn("Build Debian packages only.", result.stdout) + + +class RunWatchTest(unittest.TestCase): + def test_watch_completed_run_shows_logs(self) -> None: + module = load_run_module() + args = SimpleNamespace(run_id="123", repo="trezor/blockbook") + + with patch.object(module, "run_metadata", return_value={"status": "completed"}), patch.object( + module, "show_run_logs" + ) as show_logs, patch.object(module.subprocess, "run") as subproc_run: + module.handle_watch(args) + + show_logs.assert_called_once_with("trezor/blockbook", "123") + subproc_run.assert_not_called() + + def test_watch_in_progress_run_uses_gh_watch(self) -> None: + module = load_run_module() + args = SimpleNamespace(run_id="123", repo="trezor/blockbook") + + with patch.object(module, "run_metadata", return_value={"status": "in_progress"}), patch.object( + module, "show_run_logs" + ) as show_logs, patch.object(module.subprocess, "run") as subproc_run: + module.handle_watch(args) + + show_logs.assert_not_called() + subproc_run.assert_called_once_with( + ["gh", "run", "watch", "-R", "trezor/blockbook", "123"], check=True + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/runner.py b/.github/scripts/runner.py new file mode 100644 index 0000000000..9f7f77fa69 --- /dev/null +++ b/.github/scripts/runner.py @@ -0,0 +1,404 @@ +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +PRODUCTION_RUNNER = "production_builder" +PRODUCTION_RUNNER_LABEL = "production-builder" +LOG_PREFIX = "CI/CD Pipeline:" + + +class ValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class CoinContext: + runner_map: dict[str, str] + all_coins: list[str] + dev_buildable_coins: list[str] + has_deployability: bool + deployable_coins: list[str] + deployability_errors: dict[str, str] + + +@dataclass(frozen=True) +class BuildSelection: + requested_all: bool + coins: list[str] + skipped_prod_only: list[str] + + +def fail(message: str) -> None: + print(f"{LOG_PREFIX} error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def log(message: str) -> None: + print(f"{LOG_PREFIX} {message}", flush=True) + + +def parse_json_object(raw: str, description: str) -> dict: + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValidationError(f"cannot decode {description}: {exc}") from exc + if not isinstance(payload, dict): + raise ValidationError(f"{description} must contain a JSON object") + return payload + + +def load_vars_map(repo: str, raw: str | None = None) -> dict: + text = raw if raw is not None else os.environ.get("VARS_JSON", "") + text = text.strip() if text else "" + if text: + return parse_json_object(text, "VARS_JSON") + + try: + result = subprocess.run( + ["gh", "variable", "list", "-R", repo, "--json", "name,value"], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise ValidationError("gh CLI not found and VARS_JSON is not set") from exc + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or str(exc)).strip() + raise ValidationError( + f"failed to list repository variables for {repo}: {details}" + ) from exc + + try: + rows = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise ValidationError(f"cannot decode gh variable list output: {exc}") from exc + + if not isinstance(rows, list): + raise ValidationError("gh variable list output must be a JSON array") + + mapping = {} + for row in rows: + if not isinstance(row, dict): + continue + name = row.get("name") + if not isinstance(name, str): + continue + mapping[name] = row.get("value") + return mapping + + +def load_runner_map(vars_map: dict) -> dict[str, str]: + prefix = "BB_RUNNER_" + mapping = {} + for key, value in vars_map.items(): + if not key.startswith(prefix): + continue + coin = key[len(prefix):].strip().lower() + runner = "" if value is None else str(value).strip() + if coin and runner: + mapping[coin] = runner + return mapping + + +def parse_coin_tokens(raw: str, *, allow_all: bool) -> tuple[bool, list[str]]: + text = raw.strip() + if not text: + raise ValidationError("coins input is empty") + + if text.upper() == "ALL": + if not allow_all: + raise ValidationError("ALL is only supported in build mode") + return True, [] + + tokens = [part.strip() for part in re.split(r"[\s,]+", text) if part.strip()] + if not tokens: + raise ValidationError("coins input resolved to an empty list") + if any(token.upper() == "ALL" for token in tokens): + if not allow_all: + raise ValidationError("ALL is only supported in build mode") + raise ValidationError("ALL must be used alone") + + seen = set() + result = [] + for coin in tokens: + normalized = coin.lower() + if normalized in seen: + continue + seen.add(normalized) + result.append(normalized) + return False, result + + +def is_production_only_runner(runner: str) -> bool: + return runner == PRODUCTION_RUNNER + + +def build_runner_labels(runner: str, build_env: str) -> list[str]: + if build_env == "prod": + return ["self-hosted", PRODUCTION_RUNNER_LABEL] + return ["self-hosted", "bb-dev-selfhosted", runner] + + +def coin_config_path(workspace: Path, coin: str) -> Path: + return workspace / "configs" / "coins" / f"{coin}.json" + + +def require_coin_config(workspace: Path, coin: str) -> Path: + config_path = coin_config_path(workspace, coin) + if not config_path.exists(): + raise ValidationError(f"unknown coin '{coin}' (missing {config_path})") + return config_path + + +def normalize_coin_name(workspace: Path, coin: str) -> str: + if coin_config_path(workspace, coin).exists(): + return coin + if "_" in coin: + candidate = coin.replace("_", "-") + if coin_config_path(workspace, candidate).exists(): + return candidate + return coin + + +def validate_runner_map_configs(workspace: Path, runner_map: dict[str, str]) -> None: + missing = [] + for coin in sorted(runner_map): + config_path = coin_config_path(workspace, coin) + if not config_path.exists(): + missing.append(f"{coin} ({config_path})") + + if missing: + raise ValidationError( + "BB_RUNNER_* entries without matching configs/coins/.json: " + + ", ".join(missing) + ) + + +def normalize_runner_map(workspace: Path, runner_map: dict[str, str]) -> dict[str, str]: + normalized: dict[str, str] = {} + for coin, runner in runner_map.items(): + candidate = normalize_coin_name(workspace, coin) + if candidate in normalized and normalized[candidate] != runner: + raise ValidationError( + f"BB_RUNNER entries collide for '{candidate}' (check {coin})" + ) + normalized[candidate] = runner + return normalized + + +def load_json_file(path: Path, description: str) -> dict: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + raise ValidationError(f"cannot read {path}: {exc}") from exc + if not isinstance(payload, dict): + raise ValidationError(f"invalid {description} {path}: expected a JSON object") + return payload + + +def load_test_coin_name(config_path: Path) -> str: + config = load_json_file(config_path, "config") + + coin_cfg = config.get("coin") + if not isinstance(coin_cfg, dict): + raise ValidationError(f"invalid config {config_path}: missing coin section") + + test_name = coin_cfg.get("test_name") + if test_name is None: + return config_path.stem + if not isinstance(test_name, str): + raise ValidationError(f"invalid config {config_path}: coin.test_name must be a string") + + test_name = test_name.strip() + if not test_name: + raise ValidationError(f"invalid config {config_path}: coin.test_name must not be empty") + return test_name + + +def list_all_coins(workspace: Path, runner_map: dict[str, str]) -> list[str]: + return sorted(runner_map) + + +def load_tests_config(workspace: Path) -> dict: + tests_path = workspace / "tests" / "tests.json" + return load_json_file(tests_path, "tests config") + + +def deployability_error( + workspace: Path, + runner_map: dict[str, str], + coin: str, + tests_cfg: dict | None = None, +) -> str | None: + if coin not in runner_map: + return f"missing BB_RUNNER_{coin}" + + configured_runner = runner_map[coin] + if is_production_only_runner(configured_runner): + return ( + f"coin '{coin}' is not deployable in dev; " + f"BB_RUNNER_{coin} points to {configured_runner}" + ) + + config_path = coin_config_path(workspace, coin) + if not config_path.exists(): + return f"unknown coin '{coin}' (missing {config_path})" + + if tests_cfg is None: + tests_cfg = load_tests_config(workspace) + + lookup_coin = load_test_coin_name(config_path) + test_cfg = tests_cfg.get(lookup_coin) + if not isinstance(test_cfg, dict) or "connectivity" not in test_cfg: + return ( + f"coin '{coin}' maps to test coin '{lookup_coin}' " + "which has no connectivity tests in tests/tests.json" + ) + + return None + + +def load_coin_context( + workspace: Path, + vars_map: dict, + *, + include_deployability: bool = False, +) -> CoinContext: + runner_map = load_runner_map(vars_map) + runner_map = normalize_runner_map(workspace, runner_map) + if not runner_map: + raise ValidationError("no BB_RUNNER_* variables found") + + validate_runner_map_configs(workspace, runner_map) + all_coins = list_all_coins(workspace, runner_map) + dev_buildable_coins = [ + coin for coin in all_coins if not is_production_only_runner(runner_map[coin]) + ] + + deployability_errors = {} + deployable_coins = [] + if include_deployability: + tests_cfg = load_tests_config(workspace) + for coin in all_coins: + error = deployability_error(workspace, runner_map, coin, tests_cfg) + if error is None: + deployable_coins.append(coin) + else: + deployability_errors[coin] = error + + return CoinContext( + runner_map=runner_map, + all_coins=all_coins, + dev_buildable_coins=dev_buildable_coins, + has_deployability=include_deployability, + deployable_coins=deployable_coins, + deployability_errors=deployability_errors, + ) + + +def load_coin_context_from_repo( + workspace: Path, + repo: str, + raw_vars_json: str | None = None, + *, + include_deployability: bool = False, +) -> CoinContext: + return load_coin_context( + workspace, + load_vars_map(repo, raw_vars_json), + include_deployability=include_deployability, + ) + + +def resolve_build_selection( + context: CoinContext, + raw: str, + build_env: str, +) -> BuildSelection: + if build_env not in {"dev", "prod"}: + raise ValidationError(f"invalid build env '{build_env}', expected 'dev' or 'prod'") + + requested_all, requested = parse_coin_tokens(raw, allow_all=True) + if not requested_all: + requested = [normalize_coin_name(Path.cwd(), coin) for coin in requested] + selected = context.all_coins if requested_all else requested + + unknown = [coin for coin in selected if coin not in context.all_coins] + if unknown: + raise ValidationError( + f"unknown build coin(s): {', '.join(unknown)}. " + f"all selectable coins: {','.join(context.all_coins)}" + ) + + if build_env == "prod": + if not selected: + raise ValidationError("no coins selected after validation") + return BuildSelection(requested_all=requested_all, coins=selected, skipped_prod_only=[]) + + skipped_prod_only = [ + coin for coin in selected if coin not in context.dev_buildable_coins + ] + if skipped_prod_only and not requested_all: + noun = "coin" if len(skipped_prod_only) == 1 else "coins" + pronoun = "it" if len(skipped_prod_only) == 1 else "them" + raise ValidationError( + f"{noun} not available in build env=dev: {', '.join(skipped_prod_only)}. " + f"dev-buildable coins: {','.join(context.dev_buildable_coins)}. " + f"use --env prod to build {pronoun}" + ) + + coins = [ + coin for coin in selected if coin in context.dev_buildable_coins + ] + if not coins: + raise ValidationError("no coins selected after filtering out prod-only coins for env=dev") + + return BuildSelection( + requested_all=requested_all, + coins=coins, + skipped_prod_only=skipped_prod_only, + ) + + +def resolve_deploy_selection(context: CoinContext, raw: str) -> list[str]: + if not context.has_deployability: + raise ValidationError("deploy selection requires deployability context") + + if raw.strip().upper() == "ALL": + raise ValidationError( + "deploy does not support ALL; " + f"deployable coins: {','.join(context.deployable_coins)}" + ) + + requested_all, requested = parse_coin_tokens(raw, allow_all=False) + if requested_all: + raise ValidationError( + "deploy does not support ALL; " + f"deployable coins: {','.join(context.deployable_coins)}" + ) + requested = [normalize_coin_name(Path.cwd(), coin) for coin in requested] + + unknown = [coin for coin in requested if coin not in context.all_coins] + if unknown: + raise ValidationError( + f"unknown deploy coin(s): {', '.join(unknown)}. " + f"all selectable coins: {','.join(context.all_coins)}" + ) + + not_deployable = [coin for coin in requested if coin not in context.deployable_coins] + if not_deployable: + reasons = [ + context.deployability_errors.get(coin, f"coin '{coin}' is not deployable") + for coin in not_deployable + ] + raise ValidationError( + f"coin(s) not deployable: {', '.join(not_deployable)}. " + f"reasons: {' | '.join(reasons)}. " + f"deployable coins: {','.join(context.deployable_coins)}" + ) + + return requested diff --git a/.github/scripts/runner_test.py b/.github/scripts/runner_test.py new file mode 100644 index 0000000000..2d8c489163 --- /dev/null +++ b/.github/scripts/runner_test.py @@ -0,0 +1,119 @@ +import tempfile +import unittest +from pathlib import Path + +from runner import ( + ValidationError, + load_coin_context, + resolve_build_selection, + resolve_deploy_selection, +) + + +def write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +class RunnerSelectionTest(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.workspace = Path(self.tempdir.name) + + write_text( + self.workspace / "configs" / "coins" / "dogecoin.json", + '{"coin":{"name":"Dogecoin"}}', + ) + write_text( + self.workspace / "configs" / "coins" / "base_archive.json", + '{"coin":{"test_name":"base"}}', + ) + write_text( + self.workspace / "configs" / "coins" / "polygon_archive.json", + '{"coin":{"test_name":"polygon"}}', + ) + write_text( + self.workspace / "configs" / "coins" / "ethereum-classic.json", + '{"coin":{"test_name":"ethereum_classic"}}', + ) + write_text( + self.workspace / "tests" / "tests.json", + '{"dogecoin":{"connectivity":{}},"base":{"connectivity":{}},"polygon":{"connectivity":{}},"ethereum_classic":{"connectivity":{}}}', + ) + + self.valid_vars_map = { + "BB_RUNNER_DOGECOIN": "blockbook-dev", + "BB_RUNNER_BASE_ARCHIVE": "blockbook-dev3", + "BB_RUNNER_POLYGON_ARCHIVE": "production_builder", + "BB_RUNNER_ETHEREUM_CLASSIC": "blockbook-dev2", + } + self.stale_vars_map = { + **self.valid_vars_map, + "BB_RUNNER_STALE": "blockbook-dev2", + } + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def test_load_coin_context_rejects_runner_mapping_without_config(self) -> None: + with self.assertRaisesRegex( + ValidationError, + r"BB_RUNNER_\* entries without matching configs/coins/\.json: stale ", + ): + load_coin_context(self.workspace, self.stale_vars_map) + + def test_build_all_uses_all_configured_runner_mapped_coins(self) -> None: + context = load_coin_context(self.workspace, self.valid_vars_map) + + selection = resolve_build_selection(context, "ALL", "prod") + + self.assertEqual( + selection.coins, + ["base_archive", "dogecoin", "ethereum-classic", "polygon_archive"], + ) + + def test_build_dev_rejects_explicit_prod_only_coin(self) -> None: + context = load_coin_context(self.workspace, self.valid_vars_map) + + with self.assertRaisesRegex( + ValidationError, + "coin not available in build env=dev: polygon_archive", + ): + resolve_build_selection(context, "polygon_archive", "dev") + + def test_build_accepts_underscore_for_hyphenated_coin(self) -> None: + context = load_coin_context(self.workspace, self.valid_vars_map) + + selection = resolve_build_selection(context, "ethereum_classic", "dev") + + self.assertEqual(selection.coins, ["ethereum-classic"]) + + def test_build_dev_all_skips_prod_only_coins(self) -> None: + context = load_coin_context(self.workspace, self.valid_vars_map) + + selection = resolve_build_selection(context, "ALL", "dev") + + self.assertEqual(selection.coins, ["base_archive", "dogecoin", "ethereum-classic"]) + self.assertEqual(selection.skipped_prod_only, ["polygon_archive"]) + + def test_deploy_all_lists_deployable_coins(self) -> None: + context = load_coin_context(self.workspace, self.valid_vars_map, include_deployability=True) + + with self.assertRaisesRegex( + ValidationError, + "deploy does not support ALL; deployable coins: base_archive,dogecoin", + ): + resolve_deploy_selection(context, "ALL") + + def test_deploy_rejects_prod_only_coin_with_reason(self) -> None: + context = load_coin_context(self.workspace, self.valid_vars_map, include_deployability=True) + + with self.assertRaisesRegex( + ValidationError, + "coin 'polygon_archive' is not deployable in dev", + ): + resolve_deploy_selection(context, "polygon_archive") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/validate_branch_or_tag.py b/.github/scripts/validate_branch_or_tag.py new file mode 100755 index 0000000000..5ae9fe3320 --- /dev/null +++ b/.github/scripts/validate_branch_or_tag.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import subprocess +import sys + + +LOG_PREFIX = "CI/CD Pipeline:" +SCRIPT_NAME = "[validate-branch-or-tag]" + + +def log(message: str) -> None: + print(f"{LOG_PREFIX} {SCRIPT_NAME} {message}", file=sys.stderr, flush=True) + + +def fail(message: str) -> None: + print(f"{LOG_PREFIX} error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def ref_exists(repo: str, ref: str, kind: str) -> bool: + remote = f"https://github.com/{repo}.git" + try: + subprocess.run( + ["git", "ls-remote", "--exit-code", f"--{kind}", remote, ref], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + fail("git is required for branch/tag validation") + raise AssertionError("unreachable") from exc + except subprocess.CalledProcessError: + return False + return True + + +def validate_branch_or_tag(repo: str, ref: str) -> str: + candidate = ref.strip() + if not candidate: + fail("branch_or_tag resolved to an empty value") + + if ref_exists(repo, candidate, "heads"): + log(f"validated branch '{candidate}' in {repo}") + return "branch" + if ref_exists(repo, candidate, "tags"): + log(f"validated tag '{candidate}' in {repo}") + return "tag" + + fail(f"branch_or_tag '{candidate}' does not exist as a branch or tag in {repo}") + raise AssertionError("unreachable") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate that a branch_or_tag exists in a GitHub repository.") + parser.add_argument("--repo", required=True) + parser.add_argument("--ref", required=True) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv) + validate_branch_or_tag(args.repo, args.ref) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/validate_branch_or_tag_test.py b/.github/scripts/validate_branch_or_tag_test.py new file mode 100644 index 0000000000..630115730e --- /dev/null +++ b/.github/scripts/validate_branch_or_tag_test.py @@ -0,0 +1,25 @@ +import unittest +from unittest.mock import patch + +from validate_branch_or_tag import validate_branch_or_tag + + +class ValidateBranchOrTagTest(unittest.TestCase): + def test_accepts_existing_branch(self) -> None: + with patch("validate_branch_or_tag.ref_exists", side_effect=lambda repo, ref, kind: kind == "heads"): + kind = validate_branch_or_tag("trezor/blockbook", "master") + self.assertEqual(kind, "branch") + + def test_accepts_existing_tag(self) -> None: + with patch("validate_branch_or_tag.ref_exists", side_effect=lambda repo, ref, kind: kind == "tags"): + kind = validate_branch_or_tag("trezor/blockbook", "v1.0.0") + self.assertEqual(kind, "tag") + + def test_rejects_missing_ref(self) -> None: + with patch("validate_branch_or_tag.ref_exists", return_value=False): + with self.assertRaisesRegex(SystemExit, "1"): + validate_branch_or_tag("trezor/blockbook", "missing-ref") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/wait_for_sync.py b/.github/scripts/wait_for_sync.py new file mode 100644 index 0000000000..faff91d02c --- /dev/null +++ b/.github/scripts/wait_for_sync.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +import json +import os +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +LOG_PREFIX = "CI/CD Pipeline:" + + +def fail(message: str) -> None: + print(f"{LOG_PREFIX} error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def log(message: str) -> None: + print(f"{LOG_PREFIX} {message}", flush=True) + + +def parse_requested_coins(raw: str) -> list[str]: + text = raw.strip() + if not text: + fail("COINS_INPUT is empty") + + seen = set() + result = [] + for part in text.split(","): + coin = part.strip().lower() + if not coin or coin in seen: + continue + seen.add(coin) + result.append(coin) + if not result: + fail("COINS_INPUT resolved to an empty list") + return result + + +def normalize_http_base(raw: str) -> str: + parsed = urllib.parse.urlparse(raw.strip()) + if parsed.scheme not in ("http", "https"): + fail(f"unsupported HTTP scheme {parsed.scheme!r} in {raw!r}") + if not parsed.netloc: + fail(f"missing host in {raw!r}") + return urllib.parse.urlunparse( + (parsed.scheme, parsed.netloc, parsed.path or "/", "", "", "") + ).rstrip("/") + + +def should_upgrade_to_https(status: int, body: bytes, base_url: str) -> bool: + if status != 400: + return False + if "http request to an https server" not in body.decode("utf-8", "replace").lower(): + return False + parsed = urllib.parse.urlparse(base_url) + return parsed.scheme == "http" + + +def upgrade_http_base_to_https(raw: str) -> str: + parsed = urllib.parse.urlparse(raw) + if parsed.scheme != "http": + return raw + return urllib.parse.urlunparse( + ("https", parsed.netloc, parsed.path, "", "", "") + ).rstrip("/") + + +def resolve_http_base(coin: str) -> str: + candidates = [coin] + if "-" in coin: + candidates.append(coin.replace("-", "_")) + + for candidate in candidates: + value = os.environ.get("BB_DEV_API_URL_HTTP_" + candidate, "").strip() + if value: + return normalize_http_base(value) + + expected = ", ".join(f"BB_DEV_API_URL_HTTP_{c}" for c in candidates) + fail( + f"missing {expected} for selected test coin {coin!r}" + ) + + +def preview_body(body: bytes, limit: int = 200) -> str: + text = body.decode("utf-8", "replace").strip() + if len(text) <= limit: + return text + return text[: limit - 3] + "..." + + +def fetch_status(base_url: str, request_timeout: int) -> tuple[int, bytes]: + request = urllib.request.Request(base_url + "/api/status") + context = ssl._create_unverified_context() + with urllib.request.urlopen(request, timeout=request_timeout, context=context) as resp: + return resp.getcode(), resp.read() + + +def parse_int(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + return None + + +def parse_sync_state(body: bytes) -> tuple[bool, str]: + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + return False, f"invalid JSON: {exc}" + + blockbook = payload.get("blockbook") + if not isinstance(blockbook, dict): + return False, "response missing blockbook object" + + backend = payload.get("backend") + if backend is not None and not isinstance(backend, dict): + return False, "response missing backend object" + + in_sync = blockbook.get("inSync") + initial_sync = blockbook.get("initialSync") + best_height = parse_int(blockbook.get("bestHeight")) + backend_blocks = parse_int(backend.get("blocks")) if isinstance(backend, dict) else None + + ready = in_sync is True and initial_sync is not True + summary = ( + f"inSync={in_sync!r}, initialSync={initial_sync!r}, " + f"bestHeight={best_height!r}, backendBlocks={backend_blocks!r}" + ) + + if best_height is not None and backend_blocks is not None: + height_lag = backend_blocks - best_height + summary += f", heightLag={height_lag!r}" + if height_lag > 1: + ready = False + + return ready, summary + + +def main() -> None: + coins = parse_requested_coins(os.environ.get("COINS_INPUT", "")) + timeout_seconds = int(os.environ.get("SYNC_TIMEOUT_SECONDS", "1800")) + poll_seconds = int(os.environ.get("SYNC_POLL_SECONDS", "10")) + request_timeout = int(os.environ.get("SYNC_REQUEST_TIMEOUT_SECONDS", "20")) + + pending = {} + last_seen = {} + for coin in coins: + if coin in pending: + continue + pending[coin] = resolve_http_base(coin) + last_seen[coin] = "not checked yet" + + deadline = time.monotonic() + timeout_seconds + log( + "Waiting for Blockbook sync: " + + ", ".join(f"{coin} -> {base}" for coin, base in sorted(pending.items())) + ) + + while pending: + for coin in sorted(list(pending)): + base_url = pending[coin] + try: + status, body = fetch_status(base_url, request_timeout) + except urllib.error.HTTPError as exc: + status = exc.code + body = exc.read() + except Exception as exc: + last_seen[coin] = f"{base_url}/api/status request failed: {exc}" + continue + + if should_upgrade_to_https(status, body, base_url): + base_url = upgrade_http_base_to_https(base_url) + pending[coin] = base_url + try: + status, body = fetch_status(base_url, request_timeout) + except urllib.error.HTTPError as exc: + status = exc.code + body = exc.read() + except Exception as exc: + last_seen[coin] = f"{base_url}/api/status request failed: {exc}" + continue + + if status != 200: + last_seen[coin] = ( + f"{base_url}/api/status returned HTTP {status}: {preview_body(body)}" + ) + continue + + in_sync, summary = parse_sync_state(body) + last_seen[coin] = f"{base_url}/api/status returned HTTP 200: {summary}" + if in_sync: + log(f"{coin}: synced ({summary})") + del pending[coin] + + if not pending: + break + + remaining_seconds = int(max(0, deadline - time.monotonic())) + if remaining_seconds == 0: + break + + details = "; ".join( + f"{coin}: {last_seen[coin]}" for coin in sorted(pending) + ) + log(f"Still waiting for Blockbook sync ({remaining_seconds}s left): {details}") + time.sleep(min(poll_seconds, remaining_seconds)) + + if pending: + details = "; ".join( + f"{coin}: {last_seen[coin]}" for coin in sorted(pending) + ) + fail( + f"timed out after {timeout_seconds}s waiting for Blockbook sync. {details}" + ) + + log("All selected Blockbook instances are synced.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/wait_for_sync_test.py b/.github/scripts/wait_for_sync_test.py new file mode 100644 index 0000000000..9a1256bd59 --- /dev/null +++ b/.github/scripts/wait_for_sync_test.py @@ -0,0 +1,76 @@ +import json +import unittest + +from wait_for_sync import parse_sync_state + + +def encode_status(blockbook: dict, backend: dict | None = None) -> bytes: + payload = {"blockbook": blockbook} + if backend is not None: + payload["backend"] = backend + return json.dumps(payload).encode("utf-8") + + +class WaitForSyncTest(unittest.TestCase): + def test_accepts_when_blockbook_is_synced_and_backend_lag_is_one(self) -> None: + ready, summary = parse_sync_state( + encode_status( + { + "inSync": True, + "initialSync": False, + "bestHeight": 100, + }, + {"blocks": 101}, + ) + ) + + self.assertTrue(ready) + self.assertIn("heightLag=1", summary) + + def test_rejects_when_initial_sync_is_true(self) -> None: + ready, summary = parse_sync_state( + encode_status( + { + "inSync": True, + "initialSync": True, + "bestHeight": 100, + }, + {"blocks": 100}, + ) + ) + + self.assertFalse(ready) + self.assertIn("initialSync=True", summary) + + def test_rejects_when_backend_height_gap_is_too_large(self) -> None: + ready, summary = parse_sync_state( + encode_status( + { + "inSync": True, + "initialSync": False, + "bestHeight": 100, + }, + {"blocks": 150}, + ) + ) + + self.assertFalse(ready) + self.assertIn("heightLag=50", summary) + + def test_preserves_existing_behavior_when_backend_height_is_missing(self) -> None: + ready, summary = parse_sync_state( + encode_status( + { + "inSync": True, + "initialSync": False, + "bestHeight": 100, + } + ) + ) + + self.assertTrue(ready) + self.assertIn("backendBlocks=None", summary) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..58e53bf579 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,207 @@ +name: Build / Deploy + +on: + workflow_dispatch: + inputs: + mode: + description: "Workflow mode" + type: choice + options: + - deploy + - build + required: true + default: deploy + env: + description: "Build environment; used only when mode=build" + type: choice + options: + - dev + - prod + required: true + default: dev + backend_mode: + description: "Backend package build mode; in never mode, backend-only coins still build backend packages" + type: choice + options: + - auto + - always + - never + required: true + default: auto + coins: + description: "Comma-separated coin aliases from configs/coins; ALL is supported only in build mode" + required: true + branch_or_tag: + description: "Branch or tag to check out and deploy (leave empty for current ref)" + required: false + default: "" + +permissions: + contents: read + +jobs: + prepare_build: + name: Prepare Build Plan + runs-on: ubuntu-latest + if: ${{ inputs.mode == 'build' }} + env: + RESOLVED_BRANCH_OR_TAG: ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }} + outputs: + runner_matrix: ${{ steps.plan.outputs.runner_matrix }} + coins_csv: ${{ steps.plan.outputs.coins_csv }} + steps: + - name: Checkout workflow code + uses: actions/checkout@v4 + + - name: Validate branch or tag + run: python3 ./.github/scripts/validate_branch_or_tag.py --repo "${{ github.repository }}" --ref "${{ env.RESOLVED_BRANCH_OR_TAG }}" + + - name: Checkout requested branch or tag + uses: actions/checkout@v4 + with: + ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + + - name: Build build plan + id: plan + env: + VARS_JSON: ${{ toJSON(vars) }} + COINS_INPUT: ${{ inputs.coins }} + BUILD_ENV: ${{ inputs.env }} + run: python3 ./.github/scripts/build_plan.py + + prepare_deploy: + name: Prepare Deploy Plan + runs-on: ubuntu-latest + if: ${{ inputs.mode == 'deploy' }} + env: + RESOLVED_BRANCH_OR_TAG: ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }} + outputs: + runner_matrix: ${{ steps.plan.outputs.runner_matrix }} + e2e_regex: ${{ steps.plan.outputs.e2e_regex }} + coins_csv: ${{ steps.plan.outputs.coins_csv }} + test_coins_csv: ${{ steps.plan.outputs.test_coins_csv }} + steps: + - name: Checkout workflow code + uses: actions/checkout@v4 + + - name: Validate branch or tag + run: python3 ./.github/scripts/validate_branch_or_tag.py --repo "${{ github.repository }}" --ref "${{ env.RESOLVED_BRANCH_OR_TAG }}" + + - name: Checkout requested branch or tag + uses: actions/checkout@v4 + with: + ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + + - name: Build deploy/e2e plan + id: plan + env: + VARS_JSON: ${{ toJSON(vars) }} + COINS_INPUT: ${{ inputs.coins }} + run: python3 ./.github/scripts/deploy_plan.py + + build: + name: Build (${{ matrix.runner }}) + needs: prepare_build + if: ${{ inputs.mode == 'build' }} + env: + RESOLVED_BRANCH_OR_TAG: ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.prepare_build.outputs.runner_matrix || '[]') }} + runs-on: ${{ fromJSON(matrix.labels_json) }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Build packages + env: + BRANCH_OR_TAG: ${{ env.RESOLVED_BRANCH_OR_TAG }} + BB_PACKAGE_ROOT: /opt/blockbook-builds + BB_BUILD_ENV: ${{ inputs.env }} + run: python3 ./.github/scripts/build_packages.py --backend-mode ${{ inputs.backend_mode }} ${{ join(matrix.coins, ' ') }} + + deploy: + name: Deploy (${{ matrix.coin }}) + needs: prepare_deploy + if: ${{ inputs.mode == 'deploy' }} + env: + RESOLVED_BRANCH_OR_TAG: ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.prepare_deploy.outputs.runner_matrix || '[]') }} + runs-on: [self-hosted, bb-dev-selfhosted, "${{ matrix.runner }}"] + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Deploy blockbook package + env: + BRANCH_OR_TAG: ${{ env.RESOLVED_BRANCH_OR_TAG }} + BB_BUILD_ENV: dev + run: ./contrib/scripts/deploy-bb-and-backend.sh "${{ matrix.coin }}" --force-confnew + + wait-for-sync: + name: Wait For Sync + needs: [prepare_deploy, deploy] + if: ${{ needs.deploy.result == 'success' }} + runs-on: [self-hosted, bb-dev-selfhosted] + timeout-minutes: 31 + env: + RESOLVED_BRANCH_OR_TAG: ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }} + COINS_INPUT: ${{ needs.prepare_deploy.outputs.test_coins_csv }} + SYNC_TIMEOUT_SECONDS: "1800" + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Wait for Blockbook sync + env: + BB_BUILD_ENV: dev + run: python3 ./.github/scripts/wait_for_sync.py + + e2e-tests: + name: E2E Tests (post-deploy) + needs: [prepare_deploy, deploy, wait-for-sync] + if: ${{ needs.deploy.result == 'success' && needs.wait-for-sync.result == 'success' }} + runs-on: [self-hosted, bb-dev-selfhosted] + env: + RESOLVED_BRANCH_OR_TAG: ${{ inputs.branch_or_tag != '' && inputs.branch_or_tag || github.ref_name }} + E2E_REGEX: ${{ needs.prepare_deploy.outputs.e2e_regex }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Run e2e tests + env: + BB_BUILD_ENV: dev + run: make test-e2e ARGS="-v" diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000000..6aaa0764c5 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,61 @@ +name: Testing + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ '**' ] + +jobs: + unit-tests: + name: Unit Tests + runs-on: [self-hosted, bb-dev-selfhosted] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run unit tests + env: + BB_BUILD_ENV: dev + run: make test + + connectivity-tests: + name: Connectivity Tests + runs-on: [self-hosted, bb-dev-selfhosted] + needs: unit-tests + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Run connectivity tests + env: + BB_BUILD_ENV: dev + run: make test-connectivity + + integration-tests: + name: Integration Tests (RPC + Sync) + runs-on: [self-hosted, bb-dev-selfhosted] + needs: connectivity-tests + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Run integration tests + env: + BB_BUILD_ENV: dev + run: make test-integration ARGS="-v" diff --git a/.gitignore b/.gitignore index 5cc7d1f1b5..12ed6d50ad 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,8 @@ build/*.deb .bin-image .deb-image \.idea/ -__debug* \ No newline at end of file +__debug* +.gocache/ +__pycache__/ +.dev/ +.spec/ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index efc9e57f57..4baa49503d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -157,15 +157,4 @@ backend-deploy-and-test-zcash_testnet: - configs/coins/zcash_testnet.json tags: - blockbook - script: ./contrib/scripts/backend-deploy-and-test.sh zcash_testnet zcash-testnet zcash=test testnet3/debug.log - -backend-deploy-and-test-goerli-archive: - stage: backend-deploy-and-test - only: - refs: - - master - changes: - - configs/coins/ethereum_testnet_goerli_archive.json - tags: - - blockbook - script: ./contrib/scripts/backend-deploy-and-test.sh ethereum_testnet_goerli_archive ethereum-testnet-goerli-archive ethereum=test ethereum_testnet_goerli_archive.log + script: ./contrib/scripts/backend-deploy-and-test.sh zcash_testnet zcash-testnet zcash=test testnet3/debug.log \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..ccb2431d24 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 100, + "arrowParens": "avoid", + "bracketSpacing": true, + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "tabWidth": 4, + "useTabs": false, + "bracketSameLine": false +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8dd2c2f878..43fe3f9f5b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,7 +75,7 @@ also in [build guide](/docs/build.md#on-naming-conventions-and-versioning). You *mainnet* option. In the section *blockbook* update information how to build and configure Blockbook service. Usually they are only -*package_name*, *system_user* and *explorer_url* options. Naming conventions are are described +*package_name*, *system_user* and *explorer_url* options. Naming conventions are described [here](/docs/build.md#on-naming-conventions-and-versioning). Update *package_maintainer* and *package_maintainer_email* options in the section *meta*. diff --git a/Makefile b/Makefile index dfe5b5f395..d493e92efe 100644 --- a/Makefile +++ b/Makefile @@ -1,39 +1,49 @@ BIN_IMAGE = blockbook-build DEB_IMAGE = blockbook-build-deb PACKAGER = $(shell id -u):$(shell id -g) +DOCKER_VERSION = $(shell docker version --format '{{.Client.Version}}') BASE_IMAGE = $$(awk -F= '$$1=="ID" { print $$2 ;}' /etc/os-release):$$(awk -F= '$$1=="VERSION_ID" { print $$2 ;}' /etc/os-release | tr -d '"') NO_CACHE = false -TCMALLOC = +TCMALLOC = PORTABLE = 0 ARGS ?= +GITCOMMIT ?= $(shell git describe --always --dirty 2>/dev/null) +# Forward BB_BUILD_ENV, BB_*_RPC_URL_*, BB_*_MQ_URL_*, BB_RPC_*, and BB_DEV_API_* overrides into Docker for build/test tooling. +BB_RPC_ENV := $(shell env | awk -F= '/^BB_BUILD_ENV$$|^BB_(DEV|PROD)_RPC_URL_(HTTP|WS)_|^BB_(DEV|PROD)_MQ_URL_|^BB_RPC_(BIND_HOST|ALLOW_IP)_|^BB_DEV_API_URL_(HTTP|WS)_/ {print "-e " $$1}') TARGETS=$(subst .json,, $(shell ls configs/coins)) -.PHONY: build build-debug test deb +.PHONY: build build-debug test test-connectivity test-integration test-e2e test-all deb build: .bin-image - docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(BIN_IMAGE) make build ARGS="$(ARGS)" + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(BIN_IMAGE) make build ARGS="$(ARGS)" build-debug: .bin-image - docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(BIN_IMAGE) make build-debug ARGS="$(ARGS)" + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(BIN_IMAGE) make build-debug ARGS="$(ARGS)" test: .bin-image - docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test ARGS="$(ARGS)" + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test ARGS="$(ARGS)" test-integration: .bin-image - docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test-integration ARGS="$(ARGS)" + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test-integration ARGS="$(ARGS)" + +test-e2e: .bin-image + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) -e E2E_REGEX $(BB_RPC_ENV) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test-e2e ARGS="$(ARGS)" + +test-connectivity: .bin-image + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test-connectivity ARGS="$(ARGS)" test-all: .bin-image - docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test-all ARGS="$(ARGS)" + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v "$(CURDIR):/src" --network="host" $(BIN_IMAGE) make test-all ARGS="$(ARGS)" deb-backend-%: .deb-image - docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(DEB_IMAGE) /build/build-deb.sh backend $* $(ARGS) + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(DEB_IMAGE) /build/build-deb.sh backend $* $(ARGS) deb-blockbook-%: .deb-image - docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(DEB_IMAGE) /build/build-deb.sh blockbook $* $(ARGS) + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(DEB_IMAGE) /build/build-deb.sh blockbook $* $(ARGS) deb-%: .deb-image - docker run -t --rm -e PACKAGER=$(PACKAGER) -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(DEB_IMAGE) /build/build-deb.sh all $* $(ARGS) + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) $(BB_RPC_ENV) -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR):/src" -v "$(CURDIR)/build:/out" $(DEB_IMAGE) /build/build-deb.sh all $* $(ARGS) deb-blockbook-all: clean-deb $(addprefix deb-blockbook-, $(TARGETS)) @@ -55,7 +65,7 @@ build-images: clean-images .deb-image: .bin-image @if [ $$(build/tools/image_status.sh $(DEB_IMAGE):latest build/docker) != "ok" ]; then \ echo "Building image $(DEB_IMAGE)..."; \ - docker build --no-cache=$(NO_CACHE) -t $(DEB_IMAGE) build/docker/deb; \ + docker build --no-cache=$(NO_CACHE) --build-arg DOCKER_VERSION=$(DOCKER_VERSION) -t $(DEB_IMAGE) build/docker/deb; \ else \ echo "Image $(DEB_IMAGE) is up to date"; \ fi @@ -79,3 +89,6 @@ clean-bin-image: clean-deb-image: - docker rmi $(DEB_IMAGE) + +style: + find . -name "*.go" -exec gofmt -w {} \; diff --git a/README.md b/README.md index d49fc1d449..972400e5cd 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ # Blockbook -**Blockbook** is back-end service for Trezor wallet. Main features of **Blockbook** are: +**Blockbook** is a back-end service for Trezor Suite. The main features of **Blockbook** are: -- index of addresses and address balances of the connected block chain -- fast index search -- simple blockchain explorer -- websocket, API and legacy Bitcore Insight compatible socket.io interfaces -- support of multiple coins (Bitcoin and Ethereum type) with easy extensibility to other coins -- scripts for easy creation of debian packages for backend and blockbook +- index of addresses and address balances of the connected block chain +- fast index search +- simple blockchain explorer +- websocket, API and legacy Bitcore Insight compatible REST interfaces +- support of multiple coins (Bitcoin and Ethereum type) with easy extensibility to other coins +- scripts for easy creation of debian packages for backend and blockbook ## Build and installation instructions @@ -19,7 +19,7 @@ Memory and disk requirements for initial synchronization of **Bitcoin mainnet** Other coins should have lower requirements, depending on the size of their block chain. Note that fast SSD disks are highly recommended. -User installation guide is [here](https://wiki.trezor.io/User_manual:Running_a_local_instance_of_Trezor_Wallet_backend_(Blockbook)). +User installation guide is [here](). Developer build guide is [here](/docs/build.md). @@ -27,14 +27,15 @@ Contribution guide is [here](CONTRIBUTING.md). ## Implemented coins -Blockbook currently supports over 30 coins. The Trezor team implemented +Blockbook currently supports over 30 coins. The Trezor team implemented -- Bitcoin, Bitcoin Cash, Zcash, Dash, Litecoin, Bitcoin Gold, Ethereum, Ethereum Classic, Dogecoin, Namecoin, Vertcoin, DigiByte, Liquid +- Bitcoin, Bitcoin Cash, Zcash, Dash, Litecoin, Bitcoin Gold, Ethereum, Ethereum Classic, Dogecoin, Namecoin, Vertcoin, DigiByte, Liquid the rest of coins were implemented by the community. Testnets for some coins are also supported, for example: -- Bitcoin Testnet, Bitcoin Cash Testnet, ZCash Testnet, Ethereum Testnet Ropsten + +- Bitcoin Testnet, Bitcoin Cash Testnet, ZCash Testnet, Ethereum Testnets (Sepolia, Hoodi) List of all implemented coins is in [the registry of ports](/docs/ports.md). @@ -42,19 +43,19 @@ List of all implemented coins is in [the registry of ports](/docs/ports.md). #### Out of memory when doing initial synchronization -How to reduce memory footprint of the initial sync: +How to reduce memory footprint of the initial sync: -- disable rocksdb cache by parameter `-dbcache=0`, the default size is 500MB -- run blockbook with parameter `-workers=1`. This disables bulk import mode, which caches a lot of data in memory (not in rocksdb cache). It will run about twice as slowly but especially for smaller blockchains it is no problem at all. +- disable rocksdb cache by parameter `-dbcache=0`, the default size is 500MB +- run blockbook with parameter `-workers=1`. This disables bulk import mode, which caches a lot of data in memory (not in rocksdb cache). It will run about twice as slowly but especially for smaller blockchains it is no problem at all. Please add your experience to this [issue](https://github.com/trezor/blockbook/issues/43). #### Error `internalState: database is in inconsistent state and cannot be used` -Blockbook was killed during the initial import, most commonly by OOM killer. -By default, Blockbook performs the initial import in bulk import mode, which for performance reasons does not store all data immediately to the database. If Blockbook is killed during this phase, the database is left in an inconsistent state. +Blockbook was killed during the initial import, most commonly by OOM killer. +By default, Blockbook performs the initial import in bulk import mode, which for performance reasons does not store all data immediately to the database. If Blockbook is killed during this phase, the database is left in an inconsistent state. -See above how to reduce the memory footprint, delete the database files and run the import again. +See above how to reduce the memory footprint, delete the database files and run the import again. Check [this](https://github.com/trezor/blockbook/issues/89) or [this](https://github.com/trezor/blockbook/issues/147) issue for more info. @@ -73,3 +74,11 @@ Blockbook stores data the key-value store RocksDB. Database format is described ## API Blockbook API is described [here](/docs/api.md). + +## Environment variables + +List of environment variables that affect Blockbook's behavior is [here](/docs/env.md). + +## Security Note + +WebSocket origin checks are not enforced by default. If you expose Blockbook without a proxy that restricts origins, it is your responsibility to configure the origin allowlist (or equivalent controls). See `docs/env.md` for details. diff --git a/api/contract.go b/api/contract.go new file mode 100644 index 0000000000..3682d560b1 --- /dev/null +++ b/api/contract.go @@ -0,0 +1,173 @@ +package api + +import ( + "strings" + + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" +) + +const contractInfoProtocolErc4626 = "erc4626" + +var knownErcProtocols = []string{contractInfoProtocolErc4626} + +func contractInfoSupportsRates(standard bchain.TokenStandardName) bool { + return standard == erc4626EvmFungibleStandard() +} + +func contractInfoIncludesProtocol(protocols []string, protocol string) bool { + for _, value := range protocols { + if strings.EqualFold(strings.TrimSpace(value), protocol) { + return true + } + } + return false +} + +// ValidateErcProtocols rejects protocol values not recognised by this API. +// Empty and whitespace-only entries are tolerated for convenience. +func ValidateErcProtocols(protocols []string) error { + for _, p := range protocols { + normalized := strings.ToLower(strings.TrimSpace(p)) + if normalized == "" { + continue + } + known := false + for _, k := range knownErcProtocols { + if normalized == k { + known = true + break + } + } + if !known { + return NewAPIError("Unknown protocol: "+p, true) + } + } + return nil +} + +// ValidateProtocolsForChain rejects a non-empty protocols list on coins that +// don't support any protocol enrichments, and otherwise validates the values. +func (w *Worker) ValidateProtocolsForChain(protocols []string) error { + if len(protocols) == 0 { + return nil + } + if w.chainType != bchain.ChainEthereumType { + return NewAPIError("protocols parameter is not supported on this coin", true) + } + return ValidateErcProtocols(protocols) +} + +func (w *Worker) enrichTokenProtocols(tokens Tokens, protocols []string) { + if !contractInfoIncludesProtocol(protocols, contractInfoProtocolErc4626) { + return + } + // Read best block lazily, only once a relevant protocol was requested, so + // accountInfo requests without protocol enrichment skip the CF seek. + // On error proceed with bestHeight==0 (no in-block caching) but log. + bestHeight, bestHash, err := w.db.GetBestBlock() + if err != nil { + glog.Warningf("GetBestBlock for protocol enrichment: %v", err) + } + w.enrichErc4626Tokens(tokens, bestHeight, bestHash) +} + +// contractInfoResultFromBchain wraps bchain.ContractInfo into the API-level +// ContractInfoResult. Rates and Protocols stay nil; callers that want +// enrichment use GetContractInfoData directly. +func contractInfoResultFromBchain(ci *bchain.ContractInfo, bestHeight uint32) *ContractInfoResult { + if ci == nil { + return nil + } + return &ContractInfoResult{ + Type: ci.Type, + Standard: ci.Standard, + Contract: ci.Contract, + Name: ci.Name, + Symbol: ci.Symbol, + Decimals: ci.Decimals, + CreatedInBlock: ci.CreatedInBlock, + DestructedInBlock: ci.DestructedInBlock, + BlockHeight: bestHeight, + } +} + +func (w *Worker) buildContractInfoRates(contract string, standard bchain.TokenStandardName, currency string) *ContractInfoRates { + if !contractInfoSupportsRates(standard) || w.fiatRates == nil { + return nil + } + + currency = strings.ToLower(strings.TrimSpace(currency)) + ticker := getCurrentTicker(w.fiatRates, currency, contract) + baseRate, baseRateFound := w.GetContractBaseRate(ticker, contract, 0) + if !baseRateFound && currency == "" { + return nil + } + + rates := &ContractInfoRates{} + if baseRateFound { + rates.BaseRate = baseRate + } + if currency != "" { + rates.Currency = currency + if ticker != nil { + if secondaryRate := ticker.TokenRateInCurrency(contract, currency); secondaryRate > 0 { + rates.SecondaryRate = float64(secondaryRate) + } + } + } + return rates +} + +func (w *Worker) GetContractInfoData(contract string, currency string, protocols []string) (*ContractInfoResult, error) { + if w.chainType != bchain.ChainEthereumType { + return nil, NewAPIError("getContractInfo is not supported on this coin", true) + } + if strings.TrimSpace(contract) == "" { + return nil, NewAPIError("Missing contract", true) + } + if err := ValidateErcProtocols(protocols); err != nil { + return nil, err + } + + contractInfo, validContract, err := w.GetContractInfo(contract, bchain.UnknownTokenStandard) + if err != nil { + return nil, NewAPIError("Invalid contract, "+err.Error(), true) + } + if contractInfo == nil || !validContract { + return nil, NewAPIError("Contract not found", true) + } + + bestHeight, bestHash, err := w.db.GetBestBlock() + if err != nil { + return nil, err + } + + result := &ContractInfoResult{ + Type: contractInfo.Type, + Standard: contractInfo.Standard, + Contract: contractInfo.Contract, + Name: contractInfo.Name, + Symbol: contractInfo.Symbol, + Decimals: contractInfo.Decimals, + CreatedInBlock: contractInfo.CreatedInBlock, + DestructedInBlock: contractInfo.DestructedInBlock, + Rates: w.buildContractInfoRates(contractInfo.Contract, contractInfo.Standard, currency), + BlockHeight: bestHeight, + } + + // Probe only for ERC20-shaped contracts (or unknown/unhandled, which covers + // freshly RPC-fetched contracts with no tagged standard); ERC721/ERC1155 + // would always fail the probe. + if !contractInfoIncludesProtocol(protocols, contractInfoProtocolErc4626) || + (contractInfo.Standard != bchain.UnknownTokenStandard && contractInfo.Standard != bchain.UnhandledTokenStandard && contractInfo.Standard != erc4626EvmFungibleStandard()) { + return result, nil + } + + erc4626 := w.buildErc4626Token(contractInfo, bestHeight, bestHash) + if erc4626 == nil { + return result, nil + } + result.Protocols = &ContractInfoProtocols{Erc4626: erc4626} + return result, nil +} diff --git a/api/contract_test.go b/api/contract_test.go new file mode 100644 index 0000000000..0d2beb93f0 --- /dev/null +++ b/api/contract_test.go @@ -0,0 +1,85 @@ +package api + +import ( + "math" + "testing" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/fiat" +) + +func TestContractInfoIncludesProtocol(t *testing.T) { + if !contractInfoIncludesProtocol([]string{" ERC4626 "}, contractInfoProtocolErc4626) { + t.Fatal("expected erc4626 protocol to match case-insensitively") + } + if contractInfoIncludesProtocol([]string{"staking"}, contractInfoProtocolErc4626) { + t.Fatal("unexpected erc4626 protocol match") + } +} + +func TestBuildContractInfoRates(t *testing.T) { + originalGetter := getCurrentTicker + defer func() { + getCurrentTicker = originalGetter + }() + + tickerCalls := 0 + getCurrentTicker = func(_ *fiat.FiatRates, vsCurrency string, token string) *common.CurrencyRatesTicker { + tickerCalls++ + if vsCurrency != "usd" { + t.Fatalf("unexpected currency lookup: got %q want %q", vsCurrency, "usd") + } + if token != "0xabc" { + t.Fatalf("unexpected token lookup: got %q want %q", token, "0xabc") + } + return &common.CurrencyRatesTicker{ + Rates: map[string]float32{ + "usd": 2.5, + }, + TokenRates: map[string]float32{ + "0xabc": 1.2, + }, + } + } + + w := &Worker{fiatRates: &fiat.FiatRates{}} + rates := w.buildContractInfoRates("0xabc", erc4626EvmFungibleStandard(), "USD") + if tickerCalls != 1 { + t.Fatalf("expected one ticker lookup, got %d", tickerCalls) + } + if rates == nil { + t.Fatal("expected rates") + } + if rates.Currency != "usd" { + t.Fatalf("unexpected currency: %q", rates.Currency) + } + if math.Abs(rates.BaseRate-1.2) > 1e-6 { + t.Fatalf("unexpected base rate: got %v want %v", rates.BaseRate, 1.2) + } + if math.Abs(rates.SecondaryRate-3.0) > 1e-6 { + t.Fatalf("unexpected secondary rate: got %v want %v", rates.SecondaryRate, 3.0) + } +} + +func TestBuildContractInfoRatesSkipsUnsupportedStandards(t *testing.T) { + originalGetter := getCurrentTicker + defer func() { + getCurrentTicker = originalGetter + }() + + tickerCalls := 0 + getCurrentTicker = func(_ *fiat.FiatRates, _, _ string) *common.CurrencyRatesTicker { + tickerCalls++ + return nil + } + + w := &Worker{fiatRates: &fiat.FiatRates{}} + rates := w.buildContractInfoRates("0xabc", bchain.ERC1155TokenStandard, "usd") + if rates != nil { + t.Fatalf("expected nil rates for unsupported standard, got %+v", rates) + } + if tickerCalls != 0 { + t.Fatalf("expected no ticker lookups for unsupported standard, got %d", tickerCalls) + } +} diff --git a/api/erc4626.go b/api/erc4626.go new file mode 100644 index 0000000000..8b2cdf84cf --- /dev/null +++ b/api/erc4626.go @@ -0,0 +1,591 @@ +package api + +import ( + "encoding/hex" + "fmt" + "math/big" + "strings" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" +) + +const ( + erc4626MaxDecimals = 77 + erc4626ZeroAddress = "0x0000000000000000000000000000000000000000" + // Two sub-calls per candidate (asset + totalAssets); chunk to bound aggregate3 payload size. + erc4626ProbeChunkCandidates = 64 + + // erc4626NegativeProbeTTLDuration is how long a "definitively not a vault" + // result stays in the in-memory negative cache before re-probing. Keeping + // it expressed as wall-clock time (rather than a fixed block count) means + // the user-visible TTL is ~the same regardless of the chain's block + // cadence; the per-coin block count is derived from the chain's + // configured averageBlockTimeMs at request time. + erc4626NegativeProbeTTLDuration = 15 * time.Minute +) + +var ( + erc4626MethodAsset = erc4626MethodSelector("asset()") + erc4626MethodTotalAssets = erc4626MethodSelector("totalAssets()") + erc4626MethodConvertToAssets = erc4626MethodSelector("convertToAssets(uint256)") + erc4626MethodConvertToShares = erc4626MethodSelector("convertToShares(uint256)") + erc4626MethodPreviewDeposit = erc4626MethodSelector("previewDeposit(uint256)") + erc4626MethodPreviewRedeem = erc4626MethodSelector("previewRedeem(uint256)") + erc4626MaxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) +) + +func erc4626MethodSelector(signature string) [4]byte { + var selector [4]byte + copy(selector[:], crypto.Keccak256([]byte(signature))[:4]) + return selector +} + +func erc4626EvmFungibleStandard() bchain.TokenStandardName { + if len(bchain.EthereumTokenStandardMap) > int(bchain.FungibleToken) { + return bchain.EthereumTokenStandardMap[bchain.FungibleToken] + } + return bchain.ERC20TokenStandard +} + +// erc4626MulticallCaller is the chain-side seam used by enrichment; satisfied +// by chains whose RPC client supports Multicall3 aggregate3. +type erc4626MulticallCaller interface { + EthereumTypeMulticallAggregate3(calls []bchain.EthereumMulticallCall, blockNumber *big.Int) ([]bchain.EthereumMulticallResult, error) +} + +// erc4626BlockTimeProvider exposes the chain's configured average block time +// so the API can convert chain-time settings (negative-cache TTL) into a +// per-coin block count at request time. Implemented by EVM coins via +// EthereumRPC.AverageBlockTimeDuration. +type erc4626BlockTimeProvider interface { + AverageBlockTimeDuration() (time.Duration, error) +} + +// erc4626BlocksForDuration converts a wall-clock duration to the equivalent +// per-chain block count, rounding up so a duration of "at least N" is honored. +// Returns 0 when either input is non-positive — callers treat 0 as +// "configuration unavailable, skip the time-derived behavior." +func erc4626BlocksForDuration(d, blockTime time.Duration) uint32 { + if d <= 0 || blockTime <= 0 { + return 0 + } + n := (d + blockTime - 1) / blockTime + if n < 1 { + return 1 + } + return uint32(n) +} + +// erc4626NegativeProbeTTLBlocks resolves the negative-cache TTL to a per-coin +// block count using the chain's configured averageBlockTimeMs. Returns 0 if +// the chain doesn't expose a block time (e.g. non-EVM); the caller treats 0 +// as "do not negative-cache for this request" — safe fallback that just +// forfeits the optimization. +func (w *Worker) erc4626NegativeProbeTTLBlocks() uint32 { + provider, ok := w.chain.(erc4626BlockTimeProvider) + if !ok { + return 0 + } + bt, err := provider.AverageBlockTimeDuration() + if err != nil { + glog.Warningf("erc4626: averageBlockTime unavailable, negative cache disabled: %v", err) + return 0 + } + return erc4626BlocksForDuration(erc4626NegativeProbeTTLDuration, bt) +} + +type erc4626ContractInfoFetcher func(contract string, standard bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) + +// erc4626VaultPersister anchors the row to the observation height so a +// future disconnect of that range removes it. +type erc4626VaultPersister func(address, assetContract string) error + +// enrichErc4626Tokens marks tokens whose contract is a known ERC4626 vault. +// Known vaults are flagged from indexed metadata; remaining fungibles are +// probed in one batched multicall, with positives persisted and negatives kept +// in-memory only (so dormant/upgradeable contracts stay probeable). +func (w *Worker) enrichErc4626Tokens(tokens Tokens, bestHeight uint32, bestHash string) { + mc, _ := w.chain.(erc4626MulticallCaller) + // Sample reorgGen+bestHash before the multicall; writer rejects if the + // observed block is no longer canonical (see SetErcProtocol). + reorgGen := w.db.ReorgGeneration() + // Resolve the wall-clock negative-cache TTL into a per-coin block count + // once per request. 0 falls back to "do not negative-cache" (no-op). + negativeTTLBlocks := w.erc4626NegativeProbeTTLBlocks() + setVault := func(addr, asset string) error { + return w.db.SetContractInfoErc4626Vault(addr, asset, bestHeight, bestHash, reorgGen) + } + enrichErc4626TokensWithDeps(tokens, w.GetContractInfo, mc, setVault, erc4626NegativeProbeCache, bestHeight, negativeTTLBlocks, reorgGen) +} + +func enrichErc4626TokensWithDeps( + tokens Tokens, + getContractInfo erc4626ContractInfoFetcher, + mc erc4626MulticallCaller, + setVault erc4626VaultPersister, + negativeCache *erc4626NegativeCache, + bestHeight uint32, + negativeTTLBlocks uint32, + reorgGen uint64, +) { + var blockNumber *big.Int + if bestHeight > 0 { + blockNumber = new(big.Int).SetUint64(uint64(bestHeight)) + } + standard := erc4626EvmFungibleStandard() + + type candidate struct { + token *Token + contract string + } + var candidates []candidate + + for i := range tokens { + token := &tokens[i] + if token.Contract == "" || token.Standard != standard { + continue + } + ci, _, err := getContractInfo(token.Contract, standard) + if err != nil || ci == nil { + continue + } + if ci.IsErc4626 { + negativeCache.remove(token.Contract) + token.Protocols = append(token.Protocols, contractInfoProtocolErc4626) + continue + } + if negativeCache.contains(token.Contract, bestHeight, reorgGen) { + continue + } + candidates = append(candidates, candidate{token: token, contract: token.Contract}) + } + + if len(candidates) == 0 || mc == nil { + return + } + + for start := 0; start < len(candidates); start += erc4626ProbeChunkCandidates { + end := start + erc4626ProbeChunkCandidates + if end > len(candidates) { + end = len(candidates) + } + chunk := candidates[start:end] + calls := make([]bchain.EthereumMulticallCall, 0, 2*len(chunk)) + for _, c := range chunk { + calls = append(calls, + bchain.EthereumMulticallCall{Target: c.contract, CallData: erc4626EncodeNoArg(erc4626MethodAsset), AllowFailure: true}, + bchain.EthereumMulticallCall{Target: c.contract, CallData: erc4626EncodeNoArg(erc4626MethodTotalAssets), AllowFailure: true}, + ) + } + results, err := mc.EthereumTypeMulticallAggregate3(calls, blockNumber) + if err != nil || len(results) != len(calls) { + // Skip chunk on transport failure; the next request retries. + continue + } + + for i, c := range chunk { + assetResult := results[i*2] + totalAssetsResult := results[i*2+1] + + // EIP-4626 mandates both asset() and totalAssets(); detection requires both. + var assetContract string + if assetResult.Success { + if addr, derr := erc4626DecodeAddress(assetResult.Data); derr == nil && !strings.EqualFold(addr, erc4626ZeroAddress) { + assetContract = addr + } + } + if assetContract == "" || !totalAssetsResult.Success { + negativeCache.add(c.contract, bestHeight, negativeTTLBlocks, reorgGen) + continue + } + if _, derr := erc4626DecodeUint(totalAssetsResult.Data); derr != nil { + negativeCache.add(c.contract, bestHeight, negativeTTLBlocks, reorgGen) + continue + } + // Persistence is best-effort; on error or silent refusal (reorg + // gen/hash mismatch), the response is still flagged from the live + // probe and the negative cache is cleared so the next request retries. + if err := setVault(c.contract, assetContract); err != nil { + glog.Warningf("SetContractInfoErc4626Vault contract %v asset %v: %v", c.contract, assetContract, err) + } + negativeCache.remove(c.contract) + c.token.Protocols = append(c.token.Protocols, contractInfoProtocolErc4626) + } + } +} + +// buildErc4626Token returns the vault snapshot for one contract pinned to +// bestHeight. Cold path: 2 multicalls + lazy asset metadata. Warm (asset +// address cached): 1 multicall. Results memoized per (contract, height, +// reorgGen) and deduped by singleflight. Returns nil for non-vaults; caller +// is expected to have filtered by standard. +func (w *Worker) buildErc4626Token(contractInfo *bchain.ContractInfo, bestHeight uint32, bestHash string) *Erc4626Token { + if contractInfo == nil || contractInfo.Contract == "" { + return nil + } + mc, ok := w.chain.(erc4626MulticallCaller) + if !ok { + return nil + } + // Sample reorgGen+bestHash before the multicall; see SetErcProtocol. + reorgGen := w.db.ReorgGeneration() + setVault := func(addr, asset string) error { + return w.db.SetContractInfoErc4626Vault(addr, asset, bestHeight, bestHash, reorgGen) + } + + // bestHeight==0: no usable height, skip cache and read "latest" once. + if bestHeight == 0 { + token, _ := buildErc4626TokenWithDeps(contractInfo, mc, setVault, w.GetContractInfo, nil) + return token + } + blockNumber := new(big.Int).SetUint64(uint64(bestHeight)) + return erc4626CacheLookupOrBuild(erc4626LiveCache, erc4626CacheKey(contractInfo.Contract, bestHeight, reorgGen), func() (*Erc4626Token, error) { + return buildErc4626TokenWithDeps(contractInfo, mc, setVault, w.GetContractInfo, blockNumber) + }) +} + +// buildErc4626TokenWithDeps returns the enrichment plus a cache-policy signal: +// err==nil ⇒ stable answer (cacheable); err!=nil ⇒ transient external failure, +// don't cache. +func buildErc4626TokenWithDeps( + ci *bchain.ContractInfo, + mc erc4626MulticallCaller, + setVault erc4626VaultPersister, + getContractInfo erc4626ContractInfoFetcher, + blockNumber *big.Int, +) (*Erc4626Token, error) { + if ci.Erc4626AssetContract == "" { + return buildErc4626TokenCold(ci, mc, setVault, getContractInfo, blockNumber) + } + return buildErc4626TokenWarm(ci, mc, getContractInfo, blockNumber) +} + +// buildErc4626TokenCold is detection + first-time enrichment. (nil,nil) means +// deterministically not-a-vault at this block (cacheable). (_,err) means +// transient upstream failure (don't cache). +func buildErc4626TokenCold( + ci *bchain.ContractInfo, + mc erc4626MulticallCaller, + setVault erc4626VaultPersister, + getContractInfo erc4626ContractInfoFetcher, + blockNumber *big.Int, +) (*Erc4626Token, error) { + contract := ci.Contract + shareDec := ci.Decimals + + // Multicall A: detection + share-side conversions (skipped if shareUnit invalid). + shareUnit, shareUnitErr := erc4626UnitAmount(shareDec) + callsA := []bchain.EthereumMulticallCall{ + {Target: contract, CallData: erc4626EncodeNoArg(erc4626MethodAsset), AllowFailure: true}, + {Target: contract, CallData: erc4626EncodeNoArg(erc4626MethodTotalAssets), AllowFailure: true}, + } + if shareUnitErr == nil { + convertToAssetsData, _ := erc4626EncodeUintArg(erc4626MethodConvertToAssets, shareUnit) + previewRedeemData, _ := erc4626EncodeUintArg(erc4626MethodPreviewRedeem, shareUnit) + callsA = append(callsA, + bchain.EthereumMulticallCall{Target: contract, CallData: convertToAssetsData, AllowFailure: true}, + bchain.EthereumMulticallCall{Target: contract, CallData: previewRedeemData, AllowFailure: true}, + ) + } + resA, err := mc.EthereumTypeMulticallAggregate3(callsA, blockNumber) + if err != nil { + return nil, err + } + if len(resA) < 2 { + // Short response is transport-shaped, not a deterministic "no". + return nil, fmt.Errorf("multicall aggregate3: short response %d", len(resA)) + } + + // EIP-4626 mandates both asset() and totalAssets(); detection requires both. + // Deterministic answers — (nil,nil) is cacheable. + if !resA[0].Success { + return nil, nil + } + assetContract, err := erc4626DecodeAddress(resA[0].Data) + if err != nil || strings.EqualFold(assetContract, erc4626ZeroAddress) { + return nil, nil + } + if !resA[1].Success { + return nil, nil + } + totalAssets, err := erc4626DecodeUint(resA[1].Data) + if err != nil { + return nil, nil + } + + if err := setVault(contract, assetContract); err != nil { + glog.Warningf("SetContractInfoErc4626Vault contract %v asset %v: %v", contract, assetContract, err) + } + + result := &Erc4626Token{ + Share: &Erc4626TokenMetadata{ + Contract: contract, + Name: ci.Name, + Symbol: ci.Symbol, + Decimals: shareDec, + }, + TotalAssetsSat: (*Amount)(totalAssets), + } + var errs []string + // transientErr captures upstream transport failures only; on-chain decode + // failures stay in errs (stable, vault is already confirmed). + var transientErr error + + if shareUnitErr != nil { + errs = append(errs, "share decimals: "+shareUnitErr.Error()) + } + + if len(resA) > 2 { + result.ConvertToAssets1ShareSat = decodeMulticallAmount(resA[2], "convertToAssets", &errs) + } + if len(resA) > 3 { + result.PreviewRedeem1ShareSat = decodeMulticallAmount(resA[3], "previewRedeem", &errs) + } + + // Asset metadata: fetcher error is transient; (nil, false, nil) is a stable absence. + // Do not emit asset until decimals are known: callers use asset presence as + // the signal that conversion amounts can be scaled into whole asset units. + assetInfo, validAsset, err := getContractInfo(assetContract, bchain.UnknownTokenStandard) + if err != nil { + errs = append(errs, "asset metadata: "+err.Error()) + transientErr = err + } else if assetInfo == nil || !validAsset { + errs = append(errs, "asset metadata unavailable") + } else { + result.Asset = &Erc4626TokenMetadata{ + Contract: assetContract, + Name: assetInfo.Name, + Symbol: assetInfo.Symbol, + Decimals: assetInfo.Decimals, + } + } + + // Multicall B: asset-side conversions, only if we have a valid asset decimals. + if validAsset && assetInfo != nil { + assetUnit, err := erc4626UnitAmount(assetInfo.Decimals) + if err != nil { + errs = append(errs, "asset decimals: "+err.Error()) + } else { + convertToSharesData, _ := erc4626EncodeUintArg(erc4626MethodConvertToShares, assetUnit) + previewDepositData, _ := erc4626EncodeUintArg(erc4626MethodPreviewDeposit, assetUnit) + callsB := []bchain.EthereumMulticallCall{ + {Target: contract, CallData: convertToSharesData, AllowFailure: true}, + {Target: contract, CallData: previewDepositData, AllowFailure: true}, + } + resB, err := mc.EthereumTypeMulticallAggregate3(callsB, blockNumber) + if err != nil { + errs = append(errs, "asset-side multicall: "+err.Error()) + if transientErr == nil { + transientErr = err + } + } else if len(resB) >= 2 { + result.ConvertToShares1AssetSat = decodeMulticallAmount(resB[0], "convertToShares", &errs) + result.PreviewDeposit1AssetSat = decodeMulticallAmount(resB[1], "previewDeposit", &errs) + } + } + } + + if len(errs) > 0 { + result.Error = strings.Join(errs, "; ") + } + return result, transientErr +} + +// buildErc4626TokenWarm is the steady-state path: one multicall for all +// time-varying fields. Always returns the metadata-only result on multicall +// error (vault is already confirmed); transient errors signal cache to skip. +func buildErc4626TokenWarm( + ci *bchain.ContractInfo, + mc erc4626MulticallCaller, + getContractInfo erc4626ContractInfoFetcher, + blockNumber *big.Int, +) (*Erc4626Token, error) { + contract := ci.Contract + assetContract := ci.Erc4626AssetContract + shareDec := ci.Decimals + + result := &Erc4626Token{ + Share: &Erc4626TokenMetadata{ + Contract: contract, + Name: ci.Name, + Symbol: ci.Symbol, + Decimals: shareDec, + }, + } + var errs []string + var transientErr error // first upstream failure; non-nil tells cache to skip + + // Do not emit asset until decimals are known: callers use asset presence as + // the signal that conversion amounts can be scaled into whole asset units. + assetInfo, validAsset, err := getContractInfo(assetContract, bchain.UnknownTokenStandard) + if err != nil { + errs = append(errs, "asset metadata: "+err.Error()) + transientErr = err + } else if assetInfo == nil || !validAsset { + errs = append(errs, "asset metadata unavailable") + } else { + result.Asset = &Erc4626TokenMetadata{ + Contract: assetContract, + Name: assetInfo.Name, + Symbol: assetInfo.Symbol, + Decimals: assetInfo.Decimals, + } + } + + shareUnit, shareUnitErr := erc4626UnitAmount(shareDec) + if shareUnitErr != nil { + errs = append(errs, "share decimals: "+shareUnitErr.Error()) + } + var assetUnit *big.Int + if validAsset && assetInfo != nil { + var assetUnitErr error + assetUnit, assetUnitErr = erc4626UnitAmount(assetInfo.Decimals) + if assetUnitErr != nil { + errs = append(errs, "asset decimals: "+assetUnitErr.Error()) + } + } + + // totalAssets first, then any conversion calls whose unit amount is known. + calls := []bchain.EthereumMulticallCall{ + {Target: contract, CallData: erc4626EncodeNoArg(erc4626MethodTotalAssets), AllowFailure: true}, + } + type sink struct { + idx int + label string + target **Amount + } + sinks := []sink{ + {idx: 0, label: "totalAssets", target: &result.TotalAssetsSat}, + } + if shareUnit != nil { + convertToAssetsData, _ := erc4626EncodeUintArg(erc4626MethodConvertToAssets, shareUnit) + previewRedeemData, _ := erc4626EncodeUintArg(erc4626MethodPreviewRedeem, shareUnit) + idx := len(calls) + calls = append(calls, + bchain.EthereumMulticallCall{Target: contract, CallData: convertToAssetsData, AllowFailure: true}, + bchain.EthereumMulticallCall{Target: contract, CallData: previewRedeemData, AllowFailure: true}, + ) + sinks = append(sinks, + sink{idx: idx, label: "convertToAssets", target: &result.ConvertToAssets1ShareSat}, + sink{idx: idx + 1, label: "previewRedeem", target: &result.PreviewRedeem1ShareSat}, + ) + } + if assetUnit != nil { + convertToSharesData, _ := erc4626EncodeUintArg(erc4626MethodConvertToShares, assetUnit) + previewDepositData, _ := erc4626EncodeUintArg(erc4626MethodPreviewDeposit, assetUnit) + idx := len(calls) + calls = append(calls, + bchain.EthereumMulticallCall{Target: contract, CallData: convertToSharesData, AllowFailure: true}, + bchain.EthereumMulticallCall{Target: contract, CallData: previewDepositData, AllowFailure: true}, + ) + sinks = append(sinks, + sink{idx: idx, label: "convertToShares", target: &result.ConvertToShares1AssetSat}, + sink{idx: idx + 1, label: "previewDeposit", target: &result.PreviewDeposit1AssetSat}, + ) + } + + res, err := mc.EthereumTypeMulticallAggregate3(calls, blockNumber) + if err != nil { + errs = append(errs, "multicall: "+err.Error()) + if transientErr == nil { + transientErr = err + } + } else { + for _, s := range sinks { + if s.idx >= len(res) { + continue + } + *s.target = decodeMulticallAmount(res[s.idx], s.label, &errs) + } + } + + if len(errs) > 0 { + result.Error = strings.Join(errs, "; ") + } + return result, transientErr +} + +func decodeMulticallAmount(r bchain.EthereumMulticallResult, label string, errs *[]string) *Amount { + if !r.Success { + *errs = append(*errs, label+": call reverted") + return nil + } + v, err := erc4626DecodeUint(r.Data) + if err != nil { + *errs = append(*errs, label+": "+err.Error()) + return nil + } + return (*Amount)(v) +} + +func erc4626EncodeNoArg(selector [4]byte) string { + buf := make([]byte, 4) + copy(buf, selector[:]) + return "0x" + hex.EncodeToString(buf) +} + +func erc4626EncodeUintArg(selector [4]byte, arg *big.Int) (string, error) { + if arg == nil || arg.Sign() < 0 { + return "", fmt.Errorf("invalid uint256 argument") + } + if arg.Cmp(erc4626MaxUint256) > 0 { + return "", fmt.Errorf("uint256 argument overflows") + } + buf := make([]byte, 4+32) + copy(buf, selector[:]) + arg.FillBytes(buf[4:]) + return "0x" + hex.EncodeToString(buf), nil +} + +func erc4626DecodeHex(data string) ([]byte, error) { + if strings.HasPrefix(data, "0x") { + data = data[2:] + } + if data == "" { + return nil, fmt.Errorf("empty result") + } + if len(data)%2 != 0 { + return nil, fmt.Errorf("invalid hex length") + } + buf, err := hex.DecodeString(data) + if err != nil { + return nil, err + } + return buf, nil +} + +func erc4626DecodeUint(data string) (*big.Int, error) { + buf, err := erc4626DecodeHex(data) + if err != nil { + return nil, err + } + if len(buf) < 32 { + return nil, fmt.Errorf("result too short") + } + return new(big.Int).SetBytes(buf[:32]), nil +} + +func erc4626DecodeAddress(data string) (string, error) { + buf, err := erc4626DecodeHex(data) + if err != nil { + return "", err + } + if len(buf) < 32 { + return "", fmt.Errorf("result too short") + } + return ethcommon.BytesToAddress(buf[12:32]).Hex(), nil +} + +func erc4626UnitAmount(decimals int) (*big.Int, error) { + if decimals < 0 || decimals > erc4626MaxDecimals { + return nil, fmt.Errorf("unsupported decimals %d", decimals) + } + if decimals == 0 { + return big.NewInt(1), nil + } + return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil), nil +} diff --git a/api/erc4626_live_cache.go b/api/erc4626_live_cache.go new file mode 100644 index 0000000000..061837c190 --- /dev/null +++ b/api/erc4626_live_cache.go @@ -0,0 +1,209 @@ +package api + +import ( + "container/list" + "strconv" + "strings" + "sync" + + "golang.org/x/sync/singleflight" +) + +// erc4626CacheCapacity bounds the live-values cache, keyed by +// (contract, height, reorgGen). Old entries age out as best-block advances. +const erc4626CacheCapacity = 1024 +const erc4626NegativeProbeCacheCapacity = 4096 + +var erc4626LiveCache = newErc4626Cache(erc4626CacheCapacity) +var erc4626NegativeProbeCache = newErc4626NegativeCache(erc4626NegativeProbeCacheCapacity) + +// lruCache is a string-keyed LRU shared by the live-values and negative +// caches. Methods are nil-safe so a disabled (capacity<=0) cache no-ops. +type lruCache[V any] struct { + mu sync.Mutex + capacity int + order *list.List + items map[string]*list.Element +} + +type lruEntry[V any] struct { + key string + value V +} + +func newLRUCache[V any](capacity int) *lruCache[V] { + if capacity <= 0 { + return nil + } + return &lruCache[V]{ + capacity: capacity, + order: list.New(), + items: make(map[string]*list.Element, capacity), + } +} + +func (c *lruCache[V]) get(key string) (V, bool) { + var zero V + if c == nil { + return zero, false + } + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[key] + if !ok { + return zero, false + } + c.order.MoveToFront(el) + return el.Value.(*lruEntry[V]).value, true +} + +func (c *lruCache[V]) add(key string, value V) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + el.Value.(*lruEntry[V]).value = value + c.order.MoveToFront(el) + return + } + el := c.order.PushFront(&lruEntry[V]{key: key, value: value}) + c.items[key] = el + if c.order.Len() <= c.capacity { + return + } + oldest := c.order.Back() + if oldest == nil { + return + } + c.order.Remove(oldest) + delete(c.items, oldest.Value.(*lruEntry[V]).key) +} + +func (c *lruCache[V]) remove(key string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[key] + if !ok { + return + } + c.order.Remove(el) + delete(c.items, key) +} + +// erc4626Cache memoises Erc4626Token (including nil for non-vaults) per +// (contract, height, gen); singleflight dedupes concurrent builds. +type erc4626Cache struct { + lru *lruCache[*Erc4626Token] + sf singleflight.Group +} + +func newErc4626Cache(capacity int) *erc4626Cache { + lru := newLRUCache[*Erc4626Token](capacity) + if lru == nil { + return nil + } + return &erc4626Cache{lru: lru} +} + +// erc4626CacheKey scopes entries by (contract, height, reorgGen) so a +// same-height reorg invalidates pre-reorg entries via key mismatch. +func erc4626CacheKey(contract string, blockHeight uint32, reorgGen uint64) string { + return erc4626ContractKey(contract) + ":" + strconv.FormatUint(uint64(blockHeight), 10) + ":" + strconv.FormatUint(reorgGen, 10) +} + +// erc4626CacheLookupOrBuild returns the cached token, or runs build() once +// across concurrent callers via singleflight. build's error is a cache-policy +// signal: nil ⇒ memoise; non-nil ⇒ skip cache (so a transient failure doesn't +// poison detection for the rest of the block). Callers see only the token. +func erc4626CacheLookupOrBuild(cache *erc4626Cache, key string, build func() (*Erc4626Token, error)) *Erc4626Token { + if cache == nil { + token, _ := build() + return token + } + if cached, ok := cache.lru.get(key); ok { + return cached + } + v, _, _ := cache.sf.Do(key, func() (interface{}, error) { + // Re-check: a peer may have populated while we waited to enter Do. + if cached, ok := cache.lru.get(key); ok { + return cached, nil + } + token, err := build() + if err == nil { + cache.lru.add(key, token) + } + // Never echo build's error to waiters; they want the token. + return token, nil + }) + if v == nil { + return nil + } + return v.(*Erc4626Token) +} + +func erc4626ContractKey(contract string) string { + return strings.ToLower(contract) +} + +// erc4626NegativeCache is an in-memory LRU of recent "not a vault" results +// for accountInfo. Not persisted; entries expire after the per-add ttlBlocks +// and on reorgGen mismatch (so a pre-reorg negative misses after disconnect). +// +// ttlBlocks is supplied per add() rather than fixed at construction so the +// caller can derive it from the chain's averageBlockTimeMs at request time. +// That keeps the user-visible TTL roughly the same wall-clock duration +// across chains regardless of block cadence. +type erc4626NegativeCacheEntry struct { + expireAt uint64 + reorgGen uint64 +} + +type erc4626NegativeCache struct { + lru *lruCache[erc4626NegativeCacheEntry] +} + +func newErc4626NegativeCache(capacity int) *erc4626NegativeCache { + lru := newLRUCache[erc4626NegativeCacheEntry](capacity) + if lru == nil { + return nil + } + return &erc4626NegativeCache{lru: lru} +} + +func (c *erc4626NegativeCache) contains(contract string, currentHeight uint32, reorgGen uint64) bool { + if c == nil || currentHeight == 0 { + return false + } + key := erc4626ContractKey(contract) + entry, ok := c.lru.get(key) + if !ok { + return false + } + if entry.reorgGen != reorgGen || uint64(currentHeight) > entry.expireAt { + c.lru.remove(key) + return false + } + return true +} + +func (c *erc4626NegativeCache) add(contract string, currentHeight, ttlBlocks uint32, reorgGen uint64) { + if c == nil || currentHeight == 0 || ttlBlocks == 0 { + return + } + c.lru.add(erc4626ContractKey(contract), erc4626NegativeCacheEntry{ + expireAt: uint64(currentHeight) + uint64(ttlBlocks), + reorgGen: reorgGen, + }) +} + +func (c *erc4626NegativeCache) remove(contract string) { + if c == nil { + return + } + c.lru.remove(erc4626ContractKey(contract)) +} diff --git a/api/erc4626_live_cache_test.go b/api/erc4626_live_cache_test.go new file mode 100644 index 0000000000..6da751dd95 --- /dev/null +++ b/api/erc4626_live_cache_test.go @@ -0,0 +1,363 @@ +package api + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestErc4626Cache_HitAndMiss(t *testing.T) { + cache := newErc4626Cache(4) + build := func() (*Erc4626Token, error) { return &Erc4626Token{Error: "first"}, nil } + + got := erc4626CacheLookupOrBuild(cache, "k1", build) + if got == nil || got.Error != "first" { + t.Fatalf("first call: got %+v", got) + } + + // Same key returns cached entry without invoking build. + called := 0 + again := erc4626CacheLookupOrBuild(cache, "k1", func() (*Erc4626Token, error) { + called++ + return &Erc4626Token{Error: "second"}, nil + }) + if called != 0 { + t.Fatalf("build invoked on cache hit (called=%d)", called) + } + if again == nil || again.Error != "first" { + t.Fatalf("expected cached value, got %+v", again) + } + + // Different key triggers build. + other := erc4626CacheLookupOrBuild(cache, "k2", func() (*Erc4626Token, error) { + return &Erc4626Token{Error: "other"}, nil + }) + if other == nil || other.Error != "other" { + t.Fatalf("k2 wrong: %+v", other) + } +} + +func TestErc4626Cache_StoresNil(t *testing.T) { + cache := newErc4626Cache(4) + got := erc4626CacheLookupOrBuild(cache, "non-vault", func() (*Erc4626Token, error) { return nil, nil }) + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } + // Subsequent call must not re-invoke build for the same key. + called := 0 + got = erc4626CacheLookupOrBuild(cache, "non-vault", func() (*Erc4626Token, error) { + called++ + return nil, nil + }) + if called != 0 { + t.Fatalf("build invoked on cached nil (called=%d)", called) + } + if got != nil { + t.Fatalf("expected cached nil, got %+v", got) + } +} + +// A transient transport error must surface the value to the caller (so the +// current request still gets a sensible response) without polluting the LRU. +// Two consecutive calls must both invoke build, and the LRU must contain no +// entry for the key after either call. +func TestErc4626Cache_TransportErrorNotCached(t *testing.T) { + cache := newErc4626Cache(4) + var calls atomic.Int32 + build := func() (*Erc4626Token, error) { + calls.Add(1) + return nil, errors.New("rpc down") + } + + if got := erc4626CacheLookupOrBuild(cache, "k1", build); got != nil { + t.Fatalf("expected nil on transport error, got %+v", got) + } + if got := erc4626CacheLookupOrBuild(cache, "k1", build); got != nil { + t.Fatalf("expected nil on transport error, got %+v", got) + } + if n := calls.Load(); n != 2 { + t.Fatalf("transport-errored build must not be cached: expected 2 invocations, got %d", n) + } + if _, ok := cache.lru.get("k1"); ok { + t.Fatal("LRU must not contain an entry for a transport-errored build") + } + + // A successful follow-up must still land in the cache. + follow := erc4626CacheLookupOrBuild(cache, "k1", func() (*Erc4626Token, error) { + return &Erc4626Token{Error: "recovered"}, nil + }) + if follow == nil || follow.Error != "recovered" { + t.Fatalf("post-error retry must rebuild and cache, got %+v", follow) + } + if _, ok := cache.lru.get("k1"); !ok { + t.Fatal("LRU must contain an entry after a successful build") + } +} + +// A partial result paired with a transient error (e.g. warm-path multicall RPC +// failed but metadata is populated) must reach the caller without being cached. +func TestErc4626Cache_PartialResultWithErrorNotCached(t *testing.T) { + cache := newErc4626Cache(4) + var calls atomic.Int32 + build := func() (*Erc4626Token, error) { + calls.Add(1) + return &Erc4626Token{Error: "multicall: rpc down"}, errors.New("multicall: rpc down") + } + + first := erc4626CacheLookupOrBuild(cache, "k1", build) + if first == nil || first.Error == "" { + t.Fatalf("expected partial result returned to caller, got %+v", first) + } + second := erc4626CacheLookupOrBuild(cache, "k1", build) + if second == nil { + t.Fatal("expected partial result on second call") + } + if n := calls.Load(); n != 2 { + t.Fatalf("partial-with-error must not be cached: expected 2 invocations, got %d", n) + } + if _, ok := cache.lru.get("k1"); ok { + t.Fatal("LRU must not contain an entry for a partial result paired with an error") + } +} + +func TestErc4626Cache_LRUEvictsOldest(t *testing.T) { + cache := newErc4626Cache(2) + a := erc4626CacheLookupOrBuild(cache, "a", func() (*Erc4626Token, error) { return &Erc4626Token{Error: "a"}, nil }) + _ = erc4626CacheLookupOrBuild(cache, "b", func() (*Erc4626Token, error) { return &Erc4626Token{Error: "b"}, nil }) + // Touch a to keep it hot. + _ = erc4626CacheLookupOrBuild(cache, "a", func() (*Erc4626Token, error) { t.Fatal("a should be cached"); return nil, nil }) + // Add c -> b should be evicted, a should remain. + _ = erc4626CacheLookupOrBuild(cache, "c", func() (*Erc4626Token, error) { return &Erc4626Token{Error: "c"}, nil }) + if v, ok := cache.lru.get("a"); !ok || v != a { + t.Fatalf("a evicted unexpectedly") + } + if _, ok := cache.lru.get("b"); ok { + t.Fatal("b should have been evicted") + } + if _, ok := cache.lru.get("c"); !ok { + t.Fatal("c not cached") + } +} + +func TestErc4626Cache_SingleflightCollapsesConcurrentCalls(t *testing.T) { + cache := newErc4626Cache(4) + const concurrency = 32 + + var calls atomic.Int32 + gate := make(chan struct{}) + build := func() (*Erc4626Token, error) { + calls.Add(1) + <-gate // hold first caller until peers have all entered Do + return &Erc4626Token{Error: "shared"}, nil + } + + var wg sync.WaitGroup + results := make([]*Erc4626Token, concurrency) + wg.Add(concurrency) + for i := 0; i < concurrency; i++ { + i := i + go func() { + defer wg.Done() + results[i] = erc4626CacheLookupOrBuild(cache, "shared-key", build) + }() + } + // Wait for the first builder to enter Do; the singleflight group only + // dedupes calls that arrive while the first is still in flight. Bounded + // by a deadline so a regression that prevents calls from ever reaching 1 + // fails the test instead of hanging CI. + deadline := time.Now().Add(2 * time.Second) + for calls.Load() < 1 { + if time.Now().After(deadline) { + close(gate) + wg.Wait() + t.Fatalf("timed out waiting for first builder; calls=%d", calls.Load()) + } + time.Sleep(time.Millisecond) + } + close(gate) + wg.Wait() + + if got := calls.Load(); got != 1 { + t.Fatalf("singleflight should have collapsed to 1 build call, got %d", got) + } + for i, r := range results { + if r == nil || r.Error != "shared" { + t.Fatalf("result[%d] mismatch: %+v", i, r) + } + } +} + +// Under concurrent first-time access, an errored build must not end up in the +// LRU regardless of how many peers raced into the singleflight group, and a +// follow-up call must rebuild fresh rather than seeing a stale negative. +// +// We deliberately do NOT assert a specific singleflight collapse count here. +// Errored builds are not cached, so any goroutine that reaches Do after the +// in-flight call has returned legitimately starts its own build — the exact +// number of build invocations is scheduler-dependent (especially under -race). +// The cacheable success path is exercised by +// TestErc4626Cache_SingleflightCollapsesConcurrentCalls; this test focuses on +// the policy that distinguishes it: errors must not poison the cache. +func TestErc4626Cache_ConcurrentErrorsDoNotPoisonCache(t *testing.T) { + cache := newErc4626Cache(4) + const concurrency = 16 + + var calls atomic.Int32 + gate := make(chan struct{}) + build := func() (*Erc4626Token, error) { + calls.Add(1) + <-gate + return nil, errors.New("rpc down") + } + + var wg sync.WaitGroup + results := make([]*Erc4626Token, concurrency) + wg.Add(concurrency) + for i := 0; i < concurrency; i++ { + i := i + go func() { + defer wg.Done() + results[i] = erc4626CacheLookupOrBuild(cache, "errored-key", build) + }() + } + deadline := time.Now().Add(2 * time.Second) + for calls.Load() < 1 { + if time.Now().After(deadline) { + close(gate) + wg.Wait() + t.Fatalf("timed out waiting for first builder; calls=%d", calls.Load()) + } + time.Sleep(time.Millisecond) + } + close(gate) + wg.Wait() + + if calls.Load() < 1 { + t.Fatal("expected at least one build invocation") + } + for i, r := range results { + if r != nil { + t.Fatalf("result[%d] expected nil on errored build, got %+v", i, r) + } + } + if _, ok := cache.lru.get("errored-key"); ok { + t.Fatal("LRU must not contain an entry for an errored build, even under concurrent load") + } + + // Post-error: the next caller must rebuild fresh (no stale negative). + follow := erc4626CacheLookupOrBuild(cache, "errored-key", func() (*Erc4626Token, error) { + return &Erc4626Token{Error: "recovered"}, nil + }) + if follow == nil || follow.Error != "recovered" { + t.Fatalf("post-error retry must rebuild, got %+v", follow) + } +} + +func TestErc4626CacheKey_NormalizesContract(t *testing.T) { + if a, b := erc4626CacheKey("0xAbCd", 7, 0), erc4626CacheKey("0xabcd", 7, 0); a != b { + t.Fatalf("expected case-insensitive key, got %q vs %q", a, b) + } + if a, b := erc4626CacheKey("0xabcd", 7, 0), erc4626CacheKey("0xabcd", 8, 0); a == b { + t.Fatal("different heights must yield different keys") + } + if a, b := erc4626CacheKey("0xabcd", 7, 0), erc4626CacheKey("0xabcd", 7, 1); a == b { + t.Fatal("different reorg generations must yield different keys") + } +} + +func TestErc4626CacheLookupOrBuild_NilCacheFallsThrough(t *testing.T) { + called := 0 + got := erc4626CacheLookupOrBuild(nil, "k", func() (*Erc4626Token, error) { + called++ + return &Erc4626Token{Error: "bypass"}, nil + }) + if called != 1 || got == nil || got.Error != "bypass" { + t.Fatalf("nil cache should bypass: called=%d got=%+v", called, got) + } + + // Nil cache also drops the build error and surfaces the value (matches the + // no-bestHeight path in buildErc4626Token, which has no cache to skip). + called = 0 + got = erc4626CacheLookupOrBuild(nil, "k2", func() (*Erc4626Token, error) { + called++ + return &Erc4626Token{Error: "partial"}, errors.New("transient") + }) + if called != 1 || got == nil || got.Error != "partial" { + t.Fatalf("nil cache should still pass through partial result on error: called=%d got=%+v", called, got) + } +} + +func TestErc4626NegativeProbeCache_HitExpireAndRemove(t *testing.T) { + cache := newErc4626NegativeCache(2) + const ttl = uint32(2) + if cache.contains("0xabc", 10, 0) { + t.Fatal("empty cache should miss") + } + + cache.add("0xAbC", 10, ttl, 0) + if !cache.contains("0xabc", 10, 0) { + t.Fatal("expected hit at insertion height") + } + if !cache.contains("0xABC", 12, 0) { + t.Fatal("expected hit before expiry") + } + if cache.contains("0xabc", 13, 0) { + t.Fatal("expected miss after expiry") + } + + cache.add("0xabc", 20, ttl, 0) + cache.remove("0xABC") + if cache.contains("0xabc", 20, 0) { + t.Fatal("expected miss after explicit remove") + } +} + +func TestErc4626NegativeProbeCache_ZeroTTLBlocksIsNoOp(t *testing.T) { + // ttlBlocks == 0 represents "chain block time unavailable" — the cache + // must drop the add silently and treat it as a miss on lookup. + cache := newErc4626NegativeCache(2) + cache.add("0xabc", 10, 0, 0) + if cache.contains("0xabc", 10, 0) { + t.Fatal("entry inserted with ttlBlocks==0 should be absent") + } +} + +func TestErc4626NegativeProbeCache_ReorgGenInvalidates(t *testing.T) { + cache := newErc4626NegativeCache(2) + const ttl = uint32(100) + cache.add("0xabc", 10, ttl, 7) + if !cache.contains("0xabc", 10, 7) { + t.Fatal("hit on matching reorg generation expected") + } + if cache.contains("0xabc", 10, 8) { + t.Fatal("entry from older reorg generation must miss") + } + // the mismatched-gen lookup also evicts the entry, so a same-gen reprobe sees a fresh miss + if cache.contains("0xabc", 10, 7) { + t.Fatal("entry should have been evicted on reorg-gen mismatch") + } +} + +func TestErc4626BlocksForDuration(t *testing.T) { + // 15 minutes / 12s blocks → 75 blocks (Ethereum). + if got := erc4626BlocksForDuration(15*time.Minute, 12*time.Second); got != 75 { + t.Fatalf("Ethereum: got %d, want 75", got) + } + // 15 minutes / 250ms blocks → 3600 blocks (Arbitrum). + if got := erc4626BlocksForDuration(15*time.Minute, 250*time.Millisecond); got != 3600 { + t.Fatalf("Arbitrum: got %d, want 3600", got) + } + // Rounding up: 1ns under a clean block boundary still uses one full block. + if got := erc4626BlocksForDuration(13*time.Second, 12*time.Second); got != 2 { + t.Fatalf("ceil division: got %d, want 2", got) + } + // Zero / negative inputs disable the optimization. + if got := erc4626BlocksForDuration(0, time.Second); got != 0 { + t.Fatalf("zero duration must yield 0, got %d", got) + } + if got := erc4626BlocksForDuration(time.Minute, 0); got != 0 { + t.Fatalf("zero blockTime must yield 0, got %d", got) + } +} diff --git a/api/erc4626_test.go b/api/erc4626_test.go new file mode 100644 index 0000000000..8ed680e062 --- /dev/null +++ b/api/erc4626_test.go @@ -0,0 +1,928 @@ +package api + +import ( + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain" +) + +// fakeMulticaller records calls and replays a sequence of canned responses. +type fakeMulticaller struct { + calls [][]bchain.EthereumMulticallCall + handlers []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) + idx int +} + +func (f *fakeMulticaller) EthereumTypeMulticallAggregate3(calls []bchain.EthereumMulticallCall, _ *big.Int) ([]bchain.EthereumMulticallResult, error) { + copied := append([]bchain.EthereumMulticallCall(nil), calls...) + f.calls = append(f.calls, copied) + if f.idx >= len(f.handlers) { + return nil, fmt.Errorf("unexpected multicall call %d", f.idx) + } + h := f.handlers[f.idx] + f.idx++ + return h(calls) +} + +func encodeWordAddress(address string) string { + a := ethcommon.HexToAddress(address) + word := make([]byte, 32) + copy(word[12:], a.Bytes()) + return "0x" + hex.EncodeToString(word) +} + +func encodeWordUint(v *big.Int) string { + word := make([]byte, 32) + v.FillBytes(word) + return "0x" + hex.EncodeToString(word) +} + +func TestBuildErc4626Token_ColdPath_PersistsAssetAndIssuesTwoMulticalls(t *testing.T) { + const vault = "0x00000000000000000000000000000000000000a1" + const asset = "0x00000000000000000000000000000000000000b2" + totalAssets := big.NewInt(123456) + convertToAssets := big.NewInt(2_000_000_000_000_000_000) + previewRedeem := big.NewInt(1_999_000_000_000_000_000) + convertToShares := big.NewInt(500_000) + previewDeposit := big.NewInt(499_750) + + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + // Multicall A: asset, totalAssets, convertToAssets(1share), previewRedeem(1share) + func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + if len(calls) != 4 { + t.Fatalf("expected 4 calls in multicall A, got %d", len(calls)) + } + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(asset)}, + {Success: true, Data: encodeWordUint(totalAssets)}, + {Success: true, Data: encodeWordUint(convertToAssets)}, + {Success: true, Data: encodeWordUint(previewRedeem)}, + }, nil + }, + // Multicall B: convertToShares(1asset), previewDeposit(1asset) + func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + if len(calls) != 2 { + t.Fatalf("expected 2 calls in multicall B, got %d", len(calls)) + } + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordUint(convertToShares)}, + {Success: true, Data: encodeWordUint(previewDeposit)}, + }, nil + }, + }, + } + + var persistedAddr, persistedAsset string + persisted := 0 + persister := func(addr, ast string) error { + persisted++ + persistedAddr, persistedAsset = addr, ast + return nil + } + getContractInfo := func(contract string, _ bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + if !strings.EqualFold(contract, asset) { + t.Fatalf("unexpected getContractInfo target %s", contract) + } + return &bchain.ContractInfo{Contract: asset, Name: "USD Coin", Symbol: "USDC", Decimals: 6}, true, nil + } + + ci := &bchain.ContractInfo{Contract: vault, Name: "Vault Share", Symbol: "vUSDC", Decimals: 18} + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if err != nil { + t.Fatalf("expected nil err on a fully-successful build (cacheable), got %v", err) + } + if got == nil { + t.Fatal("expected non-nil result") + } + if got.Error != "" { + t.Fatalf("expected no error string, got %q", got.Error) + } + if got.Asset == nil || got.Asset.Decimals != 6 || got.Asset.Symbol != "USDC" { + t.Fatalf("asset metadata wrong: %+v", got.Asset) + } + if got.Share == nil || got.Share.Decimals != 18 || got.Share.Symbol != "vUSDC" { + t.Fatalf("share metadata wrong: %+v", got.Share) + } + if got.TotalAssetsSat == nil || (*big.Int)(got.TotalAssetsSat).Cmp(totalAssets) != 0 { + t.Fatalf("totalAssets wrong: %v", got.TotalAssetsSat) + } + if got.ConvertToAssets1ShareSat == nil || got.PreviewRedeem1ShareSat == nil { + t.Fatal("share-side conversions missing") + } + if got.ConvertToShares1AssetSat == nil || got.PreviewDeposit1AssetSat == nil { + t.Fatal("asset-side conversions missing") + } + if persisted != 1 || persistedAddr != vault || !strings.EqualFold(persistedAsset, asset) { + t.Fatalf("persister not called correctly: count=%d addr=%s asset=%s", persisted, persistedAddr, persistedAsset) + } + if len(mc.calls) != 2 { + t.Fatalf("expected 2 multicalls, got %d", len(mc.calls)) + } +} + +func TestBuildErc4626Token_WarmPath_OneMulticall(t *testing.T) { + const vault = "0x00000000000000000000000000000000000000a1" + const asset = "0x00000000000000000000000000000000000000b2" + totalAssets := big.NewInt(50) + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + if len(calls) != 5 { + t.Fatalf("expected 5 calls, got %d", len(calls)) + } + results := make([]bchain.EthereumMulticallResult, 5) + results[0] = bchain.EthereumMulticallResult{Success: true, Data: encodeWordUint(totalAssets)} + for i := 1; i < 5; i++ { + results[i] = bchain.EthereumMulticallResult{Success: true, Data: encodeWordUint(big.NewInt(int64(i)))} + } + return results, nil + }, + }, + } + persister := func(string, string) error { + t.Fatal("warm path must not persist") + return nil + } + getContractInfo := func(contract string, _ bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + return &bchain.ContractInfo{Contract: asset, Name: "USDC", Symbol: "USDC", Decimals: 6}, true, nil + } + ci := &bchain.ContractInfo{ + Contract: vault, + Name: "Vault Share", + Symbol: "vUSDC", + Decimals: 18, + IsErc4626: true, + Erc4626AssetContract: asset, + } + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if err != nil { + t.Fatalf("expected nil err on a fully-successful warm build, got %v", err) + } + if got == nil || got.Error != "" { + t.Fatalf("warm-path failed: %+v", got) + } + if len(mc.calls) != 1 { + t.Fatalf("warm path expected 1 multicall, got %d", len(mc.calls)) + } + if got.TotalAssetsSat == nil || (*big.Int)(got.TotalAssetsSat).Cmp(totalAssets) != 0 { + t.Fatalf("totalAssets wrong: %v", got.TotalAssetsSat) + } + if got.ConvertToAssets1ShareSat == nil || got.PreviewRedeem1ShareSat == nil || + got.ConvertToShares1AssetSat == nil || got.PreviewDeposit1AssetSat == nil { + t.Fatalf("conversion fields missing: %+v", got) + } +} + +func TestBuildErc4626Token_TotalAssetsFails_NoPersistAndReturnsNil(t *testing.T) { + // Detection must require BOTH asset() and totalAssets() to succeed. A fungible + // contract that exposes asset() returning some non-zero value but reverts on + // totalAssets() must NOT be persisted as an ERC4626 vault, otherwise accountInfo + // would falsely advertise erc4626 support for it on every subsequent request. + const vault = "0x00000000000000000000000000000000000000d1" + const fakeAsset = "0x00000000000000000000000000000000000000ee" + + for _, tc := range []struct { + name string + totalAssets bchain.EthereumMulticallResult + }{ + {"reverted", bchain.EthereumMulticallResult{Success: false, Data: "0x"}}, + {"undecodable", bchain.EthereumMulticallResult{Success: true, Data: "0x1234"}}, // < 32 bytes + } { + t.Run(tc.name, func(t *testing.T) { + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(fakeAsset)}, + tc.totalAssets, + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + }, nil + }, + }, + } + persisted := 0 + persister := func(string, string) error { + persisted++ + return nil + } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + t.Fatal("must not lazy-fetch asset metadata when detection fails") + return nil, false, nil + } + ci := &bchain.ContractInfo{Contract: vault, Decimals: 18} + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if got != nil { + t.Fatalf("expected nil when totalAssets fails, got %+v", got) + } + // Detection failure is a deterministic on-chain answer ("not a vault") + // and must be cacheable: err must be nil so the LRU memoises (nil). + if err != nil { + t.Fatalf("deterministic 'not a vault' must return nil err so the cache memoises it, got %v", err) + } + if persisted != 0 { + t.Fatalf("must not persist when totalAssets fails (persisted=%d)", persisted) + } + if len(mc.calls) != 1 { + t.Fatalf("expected exactly 1 multicall (no asset-side fetch), got %d", len(mc.calls)) + } + }) + } +} + +func TestBuildErc4626Token_NotAVault_ReturnsNil(t *testing.T) { + const vault = "0x00000000000000000000000000000000000000c1" + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(erc4626ZeroAddress)}, // asset() = 0x0 + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + }, nil + }, + }, + } + persister := func(string, string) error { + t.Fatal("must not persist as vault when contract is not a vault") + return nil + } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + t.Fatal("must not fetch asset metadata when contract is not a vault") + return nil, false, nil + } + ci := &bchain.ContractInfo{Contract: vault, Decimals: 18} + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if got != nil { + t.Fatalf("expected nil for non-vault, got %+v", got) + } + if err != nil { + t.Fatalf("'not a vault' must return nil err so the cache can memoise it, got %v", err) + } +} + +func TestBuildErc4626Token_AssetMetadataInvalid_OmitsAssetMetadata(t *testing.T) { + const vault = "0x00000000000000000000000000000000000000a1" + const asset = "0x00000000000000000000000000000000000000b2" + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(asset)}, + {Success: true, Data: encodeWordUint(big.NewInt(7))}, + {Success: true, Data: encodeWordUint(big.NewInt(8))}, + {Success: true, Data: encodeWordUint(big.NewInt(9))}, + }, nil + }, + // Multicall B should NOT be issued because asset metadata is invalid. + }, + } + persister := func(string, string) error { return nil } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + return nil, false, nil // asset contract not a known fungible token + } + ci := &bchain.ContractInfo{Contract: vault, Decimals: 18} + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if got == nil { + t.Fatal("expected partial result, got nil") + } + // (nil, false, nil) from the fetcher is a deterministic "not in our store" + // answer (no transport problem), so the build must report no transient + // error and stay cacheable for the block. + if err != nil { + t.Fatalf("deterministic 'asset metadata unavailable' must remain cacheable, got err %v", err) + } + if got.TotalAssetsSat == nil { + t.Fatal("totalAssets should still be populated from multicall A") + } + if got.Asset != nil { + t.Fatalf("asset metadata must be omitted when decimals are unavailable, got %+v", got.Asset) + } + if got.ConvertToShares1AssetSat != nil || got.PreviewDeposit1AssetSat != nil { + t.Fatalf("asset-side conversions should be skipped when asset metadata invalid") + } + if !strings.Contains(got.Error, "asset metadata unavailable") { + t.Fatalf("expected error to mention asset metadata, got %q", got.Error) + } + if len(mc.calls) != 1 { + t.Fatalf("expected 1 multicall when asset metadata invalid, got %d", len(mc.calls)) + } +} + +func TestBuildErc4626Token_ColdMulticallError_ReturnsNilAndTransientErr(t *testing.T) { + // A multicall A transport error must return (nil, err) — caller sees no + // enrichment, and the cache layer must skip persisting the negative. + const vault = "0x00000000000000000000000000000000000000a1" + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return nil, errors.New("rpc down") + }, + }, + } + persister := func(string, string) error { + t.Fatal("must not persist on transport error") + return nil + } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { return nil, false, nil } + ci := &bchain.ContractInfo{Contract: vault, Decimals: 18} + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if got != nil { + t.Fatalf("expected nil on multicall error, got %+v", got) + } + if err == nil { + t.Fatal("transport error must propagate so the cache skips memoising the negative") + } +} + +func TestBuildErc4626Token_ColdAssetMetadataError_ReturnsResultAndTransientErr(t *testing.T) { + // Cold detection succeeds, then the asset-metadata fetcher errors transiently + // (e.g. DB or RPC blip). The vault is real, so the caller must receive the + // confirmed-vault snapshot — but the build must propagate the error so the + // cache does not memoise a metadata-less view of the vault for the block. + const vault = "0x00000000000000000000000000000000000000a1" + const asset = "0x00000000000000000000000000000000000000b2" + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(asset)}, + {Success: true, Data: encodeWordUint(big.NewInt(7))}, + {Success: true, Data: encodeWordUint(big.NewInt(1))}, + {Success: true, Data: encodeWordUint(big.NewInt(2))}, + }, nil + }, + }, + } + persister := func(string, string) error { return nil } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + return nil, false, errors.New("db blip") + } + ci := &bchain.ContractInfo{Contract: vault, Decimals: 18} + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if got == nil { + t.Fatal("expected confirmed-vault result even when asset metadata fetcher errors") + } + if err == nil { + t.Fatal("metadata-fetcher transient error must propagate so the cache skips this entry") + } + if !strings.Contains(got.Error, "asset metadata") { + t.Fatalf("expected got.Error to mention asset metadata, got %q", got.Error) + } + if got.Asset != nil { + t.Fatalf("asset metadata must be omitted when metadata fetcher errors, got %+v", got.Asset) + } + if got.TotalAssetsSat == nil { + t.Fatal("totalAssets should still be populated from multicall A") + } +} + +func TestBuildErc4626Token_WarmAssetMetadataInvalid_OmitsAssetMetadata(t *testing.T) { + const vault = "0x00000000000000000000000000000000000000a1" + const asset = "0x00000000000000000000000000000000000000b2" + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + if len(calls) != 3 { + t.Fatalf("expected totalAssets and share-side calls only, got %d calls", len(calls)) + } + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordUint(big.NewInt(7))}, + {Success: true, Data: encodeWordUint(big.NewInt(1))}, + {Success: true, Data: encodeWordUint(big.NewInt(2))}, + }, nil + }, + }, + } + persister := func(string, string) error { + t.Fatal("warm path must not persist") + return nil + } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + return nil, false, nil + } + ci := &bchain.ContractInfo{ + Contract: vault, + Decimals: 18, + IsErc4626: true, + Erc4626AssetContract: asset, + } + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if err != nil { + t.Fatalf("deterministic asset metadata miss should remain cacheable, got %v", err) + } + if got == nil { + t.Fatal("expected partial warm result") + } + if got.Asset != nil { + t.Fatalf("asset metadata must be omitted when decimals are unavailable, got %+v", got.Asset) + } + if got.ConvertToShares1AssetSat != nil || got.PreviewDeposit1AssetSat != nil { + t.Fatalf("asset-side conversions should be skipped without asset decimals: %+v", got) + } + if got.ConvertToAssets1ShareSat == nil || got.PreviewRedeem1ShareSat == nil { + t.Fatalf("share-side conversions should still be returned: %+v", got) + } + if !strings.Contains(got.Error, "asset metadata unavailable") { + t.Fatalf("expected error to mention asset metadata, got %q", got.Error) + } +} + +func TestBuildErc4626Token_ColdMulticallBError_ReturnsResultAndTransientErr(t *testing.T) { + // Cold detection succeeds, asset metadata is available, but multicall B + // (asset-side conversions) errors transiently. The caller gets the partial + // snapshot; the cache must skip so a fresh attempt happens next request. + const vault = "0x00000000000000000000000000000000000000a1" + const asset = "0x00000000000000000000000000000000000000b2" + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(asset)}, + {Success: true, Data: encodeWordUint(big.NewInt(42))}, + {Success: true, Data: encodeWordUint(big.NewInt(1))}, + {Success: true, Data: encodeWordUint(big.NewInt(2))}, + }, nil + }, + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return nil, errors.New("multicall B down") + }, + }, + } + persister := func(string, string) error { return nil } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + return &bchain.ContractInfo{Contract: asset, Name: "USDC", Symbol: "USDC", Decimals: 6}, true, nil + } + ci := &bchain.ContractInfo{Contract: vault, Decimals: 18} + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if got == nil { + t.Fatal("expected partial result on multicall B error") + } + if err == nil { + t.Fatal("multicall B transient error must propagate so the cache skips this entry") + } + if got.ConvertToShares1AssetSat != nil || got.PreviewDeposit1AssetSat != nil { + t.Fatalf("asset-side conversions must be nil when multicall B failed, got %+v", got) + } + if !strings.Contains(got.Error, "asset-side multicall") { + t.Fatalf("expected got.Error to mention asset-side multicall, got %q", got.Error) + } +} + +func TestBuildErc4626Token_WarmMulticallError_ReturnsPartialAndTransientErr(t *testing.T) { + // Warm path: vault is already known. Multicall transport failure must yield + // the metadata-only partial result AND a non-nil err so the cache layer + // skips this entry rather than memoising a totalAssets-less view. + const vault = "0x00000000000000000000000000000000000000a1" + const asset = "0x00000000000000000000000000000000000000b2" + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return nil, errors.New("rpc down") + }, + }, + } + persister := func(string, string) error { + t.Fatal("warm path must not persist") + return nil + } + getContractInfo := func(string, bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + return &bchain.ContractInfo{Contract: asset, Name: "USDC", Symbol: "USDC", Decimals: 6}, true, nil + } + ci := &bchain.ContractInfo{ + Contract: vault, + Name: "Vault Share", + Symbol: "vUSDC", + Decimals: 18, + IsErc4626: true, + Erc4626AssetContract: asset, + } + got, err := buildErc4626TokenWithDeps(ci, mc, persister, getContractInfo, nil) + if got == nil { + t.Fatal("warm path must return partial result even on multicall error") + } + if err == nil { + t.Fatal("warm-path multicall error must propagate so the cache skips this entry") + } + if got.TotalAssetsSat != nil { + t.Fatalf("totalAssets must be nil when multicall failed, got %v", got.TotalAssetsSat) + } + if got.Asset == nil || got.Asset.Decimals != 6 || got.Asset.Symbol != "USDC" { + t.Fatalf("asset metadata should still be populated: %+v", got.Asset) + } + if !strings.Contains(got.Error, "multicall:") { + t.Fatalf("expected got.Error to mention multicall, got %q", got.Error) + } +} + +// --- enrichErc4626TokensWithDeps (accountInfo lazy-probe path) --- + +type fakeContractInfoStore map[string]*bchain.ContractInfo + +func (f fakeContractInfoStore) get(contract string, _ bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { + ci, ok := f[strings.ToLower(contract)] + if !ok { + return nil, false, nil + } + return ci, true, nil +} + +const erc4626Standard bchain.TokenStandardName = bchain.ERC20TokenStandard + +func TestEnrichErc4626Tokens_FlagsKnownVaultAndProbesUnprobed(t *testing.T) { + const knownVault = "0x00000000000000000000000000000000000000a1" + const unprobedVault = "0x00000000000000000000000000000000000000a2" + const knownAsset = "0x00000000000000000000000000000000000000b1" + const newAsset = "0x00000000000000000000000000000000000000b2" + + store := fakeContractInfoStore{ + strings.ToLower(knownVault): { + Contract: knownVault, + Standard: erc4626Standard, + IsErc4626: true, + Erc4626AssetContract: knownAsset, + }, + strings.ToLower(unprobedVault): { + Contract: unprobedVault, + Standard: erc4626Standard, + }, + } + + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + if len(calls) != 2 { + t.Fatalf("expected 2 sub-calls (1 unprobed candidate × 2), got %d", len(calls)) + } + if calls[0].Target != unprobedVault || calls[1].Target != unprobedVault { + t.Fatalf("unexpected targets: %s, %s", calls[0].Target, calls[1].Target) + } + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(newAsset)}, + {Success: true, Data: encodeWordUint(big.NewInt(42))}, + }, nil + }, + }, + } + + persisted := map[string]string{} + setVault := func(addr, asset string) error { + persisted[addr] = asset + return nil + } + tokens := Tokens{ + {Contract: knownVault, Standard: erc4626Standard}, + {Contract: unprobedVault, Standard: erc4626Standard}, + } + enrichErc4626TokensWithDeps(tokens, store.get, mc, setVault, nil, 0, 0, 0) + + if !slicesContains(tokens[0].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("known vault must be flagged: %v", tokens[0].Protocols) + } + if !slicesContains(tokens[1].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("freshly-probed vault must be flagged: %v", tokens[1].Protocols) + } + if persisted[unprobedVault] != newAsset { + t.Fatalf("setVault not called as expected: %v", persisted) + } + if len(mc.calls) != 1 { + t.Fatalf("expected exactly 1 batched multicall, got %d", len(mc.calls)) + } +} + +func TestEnrichErc4626Tokens_NegativeProbeDoesNotPersist(t *testing.T) { + const fakeFungible = "0x00000000000000000000000000000000000000d1" + + store := fakeContractInfoStore{ + strings.ToLower(fakeFungible): {Contract: fakeFungible, Standard: erc4626Standard}, + } + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(erc4626ZeroAddress)}, + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + }, nil + }, + }, + } + tokens := Tokens{{Contract: fakeFungible, Standard: erc4626Standard}} + enrichErc4626TokensWithDeps(tokens, store.get, mc, + func(string, string) error { t.Fatal("setVault must not be called for non-vault"); return nil }, + nil, 0, 0, 0) + if slicesContains(tokens[0].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("non-vault must not be flagged: %v", tokens[0].Protocols) + } + if len(mc.calls) != 1 { + t.Fatalf("expected one batched probe for non-vault, got %d", len(mc.calls)) + } +} + +func TestEnrichErc4626Tokens_RecentNegativeSkipsReprobe(t *testing.T) { + const fakeFungible = "0x00000000000000000000000000000000000000d2" + + store := fakeContractInfoStore{ + strings.ToLower(fakeFungible): {Contract: fakeFungible, Standard: erc4626Standard}, + } + negativeCache := newErc4626NegativeCache(4) + negativeCache.add(fakeFungible, 100, 2, 0) + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + t.Fatal("recent negative cache hit must skip multicall") + return nil, nil + }, + }, + } + + tokens := Tokens{{Contract: fakeFungible, Standard: erc4626Standard}} + enrichErc4626TokensWithDeps(tokens, store.get, mc, + func(string, string) error { t.Fatal("setVault must not be called for non-vault"); return nil }, + negativeCache, 101, 2, 0) + + if slicesContains(tokens[0].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("non-vault must not be flagged: %v", tokens[0].Protocols) + } + if len(mc.calls) != 0 { + t.Fatalf("expected zero multicalls on recent negative cache hit, got %d", len(mc.calls)) + } +} + +func TestEnrichErc4626Tokens_NegativeCacheExpiresAndReprobes(t *testing.T) { + const fakeFungible = "0x00000000000000000000000000000000000000d3" + + store := fakeContractInfoStore{ + strings.ToLower(fakeFungible): {Contract: fakeFungible, Standard: erc4626Standard}, + } + negativeCache := newErc4626NegativeCache(4) + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(erc4626ZeroAddress)}, + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + }, nil + }, + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(erc4626ZeroAddress)}, + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + }, nil + }, + }, + } + + tokens := Tokens{{Contract: fakeFungible, Standard: erc4626Standard}} + enrichErc4626TokensWithDeps(tokens, store.get, mc, + func(string, string) error { t.Fatal("setVault must not be called for non-vault"); return nil }, + negativeCache, 100, 2, 0) + enrichErc4626TokensWithDeps(tokens, store.get, mc, + func(string, string) error { t.Fatal("setVault must not be called for non-vault"); return nil }, + negativeCache, 101, 2, 0) + enrichErc4626TokensWithDeps(tokens, store.get, mc, + func(string, string) error { t.Fatal("setVault must not be called for non-vault"); return nil }, + negativeCache, 103, 2, 0) + + if len(mc.calls) != 2 { + t.Fatalf("expected probe, cached skip, then reprobe after expiry; got %d multicalls", len(mc.calls)) + } +} + +func TestEnrichErc4626Tokens_TransportErrorDoesNotPersist(t *testing.T) { + const unprobed = "0x00000000000000000000000000000000000000e1" + store := fakeContractInfoStore{ + strings.ToLower(unprobed): {Contract: unprobed, Standard: erc4626Standard}, + } + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + return nil, errors.New("rpc down") + }, + }, + } + tokens := Tokens{{Contract: unprobed, Standard: erc4626Standard}} + enrichErc4626TokensWithDeps(tokens, store.get, mc, + func(string, string) error { t.Fatal("must not setVault on transport error"); return nil }, + nil, 0, 0, 0) + if slicesContains(tokens[0].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("must not flag on transport error: %v", tokens[0].Protocols) + } +} + +func TestEnrichErc4626Tokens_NoMulticallerStillFlagsKnown(t *testing.T) { + const knownVault = "0x00000000000000000000000000000000000000f1" + const unprobed = "0x00000000000000000000000000000000000000f2" + store := fakeContractInfoStore{ + strings.ToLower(knownVault): {Contract: knownVault, Standard: erc4626Standard, IsErc4626: true}, + strings.ToLower(unprobed): {Contract: unprobed, Standard: erc4626Standard}, + } + tokens := Tokens{ + {Contract: knownVault, Standard: erc4626Standard}, + {Contract: unprobed, Standard: erc4626Standard}, + } + // nil multicaller (chain doesn't support multicall): must still flag known vaults. + enrichErc4626TokensWithDeps(tokens, store.get, nil, nil, nil, 0, 0, 0) + + if !slicesContains(tokens[0].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("known vault must be flagged even without multicaller: %v", tokens[0].Protocols) + } + if slicesContains(tokens[1].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("unprobed must not be flagged when multicaller is unavailable: %v", tokens[1].Protocols) + } +} + +func TestEnrichErc4626Tokens_BatchedMixed(t *testing.T) { + // One multicall covers multiple unprobed candidates with a mix of outcomes: + // vault, non-vault, totalAssets-decode-failure. + const vaultA = "0x0000000000000000000000000000000000000a01" + const fakeB = "0x0000000000000000000000000000000000000a02" + const brokenC = "0x0000000000000000000000000000000000000a03" + const assetA = "0x0000000000000000000000000000000000000ab1" + + store := fakeContractInfoStore{ + strings.ToLower(vaultA): {Contract: vaultA, Standard: erc4626Standard}, + strings.ToLower(fakeB): {Contract: fakeB, Standard: erc4626Standard}, + strings.ToLower(brokenC): {Contract: brokenC, Standard: erc4626Standard}, + } + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + if len(calls) != 6 { // 3 candidates × 2 sub-calls + t.Fatalf("expected 6 sub-calls, got %d", len(calls)) + } + return []bchain.EthereumMulticallResult{ + // vaultA: positive + {Success: true, Data: encodeWordAddress(assetA)}, + {Success: true, Data: encodeWordUint(big.NewInt(100))}, + // fakeB: asset zero + {Success: true, Data: encodeWordAddress(erc4626ZeroAddress)}, + {Success: true, Data: encodeWordUint(big.NewInt(0))}, + // brokenC: asset OK but totalAssets undecodable + {Success: true, Data: encodeWordAddress(assetA)}, + {Success: true, Data: "0x1234"}, // <32 bytes + }, nil + }, + }, + } + + persistedVaults := map[string]string{} + setVault := func(addr, asset string) error { + persistedVaults[addr] = asset + return nil + } + + tokens := Tokens{ + {Contract: vaultA, Standard: erc4626Standard}, + {Contract: fakeB, Standard: erc4626Standard}, + {Contract: brokenC, Standard: erc4626Standard}, + } + enrichErc4626TokensWithDeps(tokens, store.get, mc, setVault, nil, 0, 0, 0) + + if !slicesContains(tokens[0].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("vaultA should be flagged: %v", tokens[0].Protocols) + } + if slicesContains(tokens[1].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("fakeB must not be flagged: %v", tokens[1].Protocols) + } + if slicesContains(tokens[2].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("brokenC must not be flagged: %v", tokens[2].Protocols) + } + if !strings.EqualFold(persistedVaults[vaultA], assetA) { + t.Fatalf("vaultA should be persisted with asset %s, got %v", assetA, persistedVaults) + } +} + +func TestEnrichErc4626Tokens_ChunksLargeProbe(t *testing.T) { + const asset = "0x0000000000000000000000000000000000000bb1" + + store := fakeContractInfoStore{} + tokens := make(Tokens, 0, erc4626ProbeChunkCandidates+1) + expectedVaults := map[string]bool{} + for i := 0; i < erc4626ProbeChunkCandidates+1; i++ { + contract := fmt.Sprintf("0x%040x", 0x2000+i) + store[strings.ToLower(contract)] = &bchain.ContractInfo{Contract: contract, Standard: erc4626Standard} + tokens = append(tokens, Token{Contract: contract, Standard: erc4626Standard}) + if i == 0 || i == erc4626ProbeChunkCandidates { + expectedVaults[strings.ToLower(contract)] = true + } + } + + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + if len(calls) != 2*erc4626ProbeChunkCandidates { + t.Fatalf("unexpected first chunk size: %d", len(calls)) + } + results := make([]bchain.EthereumMulticallResult, len(calls)) + for i := 0; i < len(calls); i += 2 { + if i == 0 { + results[i] = bchain.EthereumMulticallResult{Success: true, Data: encodeWordAddress(asset)} + results[i+1] = bchain.EthereumMulticallResult{Success: true, Data: encodeWordUint(big.NewInt(1))} + continue + } + results[i] = bchain.EthereumMulticallResult{Success: true, Data: encodeWordAddress(erc4626ZeroAddress)} + results[i+1] = bchain.EthereumMulticallResult{Success: true, Data: encodeWordUint(big.NewInt(0))} + } + return results, nil + }, + func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + if len(calls) != 2 { + t.Fatalf("unexpected second chunk size: %d", len(calls)) + } + return []bchain.EthereumMulticallResult{ + {Success: true, Data: encodeWordAddress(asset)}, + {Success: true, Data: encodeWordUint(big.NewInt(1))}, + }, nil + }, + }, + } + + persistedVaults := map[string]string{} + enrichErc4626TokensWithDeps(tokens, store.get, mc, + func(addr, assetContract string) error { + persistedVaults[strings.ToLower(addr)] = assetContract + return nil + }, + nil, 0, 0, 0) + + if len(mc.calls) != 2 { + t.Fatalf("expected two multicall chunks, got %d", len(mc.calls)) + } + for i := range tokens { + contractKey := strings.ToLower(tokens[i].Contract) + if expectedVaults[contractKey] { + if !slicesContains(tokens[i].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("expected vault flag for %s", tokens[i].Contract) + } + if !strings.EqualFold(persistedVaults[contractKey], asset) { + t.Fatalf("expected persisted asset for %s, got %q", tokens[i].Contract, persistedVaults[contractKey]) + } + continue + } + if slicesContains(tokens[i].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("unexpected vault flag for %s", tokens[i].Contract) + } + } +} + +func TestEnrichErc4626Tokens_NonFungibleSkipped(t *testing.T) { + const nft = "0x000000000000000000000000000000000000abcd" + store := fakeContractInfoStore{} + mc := &fakeMulticaller{ + handlers: []func(calls []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error){ + func(_ []bchain.EthereumMulticallCall) ([]bchain.EthereumMulticallResult, error) { + t.Fatal("must not probe non-fungible-standard tokens") + return nil, nil + }, + }, + } + tokens := Tokens{{Contract: nft, Standard: bchain.ERC771TokenStandard}} + enrichErc4626TokensWithDeps(tokens, store.get, mc, + func(string, string) error { return nil }, + nil, 0, 0, 0) + if slicesContains(tokens[0].Protocols, contractInfoProtocolErc4626) { + t.Fatalf("non-fungible must not be flagged: %v", tokens[0].Protocols) + } + if len(mc.calls) != 0 { + t.Fatalf("expected zero multicalls, got %d", len(mc.calls)) + } +} + +func slicesContains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +func TestErc4626MathAndEncodingBoundaries(t *testing.T) { + if _, err := erc4626EncodeUintArg(erc4626MethodConvertToShares, nil); err == nil { + t.Fatal("expected nil arg error") + } + if _, err := erc4626EncodeUintArg(erc4626MethodConvertToShares, big.NewInt(-1)); err == nil { + t.Fatal("expected negative arg error") + } + if _, err := erc4626EncodeUintArg(erc4626MethodConvertToShares, new(big.Int).Add(erc4626MaxUint256, big.NewInt(1))); err == nil { + t.Fatal("expected overflow arg error") + } + if _, err := erc4626UnitAmount(78); err == nil { + t.Fatal("expected unsupported decimals error") + } + if _, err := erc4626UnitAmount(-1); err == nil { + t.Fatal("expected negative decimals error") + } + unit, err := erc4626UnitAmount(0) + if err != nil || unit.Cmp(big.NewInt(1)) != 0 { + t.Fatalf("unexpected 10^0 result: %v, %v", unit, err) + } +} diff --git a/api/ethereumtype.go b/api/ethereumtype.go new file mode 100644 index 0000000000..1f98f2eea0 --- /dev/null +++ b/api/ethereumtype.go @@ -0,0 +1,92 @@ +package api + +import ( + "sync" + + "github.com/golang/glog" + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/db" +) + +// refetch internal data +var refetchingInternalData = false +var refetchInternalDataMux sync.Mutex + +func (w *Worker) IsRefetchingInternalData() bool { + refetchInternalDataMux.Lock() + defer refetchInternalDataMux.Unlock() + return refetchingInternalData +} + +func (w *Worker) RefetchInternalData() error { + refetchInternalDataMux.Lock() + defer refetchInternalDataMux.Unlock() + if !refetchingInternalData { + refetchingInternalData = true + go w.RefetchInternalDataRoutine() + } + return nil +} + +const maxNumberOfRetires = 25 + +func (w *Worker) incrementRefetchInternalDataRetryCount(ie *db.BlockInternalDataError) { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + err := w.db.StoreBlockInternalDataErrorEthereumType(wb, &bchain.Block{ + BlockHeader: bchain.BlockHeader{ + Hash: ie.Hash, + Height: ie.Height, + }, + }, ie.ErrorMessage, ie.Retries+1) + if err != nil { + glog.Errorf("StoreBlockInternalDataErrorEthereumType %d %s, error %v", ie.Height, ie.Hash, err) + } else { + w.db.WriteBatch(wb) + } +} + +func (w *Worker) RefetchInternalDataRoutine() { + internalErrors, err := w.db.GetBlockInternalDataErrorsEthereumType() + if err == nil { + for i := range internalErrors { + ie := &internalErrors[i] + if ie.Retries >= maxNumberOfRetires { + glog.Infof("Refetching internal data for %d %s, retries exceeded", ie.Height, ie.Hash) + continue + } + glog.Infof("Refetching internal data for %d %s, retries %d", ie.Height, ie.Hash, ie.Retries) + block, err := w.chain.GetBlock(ie.Hash, ie.Height) + var blockSpecificData *bchain.EthereumBlockSpecificData + if block != nil { + blockSpecificData, _ = block.CoinSpecificData.(*bchain.EthereumBlockSpecificData) + } + if err != nil || block == nil || (blockSpecificData != nil && blockSpecificData.InternalDataError != "") { + glog.Errorf("Refetching internal data for %d %s, error %v, retrying", ie.Height, ie.Hash, err) + // try for second time to fetch the data - the 2nd attempt after the first unsuccessful has many times higher probability of success + // probably something to do with data preloaded to cache on the backend + block, err = w.chain.GetBlock(ie.Hash, ie.Height) + if err != nil || block == nil { + glog.Errorf("Refetching internal data for %d %s, error %v", ie.Height, ie.Hash, err) + continue + } + } + blockSpecificData, _ = block.CoinSpecificData.(*bchain.EthereumBlockSpecificData) + if blockSpecificData != nil && blockSpecificData.InternalDataError != "" { + glog.Errorf("Refetching internal data for %d %s, internal data error %v", ie.Height, ie.Hash, blockSpecificData.InternalDataError) + w.incrementRefetchInternalDataRetryCount(ie) + } else { + err = w.db.ReconnectInternalDataToBlockEthereumType(block) + if err != nil { + glog.Errorf("ReconnectInternalDataToBlockEthereumType %d %s, error %v", ie.Height, ie.Hash, err) + } else { + glog.Infof("Refetching internal data for %d %s, success", ie.Height, ie.Hash) + } + } + } + } + refetchInternalDataMux.Lock() + refetchingInternalData = false + refetchInternalDataMux.Unlock() +} diff --git a/api/fiat_balance_history.go b/api/fiat_balance_history.go new file mode 100644 index 0000000000..0c6f3698ba --- /dev/null +++ b/api/fiat_balance_history.go @@ -0,0 +1,194 @@ +package api + +import ( + "strings" + "time" + + "github.com/golang/glog" + "github.com/trezor/blockbook/common" +) + +func normalizeBalanceHistoryPathLabel(pathLabel string) string { + if pathLabel == "" { + return "unknown" + } + return pathLabel +} + +func normalizeCurrenciesToLowercase(currencies []string) []string { + currenciesLowercase := make([]string, len(currencies)) + for i := range currencies { + currenciesLowercase[i] = strings.ToLower(currencies[i]) + } + return currenciesLowercase +} + +func buildBalanceHistoryTimestamps(histories BalanceHistories) []int64 { + timestamps := make([]int64, len(histories)) + for i := range histories { + timestamps[i] = int64(histories[i].Time) + } + return timestamps +} + +func applyTickerToBalanceHistory(bh *BalanceHistory, ticker *common.CurrencyRatesTicker, currenciesLowercase []string) { + if ticker == nil { + return + } + if len(currenciesLowercase) == 0 { + bh.FiatRates = ticker.Rates + return + } + rates := make(map[string]float32, len(currenciesLowercase)) + for _, currency := range currenciesLowercase { + if rate, found := ticker.Rates[currency]; found { + rates[currency] = rate + } else { + rates[currency] = -1 + } + } + bh.FiatRates = rates +} + +func classifyBalanceHistoryBatchLookup(expectedLen int, tickers *[]*common.CurrencyRatesTicker, err error) (bool, string, int) { + batchFetchValid := err == nil && tickers != nil && len(*tickers) == expectedLen + if batchFetchValid { + return true, "", len(*tickers) + } + reason := "batch_error" + returnedTickers := -1 + if err == nil { + if tickers == nil { + reason = "empty_result" + } else { + returnedTickers = len(*tickers) + reason = "len_mismatch" + } + } + return false, reason, returnedTickers +} + +type balanceHistoryFallbackStats struct { + errorCount int + nilResultCount int + emptyResultCount int + firstFailedSet bool + firstFailedTimestamp int64 + firstFailedErr error +} + +func (s *balanceHistoryFallbackStats) recordFailure(ts int64, pointErr error, pointTickers *[]*common.CurrencyRatesTicker) { + if !s.firstFailedSet { + s.firstFailedSet = true + s.firstFailedTimestamp = ts + s.firstFailedErr = pointErr + } + if pointErr != nil { + s.errorCount++ + } else if pointTickers == nil { + s.nilResultCount++ + } else { + s.emptyResultCount++ + } +} + +func (s *balanceHistoryFallbackStats) failedTotal() int { + return s.errorCount + s.nilResultCount + s.emptyResultCount +} + +func (s *balanceHistoryFallbackStats) status() string { + if s.failedTotal() > 0 { + return "err" + } + return "ok" +} + +func (s *balanceHistoryFallbackStats) logSummary(total int) { + if s.failedTotal() == 0 { + return + } + glog.Errorf( + "Error finding fallback tickers for %d/%d timestamps (errors=%d nil_results=%d empty_results=%d first_failed_at=%d first_error=%v)", + s.failedTotal(), + total, + s.errorCount, + s.nilResultCount, + s.emptyResultCount, + s.firstFailedTimestamp, + s.firstFailedErr, + ) +} + +func (w *Worker) observeBalanceHistoryFiatDuration(pathLabel, mode, status string, startedAt time.Time) { + if w.metrics == nil { + return + } + w.metrics.BalanceHistoryFiatDuration.With(common.Labels{ + "path": pathLabel, + "mode": mode, + "status": status, + }).Observe(time.Since(startedAt).Seconds()) +} + +func (w *Worker) incrementBalanceHistoryFiatFallback(pathLabel, reason string) { + if w.metrics == nil { + return + } + w.metrics.BalanceHistoryFiatFallback.With(common.Labels{ + "path": pathLabel, + "reason": reason, + }).Inc() +} + +func (w *Worker) lookupBalanceHistoryBatchTickers(timestamps []int64, pathLabel string, expectedLen int) (*[]*common.CurrencyRatesTicker, bool, string, int, error) { + batchStarted := time.Now() + tickers, err := getTickersForTimestamps(w.fiatRates, timestamps, "", "") + batchFetchValid, reason, returnedTickers := classifyBalanceHistoryBatchLookup(expectedLen, tickers, err) + status := "ok" + if !batchFetchValid { + status = "err" + } + w.observeBalanceHistoryFiatDuration(pathLabel, "batch", status, batchStarted) + return tickers, batchFetchValid, reason, returnedTickers, err +} + +func applyBatchTickersToBalanceHistories(histories BalanceHistories, tickers *[]*common.CurrencyRatesTicker, currenciesLowercase []string) { + for i := range histories { + applyTickerToBalanceHistory(&histories[i], (*tickers)[i], currenciesLowercase) + } +} + +func (w *Worker) applyFallbackTickersToBalanceHistories(histories BalanceHistories, currenciesLowercase []string, pathLabel string) { + // Fallback to per-point lookup to preserve original behavior on partial failures. + fallbackStarted := time.Now() + stats := balanceHistoryFallbackStats{} + for i := range histories { + bh := &histories[i] + pointTickers, pointErr := getTickersForTimestamps(w.fiatRates, []int64{int64(bh.Time)}, "", "") + if pointErr != nil || pointTickers == nil || len(*pointTickers) == 0 { + stats.recordFailure(int64(bh.Time), pointErr, pointTickers) + continue + } + applyTickerToBalanceHistory(bh, (*pointTickers)[0], currenciesLowercase) + } + stats.logSummary(len(histories)) + w.observeBalanceHistoryFiatDuration(pathLabel, "fallback", stats.status(), fallbackStarted) +} + +func (w *Worker) setFiatRateToBalanceHistories(histories BalanceHistories, currencies []string, pathLabel string) error { + if len(histories) == 0 || w.fiatRates == nil || !w.fiatRates.Enabled { + return nil + } + pathLabel = normalizeBalanceHistoryPathLabel(pathLabel) + currenciesLowercase := normalizeCurrenciesToLowercase(currencies) + timestamps := buildBalanceHistoryTimestamps(histories) + tickers, batchFetchValid, reason, returnedTickers, err := w.lookupBalanceHistoryBatchTickers(timestamps, pathLabel, len(histories)) + if batchFetchValid { + applyBatchTickersToBalanceHistories(histories, tickers, currenciesLowercase) + return nil + } + glog.Errorf("Error finding tickers for %d timestamps (returned %d, reason %s). Error: %v", len(timestamps), returnedTickers, reason, err) + w.incrementBalanceHistoryFiatFallback(pathLabel, reason) + w.applyFallbackTickersToBalanceHistories(histories, currenciesLowercase, pathLabel) + return nil +} diff --git a/api/fiat_balance_history_test.go b/api/fiat_balance_history_test.go new file mode 100644 index 0000000000..e165b4345c --- /dev/null +++ b/api/fiat_balance_history_test.go @@ -0,0 +1,273 @@ +//go:build unittest + +package api + +import ( + "reflect" + "testing" + + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/fiat" +) + +func TestSetFiatRateToBalanceHistories_BatchesTickerLookup(t *testing.T) { + histories := BalanceHistories{ + {Time: 100}, + {Time: 200}, + {Time: 300}, + } + w := &Worker{ + fiatRates: &fiat.FiatRates{Enabled: true}, + } + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + calls := 0 + var gotTimestamps []int64 + getTickersForTimestamps = func(_ *fiat.FiatRates, timestamps []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + calls++ + gotTimestamps = append([]int64(nil), timestamps...) + tickers := []*common.CurrencyRatesTicker{ + {Rates: map[string]float32{"usd": 11, "eur": 22}}, + nil, + {Rates: map[string]float32{"usd": 33}}, + } + return &tickers, nil + } + + err := w.setFiatRateToBalanceHistories(histories, []string{"USD", "eur", "cad"}, "address") + if err != nil { + t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 ticker lookup call, got %d", calls) + } + if !reflect.DeepEqual(gotTimestamps, []int64{100, 200, 300}) { + t.Fatalf("unexpected timestamps: got %v", gotTimestamps) + } + if !reflect.DeepEqual(histories[0].FiatRates, map[string]float32{"usd": 11, "eur": 22, "cad": -1}) { + t.Fatalf("unexpected rates for histories[0]: %v", histories[0].FiatRates) + } + if histories[1].FiatRates != nil { + t.Fatalf("expected nil rates for histories[1], got %v", histories[1].FiatRates) + } + if !reflect.DeepEqual(histories[2].FiatRates, map[string]float32{"usd": 33, "eur": -1, "cad": -1}) { + t.Fatalf("unexpected rates for histories[2]: %v", histories[2].FiatRates) + } +} + +func TestSetFiatRateToBalanceHistories_AllRatesWhenCurrenciesNotSpecified(t *testing.T) { + histories := BalanceHistories{ + {Time: 100}, + } + w := &Worker{ + fiatRates: &fiat.FiatRates{Enabled: true}, + } + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + getTickersForTimestamps = func(_ *fiat.FiatRates, _ []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + tickers := []*common.CurrencyRatesTicker{ + {Rates: map[string]float32{"usd": 11, "eur": 22}}, + } + return &tickers, nil + } + + err := w.setFiatRateToBalanceHistories(histories, nil, "address") + if err != nil { + t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) + } + if !reflect.DeepEqual(histories[0].FiatRates, map[string]float32{"usd": 11, "eur": 22}) { + t.Fatalf("unexpected rates for histories[0]: %v", histories[0].FiatRates) + } +} + +func TestSetFiatRateToBalanceHistories_BatchFailureFallsBackToPerPoint(t *testing.T) { + histories := BalanceHistories{ + {Time: 100}, + {Time: 200}, + {Time: 300}, + } + w := &Worker{ + fiatRates: &fiat.FiatRates{Enabled: true}, + } + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + calls := 0 + var gotCalls [][]int64 + getTickersForTimestamps = func(_ *fiat.FiatRates, timestamps []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + calls++ + gotCalls = append(gotCalls, append([]int64(nil), timestamps...)) + if len(timestamps) > 1 { + return nil, assertError("batch error") + } + switch timestamps[0] { + case 100: + tickers := []*common.CurrencyRatesTicker{ + {Rates: map[string]float32{"usd": 11}}, + } + return &tickers, nil + case 200: + return nil, assertError("point error") + case 300: + tickers := []*common.CurrencyRatesTicker{ + {Rates: map[string]float32{"usd": 33}}, + } + return &tickers, nil + default: + tickers := []*common.CurrencyRatesTicker{} + return &tickers, nil + } + } + + err := w.setFiatRateToBalanceHistories(histories, []string{"usd"}, "address") + if err != nil { + t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) + } + if calls != 4 { + t.Fatalf("expected 4 ticker lookup calls (1 batch + 3 point), got %d", calls) + } + wantCalls := [][]int64{ + {100, 200, 300}, + {100}, + {200}, + {300}, + } + if !reflect.DeepEqual(gotCalls, wantCalls) { + t.Fatalf("unexpected lookup calls: got %v, want %v", gotCalls, wantCalls) + } + if !reflect.DeepEqual(histories[0].FiatRates, map[string]float32{"usd": 11}) { + t.Fatalf("unexpected rates for histories[0]: %v", histories[0].FiatRates) + } + if histories[1].FiatRates != nil { + t.Fatalf("expected nil rates for histories[1], got %v", histories[1].FiatRates) + } + if !reflect.DeepEqual(histories[2].FiatRates, map[string]float32{"usd": 33}) { + t.Fatalf("unexpected rates for histories[2]: %v", histories[2].FiatRates) + } +} + +func TestSetFiatRateToBalanceHistories_SkipsLookupForEmptyHistory(t *testing.T) { + w := &Worker{ + fiatRates: &fiat.FiatRates{Enabled: true}, + } + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + calls := 0 + getTickersForTimestamps = func(_ *fiat.FiatRates, _ []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + calls++ + tickers := []*common.CurrencyRatesTicker{} + return &tickers, nil + } + + err := w.setFiatRateToBalanceHistories(BalanceHistories{}, []string{"usd"}, "address") + if err != nil { + t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) + } + if calls != 0 { + t.Fatalf("expected 0 ticker lookup calls, got %d", calls) + } +} + +func TestSetFiatRateToBalanceHistories_SkipsLookupWhenFiatRatesDisabled(t *testing.T) { + histories := BalanceHistories{{Time: 100}} + w := &Worker{ + fiatRates: &fiat.FiatRates{Enabled: false}, + } + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + calls := 0 + getTickersForTimestamps = func(_ *fiat.FiatRates, _ []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + calls++ + tickers := []*common.CurrencyRatesTicker{} + return &tickers, nil + } + + err := w.setFiatRateToBalanceHistories(histories, []string{"usd"}, "address") + if err != nil { + t.Fatalf("setFiatRateToBalanceHistories returned error: %v", err) + } + if calls != 0 { + t.Fatalf("expected 0 ticker lookup calls when fiat rates are disabled, got %d", calls) + } +} + +func TestClassifyBalanceHistoryBatchLookup(t *testing.T) { + tickers := []*common.CurrencyRatesTicker{ + {Rates: map[string]float32{"usd": 1}}, + {Rates: map[string]float32{"usd": 2}}, + } + valid, reason, returned := classifyBalanceHistoryBatchLookup(2, &tickers, nil) + if !valid || reason != "" || returned != 2 { + t.Fatalf("unexpected valid result: valid=%v reason=%q returned=%d", valid, reason, returned) + } + + valid, reason, returned = classifyBalanceHistoryBatchLookup(2, nil, assertError("batch error")) + if valid || reason != "batch_error" || returned != -1 { + t.Fatalf("unexpected error result: valid=%v reason=%q returned=%d", valid, reason, returned) + } + + valid, reason, returned = classifyBalanceHistoryBatchLookup(2, nil, nil) + if valid || reason != "empty_result" || returned != -1 { + t.Fatalf("unexpected empty-result classification: valid=%v reason=%q returned=%d", valid, reason, returned) + } + + shortTickers := []*common.CurrencyRatesTicker{ + {Rates: map[string]float32{"usd": 1}}, + } + valid, reason, returned = classifyBalanceHistoryBatchLookup(2, &shortTickers, nil) + if valid || reason != "len_mismatch" || returned != 1 { + t.Fatalf("unexpected len-mismatch classification: valid=%v reason=%q returned=%d", valid, reason, returned) + } +} + +func TestBalanceHistoryFallbackStats_RecordFailureAndStatus(t *testing.T) { + stats := balanceHistoryFallbackStats{} + stats.recordFailure(123, assertError("point error"), nil) + if stats.status() != "err" { + t.Fatalf("unexpected status: got %q, want %q", stats.status(), "err") + } + if stats.failedTotal() != 1 { + t.Fatalf("unexpected failed total: got %d, want 1", stats.failedTotal()) + } + if stats.errorCount != 1 || stats.nilResultCount != 0 || stats.emptyResultCount != 0 { + t.Fatalf("unexpected counters: errors=%d nil=%d empty=%d", stats.errorCount, stats.nilResultCount, stats.emptyResultCount) + } + if stats.firstFailedTimestamp != 123 { + t.Fatalf("unexpected first failed timestamp: got %d, want 123", stats.firstFailedTimestamp) + } + if stats.firstFailedErr == nil || stats.firstFailedErr.Error() != "point error" { + t.Fatalf("unexpected first failed error: %+v", stats.firstFailedErr) + } + + stats.recordFailure(456, nil, nil) + stats.recordFailure(789, nil, &[]*common.CurrencyRatesTicker{}) + if stats.failedTotal() != 3 { + t.Fatalf("unexpected failed total after multiple failures: got %d, want 3", stats.failedTotal()) + } + if stats.errorCount != 1 || stats.nilResultCount != 1 || stats.emptyResultCount != 1 { + t.Fatalf("unexpected counters after multiple failures: errors=%d nil=%d empty=%d", stats.errorCount, stats.nilResultCount, stats.emptyResultCount) + } + if stats.firstFailedTimestamp != 123 { + t.Fatalf("first failed timestamp changed unexpectedly: got %d, want 123", stats.firstFailedTimestamp) + } +} + +type assertError string + +func (e assertError) Error() string { + return string(e) +} diff --git a/api/fiat_rates_api.go b/api/fiat_rates_api.go new file mode 100644 index 0000000000..c25a96838b --- /dev/null +++ b/api/fiat_rates_api.go @@ -0,0 +1,207 @@ +package api + +import ( + "fmt" + "sort" + "strings" + + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// removeEmpty removes empty strings from a slice. +func removeEmpty(stringSlice []string) []string { + ret := make([]string, 0, len(stringSlice)) + for _, str := range stringSlice { + if str != "" { + ret = append(ret, str) + } + } + return ret +} + +func copyTickerRates(rates map[string]float32) map[string]float32 { + copied := make(map[string]float32, len(rates)) + for k, v := range rates { + copied[k] = v + } + return copied +} + +// getFiatRatesResult checks if CurrencyRatesTicker contains all necessary data and returns formatted result. +func (w *Worker) getFiatRatesResult(currencies []string, ticker *common.CurrencyRatesTicker, token string) (*FiatTicker, error) { + if token != "" { + capacity := len(currencies) + if capacity == 0 { + capacity = len(ticker.Rates) + } + rates := make(map[string]float32, capacity) + if len(currencies) == 0 { + for currency := range ticker.Rates { + currency = strings.ToLower(currency) + rate := ticker.TokenRateInCurrency(token, currency) + if rate <= 0 { + rate = -1 + } + rates[currency] = rate + } + } else { + for _, currency := range currencies { + currency = strings.ToLower(currency) + rate := ticker.TokenRateInCurrency(token, currency) + if rate <= 0 { + rate = -1 + } + rates[currency] = rate + } + } + return &FiatTicker{ + Timestamp: ticker.Timestamp.UTC().Unix(), + Rates: rates, + }, nil + } + if len(currencies) == 0 { + // Return all available ticker rates. + return &FiatTicker{ + Timestamp: ticker.Timestamp.UTC().Unix(), + Rates: copyTickerRates(ticker.Rates), + }, nil + } + // Check if currencies from the list are available in the ticker rates. + rates := make(map[string]float32, len(currencies)) + for _, currency := range currencies { + currency = strings.ToLower(currency) + if rate, found := ticker.Rates[currency]; found { + rates[currency] = rate + } else { + rates[currency] = -1 + } + } + return &FiatTicker{ + Timestamp: ticker.Timestamp.UTC().Unix(), + Rates: rates, + }, nil +} + +// GetCurrentFiatRates returns last available fiat rates. +func (w *Worker) GetCurrentFiatRates(currencies []string, token string) (*FiatTicker, error) { + vsCurrency := "" + currencies = removeEmpty(currencies) + if len(currencies) == 1 { + vsCurrency = currencies[0] + } + ticker := getCurrentTicker(w.fiatRates, vsCurrency, token) + var err error + if ticker == nil { + if token == "" { + // fallback - get last fiat rate from db if not in current ticker + // not for tokens, many tokens do not have fiat rates at all and it is very costly + // to do DB search for token without an exchange rate + ticker, err = w.db.FiatRatesFindLastTicker(vsCurrency, token) + } + if err != nil { + return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) + } else if ticker == nil { + return nil, NewAPIError("No tickers found!", true) + } + } + result, err := w.getFiatRatesResult(currencies, ticker, token) + if err != nil { + return nil, err + } + return result, nil +} + +// makeErrorRates returns a map of currencies, with each value equal to -1 +// used when there was an error finding ticker. +func makeErrorRates(currencies []string) map[string]float32 { + rates := make(map[string]float32, len(currencies)) + for _, currency := range currencies { + rates[strings.ToLower(currency)] = -1 + } + return rates +} + +// GetFiatRatesForTimestamps returns fiat rates for each of the provided dates. +func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []string, token string) (*FiatTickers, error) { + if len(timestamps) == 0 { + return nil, NewAPIError("No timestamps provided", true) + } + vsCurrency := "" + currencies = removeEmpty(currencies) + if len(currencies) == 1 { + vsCurrency = currencies[0] + } + tickers, err := getTickersForTimestamps(w.fiatRates, timestamps, vsCurrency, token) + if err != nil { + return nil, err + } + if tickers == nil { + return nil, NewAPIError("No tickers found", true) + } + if len(*tickers) != len(timestamps) { + glog.Error("GetFiatRatesForTimestamps: number of tickers does not match timestamps ", len(*tickers), ", ", len(timestamps)) + return nil, NewAPIError("No tickers found", false) + } + fiatTickers := make([]FiatTicker, len(*tickers)) + for i, t := range *tickers { + if t == nil { + fiatTickers[i] = FiatTicker{Timestamp: timestamps[i], Rates: makeErrorRates(currencies)} + continue + } + result, err := w.getFiatRatesResult(currencies, t, token) + if err != nil { + if apiErr, ok := err.(*APIError); ok { + if apiErr.Public { + return nil, err + } + } + fiatTickers[i] = FiatTicker{Timestamp: timestamps[i], Rates: makeErrorRates(currencies)} + continue + } + fiatTickers[i] = *result + } + return &FiatTickers{Tickers: fiatTickers}, nil +} + +// GetFiatRatesForBlockID returns fiat rates for block height or block hash. +func (w *Worker) GetFiatRatesForBlockID(blockID string, currencies []string, token string) (*FiatTicker, error) { + bi, err := w.getBlockInfoFromBlockID(blockID) + if err != nil { + if err == bchain.ErrBlockNotFound { + return nil, NewAPIError(fmt.Sprintf("Block %v not found", blockID), true) + } + return nil, NewAPIError(fmt.Sprintf("Block %v not found, error: %v", blockID, err), false) + } + tickers, err := w.GetFiatRatesForTimestamps([]int64{bi.Time}, currencies, token) + if err != nil || tickers == nil || len(tickers.Tickers) == 0 { + return nil, err + } + return &tickers.Tickers[0], nil +} + +// GetAvailableVsCurrencies returns the list of available versus currencies for exchange rates. +func (w *Worker) GetAvailableVsCurrencies(timestamp int64, token string) (*AvailableVsCurrencies, error) { + tickers, err := getTickersForTimestamps(w.fiatRates, []int64{timestamp}, "", token) + if err != nil { + return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) + } + if tickers == nil || len(*tickers) == 0 { + return nil, NewAPIError("No tickers found", true) + } + ticker := (*tickers)[0] + if ticker == nil { + return nil, NewAPIError("No tickers found", true) + } + keys := make([]string, 0, len(ticker.Rates)) + for k := range ticker.Rates { + keys = append(keys, k) + } + sort.Strings(keys) // sort to get deterministic results + + return &AvailableVsCurrencies{ + Timestamp: ticker.Timestamp.Unix(), + Tickers: keys, + }, nil +} diff --git a/api/fiat_rates_api_test.go b/api/fiat_rates_api_test.go new file mode 100644 index 0000000000..6c710d8248 --- /dev/null +++ b/api/fiat_rates_api_test.go @@ -0,0 +1,334 @@ +//go:build unittest + +package api + +import ( + "reflect" + "strings" + "testing" + "time" + + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/fiat" +) + +func requireAPIError(t *testing.T, err error, wantPublic bool) *APIError { + t.Helper() + if err == nil { + t.Fatal("expected API error, got nil") + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T (%v)", err, err) + } + if apiErr.Public != wantPublic { + t.Fatalf("unexpected API error visibility: got %v, want %v", apiErr.Public, wantPublic) + } + return apiErr +} + +func TestRemoveEmpty(t *testing.T) { + got := removeEmpty([]string{"usd", "", "eur", "", ""}) + want := []string{"usd", "eur"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected filtered currencies: got %v, want %v", got, want) + } +} + +func TestMakeErrorRates(t *testing.T) { + got := makeErrorRates([]string{"USD", "eur", "Usd"}) + want := map[string]float32{ + "usd": -1, + "eur": -1, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected error rates: got %v, want %v", got, want) + } +} + +func TestGetFiatRatesResult_NonTokenSelectedCurrencies(t *testing.T) { + w := &Worker{} + ticker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000000, 0), + Rates: map[string]float32{ + "usd": 1.23, + "eur": 0.99, + }, + } + + got, err := w.getFiatRatesResult([]string{"USD", "gbp"}, ticker, "") + if err != nil { + t.Fatalf("getFiatRatesResult returned error: %v", err) + } + + want := &FiatTicker{ + Timestamp: ticker.Timestamp.UTC().Unix(), + Rates: map[string]float32{ + "usd": 1.23, + "gbp": -1, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected fiat ticker: got %+v, want %+v", got, want) + } +} + +func TestGetFiatRatesResult_NonTokenAllCurrenciesReturnsCopy(t *testing.T) { + w := &Worker{} + ticker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000001, 0), + Rates: map[string]float32{ + "usd": 1.5, + "eur": 1.2, + }, + } + + got, err := w.getFiatRatesResult(nil, ticker, "") + if err != nil { + t.Fatalf("getFiatRatesResult returned error: %v", err) + } + if !reflect.DeepEqual(got.Rates, ticker.Rates) { + t.Fatalf("unexpected all-rates result: got %v, want %v", got.Rates, ticker.Rates) + } + + got.Rates["usd"] = 999 + if ticker.Rates["usd"] == 999 { + t.Fatalf("ticker rates were modified through result map") + } +} + +func TestGetFiatRatesResult_TokenRates(t *testing.T) { + w := &Worker{} + ticker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000002, 0), + Rates: map[string]float32{ + "usd": 2, + "eur": 3, + }, + TokenRates: map[string]float32{ + "0xtoken": 4, + }, + } + + got, err := w.getFiatRatesResult([]string{"USD", "EUR", "JPY"}, ticker, "0xToken") + if err != nil { + t.Fatalf("getFiatRatesResult returned error: %v", err) + } + want := map[string]float32{ + "usd": 8, + "eur": 12, + "jpy": -1, + } + if !reflect.DeepEqual(got.Rates, want) { + t.Fatalf("unexpected token rates: got %v, want %v", got.Rates, want) + } +} + +func TestGetCurrentFiatRates_UsesGetterAndCurrencyFilter(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + originalGetter := getCurrentTicker + defer func() { + getCurrentTicker = originalGetter + }() + + ticker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000003, 0), + Rates: map[string]float32{"usd": 1.01}, + } + calls := 0 + gotVsCurrency := "" + gotToken := "" + getCurrentTicker = func(_ *fiat.FiatRates, vsCurrency string, token string) *common.CurrencyRatesTicker { + calls++ + gotVsCurrency = vsCurrency + gotToken = token + return ticker + } + + got, err := w.GetCurrentFiatRates([]string{"", "USD"}, "") + if err != nil { + t.Fatalf("GetCurrentFiatRates returned error: %v", err) + } + if calls != 1 { + t.Fatalf("expected one ticker call, got %d", calls) + } + if gotVsCurrency != "USD" { + t.Fatalf("unexpected vsCurrency: got %q, want %q", gotVsCurrency, "USD") + } + if gotToken != "" { + t.Fatalf("unexpected token: got %q, want empty", gotToken) + } + wantRates := map[string]float32{"usd": 1.01} + if !reflect.DeepEqual(got.Rates, wantRates) { + t.Fatalf("unexpected rates: got %v, want %v", got.Rates, wantRates) + } +} + +func TestGetCurrentFiatRates_TokenWithoutTickerReturnsPublicError(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + originalGetter := getCurrentTicker + defer func() { + getCurrentTicker = originalGetter + }() + + getCurrentTicker = func(_ *fiat.FiatRates, _, _ string) *common.CurrencyRatesTicker { + return nil + } + + _, err := w.GetCurrentFiatRates(nil, "0xtoken") + apiErr := requireAPIError(t, err, true) + if apiErr.Text != "No tickers found!" { + t.Fatalf("unexpected error text: got %q", apiErr.Text) + } +} + +func TestGetFiatRatesForTimestamps_EmptyInput(t *testing.T) { + w := &Worker{} + _, err := w.GetFiatRatesForTimestamps(nil, []string{"usd"}, "") + apiErr := requireAPIError(t, err, true) + if apiErr.Text != "No timestamps provided" { + t.Fatalf("unexpected error text: got %q", apiErr.Text) + } +} + +func TestGetFiatRatesForTimestamps_LenMismatchReturnsNonPublicError(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + getTickersForTimestamps = func(_ *fiat.FiatRates, _ []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + tickers := []*common.CurrencyRatesTicker{ + {Timestamp: time.Unix(1700000004, 0), Rates: map[string]float32{"usd": 1}}, + } + return &tickers, nil + } + + _, err := w.GetFiatRatesForTimestamps([]int64{1, 2}, []string{"usd"}, "") + apiErr := requireAPIError(t, err, false) + if apiErr.Text != "No tickers found" { + t.Fatalf("unexpected error text: got %q", apiErr.Text) + } +} + +func TestGetFiatRatesForTimestamps_NilTickerEntryFallsBackToErrorRates(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + getTickersForTimestamps = func(_ *fiat.FiatRates, timestamps []int64, vsCurrency, token string) (*[]*common.CurrencyRatesTicker, error) { + if !reflect.DeepEqual(timestamps, []int64{100, 200}) { + t.Fatalf("unexpected timestamps: got %v", timestamps) + } + if vsCurrency != "" || token != "" { + t.Fatalf("unexpected lookup args: vsCurrency=%q token=%q", vsCurrency, token) + } + tickers := []*common.CurrencyRatesTicker{ + {Timestamp: time.Unix(1700000005, 0), Rates: map[string]float32{"usd": 1.5}}, + nil, + } + return &tickers, nil + } + + got, err := w.GetFiatRatesForTimestamps([]int64{100, 200}, []string{"USD", "EUR"}, "") + if err != nil { + t.Fatalf("GetFiatRatesForTimestamps returned error: %v", err) + } + if len(got.Tickers) != 2 { + t.Fatalf("unexpected ticker count: got %d, want 2", len(got.Tickers)) + } + if !reflect.DeepEqual(got.Tickers[0].Rates, map[string]float32{"usd": 1.5, "eur": -1}) { + t.Fatalf("unexpected first ticker rates: %v", got.Tickers[0].Rates) + } + if got.Tickers[1].Timestamp != 200 { + t.Fatalf("unexpected fallback timestamp: got %d, want 200", got.Tickers[1].Timestamp) + } + if !reflect.DeepEqual(got.Tickers[1].Rates, map[string]float32{"usd": -1, "eur": -1}) { + t.Fatalf("unexpected fallback rates: %v", got.Tickers[1].Rates) + } +} + +func TestGetAvailableVsCurrencies_SortedAndDeterministic(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + getTickersForTimestamps = func(_ *fiat.FiatRates, timestamps []int64, vsCurrency, token string) (*[]*common.CurrencyRatesTicker, error) { + if !reflect.DeepEqual(timestamps, []int64{123}) { + t.Fatalf("unexpected timestamps: got %v", timestamps) + } + if vsCurrency != "" || token != "0xtoken" { + t.Fatalf("unexpected lookup args: vsCurrency=%q token=%q", vsCurrency, token) + } + tickers := []*common.CurrencyRatesTicker{ + { + Timestamp: time.Unix(1700000006, 0), + Rates: map[string]float32{ + "usd": 1, + "cad": 2, + "eur": 3, + }, + }, + } + return &tickers, nil + } + + got, err := w.GetAvailableVsCurrencies(123, "0xtoken") + if err != nil { + t.Fatalf("GetAvailableVsCurrencies returned error: %v", err) + } + if !reflect.DeepEqual(got.Tickers, []string{"cad", "eur", "usd"}) { + t.Fatalf("unexpected sorted tickers: got %v", got.Tickers) + } + if got.Timestamp != 1700000006 { + t.Fatalf("unexpected timestamp: got %d", got.Timestamp) + } +} + +func TestGetAvailableVsCurrencies_PropagatesProviderErrorAsNonPublic(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + getTickersForTimestamps = func(_ *fiat.FiatRates, _ []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + return nil, fiatRatesTestError("provider failure") + } + + _, err := w.GetAvailableVsCurrencies(123, "") + apiErr := requireAPIError(t, err, false) + if !strings.Contains(apiErr.Text, "provider failure") { + t.Fatalf("unexpected error text: got %q", apiErr.Text) + } +} + +func TestGetAvailableVsCurrencies_NilFirstTickerReturnsPublicError(t *testing.T) { + w := &Worker{fiatRates: &fiat.FiatRates{}} + originalGetter := getTickersForTimestamps + defer func() { + getTickersForTimestamps = originalGetter + }() + + getTickersForTimestamps = func(_ *fiat.FiatRates, _ []int64, _, _ string) (*[]*common.CurrencyRatesTicker, error) { + tickers := []*common.CurrencyRatesTicker{nil} + return &tickers, nil + } + + _, err := w.GetAvailableVsCurrencies(123, "0xtoken") + apiErr := requireAPIError(t, err, true) + if apiErr.Text != "No tickers found" { + t.Fatalf("unexpected error text: got %q", apiErr.Text) + } +} + +type fiatRatesTestError string + +func (e fiatRatesTestError) Error() string { + return string(e) +} diff --git a/api/types.go b/api/types.go index 23d4dd1fc4..ea592a2cc6 100644 --- a/api/types.go +++ b/api/types.go @@ -3,12 +3,13 @@ package api import ( "encoding/json" "errors" + "fmt" "math/big" "sort" + "strings" "time" "github.com/trezor/blockbook/bchain" - "github.com/trezor/blockbook/bchain/coins/eth" "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" ) @@ -40,8 +41,8 @@ var ErrUnsupportedXpub = errors.New("XPUB not supported") // APIError extends error by information if the error details should be returned to the end user type APIError struct { - Text string - Public bool + Text string `ts_doc:"Human-readable error message describing the issue."` + Public bool `ts_doc:"Whether the error message can safely be shown to the end user."` } func (e *APIError) Error() string { @@ -56,16 +57,16 @@ func NewAPIError(s string, public bool) error { } } -// Amount is datatype holding amounts +// Amount is a datatype holding amounts type Amount big.Int -// IsZeroBigInt if big int has zero value +// IsZeroBigInt checks if big int has zero value func IsZeroBigInt(b *big.Int) bool { return len(b.Bits()) == 0 } // Compare returns an integer comparing two Amounts. The result will be 0 if a == b, -1 if a < b, and +1 if a > b. -// Nil Amount is always less then non nil amount, two nil Amounts are equal +// Nil Amount is always less then non-nil amount, two nil Amounts are equal func (a *Amount) Compare(b *Amount) int { if b == nil { if a == nil { @@ -87,6 +88,21 @@ func (a *Amount) MarshalJSON() (out []byte, err error) { return []byte(`"` + (*big.Int)(a).String() + `"`), nil } +func (a *Amount) UnmarshalJSON(data []byte) error { + s := strings.Trim(string(data), "\"") + if len(s) > 0 { + bigValue, parsed := new(big.Int).SetString(s, 10) + if !parsed { + return fmt.Errorf("couldn't parse number: %s", s) + } + *a = Amount(*bigValue) + } else { + // assuming empty string means zero + *a = Amount{} + } + return nil +} + func (a *Amount) String() string { if a == nil { return "" @@ -108,8 +124,7 @@ func (a *Amount) AsBigInt() big.Int { } // AsInt64 returns Amount as int64 (0 if Amount is nil). -// It is used only for legacy interfaces (socket.io) -// and generally not recommended to use for possible loss of precision. +// It is generally not recommended to use for possible loss of precision. func (a *Amount) AsInt64() int64 { if a == nil { return 0 @@ -119,60 +134,117 @@ func (a *Amount) AsInt64() int64 { // Vin contains information about single transaction input type Vin struct { - Txid string `json:"txid,omitempty"` - Vout uint32 `json:"vout,omitempty"` - Sequence int64 `json:"sequence,omitempty"` - N int `json:"n"` - AddrDesc bchain.AddressDescriptor `json:"-"` - Addresses []string `json:"addresses,omitempty"` - IsAddress bool `json:"isAddress"` - IsOwn bool `json:"isOwn,omitempty"` - ValueSat *Amount `json:"value,omitempty"` - Hex string `json:"hex,omitempty"` - Asm string `json:"asm,omitempty"` - Coinbase string `json:"coinbase,omitempty"` + Txid string `json:"txid,omitempty" ts_doc:"ID/hash of the originating transaction (where the UTXO comes from)."` + Vout uint32 `json:"vout,omitempty" ts_doc:"Index of the output in the referenced transaction."` + Sequence int64 `json:"sequence,omitempty" ts_doc:"Sequence number for this input (e.g. 4294967293)."` + N int `json:"n" ts_doc:"Relative index of this input within the transaction."` + AddrDesc bchain.AddressDescriptor `json:"-" ts_doc:"Internal address descriptor for backend usage (not exposed via JSON)."` + Addresses []string `json:"addresses,omitempty" ts_doc:"List of addresses associated with this input."` + IsAddress bool `json:"isAddress" ts_doc:"Indicates if this input is from a known address."` + IsOwn bool `json:"isOwn,omitempty" ts_doc:"Indicates if this input belongs to the wallet in context."` + ValueSat *Amount `json:"value,omitempty" ts_doc:"Amount (in satoshi or base units) of the input."` + Hex string `json:"hex,omitempty" ts_doc:"Raw script hex data for this input."` + Asm string `json:"asm,omitempty" ts_doc:"Disassembled script for this input."` + Coinbase string `json:"coinbase,omitempty" ts_doc:"Data for coinbase inputs (when mining)."` } // Vout contains information about single transaction output type Vout struct { - ValueSat *Amount `json:"value,omitempty"` - N int `json:"n"` - Spent bool `json:"spent,omitempty"` - SpentTxID string `json:"spentTxId,omitempty"` - SpentIndex int `json:"spentIndex,omitempty"` - SpentHeight int `json:"spentHeight,omitempty"` - Hex string `json:"hex,omitempty"` - Asm string `json:"asm,omitempty"` - AddrDesc bchain.AddressDescriptor `json:"-"` - Addresses []string `json:"addresses"` - IsAddress bool `json:"isAddress"` - IsOwn bool `json:"isOwn,omitempty"` - Type string `json:"type,omitempty"` -} - -// MultiTokenValue contains values for contract with id and value (like ERC1155) + ValueSat *Amount `json:"value,omitempty" ts_doc:"Amount (in satoshi or base units) of the output."` + N int `json:"n" ts_doc:"Relative index of this output within the transaction."` + Spent bool `json:"spent,omitempty" ts_doc:"Indicates whether this output has been spent."` + SpentTxID string `json:"spentTxId,omitempty" ts_doc:"Transaction ID in which this output was spent."` + SpentIndex int `json:"spentIndex,omitempty" ts_doc:"Index of the input that spent this output."` + SpentHeight int `json:"spentHeight,omitempty" ts_doc:"Block height at which this output was spent."` + Hex string `json:"hex,omitempty" ts_doc:"Raw script hex data for this output - aka ScriptPubKey."` + Asm string `json:"asm,omitempty" ts_doc:"Disassembled script for this output."` + AddrDesc bchain.AddressDescriptor `json:"-" ts_doc:"Internal address descriptor for backend usage (not exposed via JSON)."` + Addresses []string `json:"addresses" ts_doc:"List of addresses associated with this output."` + IsAddress bool `json:"isAddress" ts_doc:"Indicates whether this output is owned by valid address."` + IsOwn bool `json:"isOwn,omitempty" ts_doc:"Indicates if this output belongs to the wallet in context."` + Type string `json:"type,omitempty" ts_doc:"Output script type (e.g., 'P2PKH', 'P2SH')."` +} + +// MultiTokenValue contains values for contracts with multiple token IDs type MultiTokenValue struct { - Id *Amount `json:"id,omitempty"` - Value *Amount `json:"value,omitempty"` + Id *Amount `json:"id,omitempty" ts_doc:"Token ID (for ERC1155)."` + Value *Amount `json:"value,omitempty" ts_doc:"Amount of that specific token ID."` +} + +// Erc4626TokenMetadata contains token metadata used in ERC4626 payloads. +type Erc4626TokenMetadata struct { + Contract string `json:"contract" ts_doc:"Token contract address."` + Name string `json:"name,omitempty" ts_doc:"Human-readable token name."` + Symbol string `json:"symbol,omitempty" ts_doc:"Token symbol."` + Decimals int `json:"decimals" ts_doc:"Token decimals."` +} + +// Erc4626Token contains ERC4626 vault details for a fungible token. +type Erc4626Token struct { + Asset *Erc4626TokenMetadata `json:"asset,omitempty" ts_doc:"Metadata of the underlying asset token. Omitted when decimals cannot be resolved."` + Share *Erc4626TokenMetadata `json:"share,omitempty" ts_doc:"Metadata of the vault share token."` + TotalAssetsSat *Amount `json:"totalAssets,omitempty" ts_doc:"Total underlying assets managed by the vault."` + ConvertToAssets1ShareSat *Amount `json:"convertToAssets1Share,omitempty" ts_doc:"Underlying assets for one whole share unit."` + ConvertToShares1AssetSat *Amount `json:"convertToShares1Asset,omitempty" ts_doc:"Shares for one whole underlying asset unit."` + PreviewDeposit1AssetSat *Amount `json:"previewDeposit1Asset,omitempty" ts_doc:"Previewed shares minted for one whole underlying asset unit."` + PreviewRedeem1ShareSat *Amount `json:"previewRedeem1Share,omitempty" ts_doc:"Previewed assets redeemed for one whole share unit."` + Error string `json:"error,omitempty" ts_doc:"Error message for partial failures while fetching ERC4626 fields."` +} + +// ContractInfoRates contains current price data for a single contract when available. +type ContractInfoRates struct { + BaseRate float64 `json:"baseRate,omitempty" ts_doc:"Current price of one whole token in the chain base currency, when available."` + Currency string `json:"currency,omitempty" ts_doc:"Requested secondary currency code for the secondaryRate field, lower-cased."` + SecondaryRate float64 `json:"secondaryRate,omitempty" ts_doc:"Current price of one whole token in the requested secondary currency, when available."` +} + +// ContractInfoProtocols holds rich, freshly-fetched protocol enrichments +// returned by getContractInfo. +type ContractInfoProtocols struct { + Erc4626 *Erc4626Token `json:"erc4626,omitempty" ts_doc:"ERC4626 vault details when explicitly requested and detected."` +} + +// TokenProtocols lists protocol identifiers the contract participates in +// (e.g., "erc4626"). Sourced from indexed metadata; no RPC. Use +// getContractInfo for fresh per-vault data. +type TokenProtocols []string + +// ContractInfoResult contains contract metadata and optional enrichments for a single contract. +type ContractInfoResult struct { + // Deprecated: Use Standard instead. + Type bchain.TokenStandardName `json:"type" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'" ts_doc:"@deprecated: Use standard instead."` + Standard bchain.TokenStandardName `json:"standard" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'"` + Contract string `json:"contract" ts_doc:"Smart contract address."` + Name string `json:"name" ts_doc:"Readable name of the contract."` + Symbol string `json:"symbol" ts_doc:"Symbol for tokens under this contract, if applicable."` + Decimals int `json:"decimals" ts_doc:"Number of decimal places, if applicable."` + CreatedInBlock uint32 `json:"createdInBlock,omitempty" ts_doc:"Block height where contract was first created."` + DestructedInBlock uint32 `json:"destructedInBlock,omitempty" ts_doc:"Block height where contract was destroyed (if any)."` + Rates *ContractInfoRates `json:"rates,omitempty" ts_doc:"Current rate data for the contract when available."` + Protocols *ContractInfoProtocols `json:"protocols,omitempty" ts_doc:"Optional protocol-specific enrichments requested by the caller."` + BlockHeight uint32 `json:"blockHeight" ts_doc:"Indexed best block height used as freshness metadata for this response."` } // Token contains info about tokens held by an address type Token struct { - Type bchain.TokenTypeName `json:"type"` - Name string `json:"name"` - Path string `json:"path,omitempty"` - Contract string `json:"contract,omitempty"` - Transfers int `json:"transfers"` - Symbol string `json:"symbol,omitempty"` - Decimals int `json:"decimals,omitempty"` - BalanceSat *Amount `json:"balance,omitempty"` - BaseValue float64 `json:"baseValue,omitempty"` // value in the base currency (ETH for Ethereum) - SecondaryValue float64 `json:"secondaryValue,omitempty"` // value in secondary (fiat) currency, if specified - Ids []Amount `json:"ids,omitempty"` // multiple ERC721 tokens - MultiTokenValues []MultiTokenValue `json:"multiTokenValues,omitempty"` // multiple ERC1155 tokens - TotalReceivedSat *Amount `json:"totalReceived,omitempty"` - TotalSentSat *Amount `json:"totalSent,omitempty"` - ContractIndex string `json:"-"` + // Deprecated: Use Standard instead. + Type bchain.TokenStandardName `json:"type" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'" ts_doc:"@deprecated: Use standard instead."` + Standard bchain.TokenStandardName `json:"standard" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'"` + Name string `json:"name" ts_doc:"Readable name of the token."` + Path string `json:"path,omitempty" ts_doc:"Derivation path if this token is derived from an XPUB-based address."` + Contract string `json:"contract,omitempty" ts_doc:"Contract address on-chain."` + Transfers int `json:"transfers" ts_doc:"Total number of token transfers for this address."` + Symbol string `json:"symbol,omitempty" ts_doc:"Symbol for the token (e.g., 'ETH', 'USDT')."` + Decimals int `json:"decimals,omitempty" ts_doc:"Number of decimals for this token."` + BalanceSat *Amount `json:"balance,omitempty" ts_doc:"Current token balance (in minimal base units)."` + BaseValue float64 `json:"baseValue,omitempty" ts_doc:"Value in the base currency (e.g. ETH for ERC20 tokens)."` + SecondaryValue float64 `json:"secondaryValue,omitempty" ts_doc:"Value in a secondary currency (e.g. fiat), if available."` + Ids []Amount `json:"ids,omitempty" ts_doc:"List of token IDs (for ERC721, each ID is a unique collectible)."` + MultiTokenValues []MultiTokenValue `json:"multiTokenValues,omitempty" ts_doc:"Multiple ERC1155 token balances (id + value)."` + TotalReceivedSat *Amount `json:"totalReceived,omitempty" ts_doc:"Total amount of tokens received."` + TotalSentSat *Amount `json:"totalSent,omitempty" ts_doc:"Total amount of tokens sent."` + Protocols TokenProtocols `json:"protocols,omitempty" ts_doc:"Protocol identifiers the contract participates in (e.g., \"erc4626\"); for fresh per-vault data, use getContractInfo."` + ContractIndex string `json:"-"` } // Tokens is array of Token @@ -204,84 +276,98 @@ func (a Tokens) Less(i, j int) bool { // TokenTransfer contains info about a token transfer done in a transaction type TokenTransfer struct { - Type bchain.TokenTypeName `json:"type"` - From string `json:"from"` - To string `json:"to"` - Contract string `json:"contract"` - Name string `json:"name"` - Symbol string `json:"symbol"` - Decimals int `json:"decimals"` - Value *Amount `json:"value,omitempty"` - MultiTokenValues []MultiTokenValue `json:"multiTokenValues,omitempty"` -} - + // Deprecated: Use Standard instead. + Type bchain.TokenStandardName `json:"type" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'" ts_doc:"@deprecated: Use standard instead."` + Standard bchain.TokenStandardName `json:"standard" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'"` + From string `json:"from" ts_doc:"Source address of the token transfer."` + To string `json:"to" ts_doc:"Destination address of the token transfer."` + Contract string `json:"contract" ts_doc:"Contract address of the token."` + Name string `json:"name,omitempty" ts_doc:"Token name."` + Symbol string `json:"symbol,omitempty" ts_doc:"Token symbol."` + Decimals int `json:"decimals,omitempty" ts_doc:"Number of decimals for this token (if applicable)."` + Value *Amount `json:"value,omitempty" ts_doc:"Amount (in base units) of tokens transferred."` + MultiTokenValues []MultiTokenValue `json:"multiTokenValues,omitempty" ts_doc:"List of multiple ID-value pairs for ERC1155 transfers."` +} + +// EthereumInternalTransfer represents internal transaction data in Ethereum-like blockchains type EthereumInternalTransfer struct { - Type bchain.EthereumInternalTransactionType `json:"type"` - From string `json:"from"` - To string `json:"to"` - Value *Amount `json:"value"` + Type bchain.EthereumInternalTransactionType `json:"type" ts_doc:"Type of internal transfer (CALL, CREATE, etc.)."` + From string `json:"from" ts_doc:"Address from which the transfer originated."` + To string `json:"to" ts_doc:"Address to which the transfer was sent."` + Value *Amount `json:"value" ts_doc:"Value transferred internally (in Wei or base units)."` } -// EthereumSpecific contains ethereum specific transaction data +// EthereumSpecific contains ethereum-specific transaction data type EthereumSpecific struct { - Type bchain.EthereumInternalTransactionType `json:"type,omitempty"` - CreatedContract string `json:"createdContract,omitempty"` - Status eth.TxStatus `json:"status"` // 1 OK, 0 Fail, -1 pending - Error string `json:"error,omitempty"` - Nonce uint64 `json:"nonce"` - GasLimit *big.Int `json:"gasLimit"` - GasUsed *big.Int `json:"gasUsed"` - GasPrice *Amount `json:"gasPrice"` - Data string `json:"data,omitempty"` - ParsedData *bchain.EthereumParsedInputData `json:"parsedData,omitempty"` - InternalTransfers []EthereumInternalTransfer `json:"internalTransfers,omitempty"` -} - + Type bchain.EthereumInternalTransactionType `json:"type,omitempty" ts_doc:"High-level type of the Ethereum tx (e.g., 'call', 'create')."` + CreatedContract string `json:"createdContract,omitempty" ts_doc:"Address of contract created by this transaction, if any."` + Status bchain.TxStatus `json:"status" ts_doc:"Execution status of the transaction (1: success, 0: fail, -1: pending)."` + Error string `json:"error,omitempty" ts_doc:"Error encountered during execution, if any."` + Nonce uint64 `json:"nonce" ts_doc:"Transaction nonce (sequential number from the sender)."` + GasLimit *big.Int `json:"gasLimit" ts_doc:"Maximum gas allowed by the sender for this transaction."` + GasUsed *big.Int `json:"gasUsed,omitempty" ts_doc:"Actual gas consumed by the transaction execution."` + GasPrice *Amount `json:"gasPrice,omitempty" ts_doc:"Price (in Wei or base units) per gas unit."` + MaxPriorityFeePerGas *Amount `json:"maxPriorityFeePerGas,omitempty"` + MaxFeePerGas *Amount `json:"maxFeePerGas,omitempty"` + BaseFeePerGas *Amount `json:"baseFeePerGas,omitempty"` + L1Fee *big.Int `json:"l1Fee,omitempty" ts_doc:"Fee used for L1 part in rollups (e.g. Optimism)."` + L1FeeScalar string `json:"l1FeeScalar,omitempty" ts_doc:"Scaling factor for L1 fees in certain Layer 2 solutions."` + L1GasPrice *Amount `json:"l1GasPrice,omitempty" ts_doc:"Gas price for L1 component, if applicable."` + L1GasUsed *big.Int `json:"l1GasUsed,omitempty" ts_doc:"Amount of gas used in L1 for this tx, if applicable."` + Data string `json:"data,omitempty" ts_doc:"Hex-encoded input data for the transaction."` + ParsedData *bchain.EthereumParsedInputData `json:"parsedData,omitempty" ts_doc:"Decoded transaction data (function name, params, etc.)."` + InternalTransfers []EthereumInternalTransfer `json:"internalTransfers,omitempty" ts_doc:"List of internal (sub-call) transfers."` +} + +// AddressAlias holds a specialized alias for an address type AddressAlias struct { - Type string - Alias string + Type string `ts_doc:"Type of alias, e.g., user-defined name or contract name."` + Alias string `ts_doc:"Alias string for the address."` } + +// AddressAliasesMap is a map of address strings to their alias definitions type AddressAliasesMap map[string]AddressAlias // Tx holds information about a transaction type Tx struct { - Txid string `json:"txid"` - Version int32 `json:"version,omitempty"` - Locktime uint32 `json:"lockTime,omitempty"` - Vin []Vin `json:"vin"` - Vout []Vout `json:"vout"` - Blockhash string `json:"blockHash,omitempty"` - Blockheight int `json:"blockHeight"` - Confirmations uint32 `json:"confirmations"` - ConfirmationETABlocks uint32 `json:"confirmationETABlocks,omitempty"` - ConfirmationETASeconds int64 `json:"confirmationETASeconds,omitempty"` - Blocktime int64 `json:"blockTime"` - Size int `json:"size,omitempty"` - VSize int `json:"vsize,omitempty"` - ValueOutSat *Amount `json:"value"` - ValueInSat *Amount `json:"valueIn,omitempty"` - FeesSat *Amount `json:"fees,omitempty"` - Hex string `json:"hex,omitempty"` - Rbf bool `json:"rbf,omitempty"` - CoinSpecificData json.RawMessage `json:"coinSpecificData,omitempty"` - TokenTransfers []TokenTransfer `json:"tokenTransfers,omitempty"` - EthereumSpecific *EthereumSpecific `json:"ethereumSpecific,omitempty"` - AddressAliases AddressAliasesMap `json:"addressAliases,omitempty"` + Txid string `json:"txid" ts_doc:"Transaction ID (hash)."` + Version int32 `json:"version,omitempty" ts_doc:"Version of the transaction (if applicable)."` + Locktime uint32 `json:"lockTime,omitempty" ts_doc:"Locktime indicating earliest time/height transaction can be mined."` + Vin []Vin `json:"vin" ts_doc:"Array of inputs for this transaction."` + Vout []Vout `json:"vout" ts_doc:"Array of outputs for this transaction."` + Blockhash string `json:"blockHash,omitempty" ts_doc:"Hash of the block containing this transaction."` + Blockheight int `json:"blockHeight" ts_doc:"Block height in which this transaction was included."` + Confirmations uint32 `json:"confirmations" ts_doc:"Number of confirmations (blocks mined after this tx's block)."` + ConfirmationETABlocks uint32 `json:"confirmationETABlocks,omitempty" ts_doc:"Estimated blocks remaining until confirmation (if unconfirmed)."` + ConfirmationETASeconds int64 `json:"confirmationETASeconds,omitempty" ts_doc:"Estimated seconds remaining until confirmation (if unconfirmed)."` + Blocktime int64 `json:"blockTime" ts_doc:"Unix timestamp of the block in which this transaction was included. 0 if unconfirmed."` + Size int `json:"size,omitempty" ts_doc:"Transaction size in bytes."` + VSize int `json:"vsize,omitempty" ts_doc:"Virtual size in bytes, for SegWit-enabled chains."` + ValueOutSat *Amount `json:"value" ts_doc:"Total value of all outputs (in satoshi or base units)."` + ValueInSat *Amount `json:"valueIn,omitempty" ts_doc:"Total value of all inputs (in satoshi or base units)."` + FeesSat *Amount `json:"fees,omitempty" ts_doc:"Transaction fee (inputs - outputs)."` + Hex string `json:"hex,omitempty" ts_doc:"Raw hex-encoded transaction data."` + Rbf bool `json:"rbf,omitempty" ts_doc:"Indicates if this transaction is replace-by-fee (RBF) enabled."` + CoinSpecificData json.RawMessage `json:"coinSpecificData,omitempty" ts_type:"any" ts_doc:"Blockchain-specific extended data."` + ChainExtraData *TxChainExtraData `json:"chainExtraData,omitempty" ts_type:"{ payloadType: 'tron'; payload?: TronChainExtraData } | { payloadType: string; payload?: any }" ts_doc:"Additional normalized chain-specific transaction data. Use payloadType as discriminator for payload."` + TokenTransfers []TokenTransfer `json:"tokenTransfers,omitempty" ts_doc:"List of token transfers that occurred in this transaction."` + EthereumSpecific *EthereumSpecific `json:"ethereumSpecific,omitempty" ts_doc:"Ethereum-like blockchain specific data (if applicable)."` + AddressAliases AddressAliasesMap `json:"addressAliases,omitempty" ts_doc:"Aliases for addresses involved in this transaction."` } // FeeStats contains detailed block fee statistics type FeeStats struct { - TxCount int `json:"txCount"` - TotalFeesSat *Amount `json:"totalFeesSat"` - AverageFeePerKb int64 `json:"averageFeePerKb"` - DecilesFeePerKb [11]int64 `json:"decilesFeePerKb"` + TxCount int `json:"txCount" ts_doc:"Number of transactions in the given block."` + TotalFeesSat *Amount `json:"totalFeesSat" ts_doc:"Sum of all fees in satoshi or base units."` + AverageFeePerKb int64 `json:"averageFeePerKb" ts_doc:"Average fee per kilobyte in satoshi or base units."` + DecilesFeePerKb [11]int64 `json:"decilesFeePerKb" ts_doc:"Fee distribution deciles (0%..100%) in satoshi or base units per kB."` } // Paging contains information about paging for address, blocks and block type Paging struct { - Page int `json:"page,omitempty"` - TotalPages int `json:"totalPages,omitempty"` - ItemsOnPage int `json:"itemsOnPage,omitempty"` + Page int `json:"page,omitempty" ts_doc:"Current page index."` + TotalPages int `json:"totalPages,omitempty" ts_doc:"Total number of pages available."` + ItemsOnPage int `json:"itemsOnPage,omitempty" ts_doc:"Number of items returned on this page."` } // TokensToReturn specifies what tokens are returned by GetAddress and GetXpubAddress @@ -307,56 +393,76 @@ const ( // AddressFilter is used to filter data returned from GetAddress api method type AddressFilter struct { - Vout int - Contract string - FromHeight uint32 - ToHeight uint32 - TokensToReturn TokensToReturn + Vout int `ts_doc:"Specifies which output index we are interested in filtering (or use the special constants)."` + Contract string `ts_doc:"Contract address to filter by, if applicable."` + FromHeight uint32 `ts_doc:"Starting block height for filtering transactions."` + ToHeight uint32 `ts_doc:"Ending block height for filtering transactions."` + TokensToReturn TokensToReturn `ts_doc:"Which tokens to include in the result set."` + Protocols []string `ts_doc:"Optional protocol enrichments to include. Supported values currently include 'erc4626'."` // OnlyConfirmed set to true will ignore mempool transactions; mempool is also ignored if FromHeight/ToHeight filter is specified - OnlyConfirmed bool + OnlyConfirmed bool `ts_doc:"If true, ignores mempool (unconfirmed) transactions."` +} + +// StakingPool holds data about address participation in a staking pool contract +type StakingPool struct { + Contract string `json:"contract" ts_doc:"Staking pool contract address on-chain."` + Name string `json:"name" ts_doc:"Name of the staking pool contract."` + PendingBalance *Amount `json:"pendingBalance" ts_doc:"Balance pending deposit or withdrawal, if any."` + PendingDepositedBalance *Amount `json:"pendingDepositedBalance" ts_doc:"Any pending deposit that is not yet finalized."` + DepositedBalance *Amount `json:"depositedBalance" ts_doc:"Currently deposited/staked balance."` + WithdrawTotalAmount *Amount `json:"withdrawTotalAmount" ts_doc:"Total amount withdrawn from this pool by the address."` + ClaimableAmount *Amount `json:"claimableAmount" ts_doc:"Rewards or principal currently claimable by the address."` + RestakedReward *Amount `json:"restakedReward" ts_doc:"Total rewards that have been restaked automatically."` + AutocompoundBalance *Amount `json:"autocompoundBalance" ts_doc:"Any balance automatically reinvested into the pool."` } -// Address holds information about address and its transactions +// Address holds information about an address and its transactions type Address struct { Paging - AddrStr string `json:"address"` - BalanceSat *Amount `json:"balance"` - TotalReceivedSat *Amount `json:"totalReceived,omitempty"` - TotalSentSat *Amount `json:"totalSent,omitempty"` - UnconfirmedBalanceSat *Amount `json:"unconfirmedBalance"` - UnconfirmedTxs int `json:"unconfirmedTxs"` - Txs int `json:"txs"` - NonTokenTxs int `json:"nonTokenTxs,omitempty"` - InternalTxs int `json:"internalTxs,omitempty"` - Transactions []*Tx `json:"transactions,omitempty"` - Txids []string `json:"txids,omitempty"` - Nonce string `json:"nonce,omitempty"` - UsedTokens int `json:"usedTokens,omitempty"` - Tokens Tokens `json:"tokens,omitempty"` - SecondaryValue float64 `json:"secondaryValue,omitempty"` // address value in secondary currency - TokensBaseValue float64 `json:"tokensBaseValue,omitempty"` - TokensSecondaryValue float64 `json:"tokensSecondaryValue,omitempty"` - TotalBaseValue float64 `json:"totalBaseValue,omitempty"` // value including tokens in base currency - TotalSecondaryValue float64 `json:"totalSecondaryValue,omitempty"` // value including tokens in secondary currency - ContractInfo *bchain.ContractInfo `json:"contractInfo,omitempty"` - Erc20Contract *bchain.ContractInfo `json:"erc20Contract,omitempty"` // deprecated - AddressAliases AddressAliasesMap `json:"addressAliases,omitempty"` + AddrStr string `json:"address" ts_doc:"The address string in standard format."` + BalanceSat *Amount `json:"balance" ts_doc:"Current confirmed balance (in satoshi or base units)."` + TotalReceivedSat *Amount `json:"totalReceived,omitempty" ts_doc:"Total amount ever received by this address."` + TotalSentSat *Amount `json:"totalSent,omitempty" ts_doc:"Total amount ever sent by this address."` + UnconfirmedBalanceSat *Amount `json:"unconfirmedBalance" ts_doc:"Unconfirmed balance for this address."` + UnconfirmedTxs int `json:"unconfirmedTxs" ts_doc:"Number of unconfirmed transactions for this address."` + UnconfirmedSending *Amount `json:"unconfirmedSending,omitempty" ts_doc:"Unconfirmed outgoing balance for this address."` + UnconfirmedReceiving *Amount `json:"unconfirmedReceiving,omitempty" ts_doc:"Unconfirmed incoming balance for this address."` + Txs int `json:"txs" ts_doc:"Number of transactions for this address (including confirmed)."` + AddrTxCount int `json:"addrTxCount,omitempty" ts_doc:"Historical total count of transactions, if known."` + NonTokenTxs int `json:"nonTokenTxs,omitempty" ts_doc:"Number of transactions not involving tokens (pure coin transfers)."` + InternalTxs int `json:"internalTxs,omitempty" ts_doc:"Number of internal transactions (e.g., Ethereum calls)."` + Transactions []*Tx `json:"transactions,omitempty" ts_doc:"List of transaction details (if requested)."` + Txids []string `json:"txids,omitempty" ts_doc:"List of transaction IDs (if detailed data is not requested)."` + Nonce string `json:"nonce,omitempty" ts_doc:"Current transaction nonce for Ethereum-like addresses."` + UsedTokens int `json:"usedTokens,omitempty" ts_doc:"Number of tokens with any historical usage at this address."` + Tokens Tokens `json:"tokens,omitempty" ts_doc:"List of tokens associated with this address."` + SecondaryValue float64 `json:"secondaryValue,omitempty" ts_doc:"Total value of the address in secondary currency (e.g. fiat)."` + TokensBaseValue float64 `json:"tokensBaseValue,omitempty" ts_doc:"Sum of token values in base currency."` + TokensSecondaryValue float64 `json:"tokensSecondaryValue,omitempty" ts_doc:"Sum of token values in secondary currency (fiat)."` + TotalBaseValue float64 `json:"totalBaseValue,omitempty" ts_doc:"Address's entire value in base currency, including tokens."` + TotalSecondaryValue float64 `json:"totalSecondaryValue,omitempty" ts_doc:"Address's entire value in secondary currency, including tokens."` + ContractInfo *ContractInfoResult `json:"contractInfo,omitempty" ts_doc:"Extra info if the address is a contract. Shape matches getContractInfo; rates and protocols are populated only when explicitly requested via getContractInfo."` + // Deprecated: replaced by ContractInfo + Erc20Contract *ContractInfoResult `json:"erc20Contract,omitempty" ts_doc:"@deprecated: replaced by contractInfo"` + AddressAliases AddressAliasesMap `json:"addressAliases,omitempty" ts_doc:"Aliases assigned to this address."` + StakingPools []StakingPool `json:"stakingPools,omitempty" ts_doc:"List of staking pool data if address interacts with staking."` + ChainExtraData *AccountChainExtraData `json:"chainExtraData,omitempty" ts_type:"{ payloadType: 'tron'; payload?: TronAccountExtraData } | { payloadType: string; payload?: any }" ts_doc:"Additional normalized chain-specific account/address data. Use payloadType as discriminator for payload."` // helpers for explorer - Filter string `json:"-"` - XPubAddresses map[string]struct{} `json:"-"` + Filter string `json:"-" ts_doc:"Filter used internally for data retrieval."` + XPubAddresses map[string]struct{} `json:"-" ts_doc:"Set of derived XPUB addresses (internal usage)."` } // Utxo is one unspent transaction output type Utxo struct { - Txid string `json:"txid"` - Vout int32 `json:"vout"` - AmountSat *Amount `json:"value"` - Height int `json:"height,omitempty"` - Confirmations int `json:"confirmations"` - Address string `json:"address,omitempty"` - Path string `json:"path,omitempty"` - Locktime uint32 `json:"lockTime,omitempty"` - Coinbase bool `json:"coinbase,omitempty"` + Txid string `json:"txid" ts_doc:"Transaction ID in which this UTXO was created."` + Vout int32 `json:"vout" ts_doc:"Index of the output in that transaction."` + AmountSat *Amount `json:"value" ts_doc:"Value of this UTXO (in satoshi or base units)."` + Height int `json:"height,omitempty" ts_doc:"Block height in which the UTXO was confirmed."` + Confirmations int `json:"confirmations" ts_doc:"Number of confirmations for this UTXO."` + Address string `json:"address,omitempty" ts_doc:"Address to which this UTXO belongs."` + Path string `json:"path,omitempty" ts_doc:"Derivation path for XPUB-based wallets, if applicable."` + Locktime uint32 `json:"lockTime,omitempty" ts_doc:"If non-zero, locktime required before spending this UTXO."` + Coinbase bool `json:"coinbase,omitempty" ts_doc:"Indicates if this UTXO originated from a coinbase transaction."` } // Utxos is array of Utxo @@ -379,13 +485,13 @@ func (a Utxos) Less(i, j int) bool { // BalanceHistory contains info about one point in time of balance history type BalanceHistory struct { - Time uint32 `json:"time"` - Txs uint32 `json:"txs"` - ReceivedSat *Amount `json:"received"` - SentSat *Amount `json:"sent"` - SentToSelfSat *Amount `json:"sentToSelf"` - FiatRates map[string]float32 `json:"rates,omitempty"` - Txid string `json:"txid,omitempty"` + Time uint32 `json:"time" ts_doc:"Unix timestamp for this point in the balance history."` + Txs uint32 `json:"txs" ts_doc:"Number of transactions in this interval."` + ReceivedSat *Amount `json:"received" ts_doc:"Amount received in this interval (in satoshi or base units)."` + SentSat *Amount `json:"sent" ts_doc:"Amount sent in this interval (in satoshi or base units)."` + SentToSelfSat *Amount `json:"sentToSelf" ts_doc:"Amount sent to the same address (self-transfer)."` + FiatRates map[string]float32 `json:"rates,omitempty" ts_doc:"Exchange rates at this point in time, if available."` + Txid string `json:"txid,omitempty" ts_doc:"Transaction ID if the time corresponds to a specific tx."` } // BalanceHistories is array of BalanceHistory @@ -447,101 +553,131 @@ func (a BalanceHistories) SortAndAggregate(groupByTime uint32) BalanceHistories // Blocks is list of blocks with paging information type Blocks struct { Paging - Blocks []db.BlockInfo `json:"blocks"` + Blocks []db.BlockInfo `json:"blocks" ts_doc:"List of blocks."` } // BlockInfo contains extended block header data and a list of block txids type BlockInfo struct { - Hash string `json:"hash"` - Prev string `json:"previousBlockHash,omitempty"` - Next string `json:"nextBlockHash,omitempty"` - Height uint32 `json:"height"` - Confirmations int `json:"confirmations"` - Size int `json:"size"` - Time int64 `json:"time,omitempty"` - Version common.JSONNumber `json:"version"` - MerkleRoot string `json:"merkleRoot"` - Nonce string `json:"nonce"` - Bits string `json:"bits"` - Difficulty string `json:"difficulty"` - Txids []string `json:"tx,omitempty"` + Hash string `json:"hash" ts_doc:"Block hash."` + Prev string `json:"previousBlockHash,omitempty" ts_doc:"Hash of the previous block in the chain."` + Next string `json:"nextBlockHash,omitempty" ts_doc:"Hash of the next block, if known."` + Height uint32 `json:"height" ts_doc:"Block height (0-based index in the chain)."` + Confirmations int `json:"confirmations" ts_doc:"Number of confirmations of this block (distance from best chain tip)."` + Size int `json:"size" ts_doc:"Size of the block in bytes."` + Time int64 `json:"time,omitempty" ts_doc:"Timestamp of when this block was mined."` + Version common.JSONNumber `json:"version" ts_doc:"Block version (chain-specific meaning)."` + MerkleRoot string `json:"merkleRoot" ts_doc:"Merkle root of the block's transactions."` + Nonce string `json:"nonce" ts_doc:"Nonce used in the mining process."` + Bits string `json:"bits" ts_doc:"Compact representation of the target threshold."` + Difficulty string `json:"difficulty" ts_doc:"Difficulty target for mining this block."` + Txids []string `json:"tx,omitempty" ts_doc:"List of transaction IDs included in this block."` } // Block contains information about block type Block struct { Paging BlockInfo - TxCount int `json:"txCount"` - Transactions []*Tx `json:"txs,omitempty"` - AddressAliases AddressAliasesMap `json:"addressAliases,omitempty"` + TxCount int `json:"txCount" ts_doc:"Total count of transactions in this block."` + Transactions []*Tx `json:"txs,omitempty" ts_doc:"List of full transaction details (if requested)."` + AddressAliases AddressAliasesMap `json:"addressAliases,omitempty" ts_doc:"Optional aliases for addresses found in this block."` } // BlockRaw contains raw block in hex type BlockRaw struct { - Hex string `json:"hex"` + Hex string `json:"hex" ts_doc:"Hex-encoded block data."` } // BlockbookInfo contains information about the running blockbook instance type BlockbookInfo struct { - Coin string `json:"coin"` - Host string `json:"host"` - Version string `json:"version"` - GitCommit string `json:"gitCommit"` - BuildTime string `json:"buildTime"` - SyncMode bool `json:"syncMode"` - InitialSync bool `json:"initialSync"` - InSync bool `json:"inSync"` - BestHeight uint32 `json:"bestHeight"` - LastBlockTime time.Time `json:"lastBlockTime"` - InSyncMempool bool `json:"inSyncMempool"` - LastMempoolTime time.Time `json:"lastMempoolTime"` - MempoolSize int `json:"mempoolSize"` - Decimals int `json:"decimals"` - DbSize int64 `json:"dbSize"` - HasFiatRates bool `json:"hasFiatRates,omitempty"` - HasTokenFiatRates bool `json:"hasTokenFiatRates,omitempty"` - CurrentFiatRatesTime *time.Time `json:"currentFiatRatesTime,omitempty"` - HistoricalFiatRatesTime *time.Time `json:"historicalFiatRatesTime,omitempty"` - HistoricalTokenFiatRatesTime *time.Time `json:"historicalTokenFiatRatesTime,omitempty"` - DbSizeFromColumns int64 `json:"dbSizeFromColumns,omitempty"` - DbColumns []common.InternalStateColumn `json:"dbColumns,omitempty"` - About string `json:"about"` + Coin string `json:"coin" ts_doc:"Coin name, e.g. 'Bitcoin'."` + Network string `json:"network" ts_doc:"Network shortcut, e.g. 'BTC'."` + Host string `json:"host" ts_doc:"Hostname of the blockbook instance, e.g. 'backend5'."` + Version string `json:"version" ts_doc:"Running blockbook version, e.g. '0.4.0'."` + GitCommit string `json:"gitCommit" ts_doc:"Git commit hash of the running blockbook, e.g. 'a0960c8e'."` + BuildTime string `json:"buildTime" ts_doc:"Build time of running blockbook, e.g. '2024-08-08T12:32:50+00:00'."` + SyncMode bool `json:"syncMode" ts_doc:"If true, blockbook is syncing from scratch or in a special sync mode."` + InitialSync bool `json:"initialSync" ts_doc:"Indicates if blockbook is in its initial sync phase."` + InSync bool `json:"inSync" ts_doc:"Indicates if the backend is fully synced with the blockchain."` + BestHeight uint32 `json:"bestHeight" ts_doc:"Best (latest) block height according to this instance."` + LastBlockTime time.Time `json:"lastBlockTime" ts_doc:"Timestamp of the latest block in the chain."` + InSyncMempool bool `json:"inSyncMempool" ts_doc:"Indicates if mempool info is synced as well."` + LastMempoolTime time.Time `json:"lastMempoolTime" ts_doc:"Timestamp of the last mempool update."` + MempoolSize int `json:"mempoolSize" ts_doc:"Number of unconfirmed transactions in the mempool."` + Decimals int `json:"decimals" ts_doc:"Number of decimals for this coin's base unit."` + DbSize int64 `json:"dbSize" ts_doc:"Size of the underlying database in bytes."` + HasFiatRates bool `json:"hasFiatRates,omitempty" ts_doc:"Whether this instance provides fiat exchange rates."` + HasTokenFiatRates bool `json:"hasTokenFiatRates,omitempty" ts_doc:"Whether this instance provides fiat exchange rates for tokens."` + CurrentFiatRatesTime *time.Time `json:"currentFiatRatesTime,omitempty" ts_doc:"Timestamp of the latest fiat rates update."` + HistoricalFiatRatesTime *time.Time `json:"historicalFiatRatesTime,omitempty" ts_doc:"Timestamp of the latest historical fiat rates update."` + HistoricalTokenFiatRatesTime *time.Time `json:"historicalTokenFiatRatesTime,omitempty" ts_doc:"Timestamp of the latest historical token fiat rates update."` + SupportedStakingPools []string `json:"supportedStakingPools,omitempty" ts_doc:"List of contract addresses supported for staking."` + DbSizeFromColumns int64 `json:"dbSizeFromColumns,omitempty" ts_doc:"Optional calculated DB size from columns."` + DbColumns []common.InternalStateColumn `json:"dbColumns,omitempty" ts_doc:"List of columns/tables in the DB for internal state."` + About string `json:"about" ts_doc:"Additional human-readable info about this blockbook instance."` } // SystemInfo contains information about the running blockbook and backend instance type SystemInfo struct { - Blockbook *BlockbookInfo `json:"blockbook"` - Backend *common.BackendInfo `json:"backend"` + Blockbook *BlockbookInfo `json:"blockbook" ts_doc:"Blockbook instance information."` + Backend *common.BackendInfo `json:"backend" ts_doc:"Information about the connected backend node."` } // MempoolTxid contains information about a transaction in mempool type MempoolTxid struct { - Time int64 `json:"time"` - Txid string `json:"txid"` + Time int64 `json:"time" ts_doc:"Timestamp when the transaction was received in the mempool."` + Txid string `json:"txid" ts_doc:"Transaction hash for this mempool entry."` } // MempoolTxids contains a list of mempool txids with paging information type MempoolTxids struct { Paging - Mempool []MempoolTxid `json:"mempool"` - MempoolSize int `json:"mempoolSize"` + Mempool []MempoolTxid `json:"mempool" ts_doc:"List of transactions currently in the mempool."` + MempoolSize int `json:"mempoolSize" ts_doc:"Number of unconfirmed transactions in the mempool."` } // FiatTicker contains formatted CurrencyRatesTicker data type FiatTicker struct { - Timestamp int64 `json:"ts,omitempty"` - Rates map[string]float32 `json:"rates"` - Error string `json:"error,omitempty"` + Timestamp int64 `json:"ts,omitempty" ts_doc:"Unix timestamp for these fiat rates."` + Rates map[string]float32 `json:"rates" ts_doc:"Map of currency codes to their exchange rate."` + Error string `json:"error,omitempty" ts_doc:"Any error message encountered while fetching rates."` } // FiatTickers contains a formatted CurrencyRatesTicker list type FiatTickers struct { - Tickers []FiatTicker `json:"tickers"` + Tickers []FiatTicker `json:"tickers" ts_doc:"List of fiat tickers with timestamps and rates."` } // AvailableVsCurrencies contains formatted data about available versus currencies for exchange rates type AvailableVsCurrencies struct { - Timestamp int64 `json:"ts,omitempty"` - Tickers []string `json:"available_currencies"` - Error string `json:"error,omitempty"` + Timestamp int64 `json:"ts,omitempty" ts_doc:"Timestamp for the available currency list."` + Tickers []string `json:"available_currencies" ts_doc:"List of currency codes (e.g., USD, EUR) supported by the rates."` + Error string `json:"error,omitempty" ts_doc:"Error message, if any, when fetching the available currencies."` +} + +// Eip1559Fee +type Eip1559Fee struct { + MaxFeePerGas *Amount `json:"maxFeePerGas"` + MaxPriorityFeePerGas *Amount `json:"maxPriorityFeePerGas"` + MinWaitTimeEstimate int `json:"minWaitTimeEstimate,omitempty"` + MaxWaitTimeEstimate int `json:"maxWaitTimeEstimate,omitempty"` +} + +// Eip1559Fees +type Eip1559Fees struct { + BaseFeePerGas *Amount `json:"baseFeePerGas,omitempty"` + Low *Eip1559Fee `json:"low,omitempty"` + Medium *Eip1559Fee `json:"medium,omitempty"` + High *Eip1559Fee `json:"high,omitempty"` + Instant *Eip1559Fee `json:"instant,omitempty"` + NetworkCongestion float64 `json:"networkCongestion,omitempty"` + LatestPriorityFeeRange []*Amount `json:"latestPriorityFeeRange,omitempty"` + HistoricalPriorityFeeRange []*Amount `json:"historicalPriorityFeeRange,omitempty"` + HistoricalBaseFeeRange []*Amount `json:"historicalBaseFeeRange,omitempty"` + PriorityFeeTrend string `json:"priorityFeeTrend,omitempty" ts_type:"'up' | 'down'"` + BaseFeeTrend string `json:"baseFeeTrend,omitempty" ts_type:"'up' | 'down'"` +} + +type LongTermFeeRate struct { + FeePerUnit string `json:"feePerUnit" ts_doc:"Long term fee rate (in sat/kByte)."` + Blocks uint64 `json:"blocks" ts_doc:"Amount of blocks used for the long term fee rate estimation."` } diff --git a/api/types_chainextradata.go b/api/types_chainextradata.go new file mode 100644 index 0000000000..d202d9ff13 --- /dev/null +++ b/api/types_chainextradata.go @@ -0,0 +1,18 @@ +package api + +import ( + "encoding/json" + + "github.com/trezor/blockbook/bchain" +) + +type chainExtraDataBase struct { + PayloadType bchain.ChainExtraPayloadType `json:"payloadType" ts_doc:"Payload discriminator, e.g. 'tron'."` + Payload json.RawMessage `json:"payload,omitempty" ts_type:"any" ts_doc:"Chain-specific payload."` +} + +// TxChainExtraData wraps normalized chain-specific transaction data with a payload discriminator. +type TxChainExtraData chainExtraDataBase + +// AccountChainExtraData wraps normalized chain-specific account/address data with a payload discriminator. +type AccountChainExtraData chainExtraDataBase diff --git a/api/types_test.go b/api/types_test.go index d2d53380a3..12bb8fec9f 100644 --- a/api/types_test.go +++ b/api/types_test.go @@ -47,6 +47,15 @@ func TestAmount_MarshalJSON(t *testing.T) { if !reflect.DeepEqual(string(b), tt.want) { t.Errorf("json.Marshal() = %v, want %v", string(b), tt.want) } + var parsed amounts + err = json.Unmarshal(b, &parsed) + if err != nil { + t.Errorf("json.Unmarshal() error = %v", err) + return + } + if !reflect.DeepEqual(parsed, tt.a) { + t.Errorf("json.Unmarshal() = %v, want %v", parsed, tt.a) + } }) } } diff --git a/api/worker.go b/api/worker.go index 5f0873a3b8..708e82c7d3 100644 --- a/api/worker.go +++ b/api/worker.go @@ -19,6 +19,7 @@ import ( "github.com/trezor/blockbook/bchain/coins/eth" "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/fiat" ) // Worker is handle to api worker @@ -31,11 +32,23 @@ type Worker struct { useAddressAliases bool mempool bchain.Mempool is *common.InternalState + fiatRates *fiat.FiatRates metrics *common.Metrics } +var getTickersForTimestamps = func(fr *fiat.FiatRates, timestamps []int64, vsCurrency string, token string) (*[]*common.CurrencyRatesTicker, error) { + return fr.GetTickersForTimestamps(timestamps, vsCurrency, token) +} + +var getCurrentTicker = func(fr *fiat.FiatRates, vsCurrency string, token string) *common.CurrencyRatesTicker { + return fr.GetCurrentTicker(vsCurrency, token) +} + +// contractInfoCache is a temporary cache of contract information for ethereum token transfers +type contractInfoCache = map[string]*bchain.ContractInfo + // NewWorker creates new api worker -func NewWorker(db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, metrics *common.Metrics, is *common.InternalState) (*Worker, error) { +func NewWorker(db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, metrics *common.Metrics, is *common.InternalState, fiatRates *fiat.FiatRates) (*Worker, error) { w := &Worker{ db: db, txCache: txCache, @@ -45,6 +58,7 @@ func NewWorker(db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, useAddressAliases: chain.GetChainParser().UseAddressAliases(), mempool: mempool, is: is, + fiatRates: fiatRates, metrics: metrics, } if w.chainType == bchain.ChainBitcoinType { @@ -101,6 +115,19 @@ func (w *Worker) setSpendingTxToVout(vout *Vout, txid string, height uint32) err // GetSpendingTxid returns transaction id of transaction that spent given output func (w *Worker) GetSpendingTxid(txid string, n int) (string, error) { + if w.db.HasExtendedIndex() { + tsp, err := w.db.GetTxAddresses(txid) + if err != nil { + return "", err + } else if tsp == nil { + glog.Warning("DB inconsistency: tx ", txid, ": not found in txAddresses") + return "", NewAPIError(fmt.Sprintf("Txid %v not found", txid), false) + } + if n >= len(tsp.Outputs) || n < 0 { + return "", NewAPIError(fmt.Sprintf("Passed incorrect vout index %v for tx %v, len vout %v", n, txid, len(tsp.Outputs)), false) + } + return tsp.Outputs[n].SpentTxid, nil + } start := time.Now() tx, err := w.getTransaction(txid, false, false, nil) if err != nil { @@ -142,6 +169,36 @@ func (w *Worker) newAddressesMapForAliases() map[string]struct{} { return nil } +func (w *Worker) getTxChainExtraData(tx *bchain.Tx) (*TxChainExtraData, error) { + payload, err := w.chainParser.GetChainExtraData(tx) + if err != nil { + return nil, err + } + if len(payload) == 0 { + return nil, nil + } + + return &TxChainExtraData{ + PayloadType: w.chainParser.GetChainExtraPayloadType(), + Payload: payload, + }, nil +} + +func (w *Worker) getAccountChainExtraData(addrDesc bchain.AddressDescriptor) (*AccountChainExtraData, error) { + payload, err := w.chain.GetAddressChainExtraData(addrDesc) + if err != nil { + return nil, err + } + if len(payload) == 0 { + return nil, nil + } + + return &AccountChainExtraData{ + PayloadType: w.chainParser.GetChainExtraPayloadType(), + Payload: payload, + }, nil +} + func (w *Worker) getAddressAliases(addresses map[string]struct{}) AddressAliasesMap { if len(addresses) > 0 { aliases := make(AddressAliasesMap) @@ -153,9 +210,18 @@ func (w *Worker) getAddressAliases(addresses map[string]struct{}) AddressAliases } for a := range addresses { if w.chainType == bchain.ChainEthereumType { - ci, err := w.db.GetContractInfoForAddress(a) - if err == nil && ci != nil && ci.Name != "" { - aliases[a] = AddressAlias{Type: "Contract", Alias: ci.Name} + addrDesc, err := w.chainParser.GetAddrDescFromAddress(a) + if err != nil || addrDesc == nil { + continue + } + ci, err := w.db.GetContractInfo(addrDesc, bchain.UnknownTokenStandard) + if err == nil && ci != nil { + if ci.Standard == bchain.UnhandledTokenStandard { + ci, _, err = w.getContractDescriptorInfo(addrDesc, bchain.UnknownTokenStandard) + } + if err == nil && ci != nil && ci.Name != "" { + aliases[a] = AddressAlias{Type: "Contract", Alias: ci.Name} + } } } n := w.db.GetAddressAlias(a) @@ -179,6 +245,11 @@ func (w *Worker) GetTransaction(txid string, spendingTxs bool, specificJSON bool return tx, nil } +// GetRawTransaction gets raw transaction data in hex format from txid +func (w *Worker) GetRawTransaction(txid string) (string, error) { + return w.chain.EthereumTypeGetRawTransaction(txid) +} + // getTransaction reads transaction data from txid func (w *Worker) getTransaction(txid string, spendingTxs bool, specificJSON bool, addresses map[string]struct{}) (*Tx, error) { bchainTx, height, err := w.txCache.GetTransaction(txid) @@ -188,7 +259,7 @@ func (w *Worker) getTransaction(txid string, spendingTxs bool, specificJSON bool } return nil, NewAPIError(fmt.Sprintf("Transaction '%v' not found (%v)", txid, err), true) } - return w.getTransactionFromBchainTx(bchainTx, height, spendingTxs, specificJSON, addresses) + return w.GetTransactionFromBchainTx(bchainTx, height, spendingTxs, specificJSON, addresses) } func (w *Worker) getParsedEthereumInputData(data string) *bchain.EthereumParsedInputData { @@ -205,7 +276,7 @@ func (w *Worker) getParsedEthereumInputData(data string) *bchain.EthereumParsedI return nil } } - return eth.ParseInputData(signatures, data) + return w.chainParser.ParseInputData(signatures, data) } // getConfirmationETA returns confirmation ETA in seconds and blocks @@ -251,8 +322,8 @@ func (w *Worker) getConfirmationETA(tx *Tx) (int64, uint32) { return etaSeconds, etaBlocks } -// getTransactionFromBchainTx reads transaction data from txid -func (w *Worker) getTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spendingTxs bool, specificJSON bool, addresses map[string]struct{}) (*Tx, error) { +// GetTransactionFromBchainTx reads transaction data from txid +func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spendingTxs bool, specificJSON bool, addresses map[string]struct{}) (*Tx, error) { var err error var ta *db.TxAddresses var tokens []TokenTransfer @@ -368,10 +439,16 @@ func (w *Worker) getTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe aggregateAddresses(addresses, vout.Addresses, vout.IsAddress) if ta != nil { vout.Spent = ta.Outputs[i].Spent - if spendingTxs && vout.Spent { - err = w.setSpendingTxToVout(vout, bchainTx.Txid, uint32(height)) - if err != nil { - glog.Errorf("setSpendingTxToVout error %v, %v, output %v", err, vout.AddrDesc, vout.N) + if vout.Spent { + if w.db.HasExtendedIndex() { + vout.SpentTxID = ta.Outputs[i].SpentTxid + vout.SpentIndex = int(ta.Outputs[i].SpentIndex) + vout.SpentHeight = int(ta.Outputs[i].SpentHeight) + } else if spendingTxs { + err = w.setSpendingTxToVout(vout, bchainTx.Txid, uint32(height)) + if err != nil { + glog.Errorf("setSpendingTxToVout error %v, %v, output %v", err, vout.AddrDesc, vout.N) + } } } } @@ -389,10 +466,10 @@ func (w *Worker) getTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe glog.Errorf("GetTokenTransfersFromTx error %v, %v", err, bchainTx) } tokens = w.getEthereumTokensTransfers(tokenTransfers, addresses) - ethTxData := eth.GetEthereumTxData(bchainTx) + ethTxData := w.chainParser.GetEthereumTxData(bchainTx) var internalData *bchain.EthereumInternalData - if eth.ProcessInternalTransactions { + if bchain.ProcessInternalTransactions { internalData, err = w.db.GetEthereumInternalData(bchainTx.Txid) if err != nil { return nil, err @@ -404,18 +481,28 @@ func (w *Worker) getTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe // mempool txs do not have fees yet if ethTxData.GasUsed != nil { feesSat.Mul(ethTxData.GasPrice, ethTxData.GasUsed) + if ethTxData.L1Fee != nil { + feesSat.Add(&feesSat, ethTxData.L1Fee) + } } if len(bchainTx.Vout) > 0 { valOutSat = bchainTx.Vout[0].ValueSat } ethSpecific = &EthereumSpecific{ - GasLimit: ethTxData.GasLimit, - GasPrice: (*Amount)(ethTxData.GasPrice), - GasUsed: ethTxData.GasUsed, - Nonce: ethTxData.Nonce, - Status: ethTxData.Status, - Data: ethTxData.Data, - ParsedData: parsedInputData, + GasLimit: ethTxData.GasLimit, + GasPrice: (*Amount)(ethTxData.GasPrice), + MaxPriorityFeePerGas: (*Amount)(ethTxData.MaxPriorityFeePerGas), + MaxFeePerGas: (*Amount)(ethTxData.MaxFeePerGas), + BaseFeePerGas: (*Amount)(ethTxData.BaseFeePerGas), + GasUsed: ethTxData.GasUsed, + L1Fee: ethTxData.L1Fee, + L1FeeScalar: ethTxData.L1FeeScalar, + L1GasPrice: (*Amount)(ethTxData.L1GasPrice), + L1GasUsed: ethTxData.L1GasUsed, + Nonce: ethTxData.Nonce, + Status: ethTxData.Status, + Data: ethTxData.Data, + ParsedData: parsedInputData, } if internalData != nil { ethSpecific.Type = internalData.Type @@ -436,6 +523,7 @@ func (w *Worker) getTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe } var sj json.RawMessage + var chainExtraData *TxChainExtraData // return CoinSpecificData for all mempool transactions or if requested if specificJSON || bchainTx.Confirmations == 0 { sj, err = w.chain.GetTransactionSpecific(bchainTx) @@ -443,6 +531,10 @@ func (w *Worker) getTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe return nil, err } } + chainExtraData, err = w.getTxChainExtraData(bchainTx) + if err != nil { + glog.Warningf("GetTxChainExtraData error %v, %v", err, bchainTx) + } r := &Tx{ Blockhash: blockhash, Blockheight: height, @@ -461,6 +553,7 @@ func (w *Worker) getTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe Vin: vins, Vout: vouts, CoinSpecificData: sj, + ChainExtraData: chainExtraData, TokenTransfers: tokens, EthereumSpecific: ethSpecific, } @@ -479,6 +572,7 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, var pValInSat *big.Int var tokens []TokenTransfer var ethSpecific *EthereumSpecific + var chainExtraData *TxChainExtraData addresses := w.newAddressesMapForAliases() vins := make([]Vin, len(mempoolTx.Vin)) rbf := false @@ -544,15 +638,28 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, valOutSat = mempoolTx.Vout[0].ValueSat } tokens = w.getEthereumTokensTransfers(mempoolTx.TokenTransfers, addresses) - ethTxData := eth.GetEthereumTxDataFromSpecificData(mempoolTx.CoinSpecificData) + ethTxData := w.chainParser.GetEthereumTxData(&bchain.Tx{ + Txid: mempoolTx.Txid, + CoinSpecificData: mempoolTx.CoinSpecificData, + }) ethSpecific = &EthereumSpecific{ - GasLimit: ethTxData.GasLimit, - GasPrice: (*Amount)(ethTxData.GasPrice), - GasUsed: ethTxData.GasUsed, - Nonce: ethTxData.Nonce, - Status: ethTxData.Status, - Data: ethTxData.Data, - } + GasLimit: ethTxData.GasLimit, + GasPrice: (*Amount)(ethTxData.GasPrice), + MaxPriorityFeePerGas: (*Amount)(ethTxData.MaxPriorityFeePerGas), + MaxFeePerGas: (*Amount)(ethTxData.MaxFeePerGas), + BaseFeePerGas: (*Amount)(ethTxData.BaseFeePerGas), + GasUsed: ethTxData.GasUsed, + Nonce: ethTxData.Nonce, + Status: ethTxData.Status, + Data: ethTxData.Data, + } + } + chainExtraData, err = w.getTxChainExtraData(&bchain.Tx{ + Txid: mempoolTx.Txid, + CoinSpecificData: mempoolTx.CoinSpecificData, + }) + if err != nil { + glog.Warningf("GetTxChainExtraData error %v, %v", err, mempoolTx.Txid) } r := &Tx{ Blocktime: mempoolTx.Blocktime, @@ -568,6 +675,7 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, Rbf: rbf, Vin: vins, Vout: vouts, + ChainExtraData: chainExtraData, TokenTransfers: tokens, EthereumSpecific: ethSpecific, AddressAliases: w.getAddressAliases(addresses), @@ -576,32 +684,32 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, return r, nil } -func (w *Worker) getContractInfo(contract string, typeFromContext bchain.TokenTypeName) (*bchain.ContractInfo, bool, error) { +func (w *Worker) GetContractInfo(contract string, standardFromContext bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { cd, err := w.chainParser.GetAddrDescFromAddress(contract) if err != nil { return nil, false, err } - return w.getContractDescriptorInfo(cd, typeFromContext) + return w.getContractDescriptorInfo(cd, standardFromContext) } -func (w *Worker) getContractDescriptorInfo(cd bchain.AddressDescriptor, typeFromContext bchain.TokenTypeName) (*bchain.ContractInfo, bool, error) { +func (w *Worker) getContractDescriptorInfo(cd bchain.AddressDescriptor, standardFromContext bchain.TokenStandardName) (*bchain.ContractInfo, bool, error) { var err error validContract := true - contractInfo, err := w.db.GetContractInfo(cd, typeFromContext) + contractInfo, err := w.db.GetContractInfo(cd, standardFromContext) if err != nil { return nil, false, err } if contractInfo == nil { // log warning only if the contract should have been known from processing of the internal data - if eth.ProcessInternalTransactions { - glog.Warningf("Contract %v %v not found in DB", cd, typeFromContext) + if bchain.ProcessInternalTransactions { + glog.Warningf("Contract %v %v not found in DB", cd, standardFromContext) } contractInfo, err = w.chain.GetContractInfo(cd) if err != nil { glog.Errorf("GetContractInfo from chain error %v, contract %v", err, cd) } if contractInfo == nil { - contractInfo = &bchain.ContractInfo{Type: bchain.UnknownTokenType, Decimals: w.chainParser.AmountDecimals()} + contractInfo = &bchain.ContractInfo{Standard: bchain.UnknownTokenStandard, Decimals: w.chainParser.AmountDecimals()} addresses, _, _ := w.chainParser.GetAddressesFromAddrDesc(cd) if len(addresses) > 0 { contractInfo.Contract = addresses[0] @@ -609,14 +717,15 @@ func (w *Worker) getContractDescriptorInfo(cd bchain.AddressDescriptor, typeFrom validContract = false } else { - if typeFromContext != bchain.UnknownTokenType && contractInfo.Type == bchain.UnknownTokenType { - contractInfo.Type = typeFromContext + if standardFromContext != bchain.UnknownTokenStandard && contractInfo.Standard == bchain.UnknownTokenStandard { + contractInfo.Standard = standardFromContext + contractInfo.Type = standardFromContext } if err = w.db.StoreContractInfo(contractInfo); err != nil { glog.Errorf("StoreContractInfo error %v, contract %v", err, cd) } } - } else if (len(contractInfo.Name) > 0 && contractInfo.Name[0] == 0) || (len(contractInfo.Symbol) > 0 && contractInfo.Symbol[0] == 0) { + } else if (contractInfo.Standard == bchain.UnhandledTokenStandard || len(contractInfo.Name) > 0 && contractInfo.Name[0] == 0) || (len(contractInfo.Symbol) > 0 && contractInfo.Symbol[0] == 0) { // fix contract name/symbol that was parsed as a string consisting of zeroes blockchainContractInfo, err := w.chain.GetContractInfo(cd) if err != nil { @@ -635,6 +744,11 @@ func (w *Worker) getContractDescriptorInfo(cd bchain.AddressDescriptor, typeFrom if blockchainContractInfo != nil { contractInfo.Decimals = blockchainContractInfo.Decimals } + if contractInfo.Standard == bchain.UnhandledTokenStandard { + glog.Infof("Contract %v %v [%s] handled", cd, standardFromContext, contractInfo.Name) + contractInfo.Standard = standardFromContext + contractInfo.Type = standardFromContext + } if err = w.db.StoreContractInfo(contractInfo); err != nil { glog.Errorf("StoreContractInfo error %v, contract %v", err, cd) } @@ -644,39 +758,50 @@ func (w *Worker) getContractDescriptorInfo(cd bchain.AddressDescriptor, typeFrom } func (w *Worker) getEthereumTokensTransfers(transfers bchain.TokenTransfers, addresses map[string]struct{}) []TokenTransfer { - sort.Sort(transfers) tokens := make([]TokenTransfer, len(transfers)) - for i := range transfers { - t := transfers[i] - typeName := bchain.EthereumTokenTypeMap[t.Type] - contractInfo, _, err := w.getContractInfo(t.Contract, typeName) - if err != nil { - glog.Errorf("getContractInfo error %v, contract %v", err, t.Contract) - continue - } - var value *Amount - var values []MultiTokenValue - if t.Type == bchain.MultiToken { - values = make([]MultiTokenValue, len(t.MultiTokenValues)) - for j := range values { - values[j].Id = (*Amount)(&t.MultiTokenValues[j].Id) - values[j].Value = (*Amount)(&t.MultiTokenValues[j].Value) + if len(transfers) > 0 { + sort.Sort(transfers) + contractCache := make(contractInfoCache) + for i := range transfers { + t := transfers[i] + standard := bchain.EthereumTokenStandardMap[t.Standard] + var contractInfo *bchain.ContractInfo + if info, ok := contractCache[t.Contract]; ok { + contractInfo = info + } else { + info, _, err := w.GetContractInfo(t.Contract, standard) + if err != nil { + glog.Errorf("getContractInfo error %v, contract %v", err, t.Contract) + continue + } + contractInfo = info + contractCache[t.Contract] = info + } + var value *Amount + var values []MultiTokenValue + if t.Standard == bchain.MultiToken { + values = make([]MultiTokenValue, len(t.MultiTokenValues)) + for j := range values { + values[j].Id = (*Amount)(&t.MultiTokenValues[j].Id) + values[j].Value = (*Amount)(&t.MultiTokenValues[j].Value) + } + } else { + value = (*Amount)(&t.Value) + } + aggregateAddress(addresses, t.From) + aggregateAddress(addresses, t.To) + tokens[i] = TokenTransfer{ + Type: standard, + Standard: standard, + Contract: t.Contract, + From: t.From, + To: t.To, + Value: value, + MultiTokenValues: values, + Decimals: contractInfo.Decimals, + Name: contractInfo.Name, + Symbol: contractInfo.Symbol, } - } else { - value = (*Amount)(&t.Value) - } - aggregateAddress(addresses, t.From) - aggregateAddress(addresses, t.To) - tokens[i] = TokenTransfer{ - Type: typeName, - Contract: t.Contract, - From: t.From, - To: t.To, - Value: value, - MultiTokenValues: values, - Decimals: contractInfo.Decimals, - Name: contractInfo.Name, - Symbol: contractInfo.Symbol, } } return tokens @@ -695,7 +820,7 @@ func (w *Worker) GetEthereumTokenURI(contract string, id string) (string, *bchai if err != nil { return "", nil, err } - ci, _, err := w.getContractDescriptorInfo(cd, bchain.UnknownTokenType) + ci, _, err := w.getContractDescriptorInfo(cd, bchain.UnknownTokenStandard) if err != nil { return "", nil, err } @@ -825,6 +950,10 @@ func (w *Worker) txFromTxAddress(txid string, ta *db.TxAddresses, bi *db.BlockIn if err != nil { glog.Errorf("tai.Addresses error %v, tx %v, input %v, tai %+v", err, txid, i, tai) } + if w.db.HasExtendedIndex() { + vin.Txid = tai.Txid + vin.Vout = tai.Vout + } aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } vouts := make([]Vout, len(ta.Outputs)) @@ -839,6 +968,11 @@ func (w *Worker) txFromTxAddress(txid string, ta *db.TxAddresses, bi *db.BlockIn glog.Errorf("tai.Addresses error %v, tx %v, output %v, tao %+v", err, txid, i, tao) } vout.Spent = tao.Spent + if vout.Spent && w.db.HasExtendedIndex() { + vout.SpentTxID = tao.SpentTxid + vout.SpentIndex = int(tao.SpentIndex) + vout.SpentHeight = int(tao.SpentHeight) + } aggregateAddresses(addresses, vout.Addresses, vout.IsAddress) } // for coinbase transactions valIn is 0 @@ -858,23 +992,54 @@ func (w *Worker) txFromTxAddress(txid string, ta *db.TxAddresses, bi *db.BlockIn Vin: vins, Vout: vouts, } + if w.chainParser.SupportsVSize() { + r.VSize = int(ta.VSize) + } else { + r.Size = int(ta.VSize) + } return r } func computePaging(count, page, itemsOnPage int) (Paging, int, int, int) { - from := page * itemsOnPage + + if page < 0 { + page = 0 + } + if itemsOnPage <= 0 { + itemsOnPage = 1 + } + if count < 0 { + count = 0 + } + + safeMultiply := func(a, b int) int { + const maxSafeInt = 1000000000 + if a > 0 && b > 0 { + if a > maxSafeInt/b { + return maxSafeInt + } + return a * b + } + return 0 + } + totalPages := (count - 1) / itemsOnPage if totalPages < 0 { totalPages = 0 } + + from := safeMultiply(page, itemsOnPage) + if from >= count { page = totalPages + from = safeMultiply(page, itemsOnPage) } - from = page * itemsOnPage - to := (page + 1) * itemsOnPage + + to := safeMultiply(page+1, itemsOnPage) if to > count { to = count } + return Paging{ ItemsOnPage: itemsOnPage, Page: page + 1, @@ -882,9 +1047,9 @@ func computePaging(count, page, itemsOnPage int) (Paging, int, int, int) { }, from, to, page } -func (w *Worker) getEthereumContractBalance(addrDesc bchain.AddressDescriptor, index int, c *db.AddrContract, details AccountDetails, ticker *common.CurrencyRatesTicker, secondaryCoin string) (*Token, error) { - typeName := bchain.EthereumTokenTypeMap[c.Type] - ci, validContract, err := w.getContractDescriptorInfo(c.Contract, typeName) +func (w *Worker) getEthereumContractBalance(addrDesc bchain.AddressDescriptor, index int, c *db.AddrContract, details AccountDetails, ticker *common.CurrencyRatesTicker, secondaryCoin string, erc20Balance *big.Int, erc20Batched bool) (*Token, error) { + standard := bchain.EthereumTokenStandardMap[c.Standard] + ci, validContract, err := w.getContractDescriptorInfo(c.Contract, standard) if err != nil { return nil, errors.Annotatef(err, "getEthereumContractBalance %v", c.Contract) } @@ -892,20 +1057,29 @@ func (w *Worker) getEthereumContractBalance(addrDesc bchain.AddressDescriptor, i Contract: ci.Contract, Name: ci.Name, Symbol: ci.Symbol, - Type: typeName, + Type: standard, + Standard: standard, Transfers: int(c.Txs), Decimals: ci.Decimals, ContractIndex: strconv.Itoa(index), } // return contract balances/values only at or above AccountDetailsTokenBalances if details >= AccountDetailsTokenBalances && validContract { - if c.Type == bchain.FungibleToken { + if c.Standard == bchain.FungibleToken { // get Erc20 Contract Balance from blockchain, balance obtained from adding and subtracting transfers is not correct - b, err := w.chain.EthereumTypeGetErc20ContractBalance(addrDesc, c.Contract) - if err != nil { - // return nil, nil, nil, errors.Annotatef(err, "EthereumTypeGetErc20ContractBalance %v %v", addrDesc, c.Contract) - glog.Warningf("EthereumTypeGetErc20ContractBalance addr %v, contract %v, %v", addrDesc, c.Contract, err) - } else { + // Prefer pre-fetched batch balance when available to avoid redundant RPC calls. + // If the contract was already part of a batch attempt, skip the per-contract fallback: + // a nil result there indicates the call failed or returned an unparseable value, and + // retrying as a single call would only amplify RPC load without changing the outcome. + b := erc20Balance + if b == nil && !erc20Batched { + b, err = w.chain.EthereumTypeGetErc20ContractBalance(addrDesc, c.Contract) + if err != nil { + // return nil, nil, nil, errors.Annotatef(err, "EthereumTypeGetErc20ContractBalance %v %v", addrDesc, c.Contract) + glog.Warningf("EthereumTypeGetErc20ContractBalance addr %v, contract %v, %v", addrDesc, c.Contract, err) + } + } + if b != nil { t.BalanceSat = (*Amount)(b) if secondaryCoin != "" { baseRate, found := w.GetContractBaseRate(ticker, t.Contract, 0) @@ -945,10 +1119,23 @@ func (w *Worker) getEthereumContractBalance(addrDesc bchain.AddressDescriptor, i return &t, nil } +func hasEthereumTokenHoldingsField(t *Token) bool { + if t == nil { + return false + } + if t.BalanceSat != nil { + return true + } + if len(t.Ids) > 0 { + return true + } + return len(t.MultiTokenValues) > 0 +} + // a fallback method in case internal transactions are not processed and there is no indexed info about contract balance for an address func (w *Worker) getEthereumContractBalanceFromBlockchain(addrDesc, contract bchain.AddressDescriptor, details AccountDetails) (*Token, error) { var b *big.Int - ci, validContract, err := w.getContractDescriptorInfo(contract, bchain.UnknownTokenType) + ci, validContract, err := w.getContractDescriptorInfo(contract, bchain.UnknownTokenStandard) if err != nil { return nil, errors.Annotatef(err, "GetContractInfo %v", contract) } @@ -963,7 +1150,8 @@ func (w *Worker) getEthereumContractBalanceFromBlockchain(addrDesc, contract bch b = nil } return &Token{ - Type: ci.Type, + Type: ci.Standard, + Standard: ci.Standard, BalanceSat: (*Amount)(b), Contract: ci.Contract, Name: ci.Name, @@ -974,24 +1162,27 @@ func (w *Worker) getEthereumContractBalanceFromBlockchain(addrDesc, contract bch }, nil } -// GetContractBaseRate returns contract rate in base coin from the ticker or DB at the blocktime. Zero blocktime means now. -func (w *Worker) GetContractBaseRate(ticker *common.CurrencyRatesTicker, contract string, blocktime int64) (float64, bool) { +// GetContractBaseRate returns contract rate in base coin from the ticker or DB at the timestamp. Zero timestamp means now. +func (w *Worker) GetContractBaseRate(ticker *common.CurrencyRatesTicker, token string, timestamp int64) (float64, bool) { if ticker == nil { return 0, false } - rate, found := ticker.GetTokenRate(contract) + rate, found := ticker.GetTokenRate(token) if !found { - var date time.Time - if blocktime == 0 { - date = time.Now().UTC() + if timestamp == 0 { + ticker = w.fiatRates.GetCurrentTicker("", token) } else { - date = time.Unix(blocktime, 0).UTC() + tickers, err := w.fiatRates.GetTickersForTimestamps([]int64{timestamp}, "", token) + if err != nil || tickers == nil || len(*tickers) == 0 { + ticker = nil + } else { + ticker = (*tickers)[0] + } } - ticker, _ = w.db.FiatRatesFindTicker(&date, "", contract) if ticker == nil { return 0, false } - rate, found = ticker.GetTokenRate(contract) + rate, found = ticker.GetTokenRate(token) } return float64(rate), found @@ -1006,6 +1197,16 @@ type ethereumTypeAddressData struct { totalResults int tokensBaseValue float64 tokensSecondaryValue float64 + stakingPools []StakingPool +} + +func (w *Worker) getSecondaryTicker(secondaryCoin string) *common.CurrencyRatesTicker { + // Secondary fiat values are computed only when a secondary currency is + // requested, so skip ticker lookup otherwise. + if secondaryCoin == "" || w.fiatRates == nil { + return nil + } + return getCurrentTicker(w.fiatRates, "", "") } func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescriptor, details AccountDetails, filter *AddressFilter, secondaryCoin string) (*db.AddrBalance, *ethereumTypeAddressData, error) { @@ -1013,22 +1214,26 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto var n uint64 // unknown number of results for paging initially d := ethereumTypeAddressData{totalResults: -1} + // Load cached contract list and totals from the index; this drives token lookups. ca, err := w.db.GetAddrDescContracts(addrDesc) if err != nil { return nil, nil, NewAPIError(fmt.Sprintf("Address not found, %v", err), true) } + // Always fetch the native balance from the backend. b, err := w.chain.EthereumTypeGetBalance(addrDesc) if err != nil { return nil, nil, errors.Annotatef(err, "EthereumTypeGetBalance %v", addrDesc) } var filterDesc bchain.AddressDescriptor if filter.Contract != "" { + // Optional contract filter narrows token balances and tx paging to a single contract. filterDesc, err = w.chainParser.GetAddrDescFromAddress(filter.Contract) if err != nil { return nil, nil, NewAPIError(fmt.Sprintf("Invalid contract filter, %v", err), true) } } if ca != nil { + // Address has indexed contract/tx data; include totals and nonce. ba = &db.AddrBalance{ Txs: uint32(ca.TotalTxs), } @@ -1039,7 +1244,38 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto if err != nil { return nil, nil, errors.Annotatef(err, "EthereumTypeGetNonce %v", addrDesc) } - ticker := w.is.GetCurrentTicker("", "") + ticker := w.getSecondaryTicker(secondaryCoin) + var erc20Balances map[string]*big.Int + if details >= AccountDetailsTokenBalances && len(ca.Contracts) > 1 { + // Batch ERC20 balanceOf calls to cut per-contract RPC; fallback is single-call per contract. + erc20Contracts := make([]bchain.AddressDescriptor, 0, len(ca.Contracts)) + for i := range ca.Contracts { + c := &ca.Contracts[i] + // Only fungible tokens are eligible; respect a contract filter if present. + if c.Standard != bchain.FungibleToken { + continue + } + if len(filterDesc) > 0 && !bytes.Equal(filterDesc, c.Contract) { + continue + } + erc20Contracts = append(erc20Contracts, c.Contract) + } + if len(erc20Contracts) > 1 { + balances, err := w.chain.EthereumTypeGetErc20ContractBalances(addrDesc, erc20Contracts) + if err != nil { + glog.Warningf("EthereumTypeGetErc20ContractBalances addr %v: %v", addrDesc, err) + } else if len(balances) == len(erc20Contracts) { + // Record every batched contract as a key, even when the value is nil. Map presence + // signals that the batch already covered this contract so the consumer must not + // fall back to a single call - that fallback was the source of N-fold RPC + // amplification when batches returned per-element errors or unparseable results. + erc20Balances = make(map[string]*big.Int, len(erc20Contracts)) + for i, bal := range balances { + erc20Balances[string(erc20Contracts[i])] = bal + } + } + } + } if details > AccountDetailsBasic { d.tokens = make([]Token, len(ca.Contracts)) var j int @@ -1052,10 +1288,22 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto // filter only transactions of this contract filter.Vout = i + db.ContractIndexOffset } - t, err := w.getEthereumContractBalance(addrDesc, i+db.ContractIndexOffset, c, details, ticker, secondaryCoin) + // Use prefetched batch balances when available. Map presence (not value) marks the + // contract as batched so the helper skips its per-contract RPC fallback. + var erc20Balance *big.Int + var erc20Batched bool + if erc20Balances != nil { + erc20Balance, erc20Batched = erc20Balances[string(c.Contract)] + } + t, err := w.getEthereumContractBalance(addrDesc, i+db.ContractIndexOffset, c, details, ticker, secondaryCoin, erc20Balance, erc20Batched) if err != nil { return nil, nil, err } + // tokenBalances responses should not contain metadata-only tokens + // without any holdings field. + if details >= AccountDetailsTokenBalances && !hasEthereumTokenHoldingsField(t) { + continue + } d.tokens[j] = *t d.tokensBaseValue += t.BaseValue d.tokensSecondaryValue += t.SecondaryValue @@ -1063,11 +1311,18 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto } d.tokens = d.tokens[:j] sort.Sort(d.tokens) + w.enrichTokenProtocols(d.tokens, filter.Protocols) } - d.contractInfo, err = w.db.GetContractInfo(addrDesc, "") + d.contractInfo, err = w.db.GetContractInfo(addrDesc, bchain.UnknownTokenStandard) if err != nil { return nil, nil, err } + if d.contractInfo != nil && d.contractInfo.Standard == bchain.UnhandledTokenStandard { + d.contractInfo, _, err = w.getContractDescriptorInfo(addrDesc, bchain.UnknownTokenStandard) + if err != nil { + return nil, nil, err + } + } if filter.FromHeight == 0 && filter.ToHeight == 0 { // compute total results for paging if filter.Vout == AddressFilterVoutOff { @@ -1096,6 +1351,7 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto d.nonce = strconv.Itoa(int(n)) // special handling if filtering for a contract, return the contract details even though the address had no transactions with it if len(d.tokens) == 0 && len(filterDesc) > 0 && details >= AccountDetailsTokens { + // Query the backend directly to return contract metadata/balance for filtered views. t, err := w.getEthereumContractBalanceFromBlockchain(addrDesc, filterDesc, details) if err != nil { return nil, nil, err @@ -1105,9 +1361,44 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto filter.Vout = AddressFilterVoutQueryNotNecessary d.totalResults = -1 } + // if staking pool enabled, fetch the staking pool details + if details >= AccountDetailsBasic { + if len(w.chain.EthereumTypeGetSupportedStakingPools()) > 0 { + // Staking pools are fetched separately and do not participate in ERC20 batching. + d.stakingPools, err = w.getStakingPoolsData(addrDesc) + if err != nil { + return nil, nil, err + } + } + } return ba, &d, nil } +func (w *Worker) getStakingPoolsData(addrDesc bchain.AddressDescriptor) ([]StakingPool, error) { + var pools []StakingPool + if len(w.chain.EthereumTypeGetSupportedStakingPools()) > 0 { + sp, err := w.chain.EthereumTypeGetStakingPoolsData(addrDesc) + if err != nil { + return nil, err + } + for i := range sp { + p := &sp[i] + pools = append(pools, StakingPool{ + Contract: p.Contract, + Name: p.Name, + PendingBalance: (*Amount)(&p.PendingBalance), + PendingDepositedBalance: (*Amount)(&p.PendingDepositedBalance), + DepositedBalance: (*Amount)(&p.DepositedBalance), + WithdrawTotalAmount: (*Amount)(&p.WithdrawTotalAmount), + ClaimableAmount: (*Amount)(&p.ClaimableAmount), + RestakedReward: (*Amount)(&p.RestakedReward), + AutocompoundBalance: (*Amount)(&p.AutocompoundBalance), + }) + } + } + return pools, nil +} + func (w *Worker) txFromTxid(txid string, bestHeight uint32, option AccountDetails, blockInfo *db.BlockInfo, addresses map[string]struct{}) (*Tx, error) { var tx *Tx var err error @@ -1192,6 +1483,38 @@ func setIsOwnAddress(tx *Tx, address string) { // GetAddress computes address value and gets transactions for given address func (w *Worker) GetAddress(address string, page int, txsOnPage int, option AccountDetails, filter *AddressFilter, secondaryCoin string) (*Address, error) { + if w.chainType == bchain.ChainEthereumType && strings.HasSuffix(strings.ToLower(address), ".eth") { + ensResolver, ok := w.chain.(interface { + ResolveENS(string) (*bchain.ENSResolution, error) + CheckENSExpiration(string) (bool, error) + }) + if !ok { + return nil, fmt.Errorf("ENS resolution not supported for this chain") + } + + expired, err := ensResolver.CheckENSExpiration(address) + if err != nil { + glog.Errorf("ENS expiration check failed for %s: %v", address, err) + return nil, errors.New("ENS name not found") + } + if expired { + return nil, errors.New("ENS name expired") + } + + ensRes, err := ensResolver.ResolveENS(address) + if err != nil { + glog.Errorf("ENS resolution failed for %s: %v", address, err) + return nil, errors.New("ENS name not found") + } + + if ensRes == nil || ensRes.Address == "" { + return nil, fmt.Errorf("ENS name not found: %s", address) + } + + ensName := address + address = ensRes.Address + glog.Infof("ENS resolved %s to %s", ensName, ensRes.Address) + } start := time.Now() page-- if page < 0 { @@ -1202,8 +1525,11 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco txm []string txs []*Tx txids []string + accountChainExtraData *AccountChainExtraData pg Paging uBalSat big.Int + uBalSending big.Int + uBalReceiving big.Int totalReceived, totalSent *big.Int unconfirmedTxs int totalResults int @@ -1213,6 +1539,10 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco if err != nil { return nil, err } + accountChainExtraData, err = w.getAccountChainExtraData(addrDesc) + if err != nil { + glog.Warningf("GetAccountChainExtraData error %v, %v", err, address) + } if w.chainType == bchain.ChainEthereumType { ba, ed, err = w.getEthereumTypeAddressBalances(addrDesc, option, filter, secondaryCoin) if err != nil { @@ -1255,12 +1585,12 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco // skip already confirmed txs, mempool may be out of sync if tx.Confirmations == 0 { unconfirmedTxs++ - uBalSat.Add(&uBalSat, tx.getAddrVoutValue(addrDesc)) + uBalReceiving.Add(&uBalReceiving, tx.getAddrVoutValue(addrDesc)) // ethereum has a different logic - value not in input and add maximum possible fees if w.chainType == bchain.ChainEthereumType { - uBalSat.Sub(&uBalSat, tx.getAddrEthereumTypeMempoolInputValue(addrDesc)) + uBalSending.Add(&uBalSending, tx.getAddrEthereumTypeMempoolInputValue(addrDesc)) } else { - uBalSat.Sub(&uBalSat, tx.getAddrVinValue(addrDesc)) + uBalSending.Add(&uBalSending, tx.getAddrVinValue(addrDesc)) } if page == 0 { if option == AccountDetailsTxidHistory { @@ -1307,13 +1637,22 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco } } } + // On page 1, mempool items are prepended before confirmed history. + // Keep response bounded by requested page size for txid/txs details. + if page == 0 && txsOnPage > 0 { + if option == AccountDetailsTxidHistory && len(txids) > txsOnPage { + txids = txids[:txsOnPage] + } else if option >= AccountDetailsTxHistoryLight && len(txs) > txsOnPage { + txs = txs[:txsOnPage] + } + } if w.chainType == bchain.ChainBitcoinType { totalReceived = ba.ReceivedSat() totalSent = &ba.SentSat } var secondaryRate, totalSecondaryValue, totalBaseValue, secondaryValue float64 if secondaryCoin != "" { - ticker := w.is.GetCurrentTicker("", "") + ticker := w.fiatRates.GetCurrentTicker("", "") balance, err := strconv.ParseFloat((*Amount)(&ba.BalanceSat).DecimalString(w.chainParser.AmountDecimals()), 64) if ticker != nil && err == nil { r, found := ticker.Rates[secondaryCoin] @@ -1327,6 +1666,15 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco totalSecondaryValue = secondaryRate * totalBaseValue } } + uBalSat.Sub(&uBalReceiving, &uBalSending) + var contractInfoBestHeight uint32 + if ed.contractInfo != nil { + h, _, err := w.db.GetBestBlock() + if err != nil { + return nil, errors.Annotatef(err, "GetBestBlock") + } + contractInfoBestHeight = h + } r := &Address{ Paging: pg, AddrStr: address, @@ -1338,6 +1686,8 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco InternalTxs: ed.internalTxs, UnconfirmedBalanceSat: (*Amount)(&uBalSat), UnconfirmedTxs: unconfirmedTxs, + UnconfirmedSending: amountOrNil(&uBalSending), + UnconfirmedReceiving: amountOrNil(&uBalReceiving), Transactions: txs, Txids: txids, Tokens: ed.tokens, @@ -1346,18 +1696,28 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco TokensSecondaryValue: ed.tokensSecondaryValue, TotalBaseValue: totalBaseValue, TotalSecondaryValue: totalSecondaryValue, - ContractInfo: ed.contractInfo, + ContractInfo: contractInfoResultFromBchain(ed.contractInfo, contractInfoBestHeight), Nonce: ed.nonce, AddressAliases: w.getAddressAliases(addresses), + StakingPools: ed.stakingPools, + ChainExtraData: accountChainExtraData, } // keep address backward compatible, set deprecated Erc20Contract value if ERC20 token - if ed.contractInfo != nil && ed.contractInfo.Type == bchain.ERC20TokenType { - r.Erc20Contract = ed.contractInfo + if ed.contractInfo != nil && ed.contractInfo.Standard == bchain.ERC20TokenStandard { + r.Erc20Contract = r.ContractInfo } - glog.Info("GetAddress ", address, ", ", time.Since(start)) + glog.Info("GetAddress-", option, " ", address, ", ", time.Since(start)) return r, nil } +// Returns either the Amount or nil if the number is zero +func amountOrNil(num *big.Int) *Amount { + if num.Cmp(big.NewInt(0)) == 0 { + return nil + } + return (*Amount)(num) +} + func (w *Worker) balanceHistoryHeightsFromTo(fromTimestamp, toTimestamp int64) (uint32, uint32, uint32, uint32) { fromUnix := uint32(0) toUnix := maxUint32 @@ -1374,6 +1734,194 @@ func (w *Worker) balanceHistoryHeightsFromTo(fromTimestamp, toTimestamp int64) ( return fromUnix, fromHeight, toUnix, toHeight } +func (w *Worker) processInternalTransactionsForBalanceHistory(addrDesc bchain.AddressDescriptor, txid string, bh *BalanceHistory) error { + if !bchain.ProcessInternalTransactions { + return nil + } + + internalData, err := w.db.GetEthereumInternalData(txid) + if err != nil { + return err + } + if internalData == nil { + return nil + } + + for i := range internalData.Transfers { + f := &internalData.Transfers[i] + txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(f.From) + if err != nil { + return err + } + if bytes.Equal(addrDesc, txAddrDesc) { + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &f.Value) + if f.From == f.To { + (*big.Int)(bh.SentToSelfSat).Add((*big.Int)(bh.SentToSelfSat), &f.Value) + } + } + + txAddrDesc, err = w.chainParser.GetAddrDescFromAddress(f.To) + if err != nil { + return err + } + if bytes.Equal(addrDesc, txAddrDesc) { + (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &f.Value) + } + } + + return nil +} + +func addEthereumFeesToBalanceHistory(ethTxData *bchain.EthereumTxData, bh *BalanceHistory) { + var feesSat big.Int + // mempool txs do not have fees yet + if ethTxData.GasUsed != nil && ethTxData.GasPrice != nil { + feesSat.Mul(ethTxData.GasPrice, ethTxData.GasUsed) + } + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &feesSat) +} + +func (w *Worker) processPrimaryVoutForBalanceHistory( + addrDesc bchain.AddressDescriptor, + bchainTx *bchain.Tx, + selfAddrDesc map[string]struct{}, + bh *BalanceHistory, +) (bool, error) { + countSentToSelf := false + if len(bchainTx.Vout) == 0 { + return countSentToSelf, nil + } + bchainVout := &bchainTx.Vout[0] + if len(bchainVout.ScriptPubKey.Addresses) == 0 { + return countSentToSelf, nil + } + + txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(bchainVout.ScriptPubKey.Addresses[0]) + if err != nil { + return false, err + } + if bytes.Equal(addrDesc, txAddrDesc) { + (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &bchainVout.ValueSat) + } + if _, found := selfAddrDesc[string(txAddrDesc)]; found { + countSentToSelf = true + } + return countSentToSelf, nil +} + +func (w *Worker) processEthereumLikeBalanceHistory( + addrDesc bchain.AddressDescriptor, + txid string, + bchainTx *bchain.Tx, + selfAddrDesc map[string]struct{}, + ethTxData *bchain.EthereumTxData, + bh *BalanceHistory, +) error { + // Ethereum-like transactions carry one primary transfer in Vout[0]. + // Principal movement is accounted only for successful/unknown-status txs. + var value big.Int + if len(bchainTx.Vout) > 0 { + value = bchainTx.Vout[0].ValueSat + } + + countSentToSelf := false + includeTransferAmount := ethTxData.Status == bchain.TxStatusOK || ethTxData.Status == bchain.TxStatusUnknown + if includeTransferAmount { + var err error + countSentToSelf, err = w.processPrimaryVoutForBalanceHistory(addrDesc, bchainTx, selfAddrDesc, bh) + if err != nil { + return err + } + + // Internal transfers are shared accounting for Ethereum-like families. + if err := w.processInternalTransactionsForBalanceHistory(addrDesc, txid, bh); err != nil { + return err + } + } + + for i := range bchainTx.Vin { + bchainVin := &bchainTx.Vin[i] + if len(bchainVin.Addresses) == 0 { + continue + } + + txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(bchainVin.Addresses[0]) + if err != nil { + return err + } + if !bytes.Equal(addrDesc, txAddrDesc) { + continue + } + + if includeTransferAmount { + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &value) + if countSentToSelf { + if _, found := selfAddrDesc[string(txAddrDesc)]; found { + (*big.Int)(bh.SentToSelfSat).Add((*big.Int)(bh.SentToSelfSat), &value) + } + } + } + // Fees always reduce spendable balance for sender-side matches. + addEthereumFeesToBalanceHistory(ethTxData, bh) + } + + return nil +} + +func (w *Worker) processEthereumTypeBalanceHistory( + addrDesc bchain.AddressDescriptor, + txid string, + bchainTx *bchain.Tx, + selfAddrDesc map[string]struct{}, + bh *BalanceHistory, +) error { + ethTxData := w.chainParser.GetEthereumTxData(bchainTx) + + switch w.chainParser.GetChainExtraPayloadType() { + case bchain.ChainExtraPayloadTypeTron: + return w.processTronBalanceHistory(addrDesc, txid, bchainTx, selfAddrDesc, ethTxData, bh) + default: + return w.processEthereumLikeBalanceHistory(addrDesc, txid, bchainTx, selfAddrDesc, ethTxData, bh) + } +} + +func (w *Worker) processBitcoinTypeBalanceHistory( + addrDesc bchain.AddressDescriptor, + ta *db.TxAddresses, + selfAddrDesc map[string]struct{}, + bh *BalanceHistory, +) { + countSentToSelf := false + // detect if this input is the first of selfAddrDesc + // to not to count sentToSelf multiple times if counting multiple xpub addresses + ownInputIndex := -1 + for i := range ta.Inputs { + tai := &ta.Inputs[i] + if _, found := selfAddrDesc[string(tai.AddrDesc)]; found { + if ownInputIndex < 0 { + ownInputIndex = i + } + } + if bytes.Equal(addrDesc, tai.AddrDesc) { + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &tai.ValueSat) + if ownInputIndex == i { + countSentToSelf = true + } + } + } + for i := range ta.Outputs { + tao := &ta.Outputs[i] + if bytes.Equal(addrDesc, tao.AddrDesc) { + (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &tao.ValueSat) + } + if countSentToSelf { + if _, found := selfAddrDesc[string(tao.AddrDesc)]; found { + (*big.Int)(bh.SentToSelfSat).Add((*big.Int)(bh.SentToSelfSat), &tao.ValueSat) + } + } + } +} + func (w *Worker) balanceHistoryForTxid(addrDesc bchain.AddressDescriptor, txid string, fromUnix, toUnix uint32, selfAddrDesc map[string]struct{}) (*BalanceHistory, error) { var time uint32 var err error @@ -1414,146 +1962,16 @@ func (w *Worker) balanceHistoryForTxid(addrDesc bchain.AddressDescriptor, txid s SentToSelfSat: &Amount{}, Txid: txid, } - countSentToSelf := false if w.chainType == bchain.ChainBitcoinType { - // detect if this input is the first of selfAddrDesc - // to not to count sentToSelf multiple times if counting multiple xpub addresses - ownInputIndex := -1 - for i := range ta.Inputs { - tai := &ta.Inputs[i] - if _, found := selfAddrDesc[string(tai.AddrDesc)]; found { - if ownInputIndex < 0 { - ownInputIndex = i - } - } - if bytes.Equal(addrDesc, tai.AddrDesc) { - (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &tai.ValueSat) - if ownInputIndex == i { - countSentToSelf = true - } - } - } - for i := range ta.Outputs { - tao := &ta.Outputs[i] - if bytes.Equal(addrDesc, tao.AddrDesc) { - (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &tao.ValueSat) - } - if countSentToSelf { - if _, found := selfAddrDesc[string(tao.AddrDesc)]; found { - (*big.Int)(bh.SentToSelfSat).Add((*big.Int)(bh.SentToSelfSat), &tao.ValueSat) - } - } - } + w.processBitcoinTypeBalanceHistory(addrDesc, ta, selfAddrDesc, &bh) } else if w.chainType == bchain.ChainEthereumType { - var value big.Int - ethTxData := eth.GetEthereumTxData(bchainTx) - // add received amount only for OK or unknown status (old) transactions - if ethTxData.Status == eth.TxStatusOK || ethTxData.Status == eth.TxStatusUnknown { - if len(bchainTx.Vout) > 0 { - bchainVout := &bchainTx.Vout[0] - value = bchainVout.ValueSat - if len(bchainVout.ScriptPubKey.Addresses) > 0 { - txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(bchainVout.ScriptPubKey.Addresses[0]) - if err != nil { - return nil, err - } - if bytes.Equal(addrDesc, txAddrDesc) { - (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &value) - } - if _, found := selfAddrDesc[string(txAddrDesc)]; found { - countSentToSelf = true - } - } - } - // process internal transactions - if eth.ProcessInternalTransactions { - internalData, err := w.db.GetEthereumInternalData(txid) - if err != nil { - return nil, err - } - if internalData != nil { - for i := range internalData.Transfers { - f := &internalData.Transfers[i] - txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(f.From) - if err != nil { - return nil, err - } - if bytes.Equal(addrDesc, txAddrDesc) { - (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &f.Value) - if f.From == f.To { - (*big.Int)(bh.SentToSelfSat).Add((*big.Int)(bh.SentToSelfSat), &f.Value) - } - } - txAddrDesc, err = w.chainParser.GetAddrDescFromAddress(f.To) - if err != nil { - return nil, err - } - if bytes.Equal(addrDesc, txAddrDesc) { - (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &f.Value) - } - } - } - } - } - for i := range bchainTx.Vin { - bchainVin := &bchainTx.Vin[i] - if len(bchainVin.Addresses) > 0 { - txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(bchainVin.Addresses[0]) - if err != nil { - return nil, err - } - if bytes.Equal(addrDesc, txAddrDesc) { - // add received amount only for OK or unknown status (old) transactions, fees always - if ethTxData.Status == eth.TxStatusOK || ethTxData.Status == eth.TxStatusUnknown { - (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &value) - if countSentToSelf { - if _, found := selfAddrDesc[string(txAddrDesc)]; found { - (*big.Int)(bh.SentToSelfSat).Add((*big.Int)(bh.SentToSelfSat), &value) - } - } - } - var feesSat big.Int - // mempool txs do not have fees yet - if ethTxData.GasUsed != nil { - feesSat.Mul(ethTxData.GasPrice, ethTxData.GasUsed) - } - (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &feesSat) - } - } + if err := w.processEthereumTypeBalanceHistory(addrDesc, txid, bchainTx, selfAddrDesc, &bh); err != nil { + return nil, err } } return &bh, nil } -func (w *Worker) setFiatRateToBalanceHistories(histories BalanceHistories, currencies []string) error { - for i := range histories { - bh := &histories[i] - t := time.Unix(int64(bh.Time), 0) - ticker, err := w.db.FiatRatesFindTicker(&t, "", "") - if err != nil { - glog.Errorf("Error finding ticker by date %v. Error: %v", t, err) - continue - } else if ticker == nil { - continue - } - if len(currencies) == 0 { - bh.FiatRates = ticker.Rates - } else { - rates := make(map[string]float32) - for _, currency := range currencies { - currency = strings.ToLower(currency) - if rate, found := ticker.Rates[currency]; found { - rates[currency] = rate - } else { - rates[currency] = -1 - } - } - bh.FiatRates = rates - } - } - return nil -} - // GetBalanceHistory returns history of balance for given address func (w *Worker) GetBalanceHistory(address string, fromTimestamp, toTimestamp int64, currencies []string, groupBy uint32) (BalanceHistories, error) { currencies = removeEmpty(currencies) @@ -1563,6 +1981,17 @@ func (w *Worker) GetBalanceHistory(address string, fromTimestamp, toTimestamp in if err != nil { return nil, err } + // do not get balance history for contracts + if w.chainType == bchain.ChainEthereumType { + ci, err := w.db.GetContractInfo(addrDesc, bchain.UnknownTokenStandard) + if err != nil { + return nil, err + } + if ci != nil { + glog.Info("GetBalanceHistory ", address, " is a contract, skipping") + return nil, NewAPIError("GetBalanceHistory for a contract not allowed", true) + } + } fromUnix, fromHeight, toUnix, toHeight := w.balanceHistoryHeightsFromTo(fromTimestamp, toTimestamp) if fromHeight >= toHeight { return bhs, nil @@ -1582,7 +2011,10 @@ func (w *Worker) GetBalanceHistory(address string, fromTimestamp, toTimestamp in } } bha := bhs.SortAndAggregate(groupBy) - err = w.setFiatRateToBalanceHistories(bha, currencies) + if w.metrics != nil { + w.metrics.BalanceHistoryPoints.With(common.Labels{"path": "address"}).Observe(float64(len(bha))) + } + err = w.setFiatRateToBalanceHistories(bha, currencies, "address") if err != nil { return nil, err } @@ -1592,12 +2024,12 @@ func (w *Worker) GetBalanceHistory(address string, fromTimestamp, toTimestamp in func (w *Worker) waitForBackendSync() { // wait a short time if blockbook is synchronizing with backend - inSync, _, _ := w.is.GetSyncState() + inSync, _, _, _ := w.is.GetSyncState() count := 30 for !inSync && count > 0 { time.Sleep(time.Millisecond * 100) count-- - inSync, _, _ = w.is.GetSyncState() + inSync, _, _, _ = w.is.GetSyncState() } } @@ -1605,7 +2037,8 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *db.AddrB w.waitForBackendSync() var err error utxos := make(Utxos, 0, 8) - // store txids from mempool so that they are not added twice in case of import of new block while processing utxos, issue #275 + // Store mempool outpoints so they are not duplicated from index in case of + // import of new block while processing utxos, issue #275. inMempool := make(map[string]struct{}) // outputs could be spent in mempool, record and check mempool spends spentInMempool := make(map[string]struct{}) @@ -1628,7 +2061,7 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *db.AddrB // get outputs spent by the mempool tx for i := range bchainTx.Vin { vin := &bchainTx.Vin[i] - spentInMempool[vin.Txid+strconv.Itoa(int(vin.Vout))] = struct{}{} + spentInMempool[vin.Txid+":"+strconv.Itoa(int(vin.Vout))] = struct{}{} } } } @@ -1639,7 +2072,7 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *db.AddrB vad, err := w.chainParser.GetAddrDescFromVout(vout) if err == nil && bytes.Equal(addrDesc, vad) { // report only outpoints that are not spent in mempool - _, e := spentInMempool[bchainTx.Txid+strconv.Itoa(i)] + _, e := spentInMempool[bchainTx.Txid+":"+strconv.Itoa(i)] if !e { coinbase := false if len(bchainTx.Vin) == 1 && len(bchainTx.Vin[0].Coinbase) > 0 { @@ -1652,7 +2085,7 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *db.AddrB Locktime: bchainTx.LockTime, Coinbase: coinbase, }) - inMempool[bchainTx.Txid] = struct{}{} + inMempool[bchainTx.Txid+":"+strconv.Itoa(i)] = struct{}{} } } } @@ -1684,7 +2117,7 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *db.AddrB if err != nil { return nil, err } - _, e := spentInMempool[txid+strconv.Itoa(int(utxo.Vout))] + _, e := spentInMempool[txid+":"+strconv.Itoa(int(utxo.Vout))] if !e { confirmations := bestheight - int(utxo.Height) + 1 coinbase := false @@ -1698,7 +2131,7 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *db.AddrB coinbase = true } } - _, e = inMempool[txid] + _, e = inMempool[txid+":"+strconv.Itoa(int(utxo.Vout))] if !e { utxos = append(utxos, Utxo{ Txid: txid, @@ -1768,182 +2201,6 @@ func (w *Worker) GetBlocks(page int, blocksOnPage int) (*Blocks, error) { return r, nil } -// removeEmpty removes empty strings from a slice -func removeEmpty(stringSlice []string) []string { - var ret []string - for _, str := range stringSlice { - if str != "" { - ret = append(ret, str) - } - } - return ret -} - -// getFiatRatesResult checks if CurrencyRatesTicker contains all necessary data and returns formatted result -func (w *Worker) getFiatRatesResult(currencies []string, ticker *common.CurrencyRatesTicker, token string) (*FiatTicker, error) { - if token != "" { - if len(currencies) != 1 { - return nil, NewAPIError("Rates for token only for a single currency", true) - } - rate := ticker.TokenRateInCurrency(token, currencies[0]) - if rate <= 0 { - rate = -1 - } - return &FiatTicker{ - Timestamp: ticker.Timestamp.UTC().Unix(), - Rates: map[string]float32{currencies[0]: rate}, - }, nil - } - if len(currencies) == 0 { - // Return all available ticker rates - return &FiatTicker{ - Timestamp: ticker.Timestamp.UTC().Unix(), - Rates: ticker.Rates, - }, nil - } - // Check if currencies from the list are available in the ticker rates - rates := make(map[string]float32) - for _, currency := range currencies { - currency = strings.ToLower(currency) - if rate, found := ticker.Rates[currency]; found { - rates[currency] = rate - } else { - rates[currency] = -1 - } - } - return &FiatTicker{ - Timestamp: ticker.Timestamp.UTC().Unix(), - Rates: rates, - }, nil -} - -// GetFiatRatesForBlockID returns fiat rates for block height or block hash -func (w *Worker) GetFiatRatesForBlockID(blockID string, currencies []string, token string) (*FiatTicker, error) { - var ticker *common.CurrencyRatesTicker - bi, err := w.getBlockInfoFromBlockID(blockID) - if err != nil { - if err == bchain.ErrBlockNotFound { - return nil, NewAPIError(fmt.Sprintf("Block %v not found", blockID), true) - } - return nil, NewAPIError(fmt.Sprintf("Block %v not found, error: %v", blockID, err), false) - } - dbi := &db.BlockInfo{Time: bi.Time} // get Unix timestamp from block - tm := time.Unix(dbi.Time, 0) // convert it to Time object - vsCurrency := "" - currencies = removeEmpty(currencies) - if len(currencies) == 1 { - vsCurrency = currencies[0] - } - ticker, err = w.db.FiatRatesFindTicker(&tm, vsCurrency, token) - if err != nil { - return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) - } else if ticker == nil { - return nil, NewAPIError(fmt.Sprintf("No tickers available for %s", tm), true) - } - result, err := w.getFiatRatesResult(currencies, ticker, token) - if err != nil { - return nil, err - } - return result, nil -} - -// GetCurrentFiatRates returns last available fiat rates -func (w *Worker) GetCurrentFiatRates(currencies []string, token string) (*FiatTicker, error) { - vsCurrency := "" - currencies = removeEmpty(currencies) - if len(currencies) == 1 { - vsCurrency = currencies[0] - } - ticker := w.is.GetCurrentTicker(vsCurrency, token) - var err error - if ticker == nil { - ticker, err = w.db.FiatRatesFindLastTicker(vsCurrency, token) - if err != nil { - return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) - } else if ticker == nil { - return nil, NewAPIError("No tickers found!", true) - } - } - result, err := w.getFiatRatesResult(currencies, ticker, token) - if err != nil { - return nil, err - } - return result, nil -} - -// makeErrorRates returns a map of currencies, with each value equal to -1 -// used when there was an error finding ticker -func makeErrorRates(currencies []string) map[string]float32 { - rates := make(map[string]float32) - for _, currency := range currencies { - rates[strings.ToLower(currency)] = -1 - } - return rates -} - -// GetFiatRatesForTimestamps returns fiat rates for each of the provided dates -func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []string, token string) (*FiatTickers, error) { - if len(timestamps) == 0 { - return nil, NewAPIError("No timestamps provided", true) - } - vsCurrency := "" - currencies = removeEmpty(currencies) - if len(currencies) == 1 { - vsCurrency = currencies[0] - } - - ret := &FiatTickers{} - for _, timestamp := range timestamps { - date := time.Unix(timestamp, 0) - date = date.UTC() - ticker, err := w.db.FiatRatesFindTicker(&date, vsCurrency, token) - if err != nil { - glog.Errorf("Error finding ticker for date %v. Error: %v", date, err) - ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) - continue - } else if ticker == nil { - ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) - continue - } - result, err := w.getFiatRatesResult(currencies, ticker, token) - if err != nil { - if apiErr, ok := err.(*APIError); ok { - if apiErr.Public { - return nil, err - } - } - ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) - continue - } - ret.Tickers = append(ret.Tickers, *result) - } - return ret, nil -} - -// GetAvailableVsCurrencies returns the list of available versus currencies for exchange rates -func (w *Worker) GetAvailableVsCurrencies(timestamp int64, token string) (*AvailableVsCurrencies, error) { - date := time.Unix(timestamp, 0) - date = date.UTC() - - ticker, err := w.db.FiatRatesFindTicker(&date, "", strings.ToLower(token)) - if err != nil { - return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) - } else if ticker == nil { - return nil, NewAPIError(fmt.Sprintf("No tickers found for date %v.", date), true) - } - - keys := make([]string, 0, len(ticker.Rates)) - for k := range ticker.Rates { - keys = append(keys, k) - } - sort.Strings(keys) // sort to get deterministic results - - return &AvailableVsCurrencies{ - Timestamp: ticker.Timestamp.Unix(), - Tickers: keys, - }, nil -} - // getBlockHashBlockID returns block hash from block height or block hash func (w *Worker) getBlockHashBlockID(bid string) string { // try to decide if passed string (bid) is block height or block hash @@ -2144,7 +2401,7 @@ func (w *Worker) GetBlock(bid string, page int, txsOnPage int) (*Block, error) { }, nil } -// GetBlock returns paged data about block +// GetBlockRaw returns paged data about block func (w *Worker) GetBlockRaw(bid string) (*BlockRaw, error) { hash := w.getBlockHashBlockID(bid) if hash == "" { @@ -2160,6 +2417,48 @@ func (w *Worker) GetBlockRaw(bid string) (*BlockRaw, error) { return &BlockRaw{Hex: hex}, err } +// GetBlockFiltersBatch returns array of block filter data in the format ["height:hash:filter",...] if blocks greater than bestKnownBlockHash +func (w *Worker) GetBlockFiltersBatch(bestKnownBlockHash string, pageSize int) ([]string, error) { + if w.is.BlockGolombFilterP == 0 { + return nil, NewAPIError("Not supported", true) + } + if pageSize > 10000 { + return nil, NewAPIError("pageSize max 10000", true) + } + if pageSize <= 0 { + pageSize = 1000 + } + bi, err := w.chain.GetBlockInfo(bestKnownBlockHash) + if err != nil { + return nil, err + } + bestHeight, _, err := w.db.GetBestBlock() + if err != nil { + return nil, err + } + from := bi.Height + 1 + to := bestHeight + 1 + if from >= to { + return []string{}, nil + } + if to-from > uint32(pageSize) { + to = from + uint32(pageSize) + } + r := make([]string, 0, to-from) + for i := from; i < to; i++ { + blockHash, err := w.db.GetBlockHash(uint32(i)) + if err != nil { + return nil, err + } + blockFilter, err := w.db.GetBlockFilter(blockHash) + if err != nil { + return nil, err + } + r = append(r, fmt.Sprintf("%d:%s:%s", i, blockHash, blockFilter)) + } + return r, err +} + // ComputeFeeStats computes fee distribution in defined blocks and logs them to log func (w *Worker) ComputeFeeStats(blockFrom, blockTo int, stopCompute chan os.Signal) error { bestheight, _, err := w.db.GetBestBlock() @@ -2229,9 +2528,16 @@ func nonZeroTime(t time.Time) *time.Time { // GetSystemInfo returns information about system func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { - start := time.Now() + start := time.Now().UTC() vi := common.GetVersionInfo() - inSync, bestHeight, lastBlockTime := w.is.GetSyncState() + inSync, bestHeight, lastBlockTime, startSync := w.is.GetSyncState() + blockPeriod := w.is.GetAvgBlockPeriod() + if !inSync && !w.is.InitialSync { + // if less than 5 seconds into syncing, return inSync=true to avoid short time not in sync reports that confuse monitoring + if startSync.Add(5 * time.Second).After(start) { + inSync = true + } + } inSyncMempool, lastMempoolTime, mempoolSize := w.is.GetMempoolSyncState() ci, err := w.chain.GetChainInfo() var backendError string @@ -2243,6 +2549,13 @@ func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { inSync = false inSyncMempool = false } + // for networks with stable block period, set not in sync if last sync more than 12 block periods ago + if inSync && blockPeriod > 0 && w.chainType == bchain.ChainEthereumType { + threshold := 12 * time.Duration(blockPeriod) * time.Second + if lastBlockTime.Add(threshold).Before(time.Now().UTC()) { + inSync = false + } + } var columnStats []common.InternalStateColumn var internalDBSize int64 if internal { @@ -2250,11 +2563,13 @@ func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { internalDBSize = w.is.DBSizeTotal() } var currentFiatRatesTime time.Time - if w.is.CurrentTicker != nil { - currentFiatRatesTime = w.is.CurrentTicker.Timestamp + ct := w.fiatRates.GetCurrentTicker("", "") + if ct != nil { + currentFiatRatesTime = ct.Timestamp } blockbookInfo := &BlockbookInfo{ Coin: w.is.Coin, + Network: w.is.GetNetwork(), Host: w.is.Host, Version: vi.Version, GitCommit: vi.GitCommit, @@ -2273,6 +2588,7 @@ func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { CurrentFiatRatesTime: nonZeroTime(currentFiatRatesTime), HistoricalFiatRatesTime: nonZeroTime(w.is.HistoricalFiatRatesTime), HistoricalTokenFiatRatesTime: nonZeroTime(w.is.HistoricalTokenFiatRatesTime), + SupportedStakingPools: w.chain.EthereumTypeGetSupportedStakingPools(), DbSize: w.db.DatabaseSizeOnDisk(), DbSizeFromColumns: internalDBSize, DbColumns: columnStats, diff --git a/api/worker_balance_history_tron.go b/api/worker_balance_history_tron.go new file mode 100644 index 0000000000..a355ccdcc9 --- /dev/null +++ b/api/worker_balance_history_tron.go @@ -0,0 +1,178 @@ +package api + +import ( + "bytes" + "encoding/json" + "math/big" + "strings" + + "github.com/trezor/blockbook/bchain" +) + +type tronBalanceHistoryDirection int + +const ( + tronBalanceHistoryDirectionNone tronBalanceHistoryDirection = iota + tronBalanceHistoryDirectionOutgoing + tronBalanceHistoryDirectionIncoming +) + +type tronBalanceHistoryOverride struct { + direction tronBalanceHistoryDirection + amount big.Int +} + +func parseBase10BigInt(value string) (*big.Int, bool) { + value = strings.TrimSpace(value) + if value == "" { + return nil, false + } + a, ok := new(big.Int).SetString(value, 10) + return a, ok +} + +func tronBalanceHistoryOverrideFromExtraData(payload json.RawMessage, fallbackValue *big.Int) (tronBalanceHistoryOverride, bool) { + if len(payload) == 0 { + return tronBalanceHistoryOverride{}, false + } + var extra bchain.TronChainExtraData + if err := json.Unmarshal(payload, &extra); err != nil { + return tronBalanceHistoryOverride{}, false + } + return tronBalanceHistoryOverrideFromExtraDataParsed(&extra, fallbackValue) +} + +func tronBalanceHistoryOverrideFromExtraDataParsed(extra *bchain.TronChainExtraData, fallbackValue *big.Int) (tronBalanceHistoryOverride, bool) { + override := tronBalanceHistoryOverride{} + if extra == nil { + return override, false + } + + var amountText string + switch extra.Operation { + case "freeze": + override.direction = tronBalanceHistoryDirectionOutgoing + amountText = extra.StakeAmount + case "withdraw": + override.direction = tronBalanceHistoryDirectionIncoming + amountText = extra.UnstakeAmount + case "voteRewardAmount": + override.direction = tronBalanceHistoryDirectionIncoming + amountText = extra.ClaimedVoteReward + case "unfreeze": + // Unfreeze starts unlock period but funds are not yet spendable. + // Do not account principal movement in balance history at this stage. + override.direction = tronBalanceHistoryDirectionNone + override.amount.SetInt64(0) + return override, true + default: + return override, false + } + + if a, ok := parseBase10BigInt(amountText); ok { + override.amount.Set(a) + } else if fallbackValue != nil { + override.amount.Set(fallbackValue) + } else { + override.amount.SetInt64(0) + } + + return override, true +} + +func tronBalanceHistoryFeeFromExtraDataParsed(extra *bchain.TronChainExtraData) big.Int { + var fee big.Int + if extra == nil { + return fee + } + if a, ok := parseBase10BigInt(extra.TotalFee); ok { + fee.Set(a) + } + return fee +} + +func (w *Worker) processTronBalanceHistory( + addrDesc bchain.AddressDescriptor, + txid string, + bchainTx *bchain.Tx, + selfAddrDesc map[string]struct{}, + ethTxData *bchain.EthereumTxData, + bh *BalanceHistory, +) error { + // Value is kept as fallback amount source when chainExtra amount is absent. + var value big.Int + if len(bchainTx.Vout) > 0 { + value = bchainTx.Vout[0].ValueSat + } + + // Tron balance history is operation-driven (freeze/unfreeze/withdraw), + // not purely based on Ethereum-like Vout semantics + var extra *bchain.TronChainExtraData + payload, err := w.chainParser.GetChainExtraData(bchainTx) + if err == nil { + var parsed bchain.TronChainExtraData + if unmarshalErr := json.Unmarshal(payload, &parsed); unmarshalErr == nil { + extra = &parsed + } + } + feeSat := tronBalanceHistoryFeeFromExtraDataParsed(extra) + + override, hasOverride := tronBalanceHistoryOverrideFromExtraDataParsed(extra, &value) + + includeTransferAmount := ethTxData.Status == bchain.TxStatusOK || ethTxData.Status == bchain.TxStatusUnknown + countSentToSelf := false + if includeTransferAmount { + // For non-overridden Tron operations, keep generic Ethereum-like + // principal movement semantics. + if !hasOverride { + countSentToSelf, err = w.processPrimaryVoutForBalanceHistory(addrDesc, bchainTx, selfAddrDesc, bh) + if err != nil { + return err + } + } + + // Internal transfers remain shared accounting for call-style transactions. + if err := w.processInternalTransactionsForBalanceHistory(addrDesc, txid, bh); err != nil { + return err + } + } + + for i := range bchainTx.Vin { + bchainVin := &bchainTx.Vin[i] + if len(bchainVin.Addresses) == 0 { + continue + } + + txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(bchainVin.Addresses[0]) + if err != nil { + return err + } + if !bytes.Equal(addrDesc, txAddrDesc) { + continue + } + + if includeTransferAmount { + if hasOverride { + switch override.direction { + case tronBalanceHistoryDirectionOutgoing: + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &override.amount) + case tronBalanceHistoryDirectionIncoming: + (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &override.amount) + case tronBalanceHistoryDirectionNone: + // Explicitly no principal movement for this operation. + } + } else { + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &value) + if countSentToSelf { + if _, found := selfAddrDesc[string(txAddrDesc)]; found { + (*big.Int)(bh.SentToSelfSat).Add((*big.Int)(bh.SentToSelfSat), &value) + } + } + } + } + // Fees always reduce spendable balance for sender-side matches. + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &feeSat) + } + + return nil +} diff --git a/api/worker_test.go b/api/worker_test.go new file mode 100644 index 0000000000..fe3027c5ef --- /dev/null +++ b/api/worker_test.go @@ -0,0 +1,172 @@ +//go:build unittest + +package api + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/fiat" +) + +func TestGetSecondaryTicker_SkipsLookupWithoutSecondaryCurrency(t *testing.T) { + w := &Worker{ + fiatRates: &fiat.FiatRates{Enabled: true}, + } + originalGetter := getCurrentTicker + defer func() { + getCurrentTicker = originalGetter + }() + + calls := 0 + getCurrentTicker = func(_ *fiat.FiatRates, _, _ string) *common.CurrencyRatesTicker { + calls++ + return &common.CurrencyRatesTicker{} + } + + ticker := w.getSecondaryTicker("") + if ticker != nil { + t.Fatalf("expected nil ticker when secondary currency is not requested, got %+v", ticker) + } + if calls != 0 { + t.Fatalf("expected no ticker lookup call, got %d", calls) + } +} + +func TestGetSecondaryTicker_PerformsLookupWithSecondaryCurrency(t *testing.T) { + w := &Worker{ + fiatRates: &fiat.FiatRates{Enabled: true}, + } + originalGetter := getCurrentTicker + defer func() { + getCurrentTicker = originalGetter + }() + + calls := 0 + expected := &common.CurrencyRatesTicker{Rates: map[string]float32{"usd": 1}} + getCurrentTicker = func(_ *fiat.FiatRates, _, _ string) *common.CurrencyRatesTicker { + calls++ + return expected + } + + ticker := w.getSecondaryTicker("usd") + if ticker != expected { + t.Fatalf("unexpected ticker returned: got %+v, want %+v", ticker, expected) + } + if calls != 1 { + t.Fatalf("expected one ticker lookup call, got %d", calls) + } +} + +func TestTronBalanceHistoryOverrides(t *testing.T) { + tests := []struct { + name string + payload string + fallbackAmount string + hasFallbackAmount bool + wantOverride bool + wantDirection tronBalanceHistoryDirection + wantAmount string + }{ + { + name: "freeze uses stake amount", + payload: `{"operation":"freeze","stakeAmount":"42000000"}`, + fallbackAmount: "1", + hasFallbackAmount: true, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionOutgoing, + wantAmount: "42000000", + }, + { + name: "withdraw uses unstake amount", + payload: `{"operation":"withdraw","unstakeAmount":"77000000"}`, + fallbackAmount: "1", + hasFallbackAmount: true, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionIncoming, + wantAmount: "77000000", + }, + { + name: "withdraw falls back to tx value", + payload: `{"operation":"withdraw"}`, + fallbackAmount: "123", + hasFallbackAmount: true, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionIncoming, + wantAmount: "123", + }, + { + name: "vote reward amount uses claimed vote reward", + payload: `{"operation":"voteRewardAmount","claimedVoteReward":"6500000"}`, + fallbackAmount: "1", + hasFallbackAmount: true, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionIncoming, + wantAmount: "6500000", + }, + { + name: "vote reward amount falls back to tx value", + payload: `{"operation":"voteRewardAmount"}`, + fallbackAmount: "321", + hasFallbackAmount: true, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionIncoming, + wantAmount: "321", + }, + { + name: "freeze invalid amount falls back to tx value", + payload: `{"operation":"freeze","stakeAmount":"not-a-number"}`, + fallbackAmount: "999", + hasFallbackAmount: true, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionOutgoing, + wantAmount: "999", + }, + { + name: "unfreeze has explicit no-move override", + payload: `{"operation":"unfreeze","unstakeAmount":"77000000"}`, + wantOverride: true, + wantDirection: tronBalanceHistoryDirectionNone, + wantAmount: "0", + }, + { + name: "non-freeze operation has no override", + payload: `{"operation":"transfer","stakeAmount":"42000000"}`, + wantOverride: false, + }, + { + name: "invalid json has no override", + payload: `{`, + wantOverride: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var fallback *big.Int + if tt.hasFallbackAmount { + var ok bool + fallback, ok = new(big.Int).SetString(tt.fallbackAmount, 10) + if !ok { + t.Fatalf("invalid fallback amount in test: %q", tt.fallbackAmount) + } + } + + override, hasOverride := tronBalanceHistoryOverrideFromExtraData(json.RawMessage(tt.payload), fallback) + if hasOverride != tt.wantOverride { + t.Fatalf("override mismatch: got %v want %v", hasOverride, tt.wantOverride) + } + if !tt.wantOverride { + return + } + if override.direction != tt.wantDirection { + t.Fatalf("direction mismatch: got %v want %v", override.direction, tt.wantDirection) + } + if got := override.amount.String(); got != tt.wantAmount { + t.Fatalf("amount mismatch: got %s want %s", got, tt.wantAmount) + } + }) + } +} diff --git a/api/worker_token_filter_test.go b/api/worker_token_filter_test.go new file mode 100644 index 0000000000..4f6848b56c --- /dev/null +++ b/api/worker_token_filter_test.go @@ -0,0 +1,56 @@ +//go:build unittest + +package api + +import ( + "math/big" + "testing" +) + +func TestHasEthereumTokenHoldingsField(t *testing.T) { + tests := []struct { + name string + token *Token + want bool + }{ + { + name: "nil token", + token: nil, + want: false, + }, + { + name: "metadata only", + token: &Token{}, + want: false, + }, + { + name: "erc20 zero balance still has field", + token: &Token{BalanceSat: (*Amount)(big.NewInt(0))}, + want: true, + }, + { + name: "erc20 nonzero balance", + token: &Token{BalanceSat: (*Amount)(big.NewInt(42))}, + want: true, + }, + { + name: "erc721 ids", + token: &Token{Ids: []Amount{Amount(*big.NewInt(0))}}, + want: true, + }, + { + name: "erc1155 multi token values", + token: &Token{MultiTokenValues: []MultiTokenValue{{Value: (*Amount)(big.NewInt(0))}}}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasEthereumTokenHoldingsField(tt.token) + if got != tt.want { + t.Fatalf("hasEthereumTokenHoldingsField() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/api/xpub.go b/api/xpub.go index da24525883..472595576e 100644 --- a/api/xpub.go +++ b/api/xpub.go @@ -11,6 +11,7 @@ import ( "github.com/golang/glog" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" ) @@ -267,7 +268,9 @@ func (w *Worker) tokenFromXpubAddress(data *xpubData, ad *xpubAddress, changeInd } } return Token{ - Type: bchain.XPUBAddressTokenType, + // Deprecated: Use Standard instead. + Type: bchain.XPUBAddressStandard, + Standard: bchain.XPUBAddressStandard, Name: address, Decimals: w.chainParser.AmountDecimals(), BalanceSat: (*Amount)(balance), @@ -541,6 +544,7 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc } else { txCount = int(data.txCountEstimate) } + addrTxCount := int(data.txCountEstimate) usedTokens := 0 var tokens []Token var xpubAddresses map[string]struct{} @@ -555,7 +559,7 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc usedTokens++ } if option > AccountDetailsBasic { - token := w.tokenFromXpubAddress(data, ad, ci, i, option) + token := w.tokenFromXpubAddress(data, ad, int(xd.ChangeIndexes[ci]), i, option) if filter.TokensToReturn == TokensToReturnDerived || filter.TokensToReturn == TokensToReturnUsed && ad.balance != nil || filter.TokensToReturn == TokensToReturnNonzeroBalance && ad.balance != nil && !IsZeroBigInt(&ad.balance.BalanceSat) { @@ -571,7 +575,7 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc var secondaryValue float64 if secondaryCoin != "" { - ticker := w.is.GetCurrentTicker("", "") + ticker := w.fiatRates.GetCurrentTicker("", "") balance, err := strconv.ParseFloat((*Amount)(&data.balanceSat).DecimalString(w.chainParser.AmountDecimals()), 64) if ticker != nil && err == nil { r, found := ticker.Rates[secondaryCoin] @@ -589,6 +593,7 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc TotalReceivedSat: (*Amount)(&totalReceived), TotalSentSat: (*Amount)(&data.sentSat), Txs: txCount, + AddrTxCount: addrTxCount, UnconfirmedBalanceSat: (*Amount)(&uBalSat), UnconfirmedTxs: unconfirmedTxs, Transactions: txs, @@ -691,7 +696,10 @@ func (w *Worker) GetXpubBalanceHistory(xpub string, fromTimestamp, toTimestamp i } } bha := bhs.SortAndAggregate(groupBy) - err = w.setFiatRateToBalanceHistories(bha, currencies) + if w.metrics != nil { + w.metrics.BalanceHistoryPoints.With(common.Labels{"path": "xpub"}).Observe(float64(len(bha))) + } + err = w.setFiatRateToBalanceHistories(bha, currencies, "xpub") if err != nil { return nil, err } diff --git a/bchain/basechain.go b/bchain/basechain.go index bcd03d195d..ade8832ea7 100644 --- a/bchain/basechain.go +++ b/bchain/basechain.go @@ -1,6 +1,7 @@ package bchain import ( + "encoding/json" "errors" "math/big" ) @@ -39,32 +40,83 @@ func (b *BaseChain) GetMempoolEntry(txid string) (*MempoolEntry, error) { return nil, errors.New("GetMempoolEntry: not supported") } +// GetAddressChainExtraData returns no chain-specific account/address data by default. +func (b *BaseChain) GetAddressChainExtraData(addrDesc AddressDescriptor) (json.RawMessage, error) { + return nil, nil +} + +// LongTermFeeRate returns smallest fee rate from historic blocks. +func (b *BaseChain) LongTermFeeRate() (*LongTermFeeRate, error) { + return nil, errors.New("not supported") +} + // EthereumTypeGetBalance is not supported func (b *BaseChain) EthereumTypeGetBalance(addrDesc AddressDescriptor) (*big.Int, error) { - return nil, errors.New("Not supported") + return nil, errors.New("not supported") } // EthereumTypeGetNonce is not supported func (b *BaseChain) EthereumTypeGetNonce(addrDesc AddressDescriptor) (uint64, error) { - return 0, errors.New("Not supported") + return 0, errors.New("not supported") } // EthereumTypeEstimateGas is not supported func (b *BaseChain) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) { - return 0, errors.New("Not supported") + return 0, errors.New("not supported") +} + +// EthereumTypeGetEip1559Fees is not supported +func (b *BaseChain) EthereumTypeGetEip1559Fees() (*Eip1559Fees, error) { + return nil, errors.New("not supported") } // GetContractInfo is not supported func (b *BaseChain) GetContractInfo(contractDesc AddressDescriptor) (*ContractInfo, error) { - return nil, errors.New("Not supported") + return nil, errors.New("not supported") } // EthereumTypeGetErc20ContractBalance is not supported func (b *BaseChain) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc AddressDescriptor) (*big.Int, error) { - return nil, errors.New("Not supported") + return nil, errors.New("not supported") } -// GetContractInfo returns URI of non fungible or multi token defined by token id +// EthereumTypeGetErc20ContractBalances is not supported +func (b *BaseChain) EthereumTypeGetErc20ContractBalances(addrDesc AddressDescriptor, contractDescs []AddressDescriptor) ([]*big.Int, error) { + return nil, errors.New("not supported") +} + +// GetTokenURI returns URI of non fungible or multi token defined by token id func (p *BaseChain) GetTokenURI(contractDesc AddressDescriptor, tokenID *big.Int) (string, error) { - return "", errors.New("Not supported") + return "", errors.New("not supported") +} + +func (b *BaseChain) EthereumTypeGetSupportedStakingPools() []string { + return nil +} + +func (b *BaseChain) EthereumTypeGetStakingPoolsData(addrDesc AddressDescriptor) ([]StakingPoolData, error) { + return nil, errors.New("not supported") +} + +// EthereumTypeRpcCall calls eth_call with given data and to address +func (b *BaseChain) EthereumTypeRpcCall(data, to, from string) (string, error) { + return "", errors.New("not supported") +} + +// EthereumTypeRpcCallBatch performs batch eth_call requests. +func (b *BaseChain) EthereumTypeRpcCallBatch(calls []EthereumTypeRPCCall) ([]EthereumTypeRPCCallResult, error) { + return nil, errors.New("not supported") +} + +// EthereumTypeMulticallAggregate3 issues a Multicall3 aggregate3 call. +func (b *BaseChain) EthereumTypeMulticallAggregate3(calls []EthereumMulticallCall, blockNumber *big.Int) ([]EthereumMulticallResult, error) { + return nil, errors.New("not supported") +} + +func (b *BaseChain) EthereumTypeGetRawTransaction(txid string) (string, error) { + return "", errors.New("not supported") +} + +func (b *BaseChain) EthereumTypeGetTransactionReceipt(txid string) (*RpcReceipt, error) { + return nil, errors.New("not supported") } diff --git a/bchain/basemempool.go b/bchain/basemempool.go index d22c94956c..6b42847a36 100644 --- a/bchain/basemempool.go +++ b/bchain/basemempool.go @@ -14,11 +14,13 @@ type addrIndex struct { type txEntry struct { addrIndexes []addrIndex time uint32 + filter string } type txidio struct { - txid string - io []addrIndex + txid string + io []addrIndex + filter string } // BaseMempool is mempool base handle @@ -27,7 +29,6 @@ type BaseMempool struct { mux sync.Mutex txEntries map[string]txEntry addrDescToTx map[string][]Outpoint - OnNewTxAddr OnNewTxAddrFunc OnNewTx OnNewTxFunc } @@ -70,19 +71,27 @@ func (a MempoolTxidEntries) Less(i, j int) bool { // removeEntryFromMempool removes entry from mempool structs. The caller is responsible for locking! func (m *BaseMempool) removeEntryFromMempool(txid string, entry txEntry) { delete(m.txEntries, txid) + // store already processed addrDesc - it can appear multiple times as a different outpoint + processedAddrDesc := make(map[string]struct{}) for _, si := range entry.addrIndexes { outpoints, found := m.addrDescToTx[si.addrDesc] if found { - newOutpoints := make([]Outpoint, 0, len(outpoints)-1) - for _, o := range outpoints { - if o.Txid != txid { - newOutpoints = append(newOutpoints, o) + _, processed := processedAddrDesc[si.addrDesc] + if !processed { + processedAddrDesc[si.addrDesc] = struct{}{} + j := 0 + for i := 0; i < len(outpoints); i++ { + if outpoints[i].Txid != txid { + outpoints[j] = outpoints[i] + j++ + } + } + outpoints = outpoints[:j] + if len(outpoints) > 0 { + m.addrDescToTx[si.addrDesc] = outpoints + } else { + delete(m.addrDescToTx, si.addrDesc) } - } - if len(newOutpoints) > 0 { - m.addrDescToTx[si.addrDesc] = newOutpoints - } else { - delete(m.addrDescToTx, si.addrDesc) } } } diff --git a/bchain/basemempool_test.go b/bchain/basemempool_test.go new file mode 100644 index 0000000000..5842456d1f --- /dev/null +++ b/bchain/basemempool_test.go @@ -0,0 +1,176 @@ +package bchain + +import ( + reflect "reflect" + "strconv" + "testing" +) + +func generateAddIndexes(count int) []addrIndex { + rv := make([]addrIndex, count) + for i := range count { + rv[i] = addrIndex{ + addrDesc: "ad" + strconv.Itoa(i), + } + } + return rv +} + +func generateTxEntries(count int, skipTx int) map[string]txEntry { + rv := make(map[string]txEntry) + for i := range count { + if i != skipTx { + tx := "tx" + strconv.Itoa(i) + rv[tx] = txEntry{ + addrIndexes: generateAddIndexes(count), + } + } + } + return rv +} + +func generateAddrDescToTx(count int, skipTx int) map[string][]Outpoint { + rv := make(map[string][]Outpoint) + for i := range count { + ad := "ad" + strconv.Itoa(i) + op := []Outpoint{} + for j := range count { + if j != skipTx { + tx := "tx" + strconv.Itoa(j) + op = append(op, Outpoint{ + Txid: tx, + }) + } + } + if len(op) > 0 { + rv[ad] = op + } + } + return rv +} + +func TestBaseMempool_removeEntryFromMempool(t *testing.T) { + tests := []struct { + name string + m *BaseMempool + want *BaseMempool + txid string + entry txEntry + }{ + { + name: "test1", + m: &BaseMempool{ + txEntries: map[string]txEntry{ + "tx1": { + addrIndexes: []addrIndex{{addrDesc: "ad1", n: 0}, {addrDesc: "ad1", n: 1}}, + }, + "tx2": { + addrIndexes: []addrIndex{{addrDesc: "ad1"}}, + }, + }, + addrDescToTx: map[string][]Outpoint{ + "ad1": { + {Txid: "tx1", Vout: 0}, + {Txid: "tx1", Vout: 1}, + {Txid: "tx2"}, + }, + }, + }, + want: &BaseMempool{ + txEntries: map[string]txEntry{ + "tx2": { + addrIndexes: []addrIndex{{addrDesc: "ad1"}}, + }, + }, + addrDescToTx: map[string][]Outpoint{ + "ad1": {{Txid: "tx2"}}}, + }, + txid: "tx1", + entry: txEntry{ + addrIndexes: []addrIndex{ + {addrDesc: "ad1"}, + {addrDesc: "ad2"}, + }, + }, + }, + { + name: "test2", + m: &BaseMempool{ + txEntries: map[string]txEntry{ + "tx1": { + addrIndexes: []addrIndex{{addrDesc: "ad1"}, {addrDesc: "ad1", n: 1}}, + }, + }, + addrDescToTx: map[string][]Outpoint{ + "ad1": { + {Txid: "tx1", Vout: 0}, + {Txid: "tx1", Vout: 1}, + }, + }, + }, + want: &BaseMempool{ + txEntries: map[string]txEntry{}, + addrDescToTx: map[string][]Outpoint{}, + }, + txid: "tx1", + entry: txEntry{ + addrIndexes: []addrIndex{ + {addrDesc: "ad1"}, + }, + }, + }, + { + name: "generated1", + m: &BaseMempool{ + txEntries: generateTxEntries(1, -1), + addrDescToTx: generateAddrDescToTx(1, -1), + }, + want: &BaseMempool{ + txEntries: generateTxEntries(1, 0), + addrDescToTx: generateAddrDescToTx(1, 0), + }, + txid: "tx0", + entry: txEntry{ + addrIndexes: generateAddIndexes(1), + }, + }, + { + name: "generated2", + m: &BaseMempool{ + txEntries: generateTxEntries(2, -1), + addrDescToTx: generateAddrDescToTx(2, -1), + }, + want: &BaseMempool{ + txEntries: generateTxEntries(2, 1), + addrDescToTx: generateAddrDescToTx(2, 1), + }, + txid: "tx1", + entry: txEntry{ + addrIndexes: generateAddIndexes(2), + }, + }, + { + name: "generated5000", + m: &BaseMempool{ + txEntries: generateTxEntries(5000, -1), + addrDescToTx: generateAddrDescToTx(5000, -1), + }, + want: &BaseMempool{ + txEntries: generateTxEntries(5000, 2), + addrDescToTx: generateAddrDescToTx(5000, 2), + }, + txid: "tx2", + entry: txEntry{ + addrIndexes: generateAddIndexes(5000), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.m.removeEntryFromMempool(tt.txid, tt.entry) + if !reflect.DeepEqual(tt.m, tt.want) { + t.Errorf("removeEntryFromMempool() got = %+v, want %+v", tt.m, tt.want) + } + }) + } +} diff --git a/bchain/baseparser.go b/bchain/baseparser.go index f1278cc34d..5b22305cbd 100644 --- a/bchain/baseparser.go +++ b/bchain/baseparser.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "encoding/json" "math/big" + "strconv" "strings" "github.com/golang/glog" @@ -39,26 +40,27 @@ func (p *BaseParser) GetAddrDescForUnknownInput(tx *Tx, input int) AddressDescri return nil } -const zeros = "0000000000000000000000000000000000000000" +const ( + zeros = "0000000000000000000000000000000000000000" + maxAmountExpandedDigits = 1024 +) // AmountToBigInt converts amount in common.JSONNumber (string) to big.Int // it uses string operations to avoid problems with rounding func (p *BaseParser) AmountToBigInt(n common.JSONNumber) (big.Int, error) { var r big.Int - s := string(n) - i := strings.IndexByte(s, '.') - d := p.AmountDecimalPoint - if d > len(zeros) { - d = len(zeros) + d := min(p.AmountDecimalPoint, len(zeros)) + if d < 0 { + d = 0 } - if i == -1 { - s = s + zeros[:d] + s := string(n) + if strings.IndexAny(s, "eE") == -1 { + s = normalizePlainAmountToIntString(s, d) } else { - z := d - len(s) + i + 1 - if z > 0 { - s = s[:i] + s[i+1:] + zeros[:z] - } else { - s = s[:i] + s[i+1:len(s)+z] + var err error + s, err = normalizeScientificAmountToIntString(s, d) + if err != nil { + return r, errors.New("AmountToBigInt: failed to convert") } } if _, ok := r.SetString(s, 10); !ok { @@ -67,6 +69,94 @@ func (p *BaseParser) AmountToBigInt(n common.JSONNumber) (big.Int, error) { return r, nil } +func normalizePlainAmountToIntString(s string, decimalPoint int) string { + i := strings.IndexByte(s, '.') + if i == -1 { + return s + zeros[:decimalPoint] + } + z := decimalPoint - len(s) + i + 1 + if z > 0 { + return s[:i] + s[i+1:] + zeros[:z] + } + return s[:i] + s[i+1:len(s)+z] +} + +func normalizeScientificAmountToIntString(s string, decimalPoint int) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + s = "0" + } + + sign := "" + if strings.HasPrefix(s, "-") { + sign = "-" + s = s[1:] + } else if strings.HasPrefix(s, "+") { + s = s[1:] + } + if s == "" { + return "", errors.New("empty mantissa") + } + + exponent := 0 + if i := strings.IndexAny(s, "eE"); i != -1 { + if strings.IndexAny(s[i+1:], "eE") != -1 { + return "", errors.New("invalid scientific notation") + } + var err error + exponent, err = strconv.Atoi(s[i+1:]) + if err != nil { + return "", err + } + s = s[:i] + if s == "" { + return "", errors.New("empty mantissa") + } + } + + fractionDigits := 0 + if i := strings.IndexByte(s, '.'); i != -1 { + if strings.IndexByte(s[i+1:], '.') != -1 { + return "", errors.New("invalid decimal notation") + } + fractionDigits = len(s) - i - 1 + s = s[:i] + s[i+1:] + } + if s == "" { + return "", errors.New("empty value") + } + for _, c := range s { + if c < '0' || c > '9' { + return "", errors.New("invalid value") + } + } + + s = strings.TrimLeft(s, "0") + if s == "" { + return "0", nil + } + + shift := exponent - fractionDigits + decimalPoint + if shift >= 0 { + if shift > maxAmountExpandedDigits || len(s) > maxAmountExpandedDigits-shift { + return "", errors.New("expanded value too large") + } + s = s + strings.Repeat("0", shift) + } else { + keep := len(s) + shift + if keep > 0 { + s = s[:keep] + } else { + s = "0" + } + } + + if sign == "-" && s != "0" { + s = sign + s + } + return s, nil +} + // AmountToDecimalString converts amount in big.Int to string with decimal point in the place defined by the parameter d func AmountToDecimalString(a *big.Int, d int) string { if a == nil { @@ -318,7 +408,26 @@ func (p *BaseParser) EthereumTypeGetTokenTransfersFromTx(tx *Tx) (TokenTransfers return nil, errors.New("Not supported") } +// GetEthereumTxData returns default pending status for non-Ethereum-like chains. +func (p *BaseParser) GetEthereumTxData(tx *Tx) *EthereumTxData { + return &EthereumTxData{Status: TxStatusPending} +} + +// GetChainExtraData returns optional normalized chain-specific transaction data. +func (p *BaseParser) GetChainExtraData(tx *Tx) (json.RawMessage, error) { + return nil, nil +} + +// GetChainExtraPayloadType identifies the shape of normalized chain-specific transaction data. +func (p *BaseParser) GetChainExtraPayloadType() ChainExtraPayloadType { + return ChainExtraPayloadTypeUnknown +} + // FormatAddressAlias makes possible to do coin specific formatting to an address alias func (p *BaseParser) FormatAddressAlias(address string, name string) string { return name } + +func (b *BaseParser) ParseInputData(signatures *[]FourByteSignature, data string) *EthereumParsedInputData { + return nil +} diff --git a/bchain/baseparser_test.go b/bchain/baseparser_test.go index 3060ee62d8..8ba773cdfa 100644 --- a/bchain/baseparser_test.go +++ b/bchain/baseparser_test.go @@ -34,6 +34,19 @@ var amounts = []struct { {big.NewInt(12345678), "0.0000000000000000000000000000000012345678", 1234, "!"}, // test of too big number decimal places } +var scientificNotationAmounts = []struct { + a *big.Int + s string + adp int +}{ + {big.NewInt(97), "9.7e-7", 8}, + {big.NewInt(97), "9.7E-7", 8}, + {big.NewInt(970000000), "9.7e+0", 8}, + {big.NewInt(-8), "-8e-8", 8}, + {big.NewInt(12345678), "1.23456789e-1", 8}, + {big.NewInt(0), "9.7e-20", 8}, +} + func TestBaseParser_AmountToDecimalString(t *testing.T) { for _, tt := range amounts { t.Run(tt.s, func(t *testing.T) { @@ -44,6 +57,51 @@ func TestBaseParser_AmountToDecimalString(t *testing.T) { } } +func TestBaseParser_AmountToBigIntScientificNotation(t *testing.T) { + for _, tt := range scientificNotationAmounts { + t.Run(tt.s, func(t *testing.T) { + got, err := NewBaseParser(tt.adp).AmountToBigInt(common.JSONNumber(tt.s)) + if err != nil { + t.Errorf("BaseParser.AmountToBigInt() error = %v", err) + return + } + if got.Cmp(tt.a) != 0 { + t.Errorf("BaseParser.AmountToBigInt() = %v, want %v", got, tt.a) + } + }) + } +} + +func TestBaseParser_AmountToBigIntScientificNotationInvalid(t *testing.T) { + cases := []string{ + "9.7e", + "9.7ee-7", + "e-7", + "--1", + "1.2.3e1", + "1e2000", + } + for _, tc := range cases { + t.Run(tc, func(t *testing.T) { + _, err := NewBaseParser(8).AmountToBigInt(common.JSONNumber(tc)) + if err == nil { + t.Errorf("BaseParser.AmountToBigInt() expected error for %q", tc) + } + }) + } +} + +func TestBaseParser_AmountToBigIntScientificNotationExpansionLimit(t *testing.T) { + p := NewBaseParser(0) + + if _, err := p.AmountToBigInt(common.JSONNumber("1e1023")); err != nil { + t.Fatalf("BaseParser.AmountToBigInt() unexpected error at limit: %v", err) + } + if _, err := p.AmountToBigInt(common.JSONNumber("1e1024")); err == nil { + t.Fatalf("BaseParser.AmountToBigInt() expected error above limit") + } +} + func TestBaseParser_AmountToBigInt(t *testing.T) { for _, tt := range amounts { t.Run(tt.s, func(t *testing.T) { diff --git a/bchain/coins/arbitrum/arbitrumrpc.go b/bchain/coins/arbitrum/arbitrumrpc.go new file mode 100644 index 0000000000..0e60b83665 --- /dev/null +++ b/bchain/coins/arbitrum/arbitrumrpc.go @@ -0,0 +1,85 @@ +package arbitrum + +import ( + "context" + "encoding/json" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +const ( + ArbitrumOneMainNet eth.Network = 42161 + ArbitrumNovaMainNet eth.Network = 42170 +) + +// ArbitrumRPC is an interface to JSON-RPC arbitrum service. +type ArbitrumRPC struct { + *eth.EthereumRPC +} + +// NewArbitrumRPC returns new ArbitrumRPC instance. +func NewArbitrumRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + c, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &ArbitrumRPC{ + EthereumRPC: c.(*eth.EthereumRPC), + } + + return s, nil +} + +// Initialize arbitrum rpc interface +func (b *ArbitrumRPC) Initialize() error { + b.OpenRPC = eth.OpenRPC + + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) + if err != nil { + return err + } + + // set chain specific + b.Client = ec + b.RPC = rc + b.NewBlock = eth.NewEthereumNewBlock() + b.NewTx = eth.NewEthereumNewTx() + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + id, err := b.Client.NetworkID(ctx) + if err != nil { + return err + } + + // parameters for getInfo request + switch eth.Network(id.Uint64()) { + case ArbitrumOneMainNet: + b.MainNetChainID = ArbitrumOneMainNet + b.Testnet = false + b.Network = "livenet" + case ArbitrumNovaMainNet: + b.MainNetChainID = ArbitrumNovaMainNet + b.Testnet = false + b.Network = "livenet" + default: + return errors.Errorf("Unknown network id %v", id) + } + + if err = b.InitAlternativeProviders(); err != nil { + return err + } + + glog.Info("rpc: block chain ", b.Network) + + return nil +} + +func (b *ArbitrumRPC) ResolveENS(name string) (*bchain.ENSResolution, error) { + return b.EthereumRPC.ResolveENS(name) +} diff --git a/bchain/coins/avalanche/avalancherpc.go b/bchain/coins/avalanche/avalancherpc.go index c7f3d7ec1d..028d18246e 100644 --- a/bchain/coins/avalanche/avalancherpc.go +++ b/bchain/coins/avalanche/avalancherpc.go @@ -6,13 +6,10 @@ import ( "fmt" "net/url" - "github.com/ava-labs/avalanchego/api/info" - "github.com/ava-labs/coreth/core/types" - "github.com/ava-labs/coreth/ethclient" - "github.com/ava-labs/coreth/interfaces" - "github.com/ava-labs/coreth/rpc" + jsontypes "github.com/ava-labs/avalanchego/utils/json" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" "github.com/golang/glog" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" @@ -24,10 +21,48 @@ const ( MainNet eth.Network = 43114 ) +func dialRPC(rawURL string) (*rpc.Client, error) { + if rawURL == "" { + return nil, errors.New("empty rpc url") + } + opts := []rpc.ClientOption{} + if parsed, err := url.Parse(rawURL); err == nil { + if parsed.Scheme == "ws" || parsed.Scheme == "wss" { + opts = append(opts, rpc.WithWebsocketMessageSizeLimit(0)) + } + } + return rpc.DialOptions(context.Background(), rawURL, opts...) +} + +// OpenRPC opens RPC connections for Avalanche to separate HTTP and WS endpoints. +var OpenRPC = func(httpURL, wsURL string) (bchain.EVMRPCClient, bchain.EVMClient, error) { + callURL, subURL, err := eth.NormalizeRPCURLs(httpURL, wsURL) + if err != nil { + return nil, nil, err + } + callClient, err := dialRPC(callURL) + if err != nil { + return nil, nil, err + } + callRPC := &AvalancheRPCClient{Client: callClient} + subRPC := callRPC + if subURL != callURL { + subClient, err := dialRPC(subURL) + if err != nil { + callClient.Close() + return nil, nil, err + } + subRPC = &AvalancheRPCClient{Client: subClient} + } + rc := &AvalancheDualRPCClient{CallClient: callRPC, SubClient: subRPC} + c := &AvalancheClient{Client: ethclient.NewClient(callClient), AvalancheRPCClient: callRPC} + return rc, c, nil +} + // AvalancheRPC is an interface to JSON-RPC avalanche service. type AvalancheRPC struct { *eth.EthereumRPC - info info.Client + info *rpc.Client } // NewAvalancheRPC returns new AvalancheRPC instance. @@ -46,14 +81,11 @@ func NewAvalancheRPC(config json.RawMessage, pushHandler func(bchain.Notificatio // Initialize avalanche rpc interface func (b *AvalancheRPC) Initialize() error { - b.OpenRPC = func(url string) (bchain.EVMRPCClient, bchain.EVMClient, error) { - r, err := rpc.Dial(url) - if err != nil { - return nil, nil, err - } - rc := &AvalancheRPCClient{Client: r} - c := &AvalancheClient{Client: ethclient.NewClient(r)} - return rc, c, nil + b.OpenRPC = OpenRPC + + rpcClient, client, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) + if err != nil { + return err } rpcUrl, err := url.Parse(b.ChainConfig.RPCURL) @@ -66,7 +98,7 @@ func (b *AvalancheRPC) Initialize() error { scheme = "https" } - rpcClient, client, err := b.OpenRPC(b.ChainConfig.RPCURL) + infoClient, err := rpc.DialHTTP(fmt.Sprintf("%s://%s/ext/info", scheme, rpcUrl.Host)) if err != nil { return err } @@ -74,9 +106,9 @@ func (b *AvalancheRPC) Initialize() error { // set chain specific b.Client = client b.RPC = rpcClient - b.info = info.NewClient(fmt.Sprintf("%s://%s", scheme, rpcUrl.Host)) + b.info = infoClient b.MainNetChainID = MainNet - b.NewBlock = &AvalancheNewBlock{channel: make(chan *types.Header)} + b.NewBlock = &AvalancheNewBlock{channel: make(chan *Header)} b.NewTx = &AvalancheNewTx{channel: make(chan common.Hash)} ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) @@ -96,6 +128,10 @@ func (b *AvalancheRPC) Initialize() error { return errors.Errorf("Unknown network id %v", id) } + if err = b.InitAlternativeProviders(); err != nil { + return err + } + glog.Info("rpc: block chain ", b.Network) return nil @@ -105,49 +141,25 @@ func (b *AvalancheRPC) Initialize() error { func (b *AvalancheRPC) GetChainInfo() (*bchain.ChainInfo, error) { ci, err := b.EthereumRPC.GetChainInfo() if err != nil { - fmt.Println(err) return nil, err } ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - v, err := b.info.GetNodeVersion(ctx) - if err != nil { - fmt.Println("here", err) - return nil, err + var v struct { + Version string `json:"version"` + DatabaseVersion string `json:"databaseVersion"` + RPCProtocolVersion jsontypes.Uint32 `json:"rpcProtocolVersion"` + GitCommit string `json:"gitCommit"` + VMVersions map[string]string `json:"vmVersions"` } - if avm, ok := v.VMVersions["avm"]; ok { - ci.Version = avm + if err := b.info.CallContext(ctx, &v, "info.getNodeVersion"); err == nil { + if avm, ok := v.VMVersions["avm"]; ok { + ci.Version = avm + } } return ci, nil } - -// EthereumTypeEstimateGas returns estimation of gas consumption for given transaction parameters -func (b *AvalancheRPC) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) { - ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) - defer cancel() - msg := interfaces.CallMsg{} - if s, ok := eth.GetStringFromMap("from", params); ok && len(s) > 0 { - msg.From = common.HexToAddress(s) - } - if s, ok := eth.GetStringFromMap("to", params); ok && len(s) > 0 { - a := common.HexToAddress(s) - msg.To = &a - } - if s, ok := eth.GetStringFromMap("data", params); ok && len(s) > 0 { - msg.Data = common.FromHex(s) - } - if s, ok := eth.GetStringFromMap("value", params); ok && len(s) > 0 { - msg.Value, _ = hexutil.DecodeBig(s) - } - if s, ok := eth.GetStringFromMap("gas", params); ok && len(s) > 0 { - msg.Gas, _ = hexutil.DecodeUint64(s) - } - if s, ok := eth.GetStringFromMap("gasPrice", params); ok && len(s) > 0 { - msg.GasPrice, _ = hexutil.DecodeBig(s) - } - return b.Client.EstimateGas(ctx, msg) -} diff --git a/bchain/coins/avalanche/evm.go b/bchain/coins/avalanche/evm.go index 435c0fd5b9..7593593ce1 100644 --- a/bchain/coins/avalanche/evm.go +++ b/bchain/coins/avalanche/evm.go @@ -5,32 +5,32 @@ import ( "math/big" "strings" - "github.com/ava-labs/coreth/core/types" - "github.com/ava-labs/coreth/ethclient" - "github.com/ava-labs/coreth/interfaces" - "github.com/ava-labs/coreth/rpc" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" "github.com/trezor/blockbook/bchain" ) // AvalancheClient wraps a client to implement the EVMClient interface type AvalancheClient struct { - ethclient.Client + *ethclient.Client + *AvalancheRPCClient } // HeaderByNumber returns a block header that implements the EVMHeader interface func (c *AvalancheClient) HeaderByNumber(ctx context.Context, number *big.Int) (bchain.EVMHeader, error) { - h, err := c.Client.HeaderByNumber(ctx, number) - if err != nil { - return nil, err + var head *Header + err := c.AvalancheRPCClient.CallContext(ctx, &head, "eth_getBlockByNumber", bchain.ToBlockNumArg(number), false) + if err == nil && head == nil { + err = ethereum.NotFound } - - return &AvalancheHeader{Header: h}, nil + return &AvalancheHeader{Header: head}, err } // EstimateGas returns the current estimated gas cost for executing a transaction func (c *AvalancheClient) EstimateGas(ctx context.Context, msg interface{}) (uint64, error) { - return c.Client.EstimateGas(ctx, msg.(interfaces.CallMsg)) + return c.Client.EstimateGas(ctx, msg.(ethereum.CallMsg)) } // BalanceAt returns the balance for the given account at a specific block, or latest known block if no block number is provided @@ -48,6 +48,37 @@ type AvalancheRPCClient struct { *rpc.Client } +// AvalancheDualRPCClient routes calls and subscriptions to separate RPC clients. +type AvalancheDualRPCClient struct { + CallClient *AvalancheRPCClient + SubClient *AvalancheRPCClient +} + +// CallContext forwards JSON-RPC calls to the HTTP client with Avalanche-specific handling. +func (c *AvalancheDualRPCClient) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + return c.CallClient.CallContext(ctx, result, method, args...) +} + +// BatchCallContext forwards batch JSON-RPC calls to the HTTP client. +func (c *AvalancheDualRPCClient) BatchCallContext(ctx context.Context, batch []rpc.BatchElem) error { + return c.CallClient.BatchCallContext(ctx, batch) +} + +// EthSubscribe forwards subscriptions to the WebSocket client. +func (c *AvalancheDualRPCClient) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + return c.SubClient.EthSubscribe(ctx, channel, args...) +} + +// Close shuts down both underlying clients. +func (c *AvalancheDualRPCClient) Close() { + if c.SubClient != nil { + c.SubClient.Close() + } + if c.CallClient != nil && c.CallClient != c.SubClient { + c.CallClient.Close() + } +} + // EthSubscribe subscribes to events and returns a client subscription that implements the EVMClientSubscription interface func (c *AvalancheRPCClient) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { sub, err := c.Client.EthSubscribe(ctx, channel, args...) @@ -62,9 +93,12 @@ func (c *AvalancheRPCClient) EthSubscribe(ctx context.Context, channel interface func (c *AvalancheRPCClient) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { err := c.Client.CallContext(ctx, result, method, args...) // unfinalized data cannot be queried error returned when trying to query a block height greater than last finalized block - // do not throw rpc error and instead treat as ErrBlockNotFound + // treat as ErrBlockNotFound so sync retries instead of processing an empty result // https://docs.avax.network/quickstart/exchanges/integrate-exchange-with-avalanche#determining-finality - if err != nil && !strings.Contains(err.Error(), "cannot query unfinalized data") { + if err != nil { + if strings.Contains(err.Error(), "cannot query unfinalized data") { + return bchain.ErrBlockNotFound + } return err } return nil @@ -72,7 +106,7 @@ func (c *AvalancheRPCClient) CallContext(ctx context.Context, result interface{} // AvalancheHeader wraps a block header to implement the EVMHeader interface type AvalancheHeader struct { - *types.Header + *Header } // Hash returns the block hash as a hex string @@ -102,7 +136,7 @@ type AvalancheClientSubscription struct { // AvalancheNewBlock wraps a block header channel to implement the EVMNewBlockSubscriber interface type AvalancheNewBlock struct { - channel chan *types.Header + channel chan *Header } // Channel returns the underlying channel as an empty interface diff --git a/bchain/coins/avalanche/evm_test.go b/bchain/coins/avalanche/evm_test.go new file mode 100644 index 0000000000..d589d79040 --- /dev/null +++ b/bchain/coins/avalanche/evm_test.go @@ -0,0 +1,73 @@ +package avalanche + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/rpc" + "github.com/trezor/blockbook/bchain" +) + +type testAvalancheRPCService struct{} + +func (s *testAvalancheRPCService) Unfinalized() (string, error) { + return "", errors.New("cannot query unfinalized data") +} + +func (s *testAvalancheRPCService) OtherError() (string, error) { + return "", errors.New("other failure") +} + +func (s *testAvalancheRPCService) Success() (string, error) { + return "ok", nil +} + +func newTestAvalancheRPCClient(t *testing.T) *AvalancheRPCClient { + t.Helper() + + server := rpc.NewServer() + if err := server.RegisterName("test", &testAvalancheRPCService{}); err != nil { + t.Fatalf("RegisterName() error = %v", err) + } + client := rpc.DialInProc(server) + t.Cleanup(func() { + client.Close() + server.Stop() + }) + + return &AvalancheRPCClient{Client: client} +} + +func TestAvalancheRPCClientCallContextMapsUnfinalizedDataToBlockNotFound(t *testing.T) { + client := newTestAvalancheRPCClient(t) + + var result string + err := client.CallContext(context.Background(), &result, "test_unfinalized") + if !errors.Is(err, bchain.ErrBlockNotFound) { + t.Fatalf("CallContext() error = %v, want ErrBlockNotFound", err) + } +} + +func TestAvalancheRPCClientCallContextReturnsOtherErrors(t *testing.T) { + client := newTestAvalancheRPCClient(t) + + var result string + err := client.CallContext(context.Background(), &result, "test_otherError") + if err == nil || !strings.Contains(err.Error(), "other failure") { + t.Fatalf("CallContext() error = %v, want other failure", err) + } +} + +func TestAvalancheRPCClientCallContextReturnsResult(t *testing.T) { + client := newTestAvalancheRPCClient(t) + + var result string + if err := client.CallContext(context.Background(), &result, "test_success"); err != nil { + t.Fatalf("CallContext() error = %v", err) + } + if result != "ok" { + t.Fatalf("result = %q, want %q", result, "ok") + } +} diff --git a/bchain/coins/avalanche/types.go b/bchain/coins/avalanche/types.go new file mode 100644 index 0000000000..c07ebab244 --- /dev/null +++ b/bchain/coins/avalanche/types.go @@ -0,0 +1,232 @@ +package avalanche + +import ( + "encoding/json" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" +) + +// Header represents a block header in the Avalanche blockchain. +type Header struct { + RpcHash common.Hash `json:"hash" gencodec:"required"` + ParentHash common.Hash `json:"parentHash" gencodec:"required"` + UncleHash common.Hash `json:"sha3Uncles" gencodec:"required"` + Coinbase common.Address `json:"miner" gencodec:"required"` + Root common.Hash `json:"stateRoot" gencodec:"required"` + TxHash common.Hash `json:"transactionsRoot" gencodec:"required"` + ReceiptHash common.Hash `json:"receiptsRoot" gencodec:"required"` + Bloom types.Bloom `json:"logsBloom" gencodec:"required"` + Difficulty *big.Int `json:"difficulty" gencodec:"required"` + Number *big.Int `json:"number" gencodec:"required"` + GasLimit uint64 `json:"gasLimit" gencodec:"required"` + GasUsed uint64 `json:"gasUsed" gencodec:"required"` + Time uint64 `json:"timestamp" gencodec:"required"` + Extra []byte `json:"extraData" gencodec:"required"` + MixDigest common.Hash `json:"mixHash"` + Nonce types.BlockNonce `json:"nonce"` + ExtDataHash common.Hash `json:"extDataHash" gencodec:"required"` + + // BaseFee was added by EIP-1559 and is ignored in legacy headers. + BaseFee *big.Int `json:"baseFeePerGas" rlp:"optional"` + + // ExtDataGasUsed was added by Apricot Phase 4 and is ignored in legacy + // headers. + // + // It is not a uint64 like GasLimit or GasUsed because it is not possible to + // correctly encode this field optionally with uint64. + ExtDataGasUsed *big.Int `json:"extDataGasUsed" rlp:"optional"` + + // BlockGasCost was added by Apricot Phase 4 and is ignored in legacy + // headers. + BlockGasCost *big.Int `json:"blockGasCost" rlp:"optional"` + + // BlobGasUsed was added by EIP-4844 and is ignored in legacy headers. + BlobGasUsed *uint64 `json:"blobGasUsed" rlp:"optional"` + + // ExcessBlobGas was added by EIP-4844 and is ignored in legacy headers. + ExcessBlobGas *uint64 `json:"excessBlobGas" rlp:"optional"` + + // ParentBeaconRoot was added by EIP-4788 and is ignored in legacy headers. + ParentBeaconRoot *common.Hash `json:"parentBeaconBlockRoot" rlp:"optional"` +} + +// MarshalJSON marshals as JSON. +func (h Header) MarshalJSON() ([]byte, error) { + type Header struct { + ParentHash common.Hash `json:"parentHash" gencodec:"required"` + UncleHash common.Hash `json:"sha3Uncles" gencodec:"required"` + Coinbase common.Address `json:"miner" gencodec:"required"` + Root common.Hash `json:"stateRoot" gencodec:"required"` + TxHash common.Hash `json:"transactionsRoot" gencodec:"required"` + ReceiptHash common.Hash `json:"receiptsRoot" gencodec:"required"` + Bloom types.Bloom `json:"logsBloom" gencodec:"required"` + Difficulty *hexutil.Big `json:"difficulty" gencodec:"required"` + Number *hexutil.Big `json:"number" gencodec:"required"` + GasLimit hexutil.Uint64 `json:"gasLimit" gencodec:"required"` + GasUsed hexutil.Uint64 `json:"gasUsed" gencodec:"required"` + Time hexutil.Uint64 `json:"timestamp" gencodec:"required"` + Extra hexutil.Bytes `json:"extraData" gencodec:"required"` + MixDigest common.Hash `json:"mixHash"` + Nonce types.BlockNonce `json:"nonce"` + ExtDataHash common.Hash `json:"extDataHash" gencodec:"required"` + BaseFee *hexutil.Big `json:"baseFeePerGas" rlp:"optional"` + ExtDataGasUsed *hexutil.Big `json:"extDataGasUsed" rlp:"optional"` + BlockGasCost *hexutil.Big `json:"blockGasCost" rlp:"optional"` + BlobGasUsed *hexutil.Uint64 `json:"blobGasUsed" rlp:"optional"` + ExcessBlobGas *hexutil.Uint64 `json:"excessBlobGas" rlp:"optional"` + ParentBeaconRoot *common.Hash `json:"parentBeaconBlockRoot" rlp:"optional"` + Hash common.Hash `json:"hash"` + } + var enc Header + enc.ParentHash = h.ParentHash + enc.UncleHash = h.UncleHash + enc.Coinbase = h.Coinbase + enc.Root = h.Root + enc.TxHash = h.TxHash + enc.ReceiptHash = h.ReceiptHash + enc.Bloom = h.Bloom + enc.Difficulty = (*hexutil.Big)(h.Difficulty) + enc.Number = (*hexutil.Big)(h.Number) + enc.GasLimit = hexutil.Uint64(h.GasLimit) + enc.GasUsed = hexutil.Uint64(h.GasUsed) + enc.Time = hexutil.Uint64(h.Time) + enc.Extra = h.Extra + enc.MixDigest = h.MixDigest + enc.Nonce = h.Nonce + enc.ExtDataHash = h.ExtDataHash + enc.BaseFee = (*hexutil.Big)(h.BaseFee) + enc.ExtDataGasUsed = (*hexutil.Big)(h.ExtDataGasUsed) + enc.BlockGasCost = (*hexutil.Big)(h.BlockGasCost) + enc.BlobGasUsed = (*hexutil.Uint64)(h.BlobGasUsed) + enc.ExcessBlobGas = (*hexutil.Uint64)(h.ExcessBlobGas) + enc.ParentBeaconRoot = h.ParentBeaconRoot + enc.Hash = h.Hash() + return json.Marshal(&enc) +} + +// UnmarshalJSON unmarshals from JSON. +func (h *Header) UnmarshalJSON(input []byte) error { + type Header struct { + RpcHash *common.Hash `json:"hash"` + ParentHash *common.Hash `json:"parentHash" gencodec:"required"` + UncleHash *common.Hash `json:"sha3Uncles" gencodec:"required"` + Coinbase *common.Address `json:"miner" gencodec:"required"` + Root *common.Hash `json:"stateRoot" gencodec:"required"` + TxHash *common.Hash `json:"transactionsRoot" gencodec:"required"` + ReceiptHash *common.Hash `json:"receiptsRoot" gencodec:"required"` + Bloom *types.Bloom `json:"logsBloom" gencodec:"required"` + Difficulty *hexutil.Big `json:"difficulty" gencodec:"required"` + Number *hexutil.Big `json:"number" gencodec:"required"` + GasLimit *hexutil.Uint64 `json:"gasLimit" gencodec:"required"` + GasUsed *hexutil.Uint64 `json:"gasUsed" gencodec:"required"` + Time *hexutil.Uint64 `json:"timestamp" gencodec:"required"` + Extra *hexutil.Bytes `json:"extraData" gencodec:"required"` + MixDigest *common.Hash `json:"mixHash"` + Nonce *types.BlockNonce `json:"nonce"` + ExtDataHash *common.Hash `json:"extDataHash" gencodec:"required"` + BaseFee *hexutil.Big `json:"baseFeePerGas" rlp:"optional"` + ExtDataGasUsed *hexutil.Big `json:"extDataGasUsed" rlp:"optional"` + BlockGasCost *hexutil.Big `json:"blockGasCost" rlp:"optional"` + BlobGasUsed *hexutil.Uint64 `json:"blobGasUsed" rlp:"optional"` + ExcessBlobGas *hexutil.Uint64 `json:"excessBlobGas" rlp:"optional"` + ParentBeaconRoot *common.Hash `json:"parentBeaconBlockRoot" rlp:"optional"` + } + var dec Header + if err := json.Unmarshal(input, &dec); err != nil { + return err + } + if dec.RpcHash == nil { + return errors.New("missing required field 'hash' for Header") + } + h.RpcHash = *dec.RpcHash + if dec.ParentHash == nil { + return errors.New("missing required field 'parentHash' for Header") + } + h.ParentHash = *dec.ParentHash + if dec.UncleHash == nil { + return errors.New("missing required field 'sha3Uncles' for Header") + } + h.UncleHash = *dec.UncleHash + if dec.Coinbase == nil { + return errors.New("missing required field 'miner' for Header") + } + h.Coinbase = *dec.Coinbase + if dec.Root == nil { + return errors.New("missing required field 'stateRoot' for Header") + } + h.Root = *dec.Root + if dec.TxHash == nil { + return errors.New("missing required field 'transactionsRoot' for Header") + } + h.TxHash = *dec.TxHash + if dec.ReceiptHash == nil { + return errors.New("missing required field 'receiptsRoot' for Header") + } + h.ReceiptHash = *dec.ReceiptHash + if dec.Bloom == nil { + return errors.New("missing required field 'logsBloom' for Header") + } + h.Bloom = *dec.Bloom + if dec.Difficulty == nil { + return errors.New("missing required field 'difficulty' for Header") + } + h.Difficulty = (*big.Int)(dec.Difficulty) + if dec.Number == nil { + return errors.New("missing required field 'number' for Header") + } + h.Number = (*big.Int)(dec.Number) + if dec.GasLimit == nil { + return errors.New("missing required field 'gasLimit' for Header") + } + h.GasLimit = uint64(*dec.GasLimit) + if dec.GasUsed == nil { + return errors.New("missing required field 'gasUsed' for Header") + } + h.GasUsed = uint64(*dec.GasUsed) + if dec.Time == nil { + return errors.New("missing required field 'timestamp' for Header") + } + h.Time = uint64(*dec.Time) + if dec.Extra == nil { + return errors.New("missing required field 'extraData' for Header") + } + h.Extra = *dec.Extra + if dec.MixDigest != nil { + h.MixDigest = *dec.MixDigest + } + if dec.Nonce != nil { + h.Nonce = *dec.Nonce + } + if dec.ExtDataHash == nil { + return errors.New("missing required field 'extDataHash' for Header") + } + h.ExtDataHash = *dec.ExtDataHash + if dec.BaseFee != nil { + h.BaseFee = (*big.Int)(dec.BaseFee) + } + if dec.ExtDataGasUsed != nil { + h.ExtDataGasUsed = (*big.Int)(dec.ExtDataGasUsed) + } + if dec.BlockGasCost != nil { + h.BlockGasCost = (*big.Int)(dec.BlockGasCost) + } + if dec.BlobGasUsed != nil { + h.BlobGasUsed = (*uint64)(dec.BlobGasUsed) + } + if dec.ExcessBlobGas != nil { + h.ExcessBlobGas = (*uint64)(dec.ExcessBlobGas) + } + if dec.ParentBeaconRoot != nil { + h.ParentBeaconRoot = dec.ParentBeaconRoot + } + return nil +} + +// Hash returns the block hash of the header +func (h *Header) Hash() common.Hash { + return h.RpcHash +} diff --git a/bchain/coins/base/baserpc.go b/bchain/coins/base/baserpc.go new file mode 100644 index 0000000000..9120dfa22e --- /dev/null +++ b/bchain/coins/base/baserpc.go @@ -0,0 +1,81 @@ +package base + +import ( + "context" + "encoding/json" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +const ( + // MainNet is production network + MainNet eth.Network = 8453 +) + +// BaseRPC is an interface to JSON-RPC base service. +type BaseRPC struct { + *eth.EthereumRPC +} + +// NewBaseRPC returns new BaseRPC instance. +func NewBaseRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + c, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &BaseRPC{ + EthereumRPC: c.(*eth.EthereumRPC), + } + + return s, nil +} + +// Initialize base rpc interface +func (b *BaseRPC) Initialize() error { + b.OpenRPC = eth.OpenRPC + + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) + if err != nil { + return err + } + + // set chain specific + b.Client = ec + b.RPC = rc + b.MainNetChainID = MainNet + b.NewBlock = eth.NewEthereumNewBlock() + b.NewTx = eth.NewEthereumNewTx() + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + id, err := b.Client.NetworkID(ctx) + if err != nil { + return err + } + + // parameters for getInfo request + switch eth.Network(id.Uint64()) { + case MainNet: + b.Testnet = false + b.Network = "livenet" + default: + return errors.Errorf("Unknown network id %v", id) + } + + if err = b.InitAlternativeProviders(); err != nil { + return err + } + + glog.Info("rpc: block chain ", b.Network) + + return nil +} + +func (b *BaseRPC) ResolveENS(name string) (*bchain.ENSResolution, error) { + return b.EthereumRPC.ResolveENS(name) +} diff --git a/bchain/coins/bch/bcashparser_test.go b/bchain/coins/bch/bcashparser_test.go index a862f558f9..3aec739606 100644 --- a/bchain/coins/bch/bcashparser_test.go +++ b/bchain/coins/bch/bcashparser_test.go @@ -337,10 +337,15 @@ func Test_UnpackTx(t *testing.T) { t.Run(tt.name, func(t *testing.T) { b, _ := hex.DecodeString(tt.args.packedTx) got, got1, err := tt.args.parser.UnpackTx(b) + if (err != nil) != tt.wantErr { t.Errorf("unpackTx() error = %v, wantErr %v", err, tt.wantErr) return } + // ignore witness unpacking + for i := range got.Vin { + got.Vin[i].Witness = nil + } if !reflect.DeepEqual(got, tt.want) { t.Errorf("unpackTx() got = %v, want %v", got, tt.want) } diff --git a/bchain/coins/bellcoin/bellcoinparser_test.go b/bchain/coins/bellcoin/bellcoinparser_test.go index 8ed7e2959b..e747fe0312 100644 --- a/bchain/coins/bellcoin/bellcoinparser_test.go +++ b/bchain/coins/bellcoin/bellcoinparser_test.go @@ -316,6 +316,10 @@ func Test_UnpackTx(t *testing.T) { t.Errorf("unpackTx() error = %v, wantErr %v", err, tt.wantErr) return } + // ignore witness unpacking + for i := range got.Vin { + got.Vin[i].Witness = nil + } if !reflect.DeepEqual(got, tt.want) { t.Errorf("unpackTx() got = %v, want %v", got, tt.want) } diff --git a/bchain/coins/blockchain.go b/bchain/coins/blockchain.go index b70fa2355b..f57abba81d 100644 --- a/bchain/coins/blockchain.go +++ b/bchain/coins/blockchain.go @@ -4,18 +4,21 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" "math/big" + "os" "reflect" "time" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/arbitrum" "github.com/trezor/blockbook/bchain/coins/avalanche" + "github.com/trezor/blockbook/bchain/coins/base" "github.com/trezor/blockbook/bchain/coins/bch" "github.com/trezor/blockbook/bchain/coins/bellcoin" "github.com/trezor/blockbook/bchain/coins/bitcore" "github.com/trezor/blockbook/bchain/coins/bitzeny" + "github.com/trezor/blockbook/bchain/coins/bsc" "github.com/trezor/blockbook/bchain/coins/btc" "github.com/trezor/blockbook/bchain/coins/btg" "github.com/trezor/blockbook/bchain/coins/cpuchain" @@ -41,13 +44,16 @@ import ( "github.com/trezor/blockbook/bchain/coins/namecoin" "github.com/trezor/blockbook/bchain/coins/nuls" "github.com/trezor/blockbook/bchain/coins/omotenashicoin" + "github.com/trezor/blockbook/bchain/coins/optimism" "github.com/trezor/blockbook/bchain/coins/pivx" "github.com/trezor/blockbook/bchain/coins/polis" + "github.com/trezor/blockbook/bchain/coins/polygon" "github.com/trezor/blockbook/bchain/coins/qtum" "github.com/trezor/blockbook/bchain/coins/ravencoin" "github.com/trezor/blockbook/bchain/coins/ritocoin" "github.com/trezor/blockbook/bchain/coins/snowgem" "github.com/trezor/blockbook/bchain/coins/trezarcoin" + "github.com/trezor/blockbook/bchain/coins/tron" "github.com/trezor/blockbook/bchain/coins/unobtanium" "github.com/trezor/blockbook/bchain/coins/vertcoin" "github.com/trezor/blockbook/bchain/coins/viacoin" @@ -64,6 +70,7 @@ var BlockChainFactories = make(map[string]blockChainFactory) func init() { BlockChainFactories["Bitcoin"] = btc.NewBitcoinRPC BlockChainFactories["Testnet"] = btc.NewBitcoinRPC + BlockChainFactories["Testnet4"] = btc.NewBitcoinRPC BlockChainFactories["Signet"] = btc.NewBitcoinRPC BlockChainFactories["Regtest"] = btc.NewBitcoinRPC BlockChainFactories["Zcash"] = zec.NewZCashRPC @@ -71,12 +78,12 @@ func init() { BlockChainFactories["Ethereum"] = eth.NewEthereumRPC BlockChainFactories["Ethereum Archive"] = eth.NewEthereumRPC BlockChainFactories["Ethereum Classic"] = eth.NewEthereumRPC - BlockChainFactories["Ethereum Testnet Ropsten"] = eth.NewEthereumRPC - BlockChainFactories["Ethereum Testnet Ropsten Archive"] = eth.NewEthereumRPC - BlockChainFactories["Ethereum Testnet Goerli"] = eth.NewEthereumRPC - BlockChainFactories["Ethereum Testnet Goerli Archive"] = eth.NewEthereumRPC BlockChainFactories["Ethereum Testnet Sepolia"] = eth.NewEthereumRPC BlockChainFactories["Ethereum Testnet Sepolia Archive"] = eth.NewEthereumRPC + BlockChainFactories["Ethereum Testnet Holesky"] = eth.NewEthereumRPC + BlockChainFactories["Ethereum Testnet Holesky Archive"] = eth.NewEthereumRPC + BlockChainFactories["Ethereum Testnet Hoodi"] = eth.NewEthereumRPC + BlockChainFactories["Ethereum Testnet Hoodi Archive"] = eth.NewEthereumRPC BlockChainFactories["Bcash"] = bch.NewBCashRPC BlockChainFactories["Bcash Testnet"] = bch.NewBCashRPC BlockChainFactories["Bgold"] = btg.NewBGoldRPC @@ -134,29 +141,29 @@ func init() { BlockChainFactories["ECash"] = ecash.NewECashRPC BlockChainFactories["Avalanche"] = avalanche.NewAvalancheRPC BlockChainFactories["Avalanche Archive"] = avalanche.NewAvalancheRPC -} - -// GetCoinNameFromConfig gets coin name and coin shortcut from config file -func GetCoinNameFromConfig(configfile string) (string, string, string, error) { - data, err := ioutil.ReadFile(configfile) - if err != nil { - return "", "", "", errors.Annotatef(err, "Error reading file %v", configfile) - } - var cn struct { - CoinName string `json:"coin_name"` - CoinShortcut string `json:"coin_shortcut"` - CoinLabel string `json:"coin_label"` - } - err = json.Unmarshal(data, &cn) - if err != nil { - return "", "", "", errors.Annotatef(err, "Error parsing file %v", configfile) - } - return cn.CoinName, cn.CoinShortcut, cn.CoinLabel, nil + BlockChainFactories["BNB Smart Chain"] = bsc.NewBNBSmartChainRPC + BlockChainFactories["BNB Smart Chain Archive"] = bsc.NewBNBSmartChainRPC + BlockChainFactories["Polygon"] = polygon.NewPolygonRPC + BlockChainFactories["Polygon Archive"] = polygon.NewPolygonRPC + BlockChainFactories["Optimism"] = optimism.NewOptimismRPC + BlockChainFactories["Optimism Archive"] = optimism.NewOptimismRPC + BlockChainFactories["Arbitrum"] = arbitrum.NewArbitrumRPC + BlockChainFactories["Arbitrum Archive"] = arbitrum.NewArbitrumRPC + BlockChainFactories["Arbitrum Nova"] = arbitrum.NewArbitrumRPC + BlockChainFactories["Arbitrum Nova Archive"] = arbitrum.NewArbitrumRPC + BlockChainFactories["Base"] = base.NewBaseRPC + BlockChainFactories["Base Archive"] = base.NewBaseRPC + BlockChainFactories["Tron"] = tron.NewTronRPC + BlockChainFactories["Tron Testnet Nile"] = tron.NewTronRPC +} + +type metricsSetter interface { + SetMetrics(*common.Metrics) } // NewBlockChain creates bchain.BlockChain and bchain.Mempool for the coin passed by the parameter coin func NewBlockChain(coin string, configfile string, pushHandler func(bchain.NotificationType), metrics *common.Metrics) (bchain.BlockChain, bchain.Mempool, error) { - data, err := ioutil.ReadFile(configfile) + data, err := os.ReadFile(configfile) if err != nil { return nil, nil, errors.Annotatef(err, "Error reading file %v", configfile) } @@ -173,6 +180,9 @@ func NewBlockChain(coin string, configfile string, pushHandler func(bchain.Notif if err != nil { return nil, nil, err } + if withMetrics, ok := bc.(metricsSetter); ok { + withMetrics.SetMetrics(metrics) + } err = bc.Initialize() if err != nil { return nil, nil, err @@ -205,8 +215,8 @@ func (c *blockChainWithMetrics) CreateMempool(chain bchain.BlockChain) (bchain.M return c.b.CreateMempool(chain) } -func (c *blockChainWithMetrics) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOutpointFunc, onNewTxAddr bchain.OnNewTxAddrFunc, onNewTx bchain.OnNewTxFunc) error { - return c.b.InitializeMempool(addrDescForOutpoint, onNewTxAddr, onNewTx) +func (c *blockChainWithMetrics) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOutpointFunc, onNewTx bchain.OnNewTxFunc) error { + return c.b.InitializeMempool(addrDescForOutpoint, onNewTx) } func (c *blockChainWithMetrics) Shutdown(ctx context.Context) error { @@ -284,6 +294,11 @@ func (c *blockChainWithMetrics) GetTransactionSpecific(tx *bchain.Tx) (v json.Ra return c.b.GetTransactionSpecific(tx) } +func (c *blockChainWithMetrics) GetAddressChainExtraData(addrDesc bchain.AddressDescriptor) (v json.RawMessage, err error) { + defer func(s time.Time) { c.observeRPCLatency("GetAddressChainExtraData", s, err) }(time.Now()) + return c.b.GetAddressChainExtraData(addrDesc) +} + func (c *blockChainWithMetrics) GetTransactionForMempool(txid string) (v *bchain.Tx, err error) { defer func(s time.Time) { c.observeRPCLatency("GetTransactionForMempool", s, err) }(time.Now()) return c.b.GetTransactionForMempool(txid) @@ -299,9 +314,14 @@ func (c *blockChainWithMetrics) EstimateFee(blocks int) (v big.Int, err error) { return c.b.EstimateFee(blocks) } -func (c *blockChainWithMetrics) SendRawTransaction(tx string) (v string, err error) { +func (c *blockChainWithMetrics) LongTermFeeRate() (v *bchain.LongTermFeeRate, err error) { + defer func(s time.Time) { c.observeRPCLatency("LongTermFeeRate", s, err) }(time.Now()) + return c.b.LongTermFeeRate() +} + +func (c *blockChainWithMetrics) SendRawTransaction(tx string, disableAlternativeRPC bool) (v string, err error) { defer func(s time.Time) { c.observeRPCLatency("SendRawTransaction", s, err) }(time.Now()) - return c.b.SendRawTransaction(tx) + return c.b.SendRawTransaction(tx, disableAlternativeRPC) } func (c *blockChainWithMetrics) GetMempoolEntry(txid string) (v *bchain.MempoolEntry, err error) { @@ -328,6 +348,11 @@ func (c *blockChainWithMetrics) EthereumTypeEstimateGas(params map[string]interf return c.b.EthereumTypeEstimateGas(params) } +func (c *blockChainWithMetrics) EthereumTypeGetEip1559Fees() (v *bchain.Eip1559Fees, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetEip1559Fees", s, err) }(time.Now()) + return c.b.EthereumTypeGetEip1559Fees() +} + func (c *blockChainWithMetrics) GetContractInfo(contractDesc bchain.AddressDescriptor) (v *bchain.ContractInfo, err error) { defer func(s time.Time) { c.observeRPCLatency("GetContractInfo", s, err) }(time.Now()) return c.b.GetContractInfo(contractDesc) @@ -338,17 +363,80 @@ func (c *blockChainWithMetrics) EthereumTypeGetErc20ContractBalance(addrDesc, co return c.b.EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc) } -// GetContractInfo returns URI of non fungible or multi token defined by token id +func (c *blockChainWithMetrics) EthereumTypeGetErc20ContractBalances(addrDesc bchain.AddressDescriptor, contractDescs []bchain.AddressDescriptor) (v []*big.Int, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetErc20ContractBalances", s, err) }(time.Now()) + return c.b.EthereumTypeGetErc20ContractBalances(addrDesc, contractDescs) +} + +// GetTokenURI returns URI of non fungible or multi token defined by token id func (c *blockChainWithMetrics) GetTokenURI(contractDesc bchain.AddressDescriptor, tokenID *big.Int) (v string, err error) { defer func(s time.Time) { c.observeRPCLatency("GetTokenURI", s, err) }(time.Now()) return c.b.GetTokenURI(contractDesc, tokenID) } +func (c *blockChainWithMetrics) EthereumTypeGetSupportedStakingPools() []string { + return c.b.EthereumTypeGetSupportedStakingPools() +} + +func (c *blockChainWithMetrics) EthereumTypeGetStakingPoolsData(addrDesc bchain.AddressDescriptor) (v []bchain.StakingPoolData, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeStakingPoolsData", s, err) }(time.Now()) + return c.b.EthereumTypeGetStakingPoolsData(addrDesc) +} + +// EthereumTypeRpcCall calls eth_call with given data and to address +func (c *blockChainWithMetrics) EthereumTypeRpcCall(data, to, from string) (v string, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeRpcCall", s, err) }(time.Now()) + return c.b.EthereumTypeRpcCall(data, to, from) +} + +func (c *blockChainWithMetrics) EthereumTypeRpcCallBatch(calls []bchain.EthereumTypeRPCCall) (v []bchain.EthereumTypeRPCCallResult, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeRpcCallBatch", s, err) }(time.Now()) + batcher, ok := c.b.(interface { + EthereumTypeRpcCallBatch(calls []bchain.EthereumTypeRPCCall) ([]bchain.EthereumTypeRPCCallResult, error) + }) + if !ok { + return nil, errors.New("EthereumTypeRpcCallBatch: not supported") + } + return batcher.EthereumTypeRpcCallBatch(calls) +} + +func (c *blockChainWithMetrics) EthereumTypeMulticallAggregate3(calls []bchain.EthereumMulticallCall, blockNumber *big.Int) (v []bchain.EthereumMulticallResult, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeMulticallAggregate3", s, err) }(time.Now()) + caller, ok := c.b.(interface { + EthereumTypeMulticallAggregate3(calls []bchain.EthereumMulticallCall, blockNumber *big.Int) ([]bchain.EthereumMulticallResult, error) + }) + if !ok { + return nil, errors.New("EthereumTypeMulticallAggregate3: not supported") + } + return caller.EthereumTypeMulticallAggregate3(calls, blockNumber) +} + +func (c *blockChainWithMetrics) EthereumTypeGetRawTransaction(txid string) (v string, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetRawTransaction", s, err) }(time.Now()) + return c.b.EthereumTypeGetRawTransaction(txid) +} + +func (c *blockChainWithMetrics) EthereumTypeGetTransactionReceipt(txid string) (v *bchain.RpcReceipt, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetTransactionReceipt", s, err) }(time.Now()) + return c.b.EthereumTypeGetTransactionReceipt(txid) +} + type mempoolWithMetrics struct { mempool bchain.Mempool m *common.Metrics } +func (c *mempoolWithMetrics) chainTypeLabel() string { + switch c.mempool.(type) { + case *bchain.MempoolBitcoinType: + return "utxo" + case *bchain.MempoolEthereumType: + return "evm" + default: + return "other" + } +} + func (c *mempoolWithMetrics) observeRPCLatency(method string, start time.Time, err error) { var e string if err != nil { @@ -358,8 +446,26 @@ func (c *mempoolWithMetrics) observeRPCLatency(method string, start time.Time, e } func (c *mempoolWithMetrics) Resync() (count int, err error) { - defer func(s time.Time) { c.observeRPCLatency("ResyncMempool", s, err) }(time.Now()) + start := time.Now() + defer func(s time.Time) { c.observeRPCLatency("ResyncMempool", s, err) }(start) count, err = c.mempool.Resync() + duration := time.Since(start) + c.m.MempoolResyncDuration.Observe(float64(duration) / 1e6) // in milliseconds + status := "success" + if err != nil { + status = "failure" + } + throughput := 0.0 + if err == nil { + seconds := duration.Seconds() + if seconds > 0 { + throughput = float64(count) / seconds + } + } + c.m.MempoolResyncThroughput.With(common.Labels{ + "chain": c.chainTypeLabel(), + "status": status, + }).Observe(throughput) if err == nil { c.m.MempoolSize.Set(float64(count)) } @@ -384,3 +490,25 @@ func (c *mempoolWithMetrics) GetAllEntries() (v bchain.MempoolTxidEntries) { func (c *mempoolWithMetrics) GetTransactionTime(txid string) uint32 { return c.mempool.GetTransactionTime(txid) } + +func (c *mempoolWithMetrics) GetTxidFilterEntries(filterScripts string, fromTimestamp uint32) (bchain.MempoolTxidFilterEntries, error) { + return c.mempool.GetTxidFilterEntries(filterScripts, fromTimestamp) +} + +func (c *blockChainWithMetrics) ResolveENS(name string) (*bchain.ENSResolution, error) { + if ensResolver, ok := c.b.(interface { + ResolveENS(string) (*bchain.ENSResolution, error) + }); ok { + return ensResolver.ResolveENS(name) + } + return nil, errors.New("ENS resolution not supported by underlying chain") +} + +func (c *blockChainWithMetrics) CheckENSExpiration(name string) (bool, error) { + if ensResolver, ok := c.b.(interface { + CheckENSExpiration(string) (bool, error) + }); ok { + return ensResolver.CheckENSExpiration(name) + } + return false, errors.New("ENS expiration check not supported by underlying chain") +} diff --git a/bchain/coins/bsc/bscrpc.go b/bchain/coins/bsc/bscrpc.go new file mode 100644 index 0000000000..4a292bf140 --- /dev/null +++ b/bchain/coins/bsc/bscrpc.go @@ -0,0 +1,86 @@ +package bsc + +import ( + "context" + "encoding/json" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +const ( + // MainNet is production network + MainNet eth.Network = 56 + + // bsc token standard names + BEP20TokenStandard bchain.TokenStandardName = "BEP20" + BEP721TokenStandard bchain.TokenStandardName = "BEP721" + BEP1155TokenStandard bchain.TokenStandardName = "BEP1155" +) + +// BNBSmartChainRPC is an interface to JSON-RPC bsc service. +type BNBSmartChainRPC struct { + *eth.EthereumRPC +} + +// NewBNBSmartChainRPC returns new BNBSmartChainRPC instance. +func NewBNBSmartChainRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + c, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + // overwrite EthereumTokenStandardMap with bsc specific token standard names + bchain.EthereumTokenStandardMap = []bchain.TokenStandardName{BEP20TokenStandard, BEP721TokenStandard, BEP1155TokenStandard} + + s := &BNBSmartChainRPC{ + EthereumRPC: c.(*eth.EthereumRPC), + } + s.Parser.SetEnsSuffix(".bnb") + + return s, nil +} + +// Initialize bnb smart chain rpc interface +func (b *BNBSmartChainRPC) Initialize() error { + b.OpenRPC = eth.OpenRPC + + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) + if err != nil { + return err + } + + // set chain specific + b.Client = ec + b.RPC = rc + b.MainNetChainID = MainNet + b.NewBlock = eth.NewEthereumNewBlock() + b.NewTx = eth.NewEthereumNewTx() + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + id, err := b.Client.NetworkID(ctx) + if err != nil { + return err + } + + // parameters for getInfo request + switch eth.Network(id.Uint64()) { + case MainNet: + b.Testnet = false + b.Network = "livenet" + default: + return errors.Errorf("Unknown network id %v", id) + } + + if err = b.InitAlternativeProviders(); err != nil { + return err + } + + glog.Info("rpc: block chain ", b.Network) + + return nil +} diff --git a/bchain/coins/btc/alternativefeeprovider.go b/bchain/coins/btc/alternativefeeprovider.go new file mode 100644 index 0000000000..200993fd08 --- /dev/null +++ b/bchain/coins/btc/alternativefeeprovider.go @@ -0,0 +1,85 @@ +package btc + +import ( + "fmt" + "math/big" + "sync" + "time" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +type alternativeFeeProviderFee struct { + blocks int + feePerKB int +} + +type alternativeFeeProvider struct { + fees []alternativeFeeProviderFee + lastSync time.Time + chain bchain.BlockChain + mux sync.Mutex + fallbackFeePerKBIfNotAvailable int + metrics *common.Metrics + name string +} + +func (p *alternativeFeeProvider) observeRequest(status string) { + if p.metrics == nil || p.metrics.AlternativeFeeProviderRequests == nil { + return + } + p.metrics.AlternativeFeeProviderRequests.With(common.Labels{"provider": p.name, "status": status}).Inc() +} + +type alternativeFeeProviderInterface interface { + compareToDefault() + estimateFee(blocks int) (big.Int, error) +} + +func (p *alternativeFeeProvider) compareToDefault() { + output := "" + for _, fee := range p.fees { + conservative, err := p.chain.(*BitcoinRPC).blockchainEstimateSmartFee(fee.blocks, true) + if err != nil { + glog.Error(err) + return + } + economical, err := p.chain.(*BitcoinRPC).blockchainEstimateSmartFee(fee.blocks, false) + if err != nil { + glog.Error(err) + return + } + output += fmt.Sprintf("Blocks %d: alternative %d, conservative %s, economical %s\n", fee.blocks, fee.feePerKB, conservative.String(), economical.String()) + } + glog.Info("alternativeFeeProviderCompareToDefault\n", output) +} + +func (p *alternativeFeeProvider) estimateFee(blocks int) (big.Int, error) { + var r big.Int + p.mux.Lock() + defer p.mux.Unlock() + if len(p.fees) == 0 { + return r, errors.New("alternativeFeeProvider: no fees") + } + if p.lastSync.Before(time.Now().Add(time.Duration(-10) * time.Minute)) { + return r, errors.Errorf("alternativeFeeProvider: Missing recent value, last sync at %v", p.lastSync) + } + for i := range p.fees { + if p.fees[i].blocks >= blocks { + r = *big.NewInt(int64(p.fees[i].feePerKB)) + return r, nil + } + } + + if p.fallbackFeePerKBIfNotAvailable > 0 { + r = *big.NewInt(int64(p.fallbackFeePerKBIfNotAvailable)) + return r, nil + } + + // use the last value as fallback + r = *big.NewInt(int64(p.fees[len(p.fees)-1].feePerKB)) + return r, nil +} diff --git a/bchain/coins/btc/bitcoinlikeparser.go b/bchain/coins/btc/bitcoinlikeparser.go index ba99d6fecc..ab718c2b4c 100644 --- a/bchain/coins/btc/bitcoinlikeparser.go +++ b/bchain/coins/btc/bitcoinlikeparser.go @@ -231,6 +231,7 @@ func (p *BitcoinLikeParser) TxFromMsgTx(t *wire.MsgTx, parseAddresses bool) bcha Vout: in.PreviousOutPoint.Index, Sequence: in.Sequence, ScriptSig: s, + Witness: in.Witness, } } vout := make([]bchain.Vout, len(t.TxOut)) @@ -296,6 +297,7 @@ func (p *BitcoinLikeParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: w.Header.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: w.Header.Timestamp.Unix(), }, @@ -439,7 +441,7 @@ var ( ) func init() { - xpubDesriptorRegex, _ = regexp.Compile(`^(?P(sh\(wpkh|wpkh|pk|pkh|wpkh|wsh|tr))\((\[\w+/(?P\d+)'/\d+'?/\d+'?\])?(?P\w+)(/(({(?P\d+(,\d+)*)})|(<(?P\d+(;\d+)*)>)|(?P\d+))/\*)?\)+`) + xpubDesriptorRegex, _ = regexp.Compile(`^(?P(sh\(wpkh|wpkh|pk|pkh|wpkh|wsh|tr))\((\[\w+/(?P\d+)['h]/\d+['h]?/\d+['h]?\])?(?P\w+)(/(({(?P\d+(,\d+)*)})|(<(?P\d+(;\d+)*)>)|(?P\d+))/\*)?\)+`) typeSubexpIndex = xpubDesriptorRegex.SubexpIndex("type") bipSubexpIndex = xpubDesriptorRegex.SubexpIndex("bip") xpubSubexpIndex = xpubDesriptorRegex.SubexpIndex("xpub") diff --git a/bchain/coins/btc/bitcoinparser.go b/bchain/coins/btc/bitcoinparser.go index 77cbcf267e..d746e82619 100644 --- a/bchain/coins/btc/bitcoinparser.go +++ b/bchain/coins/btc/bitcoinparser.go @@ -4,11 +4,28 @@ import ( "encoding/json" "math/big" + "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" ) +// temp params for signet(wait btcd commit) +// magic numbers +const ( + Testnet4Magic wire.BitcoinNet = 0x283f161c +) + +// chain parameters +var ( + TestNet4Params chaincfg.Params +) + +func init() { + TestNet4Params = chaincfg.TestNet3Params + TestNet4Params.Net = Testnet4Magic +} + // BitcoinParser handle type BitcoinParser struct { *BitcoinLikeParser @@ -33,6 +50,8 @@ func GetChainParams(chain string) *chaincfg.Params { switch chain { case "test": return &chaincfg.TestNet3Params + case "testnet4": + return &TestNet4Params case "regtest": return &chaincfg.RegressionNetParams case "signet": @@ -61,9 +80,14 @@ type Vout struct { // Tx is blockchain transaction // unnecessary fields are commented out to avoid overhead type Tx struct { - Hex string `json:"hex"` - Txid string `json:"txid"` - Version int32 `json:"version"` + Hex string `json:"hex"` + Txid string `json:"txid"` + // Version is decoded as uint32 to tolerate non-standard/invalid tx + // versions present on Bitcoin mainnet (e.g. tx 637dd1a3...fef7413f in + // block 256818 has version 2187681472, which overflows int32). It is + // bit-cast to int32 in ParseTxFromJson to match the value the binary + // block parser produces via wire.MsgTx.Deserialize. + Version uint32 `json:"version"` LockTime uint32 `json:"locktime"` VSize int64 `json:"vsize,omitempty"` Vin []bchain.Vin `json:"vin"` @@ -89,7 +113,7 @@ func (p *BitcoinParser) ParseTxFromJson(msg json.RawMessage) (*bchain.Tx, error) // it is necessary to copy bitcoinTx to Tx to make it compatible tx.Hex = bitcoinTx.Hex tx.Txid = bitcoinTx.Txid - tx.Version = bitcoinTx.Version + tx.Version = int32(bitcoinTx.Version) tx.LockTime = bitcoinTx.LockTime tx.VSize = bitcoinTx.VSize tx.Vin = bitcoinTx.Vin diff --git a/bchain/coins/btc/bitcoinparser_test.go b/bchain/coins/btc/bitcoinparser_test.go index 201d7ca9e5..bf2ee77ced 100644 --- a/bchain/coins/btc/bitcoinparser_test.go +++ b/bchain/coins/btc/bitcoinparser_test.go @@ -467,11 +467,12 @@ func TestGetAddressesFromAddrDescTestnet(t *testing.T) { } var ( - testTx1, testTx2, testTx3 bchain.Tx + testTx1, testTx2, testTx3, testTx4 bchain.Tx testTxPacked1 = "0001e2408ba8d7af5401000000017f9a22c9cbf54bd902400df746f138f37bcf5b4d93eb755820e974ba43ed5f42040000006a4730440220037f4ed5427cde81d55b9b6a2fd08c8a25090c2c2fff3a75c1a57625ca8a7118022076c702fe55969fa08137f71afd4851c48e31082dd3c40c919c92cdbc826758d30121029f6da5623c9f9b68a9baf9c1bc7511df88fa34c6c2f71f7c62f2f03ff48dca80feffffff019c9700000000000017a9146144d57c8aff48492c9dfb914e120b20bad72d6f8773d00700" testTxPacked2 = "0007c91a899ab7da6a010000000001019d64f0c72a0d206001decbffaa722eb1044534c74eee7a5df8318e42a4323ec10000000017160014550da1f5d25a9dae2eafd6902b4194c4c6500af6ffffffff02809698000000000017a914cd668d781ece600efa4b2404dc91fd26b8b8aed8870553d7360000000017a914246655bdbd54c7e477d0ea2375e86e0db2b8f80a8702473044022076aba4ad559616905fa51d4ddd357fc1fdb428d40cb388e042cdd1da4a1b7357022011916f90c712ead9a66d5f058252efd280439ad8956a967e95d437d246710bc9012102a80a5964c5612bb769ef73147b2cf3c149bc0fd4ecb02f8097629c94ab013ffd00000000" testTxPacked3 = "00003d818bfda9aa3e02000000000102deb1999a857ab0a13d6b12fbd95ea75b409edde5f2ff747507ce42d9986a8b9d0000000000fdffffff9fd2d3361e203b2375eba6438efbef5b3075531e7e583c7cc76b7294fe7f22980000000000fdffffff02a0860100000000001600148091746745464e7555c31e9a5afceac14a02978ae7fc1c0000000000160014565ea9ff4589d3e05ba149ae6e257752bfdc2a1e0247304402207d67d320a8e813f986b35e9791935fcb736754812b7038686f5de6cfdcda99cd02201c3bb2c178e0056016437ecfe365a7eef84aa9d293ebdc566177af82e22fcdd3012103abb30c1bbe878b07b58dc169b1d061d48c60be8107f632a59778b38bf7ceea5a02473044022044f54a478cfe086e870cb026c9dcd4e14e63778bef569a4d55a6332725cd9a9802202f0e94c04e6f328fc64ad9efe552888c299750d1b8d033324825a3ff29920e030121036fcd433428aa7dc65c4f5408fa31f208c54fe4b4c6c1ae9c39a825ed4f1ac039813d0000" + testTxPacked4 = "0000a2b98ced82b6400300000000010148f8f93ebb12407809920d2ab9cc1bf01289b314eb23028c83fdab21e5fefa690100000000fdffffff0150c3000000000000160014cb888de3c89670a3061fb6ef6590f187649cca060247304402206a9db8d7157e4b0a06a1f090b9de88cdc616028b431b80617a055117877e479a02202937d6d1658d4a8afde86b245325c3bb0e769a87cb09d802bcefaa21550065e201210374aa8f312de4ebccbef55609700a39764387aa4ff5d76f1ccb4d2382e454f05b00000000" ) func init() { @@ -595,6 +596,37 @@ func init() { }, }, } + + testTx4 = bchain.Tx{ + Hex: "0300000000010148f8f93ebb12407809920d2ab9cc1bf01289b314eb23028c83fdab21e5fefa690100000000fdffffff0150c3000000000000160014cb888de3c89670a3061fb6ef6590f187649cca060247304402206a9db8d7157e4b0a06a1f090b9de88cdc616028b431b80617a055117877e479a02202937d6d1658d4a8afde86b245325c3bb0e769a87cb09d802bcefaa21550065e201210374aa8f312de4ebccbef55609700a39764387aa4ff5d76f1ccb4d2382e454f05b00000000", + Blocktime: 1724927392, + Txid: "8e3f38bf6854dd3c358be8d4f9a40a6dccc50de49616125d27af9fdbe65287eb", + LockTime: 0, + VSize: 110, + Version: 3, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "", + }, + Txid: "69fafee521abfd838c0223eb14b38912f01bccb92a0d9209784012bb3ef9f848", + Vout: 1, + Sequence: 4294967293, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(50000), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "0014cb888de3c89670a3061fb6ef6590f187649cca06", + Addresses: []string{ + "tb1qewygmc7gjec2xpslkmhkty83sajfejsxqmy5dq", + }, + }, + }, + }, + } } func TestPackTx(t *testing.T) { @@ -643,6 +675,17 @@ func TestPackTx(t *testing.T) { want: testTxPacked3, wantErr: false, }, + { + name: "testnet4-1", + args: args{ + tx: testTx4, + height: 41657, + blockTime: 1724927392, + parser: NewBitcoinParser(GetChainParams("testnet4"), &Configuration{}), + }, + want: testTxPacked4, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -701,6 +744,16 @@ func TestUnpackTx(t *testing.T) { want1: 15745, wantErr: false, }, + { + name: "testnet4-1", + args: args{ + packedTx: testTxPacked4, + parser: NewBitcoinParser(GetChainParams("testnet4"), &Configuration{}), + }, + want: &testTx4, + want1: 41657, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -710,6 +763,10 @@ func TestUnpackTx(t *testing.T) { t.Errorf("unpackTx() error = %v, wantErr %v", err, tt.wantErr) return } + // ignore witness unpacking + for i := range got.Vin { + got.Vin[i].Witness = nil + } if !reflect.DeepEqual(got, tt.want) { t.Errorf("unpackTx() got = %v, want %v", got, tt.want) } @@ -766,6 +823,18 @@ func TestParseXpubDescriptors(t *testing.T) { ChangeIndexes: []uint32{0, 1, 2}, }, }, + { + name: "tr([5c9e228d/86h/1h/0h]tpubD/{0,1,2}/*)#4rqwxvej", + xpub: "tr([5c9e228d/86h/1h/0h]tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{0,1,2}/*)#4rqwxvej", + parser: btcTestnetParser, + want: &bchain.XpubDescriptor{ + XpubDescriptor: "tr([5c9e228d/86h/1h/0h]tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{0,1,2}/*)#4rqwxvej", + Xpub: "tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN", + Type: bchain.P2TR, + Bip: "86", + ChangeIndexes: []uint32{0, 1, 2}, + }, + }, { name: "tr([5c9e228d/86'/1'/0']tpubD/<0;1;2>/*)#4rqwxvej", xpub: "tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/<0;1;2>/*)#4rqwxvej", @@ -1243,3 +1312,49 @@ func TestBitcoinParser_DerivationBasePath(t *testing.T) { }) } } + +// TestParseTxFromJson_VersionOverflow exercises ParseTxFromJson with +// non-standard/invalid tx-version values that don't fit in int32. +// Bitcoin Core serializes the transaction's version field as an unsigned +// 32-bit integer in JSON, so values above math.MaxInt32 (e.g. the historical +// mainnet tx 637dd1a3418386a418ceeac7bb58633a904dbf127fa47bbea9cc8f86fef7413f +// in block 256818, whose version is 2187681472) must be accepted and +// bit-cast to int32 to match the value produced by the binary block parser. +func TestParseTxFromJson_VersionOverflow(t *testing.T) { + p := NewBitcoinParser(GetChainParams("main"), &Configuration{}) + tests := []struct { + name string + jsonVersion string + wantVersion int32 + }{ + { + name: "standard version 2", + jsonVersion: "2", + wantVersion: 2, + }, + { + // uint32 0x826C6B40 == 2187681472, bit-cast to int32 == -2107285824 + name: "real-world overflow tx 637dd1a3...", + jsonVersion: "2187681472", + wantVersion: -2107285824, + }, + { + // uint32 0xFFFFFFFF, bit-cast to int32 == -1 + name: "uint32 max", + jsonVersion: "4294967295", + wantVersion: -1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := `{"hex":"00","txid":"637dd1a3418386a418ceeac7bb58633a904dbf127fa47bbea9cc8f86fef7413f","version":` + tt.jsonVersion + `,"locktime":0,"vin":[],"vout":[]}` + got, err := p.ParseTxFromJson([]byte(msg)) + if err != nil { + t.Fatalf("ParseTxFromJson() unexpected error: %v", err) + } + if got.Version != tt.wantVersion { + t.Errorf("ParseTxFromJson() Version = %d, want %d", got.Version, tt.wantVersion) + } + }) + } +} diff --git a/bchain/coins/btc/bitcoinrpc.go b/bchain/coins/btc/bitcoinrpc.go index b2618a0364..9c0a1f21d7 100644 --- a/bchain/coins/btc/bitcoinrpc.go +++ b/bchain/coins/btc/bitcoinrpc.go @@ -5,12 +5,9 @@ import ( "context" "encoding/hex" "encoding/json" - "io" - "io/ioutil" "math/big" "net" "net/http" - "runtime/debug" "time" "github.com/golang/glog" @@ -23,16 +20,26 @@ import ( // BitcoinRPC is an interface to JSON-RPC bitcoind service. type BitcoinRPC struct { *bchain.BaseChain - client http.Client - rpcURL string - user string - password string - Mempool *bchain.MempoolBitcoinType - ParseBlocks bool - pushHandler func(bchain.NotificationType) - mq *bchain.MQ - ChainConfig *Configuration - RPCMarshaler RPCMarshaler + client http.Client + rpcURL string + user string + password string + Mempool *bchain.MempoolBitcoinType + ParseBlocks bool + pushHandler func(bchain.NotificationType) + mq *bchain.MQ + ChainConfig *Configuration + RPCMarshaler RPCMarshaler + mempoolGolombFilterP uint8 + mempoolFilterScripts string + mempoolUseZeroedKey bool + alternativeFeeProvider alternativeFeeProviderInterface + metrics *common.Metrics +} + +// SetMetrics sets prometheus metrics collector +func (b *BitcoinRPC) SetMetrics(metrics *common.Metrics) { + b.metrics = metrics } // Configuration represents json config file @@ -50,6 +57,7 @@ type Configuration struct { BlockAddressesToKeep int `json:"block_addresses_to_keep"` MempoolWorkers int `json:"mempool_workers"` MempoolSubWorkers int `json:"mempool_sub_workers"` + MempoolResyncBatchSize int `json:"mempool_resync_batch_size,omitempty"` AddressFormat string `json:"address_format"` SupportsEstimateFee bool `json:"supports_estimate_fee"` SupportsEstimateSmartFee bool `json:"supports_estimate_smart_fee"` @@ -60,6 +68,9 @@ type Configuration struct { AlternativeEstimateFee string `json:"alternative_estimate_fee,omitempty"` AlternativeEstimateFeeParams string `json:"alternative_estimate_fee_params,omitempty"` MinimumCoinbaseConfirmations int `json:"minimumCoinbaseConfirmations,omitempty"` + MempoolGolombFilterP uint8 `json:"mempool_golomb_filter_p,omitempty"` + MempoolFilterScripts string `json:"mempool_filter_scripts,omitempty"` + MempoolFilterUseZeroedKey bool `json:"mempool_filter_use_zeroed_key,omitempty"` } // NewBitcoinRPC returns new BitcoinRPC instance. @@ -85,6 +96,10 @@ func NewBitcoinRPC(config json.RawMessage, pushHandler func(bchain.NotificationT if c.MempoolSubWorkers < 1 { c.MempoolSubWorkers = 1 } + // default to legacy per-tx resync behavior unless a batch size is specified + if c.MempoolResyncBatchSize < 1 { + c.MempoolResyncBatchSize = 1 + } // btc supports both calls, other coins overriding BitcoinRPC can change this c.SupportsEstimateFee = true c.SupportsEstimateSmartFee = true @@ -96,15 +111,18 @@ func NewBitcoinRPC(config json.RawMessage, pushHandler func(bchain.NotificationT } s := &BitcoinRPC{ - BaseChain: &bchain.BaseChain{}, - client: http.Client{Timeout: time.Duration(c.RPCTimeout) * time.Second, Transport: transport}, - rpcURL: c.RPCURL, - user: c.RPCUser, - password: c.RPCPass, - ParseBlocks: c.Parse, - ChainConfig: &c, - pushHandler: pushHandler, - RPCMarshaler: JSONMarshalerV2{}, + BaseChain: &bchain.BaseChain{}, + client: http.Client{Timeout: time.Duration(c.RPCTimeout) * time.Second, Transport: transport}, + rpcURL: c.RPCURL, + user: c.RPCUser, + password: c.RPCPass, + ParseBlocks: c.Parse, + ChainConfig: &c, + pushHandler: pushHandler, + RPCMarshaler: JSONMarshalerV2{}, + mempoolGolombFilterP: c.MempoolGolombFilterP, + mempoolFilterScripts: c.MempoolFilterScripts, + mempoolUseZeroedKey: c.MempoolFilterUseZeroedKey, } return s, nil @@ -137,11 +155,30 @@ func (b *BitcoinRPC) Initialize() error { glog.Info("rpc: block chain ", params.Name) if b.ChainConfig.AlternativeEstimateFee == "whatthefee" { - if err = InitWhatTheFee(b, b.ChainConfig.AlternativeEstimateFeeParams); err != nil { - glog.Error("InitWhatTheFee error ", err, " Reverting to default estimateFee functionality") + glog.Info("Using WhatTheFee") + if b.alternativeFeeProvider, err = NewWhatTheFee(b, b.ChainConfig.AlternativeEstimateFeeParams, b.metrics); err != nil { + glog.Error("NewWhatTheFee error ", err, " Reverting to default estimateFee functionality") + // disable AlternativeEstimateFee logic + b.alternativeFeeProvider = nil + } + } else if b.ChainConfig.AlternativeEstimateFee == "mempoolspace" { + glog.Info("Using MempoolSpaceFee") + if b.alternativeFeeProvider, err = NewMempoolSpaceFee(b, b.ChainConfig.AlternativeEstimateFeeParams, b.metrics); err != nil { + glog.Error("MempoolSpaceFee error ", err, " Reverting to default estimateFee functionality") // disable AlternativeEstimateFee logic - b.ChainConfig.AlternativeEstimateFee = "" + b.alternativeFeeProvider = nil } + } else if b.ChainConfig.AlternativeEstimateFee == "mempoolspaceblock" { + glog.Info("Using MempoolSpaceBlockFee") + if b.alternativeFeeProvider, err = NewMempoolSpaceBlockFee(b, b.ChainConfig.AlternativeEstimateFeeParams, b.metrics); err != nil { + glog.Error("MempoolSpaceBlockFee error ", err, " Reverting to default estimateFee functionality") + // disable AlternativeEstimateFee logic + b.alternativeFeeProvider = nil + } + } else if len(b.ChainConfig.AlternativeEstimateFee) > 0 { + glog.Error("AlternativeEstimateFee ", b.ChainConfig.AlternativeEstimateFee, " not supported") + } else { + glog.Info("Using default estimateFee") } return nil @@ -150,21 +187,27 @@ func (b *BitcoinRPC) Initialize() error { // CreateMempool creates mempool if not already created, however does not initialize it func (b *BitcoinRPC) CreateMempool(chain bchain.BlockChain) (bchain.Mempool, error) { if b.Mempool == nil { - b.Mempool = bchain.NewMempoolBitcoinType(chain, b.ChainConfig.MempoolWorkers, b.ChainConfig.MempoolSubWorkers) + b.Mempool = bchain.NewMempoolBitcoinType(chain, b.ChainConfig.MempoolWorkers, b.ChainConfig.MempoolSubWorkers, b.mempoolGolombFilterP, b.mempoolFilterScripts, b.mempoolUseZeroedKey, b.ChainConfig.MempoolResyncBatchSize) } return b.Mempool, nil } // InitializeMempool creates ZeroMQ subscription and sets AddrDescForOutpointFunc to the Mempool -func (b *BitcoinRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOutpointFunc, onNewTxAddr bchain.OnNewTxAddrFunc, onNewTx bchain.OnNewTxFunc) error { +func (b *BitcoinRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOutpointFunc, onNewTx bchain.OnNewTxFunc) error { if b.Mempool == nil { return errors.New("Mempool not created") } b.Mempool.AddrDescForOutpoint = addrDescForOutpoint - b.Mempool.OnNewTxAddr = onNewTxAddr b.Mempool.OnNewTx = onNewTx if b.mq == nil { - mq, err := bchain.NewMQ(b.ChainConfig.MessageQueueBinding, b.pushHandler) + bitcoinTopics := bchain.SubscriptionTopics{ + BlockSubscribe: "hashblock", + BlockReceive: "hashblock", + TxSubscribe: "hashtx", + TxReceive: "hashtx", + } + + mq, err := bchain.NewMQ(b.ChainConfig.MessageQueueBinding, b.pushHandler, bitcoinTopics) if err != nil { glog.Error("mq: ", err) return err @@ -348,6 +391,19 @@ type ResGetRawTransactionNonverbose struct { Result string `json:"result"` } +type rpcBatchRequest struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID int `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params,omitempty"` +} + +type rpcBatchResponse struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *bchain.RPCError `json:"error"` +} + // estimatesmartfee type CmdEstimateSmartFee struct { @@ -486,7 +542,8 @@ func (b *BitcoinRPC) GetChainInfo() (*bchain.ChainInfo, error) { // IsErrBlockNotFound returns true if error means block was not found func IsErrBlockNotFound(err *bchain.RPCError) bool { return err.Message == "Block not found" || - err.Message == "Block height out of range" + err.Message == "Block height out of range" || + err.Message == "Provided index is greater than the current tip" } // GetBlockHash returns hash of block in best-block-chain at given height. @@ -722,6 +779,100 @@ func (b *BitcoinRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { return tx, nil } +// GetRawTransactionsForMempoolBatch returns transactions for multiple txids using a single batch call. +func (b *BitcoinRPC) GetRawTransactionsForMempoolBatch(txids []string) (map[string]*bchain.Tx, error) { + batchSize := b.ChainConfig.MempoolResyncBatchSize + if batchSize < 1 { + batchSize = 1 + } + results := make(map[string]*bchain.Tx, len(txids)) + if len(txids) == 0 { + return results, nil + } + if batchSize == 1 { + for _, txid := range txids { + tx, err := b.GetTransactionForMempool(txid) + if err != nil { + if err == bchain.ErrTxNotFound { + continue + } + return nil, err + } + results[txid] = tx + } + return results, nil + } + for start := 0; start < len(txids); start += batchSize { + end := start + batchSize + if end > len(txids) { + end = len(txids) + } + batch := txids[start:end] + requests := make([]rpcBatchRequest, 0, len(batch)) + idToTxid := make(map[int]string, len(batch)) + for i, txid := range batch { + id := i + 1 + requests = append(requests, rpcBatchRequest{ + JSONRPC: "1.0", + ID: id, + Method: "getrawtransaction", + // Use numeric verbosity (0) for compatibility with older JSON-RPC variants. + Params: []interface{}{txid, 0}, + }) + idToTxid[id] = txid + } + var responses []rpcBatchResponse + if err := b.callBatch(requests, &responses); err != nil { + return nil, err + } + batchResults, err := decodeBatchRawTransactions(responses, idToTxid, b.Parser) + if err != nil { + return nil, err + } + for txid, tx := range batchResults { + results[txid] = tx + } + } + return results, nil +} + +func decodeBatchRawTransactions(responses []rpcBatchResponse, idToTxid map[int]string, parser bchain.BlockChainParser) (map[string]*bchain.Tx, error) { + results := make(map[string]*bchain.Tx, len(idToTxid)) + for _, resp := range responses { + txid, ok := idToTxid[resp.ID] + if !ok { + continue + } + if resp.Error != nil { + if IsMissingTx(resp.Error) { + continue + } + // Log and skip so resync can fall back to per-tx fetches for cache misses. + glog.Warning("rpc: batch getrawtransaction ", txid, ": ", resp.Error) + continue + } + trimmed := bytes.TrimSpace(resp.Result) + // Some backends return "null" without an error for missing transactions. + if len(trimmed) == 0 || (len(trimmed) == 4 && string(trimmed) == "null") { + continue + } + var hexTx string + if err := json.Unmarshal(trimmed, &hexTx); err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } + data, err := hex.DecodeString(hexTx) + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } + tx, err := parser.ParseTx(data) + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } + results[txid] = tx + } + return results, nil +} + // GetTransaction returns a transaction by the transaction ID func (b *BitcoinRPC) GetTransaction(txid string) (*bchain.Tx, error) { r, err := b.getRawTransaction(txid) @@ -766,8 +917,7 @@ func (b *BitcoinRPC) getRawTransaction(txid string) (json.RawMessage, error) { return res.Result, nil } -// EstimateSmartFee returns fee estimation -func (b *BitcoinRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, error) { +func (b *BitcoinRPC) blockchainEstimateSmartFee(blocks int, conservative bool) (big.Int, error) { // use EstimateFee if EstimateSmartFee is not supported if !b.ChainConfig.SupportsEstimateSmartFee && b.ChainConfig.SupportsEstimateFee { return b.EstimateFee(blocks) @@ -784,7 +934,6 @@ func (b *BitcoinRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, e req.Params.EstimateMode = "ECONOMICAL" } err := b.Call(&req, &res) - var r big.Int if err != nil { return r, err @@ -799,8 +948,31 @@ func (b *BitcoinRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, e return r, nil } +// EstimateSmartFee returns fee estimation +func (b *BitcoinRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, error) { + // use alternative estimator if enabled + if b.alternativeFeeProvider != nil { + r, err := b.alternativeFeeProvider.estimateFee(blocks) + // in case of error, fallback to default estimator + if err == nil { + return r, nil + } + } + return b.blockchainEstimateSmartFee(blocks, conservative) +} + // EstimateFee returns fee estimation. func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) { + var r big.Int + var err error + // use alternative estimator if enabled + if b.alternativeFeeProvider != nil { + r, err = b.alternativeFeeProvider.estimateFee(blocks) + // in case of error, fallback to default estimator + if err == nil { + return r, nil + } + } // use EstimateSmartFee if EstimateFee is not supported if !b.ChainConfig.SupportsEstimateFee && b.ChainConfig.SupportsEstimateSmartFee { return b.EstimateSmartFee(blocks, true) @@ -811,9 +983,8 @@ func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) { res := ResEstimateFee{} req := CmdEstimateFee{Method: "estimatefee"} req.Params.Blocks = blocks - err := b.Call(&req, &res) + err = b.Call(&req, &res) - var r big.Int if err != nil { return r, err } @@ -827,8 +998,23 @@ func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) { return r, nil } +// LongTermFeeRate returns smallest fee rate from historic blocks. +func (b *BitcoinRPC) LongTermFeeRate() (*bchain.LongTermFeeRate, error) { + blocks := 1008 // ~7 days of blocks, highest number estimatesmartfee supports + glog.V(1).Info("rpc: estimatesmartfee (long term fee rate) - ", blocks) + // Going for the ECONOMICAL mode, to get the lowest fee rate + feePerUnit, err := b.blockchainEstimateSmartFee(blocks, false) + if err != nil { + return nil, err + } + return &bchain.LongTermFeeRate{ + Blocks: uint64(blocks), + FeePerUnit: feePerUnit, + }, nil +} + // SendRawTransaction sends raw transaction -func (b *BitcoinRPC) SendRawTransaction(tx string) (string, error) { +func (b *BitcoinRPC) SendRawTransaction(tx string, disableAlternativeRPC bool) (string, error) { glog.V(1).Info("rpc: sendrawtransaction") res := ResSendRawTransaction{} @@ -872,24 +1058,36 @@ func (b *BitcoinRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) return res.Result, nil } -func safeDecodeResponse(body io.ReadCloser, res interface{}) (err error) { - var data []byte - defer func() { - if r := recover(); r != nil { - glog.Error("unmarshal json recovered from panic: ", r, "; data: ", string(data)) - debug.PrintStack() - if len(data) > 0 && len(data) < 2048 { - err = errors.Errorf("Error: %v", string(data)) - } else { - err = errors.New("Internal error") - } - } - }() - data, err = ioutil.ReadAll(body) +// callBatch sends a JSON-RPC batch request and decodes responses. +func (b *BitcoinRPC) callBatch(req []rpcBatchRequest, res *[]rpcBatchResponse) error { + httpData, err := json.Marshal(req) if err != nil { return err } - return json.Unmarshal(data, &res) + httpReq, err := http.NewRequest("POST", b.rpcURL, bytes.NewBuffer(httpData)) + if err != nil { + return err + } + httpReq.SetBasicAuth(b.user, b.password) + httpRes, err := b.client.Do(httpReq) + // in some cases the httpRes can contain data even if it returns error + // see http://devs.cloudimmunity.com/gotchas-and-common-mistakes-in-go-golang/ + if httpRes != nil { + defer httpRes.Body.Close() + } + if err != nil { + return err + } + // if server returns HTTP error code it might not return json with response + // handle both cases + if httpRes.StatusCode != 200 { + err = common.SafeDecodeResponseFromReader(httpRes.Body, res) + if err != nil { + return errors.Errorf("%v %v", httpRes.Status, err) + } + return nil + } + return common.SafeDecodeResponseFromReader(httpRes.Body, res) } // Call calls Backend RPC interface, using RPCMarshaler interface to marshall the request @@ -915,11 +1113,11 @@ func (b *BitcoinRPC) Call(req interface{}, res interface{}) error { // if server returns HTTP error code it might not return json with response // handle both cases if httpRes.StatusCode != 200 { - err = safeDecodeResponse(httpRes.Body, &res) + err = common.SafeDecodeResponseFromReader(httpRes.Body, &res) if err != nil { return errors.Errorf("%v %v", httpRes.Status, err) } return nil } - return safeDecodeResponse(httpRes.Body, &res) + return common.SafeDecodeResponseFromReader(httpRes.Body, &res) } diff --git a/bchain/coins/btc/bitcoinrpc_integration_test.go b/bchain/coins/btc/bitcoinrpc_integration_test.go new file mode 100644 index 0000000000..556183ad37 --- /dev/null +++ b/bchain/coins/btc/bitcoinrpc_integration_test.go @@ -0,0 +1,111 @@ +//go:build integration + +package btc + +import ( + "encoding/json" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +const blockHeightLag = 100 + +func newTestBitcoinRPC(t *testing.T) *BitcoinRPC { + t.Helper() + + cfg := bchain.LoadBlockchainCfg(t, "bitcoin") + config := Configuration{ + RPCURL: cfg.RpcUrl, + RPCUser: cfg.RpcUser, + RPCPass: cfg.RpcPass, + RPCTimeout: cfg.RpcTimeout, + Parse: cfg.Parse, + } + raw, err := json.Marshal(config) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + chain, err := NewBitcoinRPC(raw, nil) + if err != nil { + t.Fatalf("new bitcoin rpc: %v", err) + } + rpcClient, ok := chain.(*BitcoinRPC) + if !ok { + t.Fatalf("unexpected rpc client type %T", chain) + } + if err := rpcClient.Initialize(); err != nil { + t.Skipf("skipping: cannot connect to RPC at %s: %v", cfg.RpcUrl, err) + return nil + } + return rpcClient +} + +func assertBlockBasics(t *testing.T, block *bchain.Block, hash string, height uint32) { + t.Helper() + if block.Hash != hash { + t.Fatalf("hash mismatch: got %s want %s", block.Hash, hash) + } + if block.Height != height { + t.Fatalf("height mismatch: got %d want %d", block.Height, height) + } + if block.Time <= 0 { + t.Fatalf("expected block time > 0, got %d", block.Time) + } +} + +// TestBitcoinRPCGetBlockIntegration validates GetBlock by hash/height and checks +// previous hash availability for fork detection. +func TestBitcoinRPCGetBlockIntegration(t *testing.T) { + rpcClient := newTestBitcoinRPC(t) + if rpcClient == nil { + return + } + + best, err := rpcClient.GetBestBlockHeight() + if err != nil { + t.Fatalf("GetBestBlockHeight: %v", err) + } + if best <= blockHeightLag { + t.Skipf("best height %d too low for lag %d", best, blockHeightLag) + return + } + height := best - blockHeightLag + if height == 0 { + t.Skip("block height is zero, cannot validate previous hash") + return + } + + hash, err := rpcClient.GetBlockHash(height) + if err != nil { + t.Fatalf("GetBlockHash height %d: %v", height, err) + } + prevHash, err := rpcClient.GetBlockHash(height - 1) + if err != nil { + t.Fatalf("GetBlockHash height %d: %v", height-1, err) + } + + blockByHash, err := rpcClient.GetBlock(hash, 0) + if err != nil { + t.Fatalf("GetBlock by hash: %v", err) + } + assertBlockBasics(t, blockByHash, hash, height) + if blockByHash.Confirmations <= 0 { + t.Fatalf("expected confirmations > 0, got %d", blockByHash.Confirmations) + } + if blockByHash.Prev != prevHash { + t.Fatalf("previous hash mismatch: got %s want %s", blockByHash.Prev, prevHash) + } + + blockByHeight, err := rpcClient.GetBlock("", height) + if err != nil { + t.Fatalf("GetBlock by height: %v", err) + } + assertBlockBasics(t, blockByHeight, hash, height) + if blockByHeight.Prev != prevHash { + t.Fatalf("previous hash mismatch by height: got %s want %s", blockByHeight.Prev, prevHash) + } + if len(blockByHeight.Txs) != len(blockByHash.Txs) { + t.Fatalf("tx count mismatch: by hash %d vs by height %d", len(blockByHash.Txs), len(blockByHeight.Txs)) + } +} diff --git a/bchain/coins/btc/bitcoinrpc_test.go b/bchain/coins/btc/bitcoinrpc_test.go new file mode 100644 index 0000000000..6ca9a83536 --- /dev/null +++ b/bchain/coins/btc/bitcoinrpc_test.go @@ -0,0 +1,34 @@ +package btc + +import ( + "encoding/json" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +func TestDecodeBatchRawTransactions(t *testing.T) { + const txid = "ca211af71c54c3d90b83851c1d35a73669040b82742dd7f95e39953b032f7d39" + const rawTx = "01000000014ce1dd2c07c07524ed102b5bf67d9eb601f65ccd848952042ed538c7bcf5ef830b0000006b483045022100f0beea3fada8a71b7dba04357112474e089bc1bd6726b520065a3ba244dc0dcc02200126f8cbbec0c21ea8fed38481391a4df43603c89736cbdc007e5280100f5fd401210242b47391c5b851486b7113ce30cbf60c45a8e8d2a6f7145a972100015e690a25ffffffff02d0b3fb02000000001976a914d39c85c954ae3002137fe718c2af835175352b5f88ac141b0000000000001976a914198ec3f7a57bc6a1dc929dc68464149108e272bf88ac00000000" + + responses := []rpcBatchResponse{ + {ID: 1, Result: json.RawMessage("\"" + rawTx + "\"")}, + {ID: 2, Error: &bchain.RPCError{Code: -5, Message: "No such mempool or blockchain transaction"}}, + } + idToTxid := map[int]string{1: txid, 2: "missing"} + + parser := NewBitcoinParser(GetChainParams("main"), &Configuration{}) + got, err := decodeBatchRawTransactions(responses, idToTxid, parser) + if err != nil { + t.Fatalf("decodeBatchRawTransactions: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 transaction, got %d", len(got)) + } + if got[txid] == nil { + t.Fatalf("missing tx %s", txid) + } + if got[txid].Txid != txid { + t.Fatalf("expected txid %s, got %s", txid, got[txid].Txid) + } +} diff --git a/bchain/coins/btc/mempoolspace.go b/bchain/coins/btc/mempoolspace.go new file mode 100644 index 0000000000..8dc41a1bce --- /dev/null +++ b/bchain/coins/btc/mempoolspace.go @@ -0,0 +1,143 @@ +package btc + +import ( + "bytes" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// https://mempool.space/api/v1/fees/recommended returns +// {"fastestFee":41,"halfHourFee":39,"hourFee":36,"economyFee":36,"minimumFee":20} + +type mempoolSpaceFeeResult struct { + FastestFee int `json:"fastestFee"` + HalfHourFee int `json:"halfHourFee"` + HourFee int `json:"hourFee"` + EconomyFee int `json:"economyFee"` + MinimumFee int `json:"minimumFee"` +} + +type mempoolSpaceFeeParams struct { + URL string `json:"url"` + PeriodSeconds int `json:"periodSeconds"` +} + +type mempoolSpaceFeeProvider struct { + *alternativeFeeProvider + params mempoolSpaceFeeParams +} + +// NewMempoolSpaceFee initializes https://mempool.space provider +func NewMempoolSpaceFee(chain bchain.BlockChain, params string, metrics *common.Metrics) (alternativeFeeProviderInterface, error) { + p := &mempoolSpaceFeeProvider{alternativeFeeProvider: &alternativeFeeProvider{metrics: metrics, name: "mempoolspace"}} + err := json.Unmarshal([]byte(params), &p.params) + if err != nil { + return nil, err + } + if p.params.URL == "" || p.params.PeriodSeconds == 0 { + return nil, errors.New("NewMempoolSpaceFee: Missing parameters") + } + p.chain = chain + go p.mempoolSpaceFeeDownloader() + return p, nil +} + +func (p *mempoolSpaceFeeProvider) mempoolSpaceFeeDownloader() { + period := time.Duration(p.params.PeriodSeconds) * time.Second + timer := time.NewTimer(period) + counter := 0 + for { + var data mempoolSpaceFeeResult + err := p.mempoolSpaceFeeGetData(&data) + if err != nil { + glog.Error("mempoolSpaceFeeGetData ", err) + } else { + if p.mempoolSpaceFeeProcessData(&data) { + if counter%60 == 0 { + p.compareToDefault() + } + counter++ + } + } + <-timer.C + timer.Reset(period) + } +} + +func (p *mempoolSpaceFeeProvider) mempoolSpaceFeeProcessData(data *mempoolSpaceFeeResult) bool { + if data.MinimumFee == 0 || data.EconomyFee == 0 || data.HourFee == 0 || data.HalfHourFee == 0 || data.FastestFee == 0 { + glog.Errorf("mempoolSpaceFeeProcessData: invalid data %+v", data) + return false + } + p.mux.Lock() + defer p.mux.Unlock() + p.fees = make([]alternativeFeeProviderFee, 5) + // map mempoool.space fees to blocks + + // FastestFee is for 1 block + p.fees[0] = alternativeFeeProviderFee{ + blocks: 1, + feePerKB: data.FastestFee * 1000, + } + + // HalfHourFee is for 2-6 blocks + p.fees[1] = alternativeFeeProviderFee{ + blocks: 6, + feePerKB: data.HalfHourFee * 1000, + } + + // HourFee is for 7-36 blocks + p.fees[2] = alternativeFeeProviderFee{ + blocks: 36, + feePerKB: data.HourFee * 1000, + } + + // EconomyFee is for 37-200 blocks + p.fees[3] = alternativeFeeProviderFee{ + blocks: 500, + feePerKB: data.EconomyFee * 1000, + } + + // MinimumFee is for over 500 blocks + p.fees[4] = alternativeFeeProviderFee{ + blocks: 1000, + feePerKB: data.MinimumFee * 1000, + } + + p.lastSync = time.Now() + // glog.Infof("mempoolSpaceFees: %+v", p.fees) + return true +} + +func (p *mempoolSpaceFeeProvider) mempoolSpaceFeeGetData(res interface{}) error { + var httpData []byte + httpReq, err := http.NewRequest("GET", p.params.URL, bytes.NewBuffer(httpData)) + if err != nil { + return err + } + httpRes, err := http.DefaultClient.Do(httpReq) + if httpRes != nil { + defer httpRes.Body.Close() + } + if err != nil { + p.observeRequest("network_error") + return err + } + if httpRes.StatusCode != http.StatusOK { + p.observeRequest("http_" + strconv.Itoa(httpRes.StatusCode)) + return errors.New(p.params.URL + " returned status " + strconv.Itoa(httpRes.StatusCode)) + } + if err := common.SafeDecodeResponseFromReader(httpRes.Body, res); err != nil { + p.observeRequest("decode_error") + return err + } + p.observeRequest("ok") + return nil +} diff --git a/bchain/coins/btc/mempoolspace_test.go b/bchain/coins/btc/mempoolspace_test.go new file mode 100644 index 0000000000..d27d1250b0 --- /dev/null +++ b/bchain/coins/btc/mempoolspace_test.go @@ -0,0 +1,53 @@ +package btc + +import ( + "math/big" + "strconv" + "testing" +) + +func Test_mempoolSpaceFeeProvider(t *testing.T) { + m := &mempoolSpaceFeeProvider{alternativeFeeProvider: &alternativeFeeProvider{}} + m.mempoolSpaceFeeProcessData(&mempoolSpaceFeeResult{ + MinimumFee: 10, + EconomyFee: 20, + HourFee: 30, + HalfHourFee: 40, + FastestFee: 50, + }) + + tests := []struct { + blocks int + want big.Int + }{ + {0, *big.NewInt(50000)}, + {1, *big.NewInt(50000)}, + {2, *big.NewInt(40000)}, + {5, *big.NewInt(40000)}, + {6, *big.NewInt(40000)}, + {7, *big.NewInt(30000)}, + {10, *big.NewInt(30000)}, + {18, *big.NewInt(30000)}, + {19, *big.NewInt(30000)}, + {36, *big.NewInt(30000)}, + {37, *big.NewInt(20000)}, + {100, *big.NewInt(20000)}, + {101, *big.NewInt(20000)}, + {200, *big.NewInt(20000)}, + {201, *big.NewInt(20000)}, + {500, *big.NewInt(20000)}, + {501, *big.NewInt(10000)}, + {5000000, *big.NewInt(10000)}, + } + for _, tt := range tests { + t.Run(strconv.Itoa(tt.blocks), func(t *testing.T) { + got, err := m.estimateFee(tt.blocks) + if err != nil { + t.Error("estimateFee returned error ", err) + } + if got.Cmp(&tt.want) != 0 { + t.Errorf("estimateFee(%d) = %v, want %v", tt.blocks, got, tt.want) + } + }) + } +} diff --git a/bchain/coins/btc/mempoolspaceblock.go b/bchain/coins/btc/mempoolspaceblock.go new file mode 100644 index 0000000000..42e1a5924a --- /dev/null +++ b/bchain/coins/btc/mempoolspaceblock.go @@ -0,0 +1,210 @@ +package btc + +import ( + "encoding/json" + "math" + "net/http" + "strconv" + "time" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// https://mempool.space/api/v1/fees/mempool-blocks returns a list of upcoming blocks and their medianFee. +// Example response: +// [ +// { +// "blockSize": 1604493, +// "blockVSize": 997944.75, +// "nTx": 3350, +// "totalFees": 8333539, +// "medianFee": 3.0073509137538332, +// "feeRange": [ +// 2.0444444444444443, +// 2.2135922330097086, +// 2.608695652173913, +// 3.016042780748663, +// 4.0048289738430585, +// 9.27631139325092, +// 201.06951871657753 +// ] +// }, +// ... +// ] + +type mempoolSpaceBlockFeeResult struct { + BlockSize float64 `json:"blockSize"` + BlockVSize float64 `json:"blockVSize"` + NTx int `json:"nTx"` + TotalFees int `json:"totalFees"` + MedianFee float64 `json:"medianFee"` + // 2nd, 10th, 25th, 50th, 75th, 90th, 98th percentiles + FeeRange []float64 `json:"feeRange"` +} + +type mempoolSpaceBlockFeeParams struct { + URL string `json:"url"` + PeriodSeconds int `json:"periodSeconds"` + // Either number, then take the specified index. If null or missing, take the medianFee + FeeRangeIndex *int `json:"feeRangeIndex,omitempty"` + FallbackFeePerKB int `json:"fallbackFeePerKB,omitempty"` +} + +type mempoolSpaceBlockFeeProvider struct { + *alternativeFeeProvider + params mempoolSpaceBlockFeeParams +} + +// NewMempoolSpaceBlockFee initializes the provider completely. +func NewMempoolSpaceBlockFee(chain bchain.BlockChain, params string, metrics *common.Metrics) (alternativeFeeProviderInterface, error) { + var paramsParsed mempoolSpaceBlockFeeParams + err := json.Unmarshal([]byte(params), ¶msParsed) + if err != nil { + return nil, err + } + + p, err := NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain(paramsParsed) + if err != nil { + return nil, err + } + + p.chain = chain + p.metrics = metrics + p.name = "mempoolspaceblock" + go p.downloader() + return p, nil +} + +// NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain initializes the provider from already parsed parameters and without chain. +// Refactored like this for better testability. +func NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain(params mempoolSpaceBlockFeeParams) (*mempoolSpaceBlockFeeProvider, error) { + // Check mandatory parameters + if params.URL == "" { + return nil, errors.New("NewMempoolSpaceBlockFee: Missing url") + } + if params.PeriodSeconds == 0 { + return nil, errors.New("NewMempoolSpaceBlockFee: Missing periodSeconds") + } + + // Report on what is used + if params.FeeRangeIndex == nil { + glog.Info("NewMempoolSpaceBlockFee: Using median fee") + } else { + index := *params.FeeRangeIndex + if index < 0 || index > 6 { + return nil, errors.New("NewMempoolSpaceBlockFee: feeRangeIndex must be between 0 and 6") + } + glog.Infof("NewMempoolSpaceBlockFee: Using feeRangeIndex %d", index) + } + + p := &mempoolSpaceBlockFeeProvider{ + alternativeFeeProvider: &alternativeFeeProvider{}, + params: params, + } + + if params.FallbackFeePerKB > 0 { + p.fallbackFeePerKBIfNotAvailable = params.FallbackFeePerKB + } + + return p, nil +} + +func (p *mempoolSpaceBlockFeeProvider) downloader() { + period := time.Duration(p.params.PeriodSeconds) * time.Second + timer := time.NewTimer(period) + counter := 0 + for { + var data []mempoolSpaceBlockFeeResult + err := p.getData(&data) + if err != nil { + glog.Error("getData ", err) + } else { + if p.processData(&data) { + if counter%60 == 0 { + p.compareToDefault() + } + counter++ + } + } + <-timer.C + timer.Reset(period) + } +} + +func (p *mempoolSpaceBlockFeeProvider) processData(data *[]mempoolSpaceBlockFeeResult) bool { + if len(*data) == 0 { + glog.Error("processData: empty data") + return false + } + + p.mux.Lock() + defer p.mux.Unlock() + + p.fees = make([]alternativeFeeProviderFee, 0, len(*data)) + + for i, block := range *data { + var fee float64 + + if p.params.FeeRangeIndex == nil { + fee = block.MedianFee + } else { + feeRange := block.FeeRange + index := *p.params.FeeRangeIndex + if len(feeRange) > index { + fee = feeRange[index] + } else { + glog.Warningf("Block %d has too short feeRange (len=%d, required=%d). Replacing by medianFee", i, len(feeRange), index) + fee = block.MedianFee + } + } + + if fee <= 0 { + glog.Warningf("Skipping block at index %d due to invalid fee: %f", i, fee) + continue + } + + // TODO: it might make sense to not include _every_ block, but only e.g. first 20 and then some hardcoded ones like 50, 100, 200, etc. + // But even storing thousands of elements in []alternativeFeeProviderFee should not make a big performance overhead + // Depends on Suite requirements + + // We want to convert the fee to 3 significant digits + feeRounded := common.RoundToSignificantDigits(fee, 3) + feePerKB := int(math.Round(feeRounded * 1000)) + + p.fees = append(p.fees, alternativeFeeProviderFee{ + blocks: i + 1, + feePerKB: feePerKB, + }) + } + + p.lastSync = time.Now() + return true +} + +func (p *mempoolSpaceBlockFeeProvider) getData(res interface{}) error { + httpReq, err := http.NewRequest("GET", p.params.URL, nil) + if err != nil { + return err + } + httpRes, err := http.DefaultClient.Do(httpReq) + if httpRes != nil { + defer httpRes.Body.Close() + } + if err != nil { + p.observeRequest("network_error") + return err + } + if httpRes.StatusCode != http.StatusOK { + p.observeRequest("http_" + strconv.Itoa(httpRes.StatusCode)) + return errors.New(p.params.URL + " returned status " + strconv.Itoa(httpRes.StatusCode)) + } + if err := common.SafeDecodeResponseFromReader(httpRes.Body, res); err != nil { + p.observeRequest("decode_error") + return err + } + p.observeRequest("ok") + return nil +} diff --git a/bchain/coins/btc/mempoolspaceblock_test.go b/bchain/coins/btc/mempoolspaceblock_test.go new file mode 100644 index 0000000000..09e2bcfede --- /dev/null +++ b/bchain/coins/btc/mempoolspaceblock_test.go @@ -0,0 +1,198 @@ +//go:build unittest + +package btc + +import ( + "math/big" + "strconv" + "strings" + "testing" +) + +var testBlocks = []mempoolSpaceBlockFeeResult{ + { + BlockSize: 1800000, + BlockVSize: 997931, + NTx: 2500, + TotalFees: 6000000, + MedianFee: 25.1, + FeeRange: []float64{1, 5, 10, 20, 30, 50, 300}, + }, + { + BlockSize: 1750000, + BlockVSize: 997930, + NTx: 2200, + TotalFees: 4500000, + MedianFee: 7.31, + FeeRange: []float64{1, 2, 5, 10, 15, 20, 150}, + }, + { + BlockSize: 1700000, + BlockVSize: 997929, + NTx: 2000, + TotalFees: 3000000, + MedianFee: 3.14, + FeeRange: []float64{1, 1.5, 2, 5, 7, 10, 100}, + }, + { + BlockSize: 1650000, + BlockVSize: 997928, + NTx: 1800, + TotalFees: 2000000, + MedianFee: 1.34, + FeeRange: []float64{1, 1.2, 1.5, 3, 4, 5, 50}, + }, + { + BlockSize: 1600000, + BlockVSize: 997927, + NTx: 1500, + TotalFees: 1500000, + MedianFee: 1.11, + FeeRange: []float64{1, 1.05, 1.1, 1.5, 1.8, 2, 20}, + }, +} + +var estimateFeeTestCasesMedian = []struct { + blocks int + want big.Int +}{ + {0, *big.NewInt(25100)}, + {1, *big.NewInt(25100)}, + {2, *big.NewInt(7310)}, + {3, *big.NewInt(3140)}, + {4, *big.NewInt(1340)}, + {5, *big.NewInt(1110)}, + {6, *big.NewInt(1110)}, + {7, *big.NewInt(1110)}, + {10, *big.NewInt(1110)}, + {36, *big.NewInt(1110)}, + {100, *big.NewInt(1110)}, + {201, *big.NewInt(1110)}, + {501, *big.NewInt(1110)}, + {5000000, *big.NewInt(1110)}, +} + +var estimateFeeTestCasesFeeRangeIndex5FallbackSet = []struct { + blocks int + want big.Int +}{ + {0, *big.NewInt(50000)}, + {1, *big.NewInt(50000)}, + {2, *big.NewInt(20000)}, + {3, *big.NewInt(10000)}, + {4, *big.NewInt(5000)}, + {5, *big.NewInt(2000)}, + {6, *big.NewInt(1000)}, + {7, *big.NewInt(1000)}, + {10, *big.NewInt(1000)}, + {36, *big.NewInt(1000)}, + {100, *big.NewInt(1000)}, + {201, *big.NewInt(1000)}, + {501, *big.NewInt(1000)}, + {5000000, *big.NewInt(1000)}, +} + +func runEstimateFeeTest(t *testing.T, testName string, m *mempoolSpaceBlockFeeProvider, expected []struct { + blocks int + want big.Int +}) { + success := m.processData(&testBlocks) + if !success { + t.Fatalf("[%s] Expected data to be processed successfully", testName) + } + + for _, tt := range expected { + t.Run(testName+"_"+strconv.Itoa(tt.blocks), func(t *testing.T) { + got, err := m.estimateFee(tt.blocks) + if err != nil { + t.Errorf("[%s] estimateFee returned error: %v", testName, err) + } + if got.Cmp(&tt.want) != 0 { + t.Errorf("[%s] estimateFee(%d) = %v, want %v", testName, tt.blocks, got, tt.want) + } + }) + } +} + +func Test_mempoolSpaceBlockFeeProviderMedian(t *testing.T) { + // Taking the median explicitly + m, err := + NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain(mempoolSpaceBlockFeeParams{ + URL: "https://mempool.space/api/v1/fees/mempool-blocks", + PeriodSeconds: 20, + FeeRangeIndex: nil, + }) + if err != nil { + t.Fatalf("NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain returned error: %v", err) + } + runEstimateFeeTest(t, "median", m, estimateFeeTestCasesMedian) +} + +func Test_mempoolSpaceBlockFeeProviderSecondLargestIndex(t *testing.T) { + // Taking the valid index + index := 5 + m, err := + NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain(mempoolSpaceBlockFeeParams{ + URL: "https://mempool.space/api/v1/fees/mempool-blocks", + PeriodSeconds: 20, + FeeRangeIndex: &index, + FallbackFeePerKB: 1000, + }) + if err != nil { + t.Fatalf("NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain returned error: %v", err) + } + runEstimateFeeTest(t, "feeRangeIndex_5", m, estimateFeeTestCasesFeeRangeIndex5FallbackSet) +} + +func Test_mempoolSpaceBlockFeeProviderInvalidIndexTooHigh(t *testing.T) { + index := 555 + _, err := + NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain(mempoolSpaceBlockFeeParams{ + URL: "https://mempool.space/api/v1/fees/mempool-blocks", + PeriodSeconds: 20, + FeeRangeIndex: &index, + }) + + if err == nil { + t.Fatalf("expected error, got nil") + } + + expectedSubstring := "feeRangeIndex must be between 0 and 6" + if !strings.Contains(err.Error(), expectedSubstring) { + t.Errorf("expected error message to contain %q, got: %v", expectedSubstring, err) + } +} + +func Test_mempoolSpaceBlockFeeProviderMissingUrl(t *testing.T) { + _, err := + NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain(mempoolSpaceBlockFeeParams{ + PeriodSeconds: 20, + FeeRangeIndex: nil, + }) + + if err == nil { + t.Fatalf("expected error, got nil") + } + + expectedSubstring := "Missing url" + if !strings.Contains(err.Error(), expectedSubstring) { + t.Errorf("expected error message to contain %q, got: %v", expectedSubstring, err) + } +} + +func Test_mempoolSpaceBlockFeeProviderMissingPeriodSeconds(t *testing.T) { + _, err := + NewMempoolSpaceBlockFeeProviderFromParamsWithoutChain(mempoolSpaceBlockFeeParams{ + URL: "https://mempool.space/api/v1/fees/mempool-blocks", + FeeRangeIndex: nil, + }) + + if err == nil { + t.Fatalf("expected error, got nil") + } + + expectedSubstring := "Missing periodSeconds" + if !strings.Contains(err.Error(), expectedSubstring) { + t.Errorf("expected error message to contain %q, got: %v", expectedSubstring, err) + } +} diff --git a/bchain/coins/btc/whatthefee.go b/bchain/coins/btc/whatthefee.go index c0977f80d8..29f2aa0f13 100644 --- a/bchain/coins/btc/whatthefee.go +++ b/bchain/coins/btc/whatthefee.go @@ -3,16 +3,15 @@ package btc import ( "bytes" "encoding/json" - "fmt" "math" "net/http" "strconv" - "sync" "time" "github.com/golang/glog" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" ) // https://whatthefee.io returns @@ -34,49 +33,40 @@ type whatTheFeeParams struct { PeriodSeconds int `periodSeconds:"url"` } -type whatTheFeeFee struct { - blocks int - feesPerKB []int -} - -type whatTheFeeData struct { +type whatTheFeeProvider struct { + *alternativeFeeProvider params whatTheFeeParams probabilities []string - fees []whatTheFeeFee - lastSync time.Time - chain bchain.BlockChain - mux sync.Mutex } -var whatTheFee whatTheFeeData - -// InitWhatTheFee initializes https://whatthefee.io handler -func InitWhatTheFee(chain bchain.BlockChain, params string) error { - err := json.Unmarshal([]byte(params), &whatTheFee.params) +// NewWhatTheFee initializes https://whatthefee.io provider +func NewWhatTheFee(chain bchain.BlockChain, params string, metrics *common.Metrics) (alternativeFeeProviderInterface, error) { + p := whatTheFeeProvider{alternativeFeeProvider: &alternativeFeeProvider{metrics: metrics, name: "whatthefee"}} + err := json.Unmarshal([]byte(params), &p.params) if err != nil { - return err + return nil, err } - if whatTheFee.params.URL == "" || whatTheFee.params.PeriodSeconds == 0 { - return errors.New("Missing parameters") + if p.params.URL == "" || p.params.PeriodSeconds == 0 { + return nil, errors.New("NewWhatTheFee: Missing parameters") } - whatTheFee.chain = chain - go whatTheFeeDownloader() - return nil + p.chain = chain + go p.whatTheFeeDownloader() + return &p, nil } -func whatTheFeeDownloader() { - period := time.Duration(whatTheFee.params.PeriodSeconds) * time.Second +func (p *whatTheFeeProvider) whatTheFeeDownloader() { + period := time.Duration(p.params.PeriodSeconds) * time.Second timer := time.NewTimer(period) counter := 0 for { var data whatTheFeeServiceResult - err := whatTheFeeGetData(&data) + err := p.whatTheFeeGetData(&data) if err != nil { glog.Error("whatTheFeeGetData ", err) } else { - if whatTheFeeProcessData(&data) { + if p.whatTheFeeProcessData(&data) { if counter%60 == 0 { - whatTheFeeCompareToDefault() + p.compareToDefault() } counter++ } @@ -86,15 +76,15 @@ func whatTheFeeDownloader() { } } -func whatTheFeeProcessData(data *whatTheFeeServiceResult) bool { +func (p *whatTheFeeProvider) whatTheFeeProcessData(data *whatTheFeeServiceResult) bool { if len(data.Index) == 0 || len(data.Index) != len(data.Data) || len(data.Columns) == 0 { glog.Errorf("invalid data %+v", data) return false } - whatTheFee.mux.Lock() - defer whatTheFee.mux.Unlock() - whatTheFee.probabilities = data.Columns - whatTheFee.fees = make([]whatTheFeeFee, len(data.Index)) + p.mux.Lock() + defer p.mux.Unlock() + p.probabilities = data.Columns + p.fees = make([]alternativeFeeProviderFee, len(data.Index)) for i, blocks := range data.Index { if len(data.Columns) != len(data.Data[i]) { glog.Errorf("invalid data %+v", data) @@ -104,19 +94,19 @@ func whatTheFeeProcessData(data *whatTheFeeServiceResult) bool { for j, l := range data.Data[i] { fees[j] = int(1000 * math.Exp(float64(l)/100)) } - whatTheFee.fees[i] = whatTheFeeFee{ - blocks: blocks, - feesPerKB: fees, + p.fees[i] = alternativeFeeProviderFee{ + blocks: blocks, + feePerKB: fees[len(fees)/2], } } - whatTheFee.lastSync = time.Now() - glog.Infof("%+v", whatTheFee.fees) + p.lastSync = time.Now() + glog.Infof("whatTheFees: %+v", p.fees) return true } -func whatTheFeeGetData(res interface{}) error { +func (p *whatTheFeeProvider) whatTheFeeGetData(res interface{}) error { var httpData []byte - httpReq, err := http.NewRequest("GET", whatTheFee.params.URL, bytes.NewBuffer(httpData)) + httpReq, err := http.NewRequest("GET", p.params.URL, bytes.NewBuffer(httpData)) if err != nil { return err } @@ -125,32 +115,17 @@ func whatTheFeeGetData(res interface{}) error { defer httpRes.Body.Close() } if err != nil { + p.observeRequest("network_error") return err } if httpRes.StatusCode != 200 { + p.observeRequest("http_" + strconv.Itoa(httpRes.StatusCode)) return errors.New("whatthefee.io returned status " + strconv.Itoa(httpRes.StatusCode)) } - return safeDecodeResponse(httpRes.Body, &res) -} - -func whatTheFeeCompareToDefault() { - output := "" - for _, fee := range whatTheFee.fees { - output += fmt.Sprint(fee.blocks, ",") - for _, wtf := range fee.feesPerKB { - output += fmt.Sprint(wtf, ",") - } - conservative, err := whatTheFee.chain.EstimateSmartFee(fee.blocks, true) - if err != nil { - glog.Error(err) - return - } - economical, err := whatTheFee.chain.EstimateSmartFee(fee.blocks, false) - if err != nil { - glog.Error(err) - return - } - output += fmt.Sprint(conservative.String(), ",", economical.String(), "\n") + if err := common.SafeDecodeResponseFromReader(httpRes.Body, res); err != nil { + p.observeRequest("decode_error") + return err } - glog.Info("whatTheFeeCompareToDefault\n", output) + p.observeRequest("ok") + return nil } diff --git a/bchain/coins/btg/bgoldparser.go b/bchain/coins/btg/bgoldparser.go index aca095e077..8055c7d6e3 100644 --- a/bchain/coins/btg/bgoldparser.go +++ b/bchain/coins/btg/bgoldparser.go @@ -83,12 +83,18 @@ func GetChainParams(chain string) *chaincfg.Params { // headerFixedLength is the length of fixed fields of a block (i.e. without solution) // see https://github.com/BTCGPU/BTCGPU/wiki/Technical-Spec#block-header const headerFixedLength = 44 + (chainhash.HashSize * 3) +const prevHashOffset = 4 const timestampOffset = 100 const timestampLength = 4 // ParseBlock parses raw block to our Block struct func (p *BGoldParser) ParseBlock(b []byte) (*bchain.Block, error) { r := bytes.NewReader(b) + prev, err := readPrevBlockHash(r) + if err != nil { + return nil, err + } + time, err := getTimestampAndSkipHeader(r, 0) if err != nil { return nil, err @@ -107,6 +113,7 @@ func (p *BGoldParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: prev, // needed for fork detection when parsing raw blocks Size: len(b), Time: time, }, @@ -114,6 +121,21 @@ func (p *BGoldParser) ParseBlock(b []byte) (*bchain.Block, error) { }, nil } +func readPrevBlockHash(r io.ReadSeeker) (string, error) { + // Read prev hash directly so fork detection still works with raw parsing. + if _, err := r.Seek(prevHashOffset, io.SeekStart); err != nil { + // Return the seek error when the header layout can't be accessed. + return "", err + } + var prevHash chainhash.Hash + if _, err := io.ReadFull(r, prevHash[:]); err != nil { + // Return read errors for truncated or malformed headers. + return "", err + } + // Return the canonical display string for comparison in sync logic. + return prevHash.String(), nil +} + func getTimestampAndSkipHeader(r io.ReadSeeker, pver uint32) (int64, error) { _, err := r.Seek(timestampOffset, io.SeekStart) if err != nil { diff --git a/bchain/coins/btg/bgoldparser_test.go b/bchain/coins/btg/bgoldparser_test.go index 01922e2ae7..0a8f430a96 100644 --- a/bchain/coins/btg/bgoldparser_test.go +++ b/bchain/coins/btg/bgoldparser_test.go @@ -25,12 +25,14 @@ type testBlock struct { size int time int64 txs []string + prev string } var testParseBlockTxs = map[int]testBlock{ 104000: { size: 15776, time: 1295705889, + prev: "00000000000138de0496607bfc85ec4bfcebb6de0ff30048dd4bc4b12da48997", txs: []string{ "331d4ef64118e9e5be75f0f51f1a4c5057550c3320e22ff7206f3e1101f113d0", "1f4817d8e91c21d8c8d163dabccdd1875f760fd2dc34a1c2b7b8fa204e103597", @@ -84,6 +86,7 @@ var testParseBlockTxs = map[int]testBlock{ 532144: { size: 12198, time: 1528372417, + prev: "0000000048de525aea2af2ac305a7b196222fc327a34298f45110e378f838dce", txs: []string{ "574348e23301cc89535408b6927bf75f2ac88fadf8fdfb181c17941a5de02fe0", "9f048446401e7fac84963964df045b1f3992eda330a87b02871e422ff0a3fd28", @@ -143,6 +146,10 @@ func TestParseBlock(t *testing.T) { t.Errorf("ParseBlock() block time: got %d, want %d", blk.Time, tb.time) } + if blk.Prev != tb.prev { + t.Errorf("ParseBlock() prev hash: got %s, want %s", blk.Prev, tb.prev) + } + if len(blk.Txs) != len(tb.txs) { t.Errorf("ParseBlock() number of transactions: got %d, want %d", len(blk.Txs), len(tb.txs)) } diff --git a/bchain/coins/dcr/decredparser.go b/bchain/coins/dcr/decredparser.go index 8d20269dde..b96cb964f1 100644 --- a/bchain/coins/dcr/decredparser.go +++ b/bchain/coins/dcr/decredparser.go @@ -119,6 +119,7 @@ func (p *DecredParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/dcr/decredrpc.go b/bchain/coins/dcr/decredrpc.go index 07cc459849..31c5810f81 100644 --- a/bchain/coins/dcr/decredrpc.go +++ b/bchain/coins/dcr/decredrpc.go @@ -791,7 +791,7 @@ func (d *DecredRPC) EstimateFee(blocks int) (big.Int, error) { return r, nil } -func (d *DecredRPC) SendRawTransaction(tx string) (string, error) { +func (d *DecredRPC) SendRawTransaction(tx string, disableAlternativeRPC bool) (string, error) { sendRawTxRequest := &GenericCmd{ ID: 1, Method: "sendrawtransaction", diff --git a/bchain/coins/divi/diviparser.go b/bchain/coins/divi/diviparser.go index 02b461e09c..405486005d 100755 --- a/bchain/coins/divi/diviparser.go +++ b/bchain/coins/divi/diviparser.go @@ -99,6 +99,7 @@ func (p *DivicoinParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/dogecoin/dogecoinparser.go b/bchain/coins/dogecoin/dogecoinparser.go index d38de0580f..743fea106b 100644 --- a/bchain/coins/dogecoin/dogecoinparser.go +++ b/bchain/coins/dogecoin/dogecoinparser.go @@ -92,6 +92,7 @@ func (p *DogecoinParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/ecash/ecashparser.go b/bchain/coins/ecash/ecashparser.go index e4547ae8ce..2b4ef88184 100644 --- a/bchain/coins/ecash/ecashparser.go +++ b/bchain/coins/ecash/ecashparser.go @@ -3,11 +3,11 @@ package ecash import ( "fmt" - "github.com/pirk/ecashutil" "github.com/martinboehm/btcutil" "github.com/martinboehm/btcutil/chaincfg" "github.com/martinboehm/btcutil/txscript" "github.com/pirk/ecashaddr-converter/address" + "github.com/pirk/ecashutil" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/btc" ) diff --git a/bchain/coins/ecash/ecashparser_test.go b/bchain/coins/ecash/ecashparser_test.go index 218299bd10..719ded6c2e 100644 --- a/bchain/coins/ecash/ecashparser_test.go +++ b/bchain/coins/ecash/ecashparser_test.go @@ -342,6 +342,10 @@ func Test_UnpackTx(t *testing.T) { t.Errorf("unpackTx() error = %v, wantErr %v", err, tt.wantErr) return } + // ignore witness unpacking + for i := range got.Vin { + got.Vin[i].Witness = nil + } if !reflect.DeepEqual(got, tt.want) { t.Errorf("unpackTx() got = %v, want %v", got, tt.want) } diff --git a/bchain/coins/eth/alternativefeeprovider.go b/bchain/coins/eth/alternativefeeprovider.go new file mode 100644 index 0000000000..42cc257e6f --- /dev/null +++ b/bchain/coins/eth/alternativefeeprovider.go @@ -0,0 +1,39 @@ +package eth + +import ( + "sync" + "time" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +type alternativeFeeProvider struct { + eip1559Fees *bchain.Eip1559Fees + lastSync time.Time + staleSyncDuration time.Duration + chain bchain.BlockChain + mux sync.Mutex + metrics *common.Metrics + name string +} + +func (p *alternativeFeeProvider) observeRequest(status string) { + if p.metrics == nil || p.metrics.AlternativeFeeProviderRequests == nil { + return + } + p.metrics.AlternativeFeeProviderRequests.With(common.Labels{"provider": p.name, "status": status}).Inc() +} + +type alternativeFeeProviderInterface interface { + GetEip1559Fees() (*bchain.Eip1559Fees, error) +} + +func (p *alternativeFeeProvider) GetEip1559Fees() (*bchain.Eip1559Fees, error) { + p.mux.Lock() + defer p.mux.Unlock() + if p.lastSync.Add(p.staleSyncDuration).Before(time.Now()) { + return nil, nil + } + return p.eip1559Fees, nil +} diff --git a/bchain/coins/eth/alternativesendtx.go b/bchain/coins/eth/alternativesendtx.go new file mode 100644 index 0000000000..6780c210d7 --- /dev/null +++ b/bchain/coins/eth/alternativesendtx.go @@ -0,0 +1,207 @@ +package eth + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" +) + +type storedTx struct { + tx *bchain.RpcTransaction + time uint32 +} + +// AlternativeSendTxProvider handles sending transactions to alternative providers +type AlternativeSendTxProvider struct { + urls []string + onlyAlternative bool + fetchMempoolTx bool + mempoolTxs map[string]storedTx + mempoolTxsMux sync.Mutex + mempoolTxsTimeout time.Duration + rpcTimeout time.Duration + mempool *bchain.MempoolEthereumType + removeTransactionFromMempool func(string) +} + +// NewAlternativeSendTxProvider creates a new alternative send tx provider if enabled +func NewAlternativeSendTxProvider(network string, rpcTimeout int, mempoolTxsTimeout time.Duration) *AlternativeSendTxProvider { + urls := strings.Split(os.Getenv(strings.ToUpper(network)+"_ALTERNATIVE_SENDTX_URLS"), ",") + onlyAlternative := strings.ToUpper(os.Getenv(strings.ToUpper(network)+"_ALTERNATIVE_SENDTX_ONLY")) == "TRUE" + fetchMempoolTx := strings.ToUpper(os.Getenv(strings.ToUpper(network)+"_ALTERNATIVE_FETCH_MEMPOOL_TX")) == "TRUE" + // Empty URL keeps the normal public RPC send path. + if len(urls) == 0 || urls[0] == "" { + return nil + } + + provider := &AlternativeSendTxProvider{ + urls: urls, + onlyAlternative: onlyAlternative, + fetchMempoolTx: fetchMempoolTx, + rpcTimeout: time.Duration(rpcTimeout) * time.Second, + mempoolTxsTimeout: mempoolTxsTimeout, + mempoolTxs: make(map[string]storedTx), + } + + glog.Infof("Using alternative send transaction providers %v. Only alternative providers %v", urls, onlyAlternative) + if fetchMempoolTx { + glog.Infof("Alternative fetch mempool tx %v", fetchMempoolTx) + } + + return provider +} + +// SetupMempool sets up connection to the mempool +func (p *AlternativeSendTxProvider) SetupMempool(mempool *bchain.MempoolEthereumType, removeTransactionFromMempool func(string)) { + p.mempool = mempool + p.removeTransactionFromMempool = removeTransactionFromMempool +} + +// SendRawTransaction sends raw transaction to alternative providers +func (p *AlternativeSendTxProvider) SendRawTransaction(hex string) (string, error) { + var txid string + var retErr error + + for i := range p.urls { + r, err := p.callHttpStringResult(p.urls[i], "eth_sendRawTransaction", hex) + glog.Infof("eth_sendRawTransaction to %s, txid %s", p.urls[i], r) + // set success return value; or error only if there was no previous success + if err == nil || len(txid) == 0 { + txid = r + retErr = err + } + } + + if p.onlyAlternative && p.fetchMempoolTx { + p.handleMempoolTransaction(txid) + } + + return txid, retErr +} + +// handleMempoolTransaction handles the transaction when using only alternative providers +func (p *AlternativeSendTxProvider) handleMempoolTransaction(txid string) (string, error) { + hash := ethcommon.HexToHash(txid) + raw, err := p.callHttpRawResult(p.urls[0], "eth_getTransactionByHash", hash) + if err != nil || raw == nil { + glog.Errorf("eth_getTransactionByHash from %s returned error %v", p.urls[0], err) + return txid, err + } + + var tx bchain.RpcTransaction + if err := json.Unmarshal(raw, &tx); err != nil { + glog.Errorf("eth_getTransactionByHash from %s unmarshal returned error %v", p.urls[0], err) + return txid, err + } + + p.mempoolTxsMux.Lock() + // remove potential RBF transactions - with equal from and nonce + var rbfTxid string + for rbf, storedTx := range p.mempoolTxs { + if storedTx.tx.From == tx.From && storedTx.tx.AccountNonce == tx.AccountNonce { + rbfTxid = rbf + break + } + } + p.mempoolTxs[txid] = storedTx{tx: &tx, time: uint32(time.Now().Unix())} + p.mempoolTxsMux.Unlock() + + if rbfTxid != "" { + glog.Infof("eth_sendRawTransaction replacing txid %s by %s", rbfTxid, txid) + if p.removeTransactionFromMempool != nil { + p.removeTransactionFromMempool(rbfTxid) + } + } + + if p.mempool != nil { + p.mempool.AddTransactionToMempool(txid) + } + + return txid, nil +} + +// GetTransaction gets a transaction from alternative mempool cache +func (p *AlternativeSendTxProvider) GetTransaction(txid string) (*bchain.RpcTransaction, bool) { + if !p.fetchMempoolTx { + return nil, false + } + + var storedTx storedTx + var found bool + + p.mempoolTxsMux.Lock() + storedTx, found = p.mempoolTxs[txid] + p.mempoolTxsMux.Unlock() + + if found { + if time.Unix(int64(storedTx.time), 0).Before(time.Now().Add(-p.mempoolTxsTimeout)) { + p.mempoolTxsMux.Lock() + delete(p.mempoolTxs, txid) + p.mempoolTxsMux.Unlock() + return nil, false + } + return storedTx.tx, true + } + + return nil, false +} + +// RemoveTransaction removes a transaction from alternative mempool cache +func (p *AlternativeSendTxProvider) RemoveTransaction(txid string) { + if !p.fetchMempoolTx { + return + } + + p.mempoolTxsMux.Lock() + delete(p.mempoolTxs, txid) + p.mempoolTxsMux.Unlock() +} + +// UseOnlyAlternativeProvider returns true if only alternative providers should be used +func (p *AlternativeSendTxProvider) UseOnlyAlternativeProvider() bool { + return p.onlyAlternative +} + +// Helper function for calling ETH RPC over http with parameters. Creates and closes a new client for every call. +func (p *AlternativeSendTxProvider) callHttpRawResult(url string, rpcMethod string, args ...interface{}) (json.RawMessage, error) { + ctx, cancel := context.WithTimeout(context.Background(), p.rpcTimeout) + defer cancel() + client, err := rpc.DialContext(ctx, url) + if err != nil { + return nil, err + } + defer client.Close() + var raw json.RawMessage + err = client.CallContext(ctx, &raw, rpcMethod, args...) + if err != nil { + return nil, err + } else if len(raw) == 0 { + return nil, errors.New(url + " " + rpcMethod + " : failed") + } + return raw, nil +} + +// Helper function for calling ETH RPC over http with parameters and getting string result. Creates and closes a new client for every call. +func (p *AlternativeSendTxProvider) callHttpStringResult(url string, rpcMethod string, args ...interface{}) (string, error) { + raw, err := p.callHttpRawResult(url, rpcMethod, args...) + if err != nil { + return "", err + } + var result string + if err := json.Unmarshal(raw, &result); err != nil { + return "", errors.Annotatef(err, "%s %s raw result %v", url, rpcMethod, raw) + } + if result == "" { + return "", errors.New(url + " " + rpcMethod + " : failed, empty result") + } + return result, nil +} diff --git a/bchain/coins/eth/contract.go b/bchain/coins/eth/contract.go index 6405c9c813..7f53f84963 100644 --- a/bchain/coins/eth/contract.go +++ b/bchain/coins/eth/contract.go @@ -7,6 +7,8 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" + "github.com/golang/glog" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" ) @@ -29,6 +31,8 @@ const contractSymbolSignature = "0x95d89b41" const contractDecimalsSignature = "0x313ce567" const contractBalanceOfSignature = "0x70a08231" +const ENSRegistryAddress = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" // ENSRegistryAddress is the mainnet ENS registry contract address + func addressFromPaddedHex(s string) (string, error) { var t big.Int var ok bool @@ -51,16 +55,16 @@ func processTransferEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err } }() tl := len(l.Topics) - var ttt bchain.TokenType + var standard bchain.TokenStandard var value big.Int if tl == 3 { - ttt = bchain.FungibleToken + standard = bchain.FungibleToken _, ok := value.SetString(l.Data, 0) if !ok { return nil, errors.New("ERC20 log Data is not a number") } } else if tl == 4 { - ttt = bchain.NonFungibleToken + standard = bchain.NonFungibleToken _, ok := value.SetString(l.Topics[3], 0) if !ok { return nil, errors.New("ERC721 log Topics[3] is not a number") @@ -78,7 +82,7 @@ func processTransferEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err return nil, err } return &bchain.TokenTransfer{ - Type: ttt, + Standard: standard, Contract: EIP55AddressFromAddress(l.Address), From: EIP55AddressFromAddress(from), To: EIP55AddressFromAddress(to), @@ -119,7 +123,7 @@ func processERC1155TransferSingleEvent(l *bchain.RpcLog) (transfer *bchain.Token return nil, errors.New("ERC1155 log Data value is not a number") } return &bchain.TokenTransfer{ - Type: bchain.MultiToken, + Standard: bchain.MultiToken, Contract: EIP55AddressFromAddress(l.Address), From: EIP55AddressFromAddress(from), To: EIP55AddressFromAddress(to), @@ -190,7 +194,7 @@ func processERC1155TransferBatchEvent(l *bchain.RpcLog) (transfer *bchain.TokenT idValues[i] = bchain.MultiTokenValue{Id: id, Value: value} } return &bchain.TokenTransfer{ - Type: bchain.MultiToken, + Standard: bchain.MultiToken, Contract: EIP55AddressFromAddress(l.Address), From: EIP55AddressFromAddress(from), To: EIP55AddressFromAddress(to), @@ -239,7 +243,7 @@ func contractGetTransfersFromTx(tx *bchain.RpcTransaction) (bchain.TokenTransfer return nil, errors.New("Data is not a number") } r = append(r, &bchain.TokenTransfer{ - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: EIP55AddressFromAddress(tx.To), From: EIP55AddressFromAddress(tx.From), To: EIP55AddressFromAddress(to), @@ -263,7 +267,7 @@ func contractGetTransfersFromTx(tx *bchain.RpcTransaction) (bchain.TokenTransfer return nil, errors.New("Data is not a number") } r = append(r, &bchain.TokenTransfer{ - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: EIP55AddressFromAddress(tx.To), From: EIP55AddressFromAddress(from), To: EIP55AddressFromAddress(to), @@ -273,23 +277,107 @@ func contractGetTransfersFromTx(tx *bchain.RpcTransaction) (bchain.TokenTransfer return r, nil } -func (b *EthereumRPC) ethCall(data, to string) (string, error) { +// EthereumTypeRpcCall calls eth_call with given data and to address +func (b *EthereumRPC) EthereumTypeRpcCall(data, to, from string) (string, error) { + return b.EthereumTypeRpcCallAtBlock(data, to, from, nil) +} + +// EthereumTypeRpcCallAtBlock calls eth_call with given data and to address at a specific block. +func (b *EthereumRPC) EthereumTypeRpcCallAtBlock(data, to, from string, blockNumber *big.Int) (string, error) { + args := map[string]interface{}{ + "data": data, + "to": to, + } + if from != "" { + args["from"] = from + } + + b.observeEthCall("single", 1) ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() var r string - err := b.RPC.CallContext(ctx, &r, "eth_call", map[string]interface{}{ - "data": data, - "to": to, - }, "latest") + blockArg := bchain.ToBlockNumArg(blockNumber) + err := b.RPC.CallContext(ctx, &r, "eth_call", args, blockArg) if err != nil { + b.observeEthCallError("single", "rpc") return "", err } return r, nil } +// EthereumTypeRpcCallBatch executes multiple eth_call requests in one JSON-RPC batch. +func (b *EthereumRPC) EthereumTypeRpcCallBatch(calls []bchain.EthereumTypeRPCCall) ([]bchain.EthereumTypeRPCCallResult, error) { + if len(calls) == 0 { + return nil, nil + } + batcher, ok := b.RPC.(batchCaller) + if !ok { + return nil, errors.New("BatchCallContext not supported") + } + + results := make([]string, len(calls)) + batch := make([]rpc.BatchElem, len(calls)) + blockArg := bchain.ToBlockNumArg(nil) + for i := range calls { + args := map[string]interface{}{ + "data": calls[i].Data, + "to": calls[i].To, + } + if calls[i].From != "" { + args["from"] = calls[i].From + } + batch[i] = rpc.BatchElem{ + Method: "eth_call", + Args: []interface{}{args, blockArg}, + Result: &results[i], + } + } + + b.observeEthCall("batch", len(calls)) + b.observeEthCallBatch(len(calls)) + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + if err := batcher.BatchCallContext(ctx, batch); err != nil { + b.observeEthCallError("batch", "rpc") + return nil, err + } + + out := make([]bchain.EthereumTypeRPCCallResult, len(calls)) + for i := range calls { + out[i].Data = results[i] + if batch[i].Error == nil { + continue + } + b.observeEthCallError("batch", "elem") + if isNonRetriableEthCallError(batch[i].Error) { + out[i].Error = batch[i].Error + continue + } + // Retry failed elements using single eth_call to avoid losing data on partial batch failures. + data, err := b.EthereumTypeRpcCall(calls[i].Data, calls[i].To, calls[i].From) + if err != nil { + out[i].Error = err + continue + } + out[i].Data = data + } + return out, nil +} + +func erc20BalanceOfCallData(addrDesc bchain.AddressDescriptor) string { + addr := hexutil.Encode(addrDesc) + if len(addr) > 1 { + addr = addr[2:] + } + padded := "0000000000000000000000000000000000000000000000000000000000000000" + return contractBalanceOfSignature + padded[len(addr):] + addr +} + func (b *EthereumRPC) fetchContractInfo(address string) (*bchain.ContractInfo, error) { var contract bchain.ContractInfo - data, err := b.ethCall(contractNameSignature, address) + b.observeEthCallContractInfo("name") + data, err := b.EthereumTypeRpcCall(contractNameSignature, address, "") if err != nil { // ignore the error from the eth_call - since geth v1.9.15 they changed the behavior // and returning error "execution reverted" for some non contract addresses @@ -300,14 +388,16 @@ func (b *EthereumRPC) fetchContractInfo(address string) (*bchain.ContractInfo, e } name := strings.TrimSpace(parseSimpleStringProperty(data)) if name != "" { - data, err = b.ethCall(contractSymbolSignature, address) + b.observeEthCallContractInfo("symbol") + data, err = b.EthereumTypeRpcCall(contractSymbolSignature, address, "") if err != nil { // glog.Warning(errors.Annotatef(err, "Contract SymbolSignature %v", address)) return nil, nil // return nil, errors.Annotatef(err, "erc20SymbolSignature %v", address) } symbol := strings.TrimSpace(parseSimpleStringProperty(data)) - data, _ = b.ethCall(contractDecimalsSignature, address) + b.observeEthCallContractInfo("decimals") + data, _ = b.EthereumTypeRpcCall(contractDecimalsSignature, address, "") // if err != nil { // glog.Warning(errors.Annotatef(err, "Contract DecimalsSignature %v", address)) // // return nil, errors.Annotatef(err, "erc20DecimalsSignature %v", address) @@ -337,21 +427,164 @@ func (b *EthereumRPC) GetContractInfo(contractDesc bchain.AddressDescriptor) (*b // EthereumTypeGetErc20ContractBalance returns balance of ERC20 contract for given address func (b *EthereumRPC) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc bchain.AddressDescriptor) (*big.Int, error) { - addr := hexutil.Encode(addrDesc) + return b.EthereumTypeGetErc20ContractBalanceAtBlock(addrDesc, contractDesc, nil) +} + +// EthereumTypeGetErc20ContractBalanceAtBlock returns balance of ERC20 contract for given address at a specific block. +func (b *EthereumRPC) EthereumTypeGetErc20ContractBalanceAtBlock(addrDesc, contractDesc bchain.AddressDescriptor, blockNumber *big.Int) (*big.Int, error) { contract := hexutil.Encode(contractDesc) - req := contractBalanceOfSignature + "0000000000000000000000000000000000000000000000000000000000000000"[len(addr)-2:] + addr[2:] - data, err := b.ethCall(req, contract) + req := erc20BalanceOfCallData(addrDesc) + data, err := b.EthereumTypeRpcCallAtBlock(req, contract, "", blockNumber) if err != nil { return nil, err } r := parseSimpleNumericProperty(data) if r == nil { + b.observeEthCallError("single", "invalid") return nil, errors.New("Invalid balance") } return r, nil } -// GetContractInfo returns URI of non fungible or multi token defined by token id +type batchCaller interface { + BatchCallContext(context.Context, []rpc.BatchElem) error +} + +func (b *EthereumRPC) erc20BatchSize() int { + if b.ChainConfig != nil && b.ChainConfig.Erc20BatchSize > 0 { + return b.ChainConfig.Erc20BatchSize + } + return defaultErc20BatchSize +} + +// EthereumTypeGetErc20ContractBalances returns balances of multiple ERC20 contracts for given address. +// It uses RPC batch calls and returns nil entries for failed/invalid results. +func (b *EthereumRPC) EthereumTypeGetErc20ContractBalances(addrDesc bchain.AddressDescriptor, contractDescs []bchain.AddressDescriptor) ([]*big.Int, error) { + return b.EthereumTypeGetErc20ContractBalancesAtBlock(addrDesc, contractDescs, nil) +} + +// EthereumTypeGetErc20ContractBalancesAtBlock returns balances of multiple ERC20 contracts for given address at a specific block. +// It uses RPC batch calls and returns nil entries for failed/invalid results. +func (b *EthereumRPC) EthereumTypeGetErc20ContractBalancesAtBlock(addrDesc bchain.AddressDescriptor, contractDescs []bchain.AddressDescriptor, blockNumber *big.Int) ([]*big.Int, error) { + if len(contractDescs) == 0 { + return nil, nil + } + batcher, ok := b.RPC.(batchCaller) + if !ok { + // Some RPC clients do not support batching; caller will fall back to single calls. + return nil, errors.New("BatchCallContext not supported") + } + batchSize := b.erc20BatchSize() + // Same calldata for all balanceOf calls; only the contract address varies per element. + callData := erc20BalanceOfCallData(addrDesc) + balances := make([]*big.Int, len(contractDescs)) + for start := 0; start < len(contractDescs); start += batchSize { + end := start + batchSize + if end > len(contractDescs) { + end = len(contractDescs) + } + // Process a bounded slice to keep batch RPC requests within size limits. + batchBalances, err := b.erc20BalancesBatchAtBlock(batcher, callData, contractDescs[start:end], blockNumber) + if err != nil { + return nil, err + } + // Preserve original ordering when merging per-batch results. + copy(balances[start:end], batchBalances) + } + return balances, nil +} + +func (b *EthereumRPC) erc20BalancesBatch(batcher batchCaller, callData string, contractDescs []bchain.AddressDescriptor) ([]*big.Int, error) { + return b.erc20BalancesBatchAtBlock(batcher, callData, contractDescs, nil) +} + +func (b *EthereumRPC) erc20BalancesBatchAtBlock(batcher batchCaller, callData string, contractDescs []bchain.AddressDescriptor, blockNumber *big.Int) ([]*big.Int, error) { + results := make([]string, len(contractDescs)) + batch := make([]rpc.BatchElem, len(contractDescs)) + blockArg := bchain.ToBlockNumArg(blockNumber) + for i, contractDesc := range contractDescs { + args := map[string]interface{}{ + "data": callData, + "to": hexutil.Encode(contractDesc), + } + batch[i] = rpc.BatchElem{ + Method: "eth_call", + Args: []interface{}{args, blockArg}, + Result: &results[i], + } + } + b.observeEthCall("batch", len(contractDescs)) + b.observeEthCallBatch(len(contractDescs)) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + if err := batcher.BatchCallContext(ctx, batch); err != nil { + b.observeEthCallError("batch", "rpc") + // Distinct fallback metric so monitoring can alert on this path even + // though we suppress the error to keep callers (e.g. account info) + // usable on transient batch-level RPC failures. + b.ObserveChainDataFallback("erc20_batch", "rpc") + glog.Warningf("erc20 batch eth_call failed: %v, falling back to single calls", err) + balances := make([]*big.Int, len(contractDescs)) + for i, contractDesc := range contractDescs { + data, err := b.EthereumTypeRpcCallAtBlock(callData, hexutil.Encode(contractDesc), "", blockNumber) + if err != nil { + glog.Warningf("erc20 single eth_call fallback failed for %s: %v", hexutil.Encode(contractDesc), err) + continue + } + balances[i] = parseSimpleNumericProperty(data) + if balances[i] == nil { + b.observeEthCallError("single", "invalid") + glog.Warningf("erc20 single eth_call invalid result for %s: %q", hexutil.Encode(contractDesc), data) + } + } + return balances, nil + } + balances := make([]*big.Int, len(contractDescs)) + for i := range batch { + if batch[i].Error != nil { + b.observeEthCallError("batch", "elem") + if isNonRetriableEthCallError(batch[i].Error) { + continue + } + glog.Warningf("erc20 batch eth_call failed for %s: %v", hexutil.Encode(contractDescs[i]), batch[i].Error) + // In case of individual element failure in a successful batch, retry it as a single call. + data, err := b.EthereumTypeRpcCallAtBlock(callData, hexutil.Encode(contractDescs[i]), "", blockNumber) + if err != nil { + glog.Warningf("erc20 single eth_call fallback failed for %s: %v", hexutil.Encode(contractDescs[i]), err) + continue + } + balances[i] = parseSimpleNumericProperty(data) + if balances[i] == nil { + b.observeEthCallError("single", "invalid") + glog.Warningf("erc20 single eth_call invalid result for %s: %q", hexutil.Encode(contractDescs[i]), data) + } + continue + } + // Leave nil on parse failures; retrying as a single call is unlikely to help + // as malformed returns usually indicate non-conforming contract implementations. + balances[i] = parseSimpleNumericProperty(results[i]) + if balances[i] == nil { + b.observeEthCallError("batch", "invalid") + glog.Warningf("erc20 batch eth_call invalid result for %s: %q", hexutil.Encode(contractDescs[i]), results[i]) + } + } + return balances, nil +} + +func isNonRetriableEthCallError(err error) bool { + if err == nil { + return false + } + // These errors are deterministic for the given call data and won't succeed on retry. + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "execution reverted") || + strings.Contains(msg, "invalid opcode") || + strings.Contains(msg, "out of gas") || + strings.Contains(msg, "stack underflow") || + strings.Contains(msg, "revert") +} + +// GetTokenURI returns URI of non fungible or multi token defined by token id func (b *EthereumRPC) GetTokenURI(contractDesc bchain.AddressDescriptor, tokenID *big.Int) (string, error) { address := hexutil.Encode(contractDesc) // CryptoKitties do not fully support ERC721 standard, do not have tokenURI method @@ -364,7 +597,12 @@ func (b *EthereumRPC) GetTokenURI(contractDesc bchain.AddressDescriptor, tokenID } // try ERC721 tokenURI method and ERC1155 uri method for _, method := range []string{erc721TokenURIMethodSignature, erc1155URIMethodSignature} { - data, err := b.ethCall(method+id, address) + if method == erc721TokenURIMethodSignature { + b.observeEthCallTokenURI("erc721_token_uri") + } else { + b.observeEthCallTokenURI("erc1155_uri") + } + data, err := b.EthereumTypeRpcCall(method+id, address, "") if err == nil && data != "" { uri := parseSimpleStringProperty(data) // try to sanitize the URI returned from the contract diff --git a/bchain/coins/eth/contract_batch_test.go b/bchain/coins/eth/contract_batch_test.go new file mode 100644 index 0000000000..eef93ab721 --- /dev/null +++ b/bchain/coins/eth/contract_batch_test.go @@ -0,0 +1,501 @@ +package eth + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" + "github.com/trezor/blockbook/bchain" +) + +type mockBatchRPC struct { + results map[string]string + perErr map[string]error + lastBatch []rpc.BatchElem + batchSizes []int +} + +func (m *mockBatchRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} + +func (m *mockBatchRPC) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + return errors.New("not implemented") +} + +func (m *mockBatchRPC) Close() {} + +func (m *mockBatchRPC) BatchCallContext(ctx context.Context, batch []rpc.BatchElem) error { + m.lastBatch = batch + m.batchSizes = append(m.batchSizes, len(batch)) + for i := range batch { + elem := &batch[i] + if elem.Method != "eth_call" { + elem.Error = errors.New("unexpected method") + continue + } + if len(elem.Args) < 2 { + elem.Error = errors.New("missing args") + continue + } + args, ok := elem.Args[0].(map[string]interface{}) + if !ok { + elem.Error = errors.New("bad args") + continue + } + to, _ := args["to"].(string) + if err, ok := m.perErr[to]; ok { + elem.Error = err + continue + } + res, ok := m.results[to] + if !ok { + elem.Error = errors.New("missing result") + continue + } + out, ok := elem.Result.(*string) + if !ok { + elem.Error = errors.New("bad result type") + continue + } + *out = res + } + return nil +} + +type rpcCall struct { + to string + data string +} + +type mockBatchCallRPC struct { + batchResults map[string]string + batchErrors map[string]error + batchRPCErr error + callResults map[string]string + callErrors map[string]error + batchCalls []rpcCall + calls []rpcCall +} + +func (m *mockBatchCallRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} + +func (m *mockBatchCallRPC) Close() {} + +func (m *mockBatchCallRPC) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + if method != "eth_call" { + return errors.New("unexpected method") + } + if len(args) < 2 { + return errors.New("missing args") + } + argMap, ok := args[0].(map[string]interface{}) + if !ok { + return errors.New("bad args") + } + to, _ := argMap["to"].(string) + data, _ := argMap["data"].(string) + m.calls = append(m.calls, rpcCall{to: to, data: data}) + if err, ok := m.callErrors[to]; ok { + return err + } + res, ok := m.callResults[to] + if !ok { + return errors.New("missing result") + } + out, ok := result.(*string) + if !ok { + return errors.New("bad result type") + } + *out = res + return nil +} + +func (m *mockBatchCallRPC) BatchCallContext(ctx context.Context, batch []rpc.BatchElem) error { + if m.batchRPCErr != nil { + return m.batchRPCErr + } + for i := range batch { + elem := &batch[i] + if elem.Method != "eth_call" { + elem.Error = errors.New("unexpected method") + continue + } + if len(elem.Args) < 2 { + elem.Error = errors.New("missing args") + continue + } + argMap, ok := elem.Args[0].(map[string]interface{}) + if !ok { + elem.Error = errors.New("bad args") + continue + } + to, _ := argMap["to"].(string) + data, _ := argMap["data"].(string) + m.batchCalls = append(m.batchCalls, rpcCall{to: to, data: data}) + if err, ok := m.batchErrors[to]; ok { + elem.Error = err + continue + } + res, ok := m.batchResults[to] + if !ok { + elem.Error = errors.New("missing result") + continue + } + out, ok := elem.Result.(*string) + if !ok { + elem.Error = errors.New("bad result type") + continue + } + *out = res + } + return nil +} + +func TestErc20BalanceOfCallData(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + data := erc20BalanceOfCallData(bchain.AddressDescriptor(addr.Bytes())) + if !strings.HasPrefix(data, contractBalanceOfSignature) { + t.Fatalf("expected prefix %q, got %q", contractBalanceOfSignature, data) + } + payload := data[len(contractBalanceOfSignature):] + if len(payload) != 64 { + t.Fatalf("expected 64 hex chars payload, got %d", len(payload)) + } + addrHex := strings.TrimPrefix(hexutil.Encode(addr.Bytes()), "0x") + if !strings.HasSuffix(payload, addrHex) { + t.Fatalf("expected payload suffix %q, got %q", addrHex, payload) + } + padding := payload[:len(payload)-len(addrHex)] + if padding != strings.Repeat("0", len(padding)) { + t.Fatalf("expected zero padding, got %q", padding) + } +} + +func TestErc20BalancesBatchSuccess(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractAKey := hexutil.Encode(contractA.Bytes()) + contractBKey := hexutil.Encode(contractB.Bytes()) + callData := erc20BalanceOfCallData(bchain.AddressDescriptor(addr.Bytes())) + mock := &mockBatchCallRPC{ + batchResults: map[string]string{ + contractAKey: fmt.Sprintf("0x%064x", 7), + contractBKey: fmt.Sprintf("0x%064x", 9), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balances, err := rpcClient.erc20BalancesBatch(mock, callData, []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(7)) != 0 { + t.Fatalf("unexpected balance[0]: %v", balances[0]) + } + if balances[1] == nil || balances[1].Cmp(big.NewInt(9)) != 0 { + t.Fatalf("unexpected balance[1]: %v", balances[1]) + } + if len(mock.calls) != 0 { + t.Fatalf("expected no fallback calls, got %d", len(mock.calls)) + } + if len(mock.batchCalls) != 2 { + t.Fatalf("expected 2 batch calls, got %d", len(mock.batchCalls)) + } + for _, call := range mock.batchCalls { + if call.data != callData { + t.Fatalf("unexpected batch call data: %q", call.data) + } + } +} + +func TestErc20BalancesBatchFallback(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractAKey := hexutil.Encode(contractA.Bytes()) + contractBKey := hexutil.Encode(contractB.Bytes()) + callData := erc20BalanceOfCallData(bchain.AddressDescriptor(addr.Bytes())) + mock := &mockBatchCallRPC{ + batchResults: map[string]string{ + contractAKey: fmt.Sprintf("0x%064x", 1), + }, + batchErrors: map[string]error{ + contractBKey: errors.New("boom"), + }, + callResults: map[string]string{ + contractBKey: fmt.Sprintf("0x%064x", 5), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balances, err := rpcClient.erc20BalancesBatch(mock, callData, []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(1)) != 0 { + t.Fatalf("unexpected balance[0]: %v", balances[0]) + } + if balances[1] == nil || balances[1].Cmp(big.NewInt(5)) != 0 { + t.Fatalf("unexpected balance[1]: %v", balances[1]) + } + if len(mock.calls) != 1 { + t.Fatalf("expected 1 fallback call, got %d", len(mock.calls)) + } + if mock.calls[0].to != contractBKey { + t.Fatalf("expected fallback call to %q, got %q", contractBKey, mock.calls[0].to) + } + if mock.calls[0].data != callData { + t.Fatalf("expected fallback call data %q, got %q", callData, mock.calls[0].data) + } +} + +func TestErc20BalancesBatchWholeBatchRPCError(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractAKey := hexutil.Encode(contractA.Bytes()) + contractBKey := hexutil.Encode(contractB.Bytes()) + callData := erc20BalanceOfCallData(bchain.AddressDescriptor(addr.Bytes())) + mock := &mockBatchCallRPC{ + batchRPCErr: errors.New("connection reset"), + callResults: map[string]string{ + contractAKey: fmt.Sprintf("0x%064x", 11), + contractBKey: fmt.Sprintf("0x%064x", 22), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balances, err := rpcClient.erc20BalancesBatch(mock, callData, []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + }) + if err != nil { + t.Fatalf("expected nil error after fallback, got %v", err) + } + if len(balances) != 2 { + t.Fatalf("expected 2 balances, got %d", len(balances)) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(11)) != 0 { + t.Fatalf("unexpected balance[0]: %v", balances[0]) + } + if balances[1] == nil || balances[1].Cmp(big.NewInt(22)) != 0 { + t.Fatalf("unexpected balance[1]: %v", balances[1]) + } + if len(mock.calls) != 2 { + t.Fatalf("expected 2 single-call fallbacks, got %d", len(mock.calls)) + } + gotTos := map[string]bool{mock.calls[0].to: true, mock.calls[1].to: true} + if !gotTos[contractAKey] || !gotTos[contractBKey] { + t.Fatalf("expected fallbacks for both contracts, got %+v", mock.calls) + } + for _, call := range mock.calls { + if call.data != callData { + t.Fatalf("unexpected fallback call data: %q", call.data) + } + } +} + +func TestErc20BalancesBatchWholeBatchRPCErrorPartialSingleFailure(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractAKey := hexutil.Encode(contractA.Bytes()) + contractBKey := hexutil.Encode(contractB.Bytes()) + callData := erc20BalanceOfCallData(bchain.AddressDescriptor(addr.Bytes())) + mock := &mockBatchCallRPC{ + batchRPCErr: errors.New("connection reset"), + callResults: map[string]string{ + contractAKey: fmt.Sprintf("0x%064x", 11), + }, + callErrors: map[string]error{ + contractBKey: errors.New("still broken"), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balances, err := rpcClient.erc20BalancesBatch(mock, callData, []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + }) + if err != nil { + t.Fatalf("expected nil error after fallback, got %v", err) + } + if len(balances) != 2 { + t.Fatalf("expected 2 balances, got %d", len(balances)) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(11)) != 0 { + t.Fatalf("unexpected balance[0]: %v", balances[0]) + } + if balances[1] != nil { + t.Fatalf("expected balance[1] to be nil after single-call failure, got %v", balances[1]) + } +} + +func TestErc20BalancesBatchInvalidResult(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractAKey := hexutil.Encode(contractA.Bytes()) + contractBKey := hexutil.Encode(contractB.Bytes()) + callData := erc20BalanceOfCallData(bchain.AddressDescriptor(addr.Bytes())) + mock := &mockBatchCallRPC{ + batchResults: map[string]string{ + contractAKey: "0x01", + contractBKey: fmt.Sprintf("0x%064x", 2), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balances, err := rpcClient.erc20BalancesBatch(mock, callData, []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balances[0] != nil { + t.Fatalf("expected balance[0] to be nil, got %v", balances[0]) + } + if balances[1] == nil || balances[1].Cmp(big.NewInt(2)) != 0 { + t.Fatalf("unexpected balance[1]: %v", balances[1]) + } + if len(mock.calls) != 0 { + t.Fatalf("expected no fallback calls, got %d", len(mock.calls)) + } +} + +func TestEthereumTypeGetErc20ContractBalances(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractAKey := hexutil.Encode(contractA.Bytes()) + contractBKey := hexutil.Encode(contractB.Bytes()) + mock := &mockBatchRPC{ + results: map[string]string{ + contractAKey: fmt.Sprintf("0x%064x", 123), + contractBKey: fmt.Sprintf("0x%064x", 0), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances( + bchain.AddressDescriptor(addr.Bytes()), + []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(balances) != 2 { + t.Fatalf("expected 2 balances, got %d", len(balances)) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(123)) != 0 { + t.Fatalf("unexpected balance[0]: %v", balances[0]) + } + if balances[1] == nil || balances[1].Sign() != 0 { + t.Fatalf("unexpected balance[1]: %v", balances[1]) + } +} + +func TestEthereumTypeGetErc20ContractBalancesBatchSize(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractC := common.HexToAddress("0x00000000000000000000000000000000000000cc") + mock := &mockBatchRPC{ + results: map[string]string{ + hexutil.Encode(contractA.Bytes()): fmt.Sprintf("0x%064x", 1), + hexutil.Encode(contractB.Bytes()): fmt.Sprintf("0x%064x", 2), + hexutil.Encode(contractC.Bytes()): fmt.Sprintf("0x%064x", 3), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + ChainConfig: &Configuration{Erc20BatchSize: 2}, + } + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances( + bchain.AddressDescriptor(addr.Bytes()), + []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + bchain.AddressDescriptor(contractC.Bytes()), + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(balances) != 3 { + t.Fatalf("expected 3 balances, got %d", len(balances)) + } + if len(mock.batchSizes) != 2 || mock.batchSizes[0] != 2 || mock.batchSizes[1] != 1 { + t.Fatalf("unexpected batch sizes: %v", mock.batchSizes) + } +} + +func TestEthereumTypeGetErc20ContractBalancesPartialError(t *testing.T) { + addr := common.HexToAddress("0x0000000000000000000000000000000000000011") + contractA := common.HexToAddress("0x00000000000000000000000000000000000000aa") + contractB := common.HexToAddress("0x00000000000000000000000000000000000000bb") + contractAKey := hexutil.Encode(contractA.Bytes()) + contractBKey := hexutil.Encode(contractB.Bytes()) + mock := &mockBatchRPC{ + results: map[string]string{ + contractAKey: fmt.Sprintf("0x%064x", 42), + }, + perErr: map[string]error{ + contractBKey: errors.New("boom"), + }, + } + rpcClient := &EthereumRPC{ + RPC: mock, + Timeout: time.Second, + } + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances( + bchain.AddressDescriptor(addr.Bytes()), + []bchain.AddressDescriptor{ + bchain.AddressDescriptor(contractA.Bytes()), + bchain.AddressDescriptor(contractB.Bytes()), + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balances[0] == nil || balances[0].Cmp(big.NewInt(42)) != 0 { + t.Fatalf("unexpected balance[0]: %v", balances[0]) + } + if balances[1] != nil { + t.Fatalf("expected balance[1] to be nil, got %v", balances[1]) + } +} diff --git a/bchain/coins/eth/contract_test.go b/bchain/coins/eth/contract_test.go index 587d98a774..ca70c85878 100644 --- a/bchain/coins/eth/contract_test.go +++ b/bchain/coins/eth/contract_test.go @@ -133,7 +133,7 @@ func Test_contractGetTransfersFromLog(t *testing.T) { }, want: bchain.TokenTransfers{ { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: "0x5689b918D34C038901870105A6C7fc24744D31eB", From: "0x0a206d4d5ff79cb5069def7fe3598421cff09391", To: "0x6a016d7eec560549ffa0fbdb7f15c2b27302087f", @@ -171,7 +171,7 @@ func Test_contractGetTransfersFromLog(t *testing.T) { }, want: bchain.TokenTransfers{ { - Type: bchain.MultiToken, + Standard: bchain.MultiToken, Contract: "0x6Fd712E3A5B556654044608F9129040A4839E36c", From: "0xa3950b823cb063dd9afc0d27f35008b805b3ed53", To: "0x4392faf3bb96b5694ecc6ef64726f61cdd4bb0ec", @@ -195,7 +195,7 @@ func Test_contractGetTransfersFromLog(t *testing.T) { }, want: bchain.TokenTransfers{ { - Type: bchain.MultiToken, + Standard: bchain.MultiToken, Contract: "0x6c42c26a081c2f509f8bb68fb7ac3062311ccfb7", From: "0x0000000000000000000000000000000000000000", To: "0x5dc6288b35e0807a3d6feb89b3a2ff4ab773168e", @@ -247,7 +247,7 @@ func Test_contractGetTransfersFromTx(t *testing.T) { args: (b1.Txs[1].CoinSpecificData.(bchain.EthereumSpecificData)).Tx, want: bchain.TokenTransfers{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: "0x4af4114f73d1c1c903ac9e0361b379d1291808a2", From: "0x20cd153de35d469ba46127a0c8f18626b59a256a", To: "0x555ee11fbddc0e49a9bab358a8941ad95ffdb48f", @@ -260,7 +260,7 @@ func Test_contractGetTransfersFromTx(t *testing.T) { args: (b2.Txs[2].CoinSpecificData.(bchain.EthereumSpecificData)).Tx, want: bchain.TokenTransfers{ { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: "0xcda9fc258358ecaa88845f19af595e908bb7efe9", From: "0x837e3f699d85a4b0b99894567e9233dfb1dcb081", To: "0x7b62eb7fe80350dc7ec945c0b73242cb9877fb1b", diff --git a/bchain/coins/eth/dataparser.go b/bchain/coins/eth/dataparser.go index 8182692658..b3f641622b 100644 --- a/bchain/coins/eth/dataparser.go +++ b/bchain/coins/eth/dataparser.go @@ -51,10 +51,7 @@ func parseSimpleStringProperty(data string) string { // allow string properties as UTF-8 data b, err := hex.DecodeString(data) if err == nil { - i := bytes.Index(b, []byte{0}) - if i > 32 { - i = 32 - } + i := min(bytes.Index(b, []byte{0}), 32) if i > 0 { b = b[:i] } @@ -242,7 +239,7 @@ func tryParseParams(data string, params []string, parsedParams []abi.Type) []bch // ParseInputData tries to parse transaction input data from known FourByteSignatures // as there may be multiple signatures for the same four bytes, it tries to match the input to the known parameters // it does not parse tuples for now -func ParseInputData(signatures *[]bchain.FourByteSignature, data string) *bchain.EthereumParsedInputData { +func (p *EthereumParser) ParseInputData(signatures *[]bchain.FourByteSignature, data string) *bchain.EthereumParsedInputData { if len(data) <= 2 { // data is empty or 0x return &bchain.EthereumParsedInputData{Name: "Transfer"} } diff --git a/bchain/coins/eth/dataparser_test.go b/bchain/coins/eth/dataparser_test.go index b13ecd167b..34cbda8ea1 100644 --- a/bchain/coins/eth/dataparser_test.go +++ b/bchain/coins/eth/dataparser_test.go @@ -418,9 +418,11 @@ func TestParseInputData(t *testing.T) { }, }, } + parser := NewEthereumParser(1, false) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ParseInputData(tt.signatures, tt.data) + got := parser.ParseInputData(tt.signatures, tt.data) if !reflect.DeepEqual(got, tt.want) { t.Errorf("ParseInputData() = %v, want %v", got, tt.want) } diff --git a/bchain/coins/eth/erc20_batch_integration_client.go b/bchain/coins/eth/erc20_batch_integration_client.go new file mode 100644 index 0000000000..08e794d0df --- /dev/null +++ b/bchain/coins/eth/erc20_batch_integration_client.go @@ -0,0 +1,25 @@ +//go:build integration + +package eth + +import ( + "time" + + "github.com/trezor/blockbook/bchain" +) + +// NewERC20BatchIntegrationClient builds an ERC20-capable RPC client for integration tests. +// EVM chains share ERC20 balanceOf semantics (eth_call) and coin wrappers embed EthereumRPC. +func NewERC20BatchIntegrationClient(rpcURL, rpcURLWS string, batchSize int) (bchain.ERC20BatchClient, func(), error) { + rc, ec, err := OpenRPC(rpcURL, rpcURLWS) + if err != nil { + return nil, nil, err + } + client := &EthereumRPC{ + Client: ec, + RPC: rc, + Timeout: 15 * time.Second, + ChainConfig: &Configuration{RPCURL: rpcURL, RPCURLWS: rpcURLWS, Erc20BatchSize: batchSize}, + } + return client, func() { rc.Close() }, nil +} diff --git a/bchain/coins/eth/ethparser.go b/bchain/coins/eth/ethparser.go index 975b837494..30085cd8c0 100644 --- a/bchain/coins/eth/ethparser.go +++ b/bchain/coins/eth/ethparser.go @@ -7,10 +7,10 @@ import ( "strings" "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/golang/protobuf/proto" "github.com/juju/errors" "github.com/trezor/blockbook/bchain" "golang.org/x/crypto/sha3" + "google.golang.org/protobuf/proto" ) // EthereumTypeAddressDescriptorLen - the AddressDescriptor of EthereumType has fixed length @@ -22,18 +22,71 @@ const EthereumTypeTxidLen = 32 // EtherAmountDecimalPoint defines number of decimal points in Ether amounts const EtherAmountDecimalPoint = 18 +const defaultHotAddressMinContracts = 192 +const defaultHotAddressLRUCacheSize = 20000 +const defaultHotAddressMinHits = 3 +const maxHotAddressLRUCacheSize = 100_000 +const maxHotAddressMinHits = 10 +const defaultAddressContractsCacheMinSize = 300_000 +const defaultAddressContractsCacheMaxBytes int64 = 2_000_000_000 +const defaultAddressContractsCacheBulkMaxBytes int64 = 4_000_000_000 + +type AddressContractsCacheConfig struct { + MinSize int + TipMaxBytes int64 + BulkMaxBytes int64 +} + +type EthereumLikeParser interface { + bchain.BlockChainParser + EthTxToTx(tx *bchain.RpcTransaction, receipt *bchain.RpcReceipt, internalData *bchain.EthereumInternalData, blocktime int64, confirmations uint32, fixEIP55 bool) (*bchain.Tx, error) + SetEnsSuffix(suffix string) +} + // EthereumParser handle type EthereumParser struct { *bchain.BaseParser + EnsSuffix string + HotAddressMinContracts int + HotAddressLRUCacheSize int + HotAddressMinHits int + AddrContractsCacheMinSize int + AddrContractsCacheMaxBytes int64 + AddrContractsCacheBulkMaxBytes int64 + FormatAddressFunc func(addr string) string + FromDescToAddressFunc func(addrDesc bchain.AddressDescriptor) string } // NewEthereumParser returns new EthereumParser instance func NewEthereumParser(b int, addressAliases bool) *EthereumParser { - return &EthereumParser{&bchain.BaseParser{ - BlockAddressesToKeep: b, - AmountDecimalPoint: EtherAmountDecimalPoint, - AddressAliases: addressAliases, - }} + return &EthereumParser{ + BaseParser: &bchain.BaseParser{ + BlockAddressesToKeep: b, + AmountDecimalPoint: EtherAmountDecimalPoint, + AddressAliases: addressAliases, + }, + EnsSuffix: ".eth", + HotAddressMinContracts: defaultHotAddressMinContracts, + HotAddressLRUCacheSize: defaultHotAddressLRUCacheSize, + HotAddressMinHits: defaultHotAddressMinHits, + AddrContractsCacheMinSize: defaultAddressContractsCacheMinSize, + AddrContractsCacheMaxBytes: defaultAddressContractsCacheMaxBytes, + AddrContractsCacheBulkMaxBytes: defaultAddressContractsCacheBulkMaxBytes, + FormatAddressFunc: EIP55AddressFromAddress, + FromDescToAddressFunc: EIP55Address, + } +} + +func (p *EthereumParser) HotAddressConfig() (minContracts, lruSize, minHits int) { + return p.HotAddressMinContracts, p.HotAddressLRUCacheSize, p.HotAddressMinHits +} + +func (p *EthereumParser) AddressContractsCacheConfig() AddressContractsCacheConfig { + return AddressContractsCacheConfig{ + MinSize: p.AddrContractsCacheMinSize, + TipMaxBytes: p.AddrContractsCacheMaxBytes, + BulkMaxBytes: p.AddrContractsCacheBulkMaxBytes, + } } type rpcHeader struct { @@ -59,6 +112,10 @@ type rpcBlockTxids struct { Transactions []string `json:"transactions"` } +func (p *EthereumParser) SetEnsSuffix(suffix string) { + p.EnsSuffix = suffix +} + func ethNumber(n string) (int64, error) { if len(n) > 2 { return strconv.ParseInt(n[2:], 16, 64) @@ -66,7 +123,7 @@ func ethNumber(n string) (int64, error) { return 0, errors.Errorf("Not a number: '%v'", n) } -func (p *EthereumParser) ethTxToTx(tx *bchain.RpcTransaction, receipt *bchain.RpcReceipt, internalData *bchain.EthereumInternalData, blocktime int64, confirmations uint32, fixEIP55 bool) (*bchain.Tx, error) { +func (p *EthereumParser) EthTxToTx(tx *bchain.RpcTransaction, receipt *bchain.RpcReceipt, internalData *bchain.EthereumInternalData, blocktime int64, confirmations uint32, fixEIP55 bool) (*bchain.Tx, error) { txid := tx.Hash var ( fa, ta []string @@ -74,20 +131,20 @@ func (p *EthereumParser) ethTxToTx(tx *bchain.RpcTransaction, receipt *bchain.Rp ) if len(tx.From) > 2 { if fixEIP55 { - tx.From = EIP55AddressFromAddress(tx.From) + tx.From = p.FormatAddressFunc(tx.From) } fa = []string{tx.From} } if len(tx.To) > 2 { if fixEIP55 { - tx.To = EIP55AddressFromAddress(tx.To) + tx.To = p.FormatAddressFunc(tx.To) } ta = []string{tx.To} } if fixEIP55 && receipt != nil && receipt.Logs != nil { for _, l := range receipt.Logs { if len(l.Address) > 2 { - l.Address = EIP55AddressFromAddress(l.Address) + l.Address = p.FormatAddressFunc(l.Address) } } } @@ -99,8 +156,8 @@ func (p *EthereumParser) ethTxToTx(tx *bchain.RpcTransaction, receipt *bchain.Rp if fixEIP55 { for i := range internalData.Transfers { it := &internalData.Transfers[i] - it.From = EIP55AddressFromAddress(it.From) - it.To = EIP55AddressFromAddress(it.To) + it.From = p.FormatAddressFunc(it.From) + it.To = p.FormatAddressFunc(it.To) } } } @@ -208,7 +265,7 @@ func EIP55AddressFromAddress(address string) string { // GetAddressesFromAddrDesc returns addresses for given address descriptor with flag if the addresses are searchable func (p *EthereumParser) GetAddressesFromAddrDesc(addrDesc bchain.AddressDescriptor) ([]string, bool, error) { - return []string{EIP55Address(addrDesc)}, true, nil + return []string{p.FromDescToAddressFunc(addrDesc)}, true, nil } // GetScriptFromAddrDesc returns output script for given address descriptor @@ -273,6 +330,21 @@ func (p *EthereumParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ( if pt.Tx.GasPrice, err = hexDecodeBig(r.Tx.GasPrice); err != nil { return nil, errors.Annotatef(err, "Price %v", r.Tx.GasPrice) } + if len(r.Tx.MaxPriorityFeePerGas) > 0 { + if pt.Tx.MaxPriorityFeePerGas, err = hexDecodeBig(r.Tx.MaxPriorityFeePerGas); err != nil { + return nil, errors.Annotatef(err, "MaxPriorityFeePerGas %v", r.Tx.MaxPriorityFeePerGas) + } + } + if len(r.Tx.MaxFeePerGas) > 0 { + if pt.Tx.MaxFeePerGas, err = hexDecodeBig(r.Tx.MaxFeePerGas); err != nil { + return nil, errors.Annotatef(err, "MaxFeePerGas %v", r.Tx.MaxFeePerGas) + } + } + if len(r.Tx.BaseFeePerGas) > 0 { + if pt.Tx.BaseFeePerGas, err = hexDecodeBig(r.Tx.BaseFeePerGas); err != nil { + return nil, errors.Annotatef(err, "BaseFeePerGas %v", r.Tx.BaseFeePerGas) + } + } // if pt.R, err = hexDecodeBig(r.R); err != nil { // return nil, errors.Annotatef(err, "R %v", r.R) // } @@ -331,6 +403,27 @@ func (p *EthereumParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ( } pt.Receipt.Log = ptLogs + if r.Receipt.L1Fee != "" { + if pt.Receipt.L1Fee, err = hexDecodeBig(r.Receipt.L1Fee); err != nil { + return nil, errors.Annotatef(err, "L1Fee %v", r.Receipt.L1Fee) + } + } + if r.Receipt.L1FeeScalar != "" { + pt.Receipt.L1FeeScalar = []byte(r.Receipt.L1FeeScalar) + } + if r.Receipt.L1GasPrice != "" { + if pt.Receipt.L1GasPrice, err = hexDecodeBig(r.Receipt.L1GasPrice); err != nil { + return nil, errors.Annotatef(err, "L1GasPrice %v", r.Receipt.L1GasPrice) + } + } + if r.Receipt.L1GasUsed != "" { + if pt.Receipt.L1GasUsed, err = hexDecodeBig(r.Receipt.L1GasUsed); err != nil { + return nil, errors.Annotatef(err, "L1GasUsed %v", r.Receipt.L1GasUsed) + } + } + } + if len(r.ChainExtraData) > 0 { + pt.ChainExtraData = r.ChainExtraData } return proto.Marshal(pt) } @@ -345,7 +438,7 @@ func (p *EthereumParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { rt := bchain.RpcTransaction{ AccountNonce: hexutil.EncodeUint64(pt.Tx.AccountNonce), BlockNumber: hexutil.EncodeUint64(uint64(pt.BlockNumber)), - From: EIP55Address(pt.Tx.From), + From: p.FromDescToAddressFunc(pt.Tx.From), GasLimit: hexutil.EncodeUint64(pt.Tx.GasLimit), Hash: hexutil.Encode(pt.Tx.Hash), Payload: hexutil.Encode(pt.Tx.Payload), @@ -353,40 +446,67 @@ func (p *EthereumParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { // R: hexEncodeBig(pt.R), // S: hexEncodeBig(pt.S), // V: hexEncodeBig(pt.V), - To: EIP55Address(pt.Tx.To), + To: p.FromDescToAddressFunc(pt.Tx.To), TransactionIndex: hexutil.EncodeUint64(uint64(pt.Tx.TransactionIndex)), Value: hexEncodeBig(pt.Tx.Value), } + if len(pt.Tx.MaxPriorityFeePerGas) > 0 { + rt.MaxPriorityFeePerGas = hexEncodeBig(pt.Tx.MaxPriorityFeePerGas) + } + if len(pt.Tx.MaxFeePerGas) > 0 { + rt.MaxFeePerGas = hexEncodeBig(pt.Tx.MaxFeePerGas) + } + if len(pt.Tx.BaseFeePerGas) > 0 { + rt.BaseFeePerGas = hexEncodeBig(pt.Tx.BaseFeePerGas) + } var rr *bchain.RpcReceipt if pt.Receipt != nil { - logs := make([]*bchain.RpcLog, len(pt.Receipt.Log)) + rr = &bchain.RpcReceipt{ + GasUsed: hexEncodeBig(pt.Receipt.GasUsed), + Status: "", + Logs: make([]*bchain.RpcLog, len(pt.Receipt.Log)), + } for i, l := range pt.Receipt.Log { topics := make([]string, len(l.Topics)) for j, t := range l.Topics { topics[j] = hexutil.Encode(t) } - logs[i] = &bchain.RpcLog{ - Address: EIP55Address(l.Address), + rr.Logs[i] = &bchain.RpcLog{ + Address: p.FromDescToAddressFunc(l.Address), Data: hexutil.Encode(l.Data), Topics: topics, } } - status := "" // handle a special value []byte{'U'} as unknown state if len(pt.Receipt.Status) != 1 || pt.Receipt.Status[0] != 'U' { - status = hexEncodeBig(pt.Receipt.Status) + rr.Status = hexEncodeBig(pt.Receipt.Status) } - rr = &bchain.RpcReceipt{ - GasUsed: hexEncodeBig(pt.Receipt.GasUsed), - Status: status, - Logs: logs, + if len(pt.Receipt.L1Fee) > 0 { + rr.L1Fee = hexEncodeBig(pt.Receipt.L1Fee) + } + if len(pt.Receipt.L1FeeScalar) > 0 { + rr.L1FeeScalar = string(pt.Receipt.L1FeeScalar) + } + if len(pt.Receipt.L1GasPrice) > 0 { + rr.L1GasPrice = hexEncodeBig(pt.Receipt.L1GasPrice) + } + if len(pt.Receipt.L1GasUsed) > 0 { + rr.L1GasUsed = hexEncodeBig(pt.Receipt.L1GasUsed) } } // TODO handle internal transactions - tx, err := p.ethTxToTx(&rt, rr, nil, int64(pt.BlockTime), 0, false) + tx, err := p.EthTxToTx(&rt, rr, nil, int64(pt.BlockTime), 0, false) if err != nil { return nil, 0, err } + if len(pt.ChainExtraData) > 0 { + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + return nil, 0, errors.New("Missing CoinSpecificData") + } + csd.ChainExtraData = pt.ChainExtraData + tx.CoinSpecificData = csd + } return tx, pt.BlockNumber, nil } @@ -461,61 +581,50 @@ func (p *EthereumParser) EthereumTypeGetTokenTransfersFromTx(tx *bchain.Tx) (bch // FormatAddressAlias adds .eth to a name alias func (p *EthereumParser) FormatAddressAlias(address string, name string) string { - return name + ".eth" + return name + p.EnsSuffix } -// TxStatus is status of transaction -type TxStatus int - -// statuses of transaction -const ( - TxStatusUnknown = TxStatus(iota - 2) - TxStatusPending - TxStatusFailure - TxStatusOK -) - -// EthereumTxData contains ethereum specific transaction data -type EthereumTxData struct { - Status TxStatus `json:"status"` // 1 OK, 0 Fail, -1 pending, -2 unknown - Nonce uint64 `json:"nonce"` - GasLimit *big.Int `json:"gaslimit"` - GasUsed *big.Int `json:"gasused"` - GasPrice *big.Int `json:"gasprice"` - Data string `json:"data"` -} - -// GetEthereumTxData returns EthereumTxData from bchain.Tx -func GetEthereumTxData(tx *bchain.Tx) *EthereumTxData { - return GetEthereumTxDataFromSpecificData(tx.CoinSpecificData) -} - -// GetEthereumTxDataFromSpecificData returns EthereumTxData from coinSpecificData -func GetEthereumTxDataFromSpecificData(coinSpecificData interface{}) *EthereumTxData { - etd := EthereumTxData{Status: TxStatusPending} +// GetEthereumTxDataFromSpecificData returns EthereumTxData from coinSpecificData. +func GetEthereumTxDataFromSpecificData(coinSpecificData interface{}) *bchain.EthereumTxData { + etd := bchain.EthereumTxData{Status: bchain.TxStatusPending} csd, ok := coinSpecificData.(bchain.EthereumSpecificData) if ok { if csd.Tx != nil { etd.Nonce, _ = hexutil.DecodeUint64(csd.Tx.AccountNonce) etd.GasLimit, _ = hexutil.DecodeBig(csd.Tx.GasLimit) etd.GasPrice, _ = hexutil.DecodeBig(csd.Tx.GasPrice) + etd.MaxPriorityFeePerGas, _ = hexutil.DecodeBig(csd.Tx.MaxPriorityFeePerGas) + etd.MaxFeePerGas, _ = hexutil.DecodeBig(csd.Tx.MaxFeePerGas) + etd.BaseFeePerGas, _ = hexutil.DecodeBig(csd.Tx.BaseFeePerGas) etd.Data = csd.Tx.Payload } if csd.Receipt != nil { switch csd.Receipt.Status { case "0x1": - etd.Status = TxStatusOK + etd.Status = bchain.TxStatusOK case "": // old transactions did not set status - etd.Status = TxStatusUnknown + etd.Status = bchain.TxStatusUnknown default: - etd.Status = TxStatusFailure + etd.Status = bchain.TxStatusFailure } etd.GasUsed, _ = hexutil.DecodeBig(csd.Receipt.GasUsed) + etd.L1Fee, _ = hexutil.DecodeBig(csd.Receipt.L1Fee) + etd.L1GasPrice, _ = hexutil.DecodeBig(csd.Receipt.L1GasPrice) + etd.L1GasUsed, _ = hexutil.DecodeBig(csd.Receipt.L1GasUsed) + etd.L1FeeScalar = csd.Receipt.L1FeeScalar } } return &etd } +// GetEthereumTxData returns parsed transaction data for Ethereum-like chains. +func (p *EthereumParser) GetEthereumTxData(tx *bchain.Tx) *bchain.EthereumTxData { + if tx == nil { + return &bchain.EthereumTxData{Status: bchain.TxStatusPending} + } + return GetEthereumTxDataFromSpecificData(tx.CoinSpecificData) +} + const errorOutputSignature = "08c379a0" // ParseErrorFromOutput takes output field from internal transaction data and extracts an error message from it diff --git a/bchain/coins/eth/ethparser_test.go b/bchain/coins/eth/ethparser_test.go index aaee177ae6..e038cf00a8 100644 --- a/bchain/coins/eth/ethparser_test.go +++ b/bchain/coins/eth/ethparser_test.go @@ -91,16 +91,19 @@ func init() { }, CoinSpecificData: bchain.EthereumSpecificData{ Tx: &bchain.RpcTransaction{ - AccountNonce: "0xb26c", - GasPrice: "0x430e23400", - GasLimit: "0x5208", - To: "0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f", - Value: "0x1bc0159d530e6000", - Payload: "0x", - Hash: "0xcd647151552b5132b2aef7c9be00dc6f73afc5901dde157aab131335baaa853b", - BlockNumber: "0x41eee8", - From: "0x3E3a3D69dc66bA10737F531ed088954a9EC89d97", - TransactionIndex: "0xa", + AccountNonce: "0xb26c", + GasPrice: "0x430e23400", + MaxPriorityFeePerGas: "0x430e23401", + MaxFeePerGas: "0x430e23402", + BaseFeePerGas: "0x430e23403", + GasLimit: "0x5208", + To: "0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f", + Value: "0x1bc0159d530e6000", + Payload: "0x", + Hash: "0xcd647151552b5132b2aef7c9be00dc6f73afc5901dde157aab131335baaa853b", + BlockNumber: "0x41eee8", + From: "0x3E3a3D69dc66bA10737F531ed088954a9EC89d97", + TransactionIndex: "0xa", }, Receipt: &bchain.RpcReceipt{ GasUsed: "0x5208", @@ -129,16 +132,19 @@ func init() { }, CoinSpecificData: bchain.EthereumSpecificData{ Tx: &bchain.RpcTransaction{ - AccountNonce: "0xd0", - GasPrice: "0x9502f9000", - GasLimit: "0x130d5", - To: "0x4af4114F73d1c1C903aC9E0361b379D1291808A2", - Value: "0x0", - Payload: "0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000", - Hash: "0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101", - BlockNumber: "0x41eee8", - From: "0x20cD153de35D469BA46127A0C8F18626b59a256A", - TransactionIndex: "0x0"}, + AccountNonce: "0xd0", + GasPrice: "0x9502f9000", + MaxPriorityFeePerGas: "0x9502f9001", + MaxFeePerGas: "0x9502f9002", + BaseFeePerGas: "0x9502f9003", + GasLimit: "0x130d5", + To: "0x4af4114F73d1c1C903aC9E0361b379D1291808A2", + Value: "0x0", + Payload: "0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000", + Hash: "0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101", + BlockNumber: "0x41eee8", + From: "0x20cD153de35D469BA46127A0C8F18626b59a256A", + TransactionIndex: "0x0"}, Receipt: &bchain.RpcReceipt{ GasUsed: "0xcb39", Status: "0x1", @@ -374,7 +380,53 @@ func TestEthereumParser_UnpackTx(t *testing.T) { } } +func TestEthereumParser_PackUnpackChainExtraData(t *testing.T) { + p := NewEthereumParser(1, false) + original := &bchain.Tx{ + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + AccountNonce: "0x1", + GasPrice: "0x430e23400", + GasLimit: "0x5208", + To: "0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f", + Value: "0x0", + Payload: "0x", + Hash: "0xcd647151552b5132b2aef7c9be00dc6f73afc5901dde157aab131335baaa853b", + BlockNumber: "0x41eee8", + From: "0x3E3a3D69dc66bA10737F531ed088954a9EC89d97", + TransactionIndex: "0x0", + }, + Receipt: &bchain.RpcReceipt{ + GasUsed: "0x5208", + Status: "0x1", + Logs: []*bchain.RpcLog{}, + }, + ChainExtraData: []byte(`{"operation":"vote","totalFee":"12345"}`), + }, + } + + packed, err := p.PackTx(original, 4321000, 1534858022) + if err != nil { + t.Fatalf("PackTx error: %v", err) + } + + unpacked, _, err := p.UnpackTx(packed) + if err != nil { + t.Fatalf("UnpackTx error: %v", err) + } + + csd, ok := unpacked.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + t.Fatalf("unexpected CoinSpecificData type: %T", unpacked.CoinSpecificData) + } + + if !reflect.DeepEqual(csd.ChainExtraData, original.CoinSpecificData.(bchain.EthereumSpecificData).ChainExtraData) { + t.Fatalf("ChainExtraData mismatch, got %s, want %s", string(csd.ChainExtraData), string(original.CoinSpecificData.(bchain.EthereumSpecificData).ChainExtraData)) + } +} + func TestEthereumParser_GetEthereumTxData(t *testing.T) { + p := NewEthereumParser(1, false) tests := []struct { name string tx *bchain.Tx @@ -393,7 +445,7 @@ func TestEthereumParser_GetEthereumTxData(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := GetEthereumTxData(tt.tx) + got := p.GetEthereumTxData(tt.tx) if got.Data != tt.want { t.Errorf("EthereumParser.GetEthereumTxData() = %v, want %v", got.Data, tt.want) } diff --git a/bchain/coins/eth/ethrpc.go b/bchain/coins/eth/ethrpc.go index 61173b05c5..b1de17f023 100644 --- a/bchain/coins/eth/ethrpc.go +++ b/bchain/coins/eth/ethrpc.go @@ -2,14 +2,18 @@ package eth import ( "context" + "encoding/hex" "encoding/json" + stdErrors "errors" "fmt" - "io/ioutil" + "io" "math/big" "net/http" + "net/url" "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/ethereum/go-ethereum" @@ -22,6 +26,8 @@ import ( "github.com/juju/errors" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" + "golang.org/x/crypto/sha3" + "golang.org/x/sync/singleflight" ) // Network type specifies the type of ethereum network @@ -30,53 +36,159 @@ type Network uint32 const ( // MainNet is production network MainNet Network = 1 - // TestNet is Ropsten test network - TestNet Network = 3 - // TestNetGoerli is Goerli test network - TestNetGoerli Network = 5 // TestNetSepolia is Sepolia test network TestNetSepolia Network = 11155111 + // TestNetHolesky is Holesky test network + TestNetHolesky Network = 17000 + // TestNetHoodi is Hoodi test network + TestNetHoodi Network = 560048 +) + +const ( + defaultErc20BatchSize = 100 + + // Alternative/private relays expire pending txs quickly, so local pending state + // must not inherit the legacy hour-scale public mempool timeout. + defaultMempoolTxTimeoutWithAlternativeProvider = 10 * time.Minute + defaultAlternativeMempoolTxTimeout = 5 * time.Minute +) + +// Ethereum address constants +const ( + // EthereumZeroAddress is the zero address (0x0000...0000) used to check for unset addresses + EthereumZeroAddress = "0x0000000000000000000000000000000000000000" + // EthereumAddressHexLength represents the length of an Ethereum address in hex characters (20 bytes * 2) + EthereumAddressHexLength = 40 + // ENSResolverFunctionSelector is the function selector for ENS registry's resolver(bytes32) method + ENSResolverFunctionSelector = "0x0178b8bf" + // ENSAddrFunctionSelector is the function selector for the resolver's addr(bytes32) method + ENSAddrFunctionSelector = "0x3b3b57de" + // ENSExpirationFunctionSelector is the function selector for ENS registry's nameExpires(bytes32) method + ENSExpirationFunctionSelector = "0x1aa2e643" + // ENSBaseRegistrarAddress is needed for checking .eth domain expiration + ENSBaseRegistrarAddress = "0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85" ) // Configuration represents json config file type Configuration struct { - CoinName string `json:"coin_name"` - CoinShortcut string `json:"coin_shortcut"` - RPCURL string `json:"rpc_url"` - RPCTimeout int `json:"rpc_timeout"` - BlockAddressesToKeep int `json:"block_addresses_to_keep"` - AddressAliases bool `json:"address_aliases,omitempty"` - MempoolTxTimeoutHours int `json:"mempoolTxTimeoutHours"` - QueryBackendOnMempoolResync bool `json:"queryBackendOnMempoolResync"` - ProcessInternalTransactions bool `json:"processInternalTransactions"` - ProcessZeroInternalTransactions bool `json:"processZeroInternalTransactions"` - ConsensusNodeVersionURL string `json:"consensusNodeVersion"` + CoinName string `json:"coin_name"` + CoinShortcut string `json:"coin_shortcut"` + Network string `json:"network"` + RPCURL string `json:"rpc_url"` + RPCURLWS string `json:"rpc_url_ws"` + RPCTimeout int `json:"rpc_timeout"` + TraceTimeout string `json:"trace_timeout,omitempty"` + Erc20BatchSize int `json:"erc20_batch_size,omitempty"` + BlockAddressesToKeep int `json:"block_addresses_to_keep"` + HotAddressMinContracts int `json:"hot_address_min_contracts,omitempty"` + HotAddressLRUCacheSize int `json:"hot_address_lru_cache_size,omitempty"` + HotAddressMinHits int `json:"hot_address_min_hits,omitempty"` + AddressContractsCacheMinSize int `json:"address_contracts_cache_min_size,omitempty"` + AddressContractsCacheMaxBytes int64 `json:"address_contracts_cache_max_bytes,omitempty"` + AddressContractsCacheBulkMaxBytes int64 `json:"address_contracts_cache_bulk_max_bytes,omitempty"` + AddressAliases bool `json:"address_aliases,omitempty"` + MempoolTxTimeoutHours int `json:"mempoolTxTimeoutHours"` + MempoolTxTimeout string `json:"mempoolTxTimeout,omitempty"` + AlternativeMempoolTxTimeout string `json:"alternativeMempoolTxTimeout,omitempty"` + QueryBackendOnMempoolResync bool `json:"queryBackendOnMempoolResync"` + ProcessInternalTransactions bool `json:"processInternalTransactions"` + ProcessZeroInternalTransactions bool `json:"processZeroInternalTransactions"` + ConsensusNodeVersionURL string `json:"consensusNodeVersion"` + DisableMempoolSync bool `json:"disableMempoolSync,omitempty"` + Eip1559Fees bool `json:"eip1559Fees,omitempty"` + AlternativeEstimateFee string `json:"alternative_estimate_fee,omitempty"` + AlternativeEstimateFeeParams string `json:"alternative_estimate_fee_params,omitempty"` + // AverageBlockTimeMs is the chain's nominal block cadence in ms; + // required for EVM coins (translates duration settings to block counts). + AverageBlockTimeMs int `json:"averageBlockTimeMs,omitempty"` +} + +func parseNonNegativeDuration(name string, value string) (time.Duration, error) { + d, err := time.ParseDuration(value) + if err != nil { + return 0, errors.Annotatef(err, "invalid %s", name) + } + if d < 0 { + return 0, errors.Errorf("%s must not be negative", name) + } + return d, nil +} + +func parsePositiveDuration(name string, value string) (time.Duration, error) { + d, err := parseNonNegativeDuration(name, value) + if err != nil { + return 0, err + } + if d == 0 { + return 0, errors.Errorf("%s must be positive", name) + } + return d, nil +} + +// MempoolTxTimeoutDuration returns the Blockbook-side EVM mempool retention. +func (c *Configuration) MempoolTxTimeoutDuration(alternativeSendTxProviderEnabled bool) (time.Duration, error) { + if c.MempoolTxTimeout != "" { + return parseNonNegativeDuration("mempoolTxTimeout", c.MempoolTxTimeout) + } + // Keep the shorter timeout scoped to alternative/private submission only. + if alternativeSendTxProviderEnabled { + return defaultMempoolTxTimeoutWithAlternativeProvider, nil + } + return time.Duration(c.MempoolTxTimeoutHours) * time.Hour, nil +} + +// AlternativeMempoolTxTimeoutDuration returns the alternative-provider cache retention. +func (c *Configuration) AlternativeMempoolTxTimeoutDuration() (time.Duration, error) { + if c.AlternativeMempoolTxTimeout != "" { + return parsePositiveDuration("alternativeMempoolTxTimeout", c.AlternativeMempoolTxTimeout) + } + return defaultAlternativeMempoolTxTimeout, nil +} + +// AverageBlockTimeDuration returns AverageBlockTimeMs as a time.Duration. +func (c *Configuration) AverageBlockTimeDuration() (time.Duration, error) { + if c.AverageBlockTimeMs <= 0 { + return 0, errors.Errorf("averageBlockTimeMs must be a positive integer") + } + return time.Duration(c.AverageBlockTimeMs) * time.Millisecond, nil } // EthereumRPC is an interface to JSON-RPC eth service. type EthereumRPC struct { *bchain.BaseChain - Client bchain.EVMClient - RPC bchain.EVMRPCClient - MainNetChainID Network - Timeout time.Duration - Parser *EthereumParser - PushHandler func(bchain.NotificationType) - OpenRPC func(string) (bchain.EVMRPCClient, bchain.EVMClient, error) - Mempool *bchain.MempoolEthereumType - mempoolInitialized bool - bestHeaderLock sync.Mutex - bestHeader bchain.EVMHeader - bestHeaderTime time.Time - NewBlock bchain.EVMNewBlockSubscriber - newBlockSubscription bchain.EVMClientSubscription - NewTx bchain.EVMNewTxSubscriber - newTxSubscription bchain.EVMClientSubscription - ChainConfig *Configuration -} - -// ProcessInternalTransactions specifies if internal transactions are processed -var ProcessInternalTransactions bool + Client bchain.EVMClient + RPC bchain.EVMRPCClient + MainNetChainID Network + Timeout time.Duration + Parser EthereumLikeParser + PushHandler func(bchain.NotificationType) + OpenRPC func(string, string) (bchain.EVMRPCClient, bchain.EVMClient, error) + Mempool *bchain.MempoolEthereumType + mempoolInitialized bool + bestHeaderLock sync.Mutex + bestHeader bchain.EVMHeader + bestHeaderTime time.Time + // newBlockNotifyCh coalesces bursts of newHeads events into a single wake-up. + // This keeps the subscription reader unblocked while we refresh the canonical tip. + newBlockNotifyCh chan struct{} + newBlockNotifyOnce sync.Once + NewBlock bchain.EVMNewBlockSubscriber + newBlockSubscription bchain.EVMClientSubscription + NewTx bchain.EVMNewTxSubscriber + newTxSubscription bchain.EVMClientSubscription + ChainConfig *Configuration + metrics *common.Metrics + supportedStakingPools []string + stakingPoolNames []string + stakingPoolContracts []string + alternativeFeeProvider alternativeFeeProviderInterface + alternativeSendTxProvider *AlternativeSendTxProvider + InternalDataProvider bchain.EthereumInternalDataProvider + consensusMonitor *consensusVersionMonitor + // Multicall3 deployment state; lazily probed on first call. See multicall.go. + multicall3Probe atomic.Int32 + multicall3ProbeSF singleflight.Group +} // NewEthereumRPC returns new EthRPC instance. func NewEthereumRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { @@ -90,35 +202,243 @@ func NewEthereumRPC(config json.RawMessage, pushHandler func(bchain.Notification if c.BlockAddressesToKeep < 100 { c.BlockAddressesToKeep = 100 } + if c.Erc20BatchSize <= 0 { + c.Erc20BatchSize = defaultErc20BatchSize + } + if c.HotAddressMinContracts <= 0 { + c.HotAddressMinContracts = defaultHotAddressMinContracts + } + if c.HotAddressLRUCacheSize <= 0 { + c.HotAddressLRUCacheSize = defaultHotAddressLRUCacheSize + } else if c.HotAddressLRUCacheSize > maxHotAddressLRUCacheSize { + glog.Warningf("hot_address_lru_cache_size=%d is too large, clamping to %d", c.HotAddressLRUCacheSize, maxHotAddressLRUCacheSize) + c.HotAddressLRUCacheSize = maxHotAddressLRUCacheSize + } + if c.HotAddressMinHits <= 0 { + c.HotAddressMinHits = defaultHotAddressMinHits + } else if c.HotAddressMinHits > maxHotAddressMinHits { + glog.Warningf("hot_address_min_hits=%d is too large, clamping to %d", c.HotAddressMinHits, maxHotAddressMinHits) + c.HotAddressMinHits = maxHotAddressMinHits + } + if c.AddressContractsCacheMinSize <= 0 { + c.AddressContractsCacheMinSize = defaultAddressContractsCacheMinSize + } + if c.AddressContractsCacheMaxBytes <= 0 { + c.AddressContractsCacheMaxBytes = defaultAddressContractsCacheMaxBytes + } + if c.AddressContractsCacheBulkMaxBytes <= 0 { + c.AddressContractsCacheBulkMaxBytes = defaultAddressContractsCacheBulkMaxBytes + } + if c.AddressContractsCacheBulkMaxBytes < c.AddressContractsCacheMaxBytes { + glog.Warningf("address_contracts_cache_bulk_max_bytes=%d is less than address_contracts_cache_max_bytes=%d", c.AddressContractsCacheBulkMaxBytes, c.AddressContractsCacheMaxBytes) + } + if c.TraceTimeout != "" { + if _, err := time.ParseDuration(c.TraceTimeout); err != nil { + return nil, errors.Annotatef(err, "invalid trace_timeout") + } + } + if _, err := c.MempoolTxTimeoutDuration(false); err != nil { + return nil, err + } + if _, err := c.AlternativeMempoolTxTimeoutDuration(); err != nil { + return nil, err + } + if _, err := c.AverageBlockTimeDuration(); err != nil { + return nil, err + } s := &EthereumRPC{ BaseChain: &bchain.BaseChain{}, ChainConfig: &c, } + // 1-slot buffer ensures we only queue one "refresh tip" signal at a time. + s.newBlockNotifyCh = make(chan struct{}, 1) - ProcessInternalTransactions = c.ProcessInternalTransactions + bchain.ProcessInternalTransactions = c.ProcessInternalTransactions // always create parser - s.Parser = NewEthereumParser(c.BlockAddressesToKeep, c.AddressAliases) + parser := NewEthereumParser(c.BlockAddressesToKeep, c.AddressAliases) + parser.HotAddressMinContracts = c.HotAddressMinContracts + parser.HotAddressLRUCacheSize = c.HotAddressLRUCacheSize + parser.HotAddressMinHits = c.HotAddressMinHits + parser.AddrContractsCacheMinSize = c.AddressContractsCacheMinSize + parser.AddrContractsCacheMaxBytes = c.AddressContractsCacheMaxBytes + parser.AddrContractsCacheBulkMaxBytes = c.AddressContractsCacheBulkMaxBytes + s.Parser = parser s.Timeout = time.Duration(c.RPCTimeout) * time.Second s.PushHandler = pushHandler return s, nil } -// Initialize initializes ethereum rpc interface -func (b *EthereumRPC) Initialize() error { - b.OpenRPC = func(url string) (bchain.EVMRPCClient, bchain.EVMClient, error) { - r, err := rpc.Dial(url) +func (b *EthereumRPC) SetMetrics(metrics *common.Metrics) { + b.metrics = metrics +} + +// AverageBlockTimeDuration exposes the chain's nominal block cadence. +func (b *EthereumRPC) AverageBlockTimeDuration() (time.Duration, error) { + return b.ChainConfig.AverageBlockTimeDuration() +} + +func (b *EthereumRPC) observeEthCall(mode string, count int) { + if b.metrics == nil || count <= 0 { + return + } + b.metrics.EthCallRequests.With(common.Labels{"mode": mode}).Add(float64(count)) +} + +// ObserveChainDataFallback increments a metric for chain-data fallback paths. +func (b *EthereumRPC) ObserveChainDataFallback(component, reason string) { + if b.metrics == nil || component == "" || reason == "" { + return + } + b.metrics.ChainDataFallbacks.With(common.Labels{"component": component, "reason": reason}).Inc() +} + +func (b *EthereumRPC) observeEthCallError(mode, errType string) { + if b.metrics == nil { + return + } + b.metrics.EthCallErrors.With(common.Labels{"mode": mode, "type": errType}).Inc() +} + +func (b *EthereumRPC) observeEthCallBatch(size int) { + if b.metrics == nil || size <= 0 { + return + } + b.metrics.EthCallBatchSize.Observe(float64(size)) +} + +func (b *EthereumRPC) observeEthCallContractInfo(field string) { + if b.metrics == nil { + return + } + b.metrics.EthCallContractInfo.With(common.Labels{"field": field}).Inc() +} + +func (b *EthereumRPC) observeEthCallTokenURI(method string) { + if b.metrics == nil { + return + } + b.metrics.EthCallTokenURI.With(common.Labels{"method": method}).Inc() +} + +func (b *EthereumRPC) observeEthCallStakingPool(field string) { + if b.metrics == nil { + return + } + b.metrics.EthCallStakingPool.With(common.Labels{"field": field}).Inc() +} + +// EnsureSameRPCHost validates both RPC URLs and logs a warning if hosts differ. +func EnsureSameRPCHost(httpURL, wsURL string) error { + if httpURL == "" || wsURL == "" { + return nil + } + httpHost, err := rpcURLHost(httpURL) + if err != nil { + return errors.Annotatef(err, "rpc_url") + } + wsHost, err := rpcURLHost(wsURL) + if err != nil { + return errors.Annotatef(err, "rpc_url_ws") + } + if !strings.EqualFold(httpHost, wsHost) { + glog.Warningf("rpc_url host %q and rpc_url_ws host %q differ", httpHost, wsHost) + } + return nil +} + +// NormalizeRPCURLs validates HTTP and WS RPC endpoints and enforces same-host rules. +func NormalizeRPCURLs(httpURL, wsURL string) (string, string, error) { + callURL := strings.TrimSpace(httpURL) + subURL := strings.TrimSpace(wsURL) + if callURL == "" { + return "", "", errors.New("rpc_url is empty") + } + if subURL == "" { + return "", "", errors.New("rpc_url_ws is empty") + } + if err := validateRPCURLScheme(callURL, "rpc_url", []string{"http", "https"}); err != nil { + return "", "", err + } + if err := validateRPCURLScheme(subURL, "rpc_url_ws", []string{"ws", "wss"}); err != nil { + return "", "", err + } + if err := EnsureSameRPCHost(callURL, subURL); err != nil { + return "", "", err + } + return callURL, subURL, nil +} + +func validateRPCURLScheme(rawURL, field string, allowedSchemes []string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return errors.Annotatef(err, "%s", field) + } + scheme := strings.ToLower(parsed.Scheme) + if scheme == "" { + return errors.Errorf("%s missing scheme in %q", field, rawURL) + } + for _, allowed := range allowedSchemes { + if scheme == allowed { + return nil + } + } + return errors.Errorf("%s must use %s scheme: %q", field, strings.Join(allowedSchemes, " or "), rawURL) +} + +func rpcURLHost(rawURL string) (string, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return "", err + } + host := parsed.Hostname() + if host == "" { + return "", errors.Errorf("missing host in %q", rawURL) + } + return host, nil +} + +func dialRPC(rawURL string) (*rpc.Client, error) { + if rawURL == "" { + return nil, errors.New("empty rpc url") + } + opts := []rpc.ClientOption{} + if strings.HasPrefix(rawURL, "ws://") || strings.HasPrefix(rawURL, "wss://") { + opts = append(opts, rpc.WithWebsocketMessageSizeLimit(0)) + } + return rpc.DialOptions(context.Background(), rawURL, opts...) +} + +// OpenRPC opens RPC connection to ETH backend. +var OpenRPC = func(httpURL, wsURL string) (bchain.EVMRPCClient, bchain.EVMClient, error) { + callURL, subURL, err := NormalizeRPCURLs(httpURL, wsURL) + if err != nil { + return nil, nil, err + } + callClient, err := dialRPC(callURL) + if err != nil { + return nil, nil, err + } + subClient := callClient + if subURL != callURL { + subClient, err = dialRPC(subURL) if err != nil { + callClient.Close() return nil, nil, err } - rc := &EthereumRPCClient{Client: r} - ec := &EthereumClient{Client: ethclient.NewClient(r)} - return rc, ec, nil } + rc := &DualRPCClient{CallClient: callClient, SubClient: subClient} + ec := &EthereumClient{Client: ethclient.NewClient(callClient)} + return rc, ec, nil +} - rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL) +// Initialize initializes ethereum rpc interface +func (b *EthereumRPC) Initialize() error { + b.OpenRPC = OpenRPC + + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) if err != nil { return err } @@ -143,48 +463,198 @@ func (b *EthereumRPC) Initialize() error { case MainNet: b.Testnet = false b.Network = "livenet" - case TestNet: - b.Testnet = true - b.Network = "testnet" - case TestNetGoerli: - b.Testnet = true - b.Network = "goerli" case TestNetSepolia: b.Testnet = true b.Network = "sepolia" + case TestNetHolesky: + b.Testnet = true + b.Network = "holesky" + case TestNetHoodi: + b.Testnet = true + b.Network = "hoodi" default: return errors.Errorf("Unknown network id %v", id) } + + err = b.initStakingPools() + if err != nil { + return err + } + + if err = b.InitAlternativeProviders(); err != nil { + return err + } + + b.consensusMonitor = newConsensusVersionMonitor(b.ChainConfig.ConsensusNodeVersionURL) + b.consensusMonitor.start() + glog.Info("rpc: block chain ", b.Network) return nil } +const ( + consensusVersionUnreachable = "unreachable-locally" + consensusVersionPollPeriod = 60 * time.Second +) + +// consensusVersionMonitor probes the configured consensus node /eth/v1/node/version +// endpoint and caches the latest result. The cached value (real version or +// "unreachable-locally") is the signal exposed via getInfo and the Prometheus +// backend_subversion label; periodic re-probes are silent so a node being +// down does not spam the log. +type consensusVersionMonitor struct { + url string + mu sync.RWMutex + version string + stop chan struct{} + stopOnce sync.Once +} + +func newConsensusVersionMonitor(url string) *consensusVersionMonitor { + if url == "" { + return nil + } + return &consensusVersionMonitor{url: url, stop: make(chan struct{})} +} + +// start performs an initial synchronous probe (logging one WARN if it fails) +// and then launches a background goroutine that re-probes every +// consensusVersionPollPeriod. Safe to call on a nil receiver. +func (m *consensusVersionMonitor) start() { + if m == nil { + return + } + v, err := m.fetch() + if err != nil { + glog.Warningf("consensus node version probe failed for %s: %v", m.url, err) + v = consensusVersionUnreachable + } + m.set(v) + go m.run() +} + +func (m *consensusVersionMonitor) run() { + ticker := time.NewTicker(consensusVersionPollPeriod) + defer ticker.Stop() + for { + select { + case <-m.stop: + return + case <-ticker.C: + v, err := m.fetch() + if err != nil { + v = consensusVersionUnreachable + } + m.set(v) + } + } +} + +func (m *consensusVersionMonitor) fetch() (string, error) { + httpClient := &http.Client{Timeout: 2 * time.Second} + resp, err := httpClient.Get(m.url) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + var v struct { + Data struct { + Version string `json:"version"` + } `json:"data"` + } + if err := json.Unmarshal(body, &v); err != nil { + return "", err + } + return v.Data.Version, nil +} + +func (m *consensusVersionMonitor) set(v string) { + m.mu.Lock() + m.version = v + m.mu.Unlock() +} + +func (m *consensusVersionMonitor) get() string { + if m == nil { + return "" + } + m.mu.RLock() + defer m.mu.RUnlock() + return m.version +} + +func (m *consensusVersionMonitor) shutdown() { + if m == nil { + return + } + m.stopOnce.Do(func() { close(m.stop) }) +} + +// InitAlternativeProviders initializes alternative providers +func (b *EthereumRPC) InitAlternativeProviders() error { + b.initAlternativeFeeProvider() + + // Env prefix follows explicit network aliases such as OP/BASE, otherwise ETH. + network := b.ChainConfig.Network + if network == "" { + network = b.ChainConfig.CoinShortcut + } + alternativeMempoolTxTimeout, err := b.ChainConfig.AlternativeMempoolTxTimeoutDuration() + if err != nil { + return err + } + b.alternativeSendTxProvider = NewAlternativeSendTxProvider(network, b.ChainConfig.RPCTimeout, alternativeMempoolTxTimeout) + return nil +} + // CreateMempool creates mempool if not already created, however does not initialize it func (b *EthereumRPC) CreateMempool(chain bchain.BlockChain) (bchain.Mempool, error) { if b.Mempool == nil { - b.Mempool = bchain.NewMempoolEthereumType(chain, b.ChainConfig.MempoolTxTimeoutHours, b.ChainConfig.QueryBackendOnMempoolResync) - glog.Info("mempool created, MempoolTxTimeoutHours=", b.ChainConfig.MempoolTxTimeoutHours, ", QueryBackendOnMempoolResync=", b.ChainConfig.QueryBackendOnMempoolResync) + mempoolTxTimeout, err := b.ChainConfig.MempoolTxTimeoutDuration(b.alternativeSendTxProvider != nil) + if err != nil { + return nil, err + } + b.Mempool = bchain.NewMempoolEthereumType(chain, mempoolTxTimeout, b.ChainConfig.QueryBackendOnMempoolResync) + glog.Info("mempool created, MempoolTxTimeout=", mempoolTxTimeout, ", QueryBackendOnMempoolResync=", b.ChainConfig.QueryBackendOnMempoolResync, ", DisableMempoolSync=", b.ChainConfig.DisableMempoolSync) + if b.alternativeSendTxProvider != nil { + b.alternativeSendTxProvider.SetupMempool(b.Mempool, b.removeTransactionFromMempool) + } + } return b.Mempool, nil } // InitializeMempool creates subscriptions to newHeads and newPendingTransactions -func (b *EthereumRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOutpointFunc, onNewTxAddr bchain.OnNewTxAddrFunc, onNewTx bchain.OnNewTxFunc) error { +func (b *EthereumRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOutpointFunc, onNewTx bchain.OnNewTxFunc) error { if b.Mempool == nil { return errors.New("Mempool not created") } + var err error + var txs []string // get initial mempool transactions - txs, err := b.GetMempoolTransactions() - if err != nil { - return err + // workaround for an occasional `decoding block` error from getBlockRaw - try 3 times with a delay and then proceed + for i := 0; i < 3; i++ { + txs, err = b.GetMempoolTransactions() + if err == nil { + break + } + glog.Error("GetMempoolTransaction ", err) + time.Sleep(time.Second * 5) } + for _, txid := range txs { b.Mempool.AddTransactionToMempool(txid) } - b.Mempool.OnNewTxAddr = onNewTxAddr b.Mempool.OnNewTx = onNewTx if err = b.subscribeEvents(); err != nil { @@ -197,16 +667,17 @@ func (b *EthereumRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOu } func (b *EthereumRPC) subscribeEvents() error { + b.newBlockNotifyOnce.Do(func() { + go b.newBlockNotifier() + }) // new block notifications handling go func() { for { - h, ok := b.NewBlock.Read() + _, ok := b.NewBlock.Read() if !ok { break } - b.UpdateBestHeader(h) - // notify blockbook - b.PushHandler(bchain.NotificationNewBlock) + b.signalNewBlock() } }() @@ -238,26 +709,30 @@ func (b *EthereumRPC) subscribeEvents() error { if glog.V(2) { glog.Info("rpc: new tx ", hex) } - b.Mempool.AddTransactionToMempool(hex) - b.PushHandler(bchain.NotificationNewTx) + added := b.Mempool.AddTransactionToMempool(hex) + if added { + b.PushHandler(bchain.NotificationNewTx) + } } }() - // new mempool transaction subscription - if err := b.subscribe(func() (bchain.EVMClientSubscription, error) { - // invalidate the previous subscription - it is either the first one or there was an error - b.newTxSubscription = nil - ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) - defer cancel() - sub, err := b.RPC.EthSubscribe(ctx, b.NewTx.Channel(), "newPendingTransactions") - if err != nil { - return nil, errors.Annotatef(err, "EthSubscribe newPendingTransactions") + if !b.ChainConfig.DisableMempoolSync { + // new mempool transaction subscription + if err := b.subscribe(func() (bchain.EVMClientSubscription, error) { + // invalidate the previous subscription - it is either the first one or there was an error + b.newTxSubscription = nil + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + sub, err := b.RPC.EthSubscribe(ctx, b.NewTx.Channel(), "newPendingTransactions") + if err != nil { + return nil, errors.Annotatef(err, "EthSubscribe newPendingTransactions") + } + b.newTxSubscription = sub + glog.Info("Subscribed to newPendingTransactions") + return sub, nil + }); err != nil { + return err } - b.newTxSubscription = sub - glog.Info("Subscribed to newPendingTransactions") - return sub, nil - }); err != nil { - return err } return nil @@ -303,6 +778,27 @@ func (b *EthereumRPC) subscribe(f func() (bchain.EVMClientSubscription, error)) return nil } +func (b *EthereumRPC) initAlternativeFeeProvider() { + var err error + if b.ChainConfig.AlternativeEstimateFee == "1inch" { + if b.alternativeFeeProvider, err = NewOneInchFeesProvider(b, b.ChainConfig.AlternativeEstimateFeeParams, b.metrics); err != nil { + glog.Error("New1InchFeesProvider error ", err, " Reverting to default estimateFee functionality") + // disable AlternativeEstimateFee logic + b.alternativeFeeProvider = nil + } + } else if b.ChainConfig.AlternativeEstimateFee == "infura" { + if b.alternativeFeeProvider, err = NewInfuraFeesProvider(b, b.ChainConfig.AlternativeEstimateFeeParams, b.metrics); err != nil { + glog.Error("NewInfuraFeesProvider error ", err, " Reverting to default estimateFee functionality") + // disable AlternativeEstimateFee logic + b.alternativeFeeProvider = nil + } + } + if b.alternativeFeeProvider != nil { + glog.Info("Using alternative fee provider ", b.ChainConfig.AlternativeEstimateFee) + } + +} + func (b *EthereumRPC) closeRPC() { if b.newBlockSubscription != nil { b.newBlockSubscription.Unsubscribe() @@ -318,7 +814,7 @@ func (b *EthereumRPC) closeRPC() { func (b *EthereumRPC) reconnectRPC() error { glog.Info("Reconnecting RPC") b.closeRPC() - rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL) + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) if err != nil { return err } @@ -332,6 +828,7 @@ func (b *EthereumRPC) Shutdown(ctx context.Context) error { b.closeRPC() b.NewBlock.Close() b.NewTx.Close() + b.consensusMonitor.shutdown() glog.Info("rpc: shutdown") return nil } @@ -346,37 +843,6 @@ func (b *EthereumRPC) GetSubversion() string { return "" } -func (b *EthereumRPC) getConsensusVersion() string { - if b.ChainConfig.ConsensusNodeVersionURL == "" { - return "" - } - httpClient := &http.Client{ - Timeout: 2 * time.Second, - } - resp, err := httpClient.Get(b.ChainConfig.ConsensusNodeVersionURL) - if err != nil || resp.StatusCode != http.StatusOK { - glog.Error("getConsensusVersion ", err) - return "" - } - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - glog.Error("getConsensusVersion ", err) - return "" - } - type consensusVersion struct { - Data struct { - Version string `json:"version"` - } `json:"data"` - } - var v consensusVersion - err = json.Unmarshal(body, &v) - if err != nil { - glog.Error("getConsensusVersion ", err) - return "" - } - return v.Data.Version -} - // GetChainInfo returns information about the connected backend func (b *EthereumRPC) GetChainInfo() (*bchain.ChainInfo, error) { h, err := b.getBestHeader() @@ -393,13 +859,12 @@ func (b *EthereumRPC) GetChainInfo() (*bchain.ChainInfo, error) { if err := b.RPC.CallContext(ctx, &ver, "web3_clientVersion"); err != nil { return nil, err } - consensusVersion := b.getConsensusVersion() rv := &bchain.ChainInfo{ Blocks: int(h.Number().Int64()), Bestblockhash: h.Hash(), Difficulty: h.Difficulty().String(), Version: ver, - ConsensusVersion: consensusVersion, + ConsensusVersion: b.consensusMonitor.get(), } idi := int(id.Uint64()) if idi == int(b.MainNetChainID) { @@ -438,11 +903,69 @@ func (b *EthereumRPC) getBestHeader() (bchain.EVMHeader, error) { // UpdateBestHeader keeps track of the latest block header confirmed on chain func (b *EthereumRPC) UpdateBestHeader(h bchain.EVMHeader) { - glog.V(2).Info("rpc: new block header ", h.Number()) + if h == nil || h.Number() == nil { + return + } + glog.V(2).Info("rpc: new block header ", h.Number().Uint64()) + b.setBestHeader(h) +} + +func (b *EthereumRPC) signalNewBlock() { + // Non-blocking send: one pending signal is enough to refresh the tip. + select { + case b.newBlockNotifyCh <- struct{}{}: + default: + } +} + +func (b *EthereumRPC) newBlockNotifier() { + for range b.newBlockNotifyCh { + updated, err := b.refreshBestHeaderFromChain() + if err != nil { + glog.Error("refreshBestHeaderFromChain ", err) + continue + } + if updated { + b.PushHandler(bchain.NotificationNewBlock) + } + } +} + +func (b *EthereumRPC) refreshBestHeaderFromChain() (bool, error) { + if b.Client == nil { + return false, errors.New("rpc client not initialized") + } + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + h, err := b.Client.HeaderByNumber(ctx, nil) + if err != nil { + return false, err + } + if h == nil || h.Number() == nil { + return false, errors.New("best header is nil") + } + return b.setBestHeader(h), nil +} + +func (b *EthereumRPC) setBestHeader(h bchain.EVMHeader) bool { + if h == nil || h.Number() == nil { + return false + } b.bestHeaderLock.Lock() + defer b.bestHeaderLock.Unlock() + changed := false + if b.bestHeader == nil || b.bestHeader.Number() == nil { + changed = true + } else { + prevNum := b.bestHeader.Number().Uint64() + newNum := h.Number().Uint64() + if prevNum != newNum || b.bestHeader.Hash() != h.Hash() { + changed = true + } + } b.bestHeader = h b.bestHeaderTime = time.Now() - b.bestHeaderLock.Unlock() + return changed } // GetBestBlockHash returns hash of the tip of the best-block-chain @@ -471,7 +994,7 @@ func (b *EthereumRPC) GetBlockHash(height uint32) (string, error) { defer cancel() h, err := b.Client.HeaderByNumber(ctx, &n) if err != nil { - if err == ethereum.NotFound { + if err == ethereum.NotFound || stdErrors.Is(err, bchain.ErrBlockNotFound) { return "", bchain.ErrBlockNotFound } return "", errors.Annotatef(err, "height %v", height) @@ -510,7 +1033,7 @@ func (b *EthereumRPC) ethHeaderToBlockHeader(h *rpcHeader) (*bchain.BlockHeader, func (b *EthereumRPC) GetBlockHeader(hash string) (*bchain.BlockHeader, error) { raw, err := b.getBlockRaw(hash, 0, false) if err != nil { - return nil, err + return nil, errors.Annotatef(err, "hash %v", hash) } var h rpcHeader if err := json.Unmarshal(raw, &h); err != nil { @@ -545,12 +1068,17 @@ func (b *EthereumRPC) getBlockRaw(hash string, height uint32, fullTxs bool) (jso } if err != nil { return nil, errors.Annotatef(err, "hash %v, height %v", hash, height) - } else if len(raw) == 0 { + } else if len(raw) == 0 || (len(raw) == 4 && string(raw) == "null") { return nil, bchain.ErrBlockNotFound } return raw, nil } +// GetBlockRawByHashOrHeight returns raw block JSON by hash or height. +func (b *EthereumRPC) GetBlockRawByHashOrHeight(hash string, height uint32, fullTxs bool) (json.RawMessage, error) { + return b.getBlockRaw(hash, height, fullTxs) +} + func (b *EthereumRPC) processEventsForBlock(blockNumber string) (map[string][]*bchain.RpcLog, []bchain.AddressAliasRecord, error) { ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() @@ -591,19 +1119,25 @@ type rpcTraceResult struct { } func (b *EthereumRPC) getCreationContractInfo(contract string, height uint32) *bchain.ContractInfo { - ci, err := b.fetchContractInfo(contract) - if ci == nil || err != nil { - ci = &bchain.ContractInfo{ - Contract: contract, - } - } - ci.Type = bchain.UnknownTokenType + // do not fetch fetchContractInfo in sync, it slows it down + // the contract will be fetched only when asked by a client + // ci, err := b.fetchContractInfo(contract) + // if ci == nil || err != nil { + ci := &bchain.ContractInfo{ + Contract: contract, + } + // } + ci.Standard = bchain.UnhandledTokenStandard + ci.Type = bchain.UnhandledTokenStandard ci.CreatedInBlock = height return ci } func (b *EthereumRPC) processCallTrace(call *rpcCallTrace, d *bchain.EthereumInternalData, contracts []bchain.ContractInfo, blockHeight uint32) []bchain.ContractInfo { value, err := hexutil.DecodeBig(call.Value) + if err != nil { + value = new(big.Int) + } if call.Type == "CREATE" || call.Type == "CREATE2" { d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{ Type: bchain.CREATE, @@ -639,22 +1173,48 @@ func (b *EthereumRPC) processCallTrace(call *rpcCallTrace, d *bchain.EthereumInt return contracts } -// getInternalDataForBlock fetches debug trace using callTracer, extracts internal transfers and creations and destructions of contracts -func (b *EthereumRPC) getInternalDataForBlock(blockHash string, blockHeight uint32, transactions []bchain.RpcTransaction) ([]bchain.EthereumInternalData, []bchain.ContractInfo, error) { +// getInternalDataForBlock fetches debug trace using callTracer, extracts internal transfers/creations/destructions; ctx controls cancellation. +func (b *EthereumRPC) getInternalDataForBlock(ctx context.Context, blockHash string, blockHeight uint32, transactions []bchain.RpcTransaction) ([]bchain.EthereumInternalData, []bchain.ContractInfo, error) { + if b.InternalDataProvider != nil { + return b.InternalDataProvider.GetInternalDataForBlock(blockHash, blockHeight, transactions) + } + data := make([]bchain.EthereumInternalData, len(transactions)) contracts := make([]bchain.ContractInfo, 0) - if ProcessInternalTransactions { - ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) - defer cancel() + if bchain.ProcessInternalTransactions { var trace []rpcTraceResult - err := b.RPC.CallContext(ctx, &trace, "debug_traceBlockByHash", blockHash, map[string]interface{}{"tracer": "callTracer"}) + traceConfig := map[string]interface{}{"tracer": "callTracer"} + if b.ChainConfig.TraceTimeout != "" { + traceConfig["timeout"] = b.ChainConfig.TraceTimeout + } + err := b.RPC.CallContext(ctx, &trace, "debug_traceBlockByHash", blockHash, traceConfig) // Use caller-provided ctx for timeout/cancel. if err != nil { glog.Error("debug_traceBlockByHash block ", blockHash, ", error ", err) return data, contracts, err } if len(trace) != len(data) { - glog.Error("debug_traceBlockByHash block ", blockHash, ", error: trace length does not match block length ", len(trace), "!=", len(data)) - return data, contracts, err + if len(trace) < len(data) { + for i := range transactions { + tx := &transactions[i] + // bridging transactions in Polygon do not create trace and cause mismatch between the trace size and block size, it is necessary to adjust the trace size + // bridging transaction that from and to zero address + if tx.To == "0x0000000000000000000000000000000000000000" && tx.From == "0x0000000000000000000000000000000000000000" { + if i >= len(trace) { + trace = append(trace, rpcTraceResult{}) + } else { + trace = append(trace[:i+1], trace[i:]...) + trace[i] = rpcTraceResult{} + } + } + } + } + if len(trace) != len(data) { + e := fmt.Sprint("trace length does not match block length ", len(trace), "!=", len(data)) + glog.Error("debug_traceBlockByHash block ", blockHash, ", error: ", e) + return data, contracts, errors.New(e) + } else { + glog.Warning("debug_traceBlockByHash block ", blockHash, ", trace adjusted to match the number of transactions in block") + } } for i, result := range trace { r := &result.Result @@ -701,33 +1261,62 @@ func (b *EthereumRPC) GetBlock(hash string, height uint32) (*bchain.Block, error if err != nil { return nil, err } - var head rpcHeader - if err := json.Unmarshal(raw, &head); err != nil { - return nil, errors.Annotatef(err, "hash %v, height %v", hash, height) + var block struct { + rpcHeader // Embed to unmarshal header and txs in one pass. + rpcBlockTransactions // Embed to avoid a second JSON decode. } - var body rpcBlockTransactions - if err := json.Unmarshal(raw, &body); err != nil { + if err := json.Unmarshal(raw, &block); err != nil { // Single decode to reduce CPU overhead. return nil, errors.Annotatef(err, "hash %v, height %v", hash, height) } + head := block.rpcHeader + body := block.rpcBlockTransactions bbh, err := b.ethHeaderToBlockHeader(&head) if err != nil { return nil, errors.Annotatef(err, "hash %v, height %v", hash, height) } - // get block events - // TODO - could be possibly done in parallel to getInternalDataForBlock - logs, ens, err := b.processEventsForBlock(head.Number) - if err != nil { - return nil, err - } + // Run event/log processing and internal data extraction in parallel; allow early return on log failure. + ctxInternal, cancelInternal := context.WithTimeout(context.Background(), b.Timeout) // Cancel trace RPC on log error or timeout. + defer cancelInternal() // Ensure timer resources are released on any return path. + type logsResult struct { // Bundles processEventsForBlock outputs for channel return. + logs map[string][]*bchain.RpcLog + ens []bchain.AddressAliasRecord + err error + } + type internalResult struct { // Bundles getInternalDataForBlock outputs for channel return. + data []bchain.EthereumInternalData + contracts []bchain.ContractInfo + err error + } + logsCh := make(chan logsResult, 1) // Buffered so send won't block if we return early. + internalCh := make(chan internalResult, 1) // Buffered to avoid goroutine leak on early return. + go func() { + logs, ens, err := b.processEventsForBlock(head.Number) + logsCh <- logsResult{logs: logs, ens: ens, err: err} // Send result without shared state. + }() + go func() { + data, contracts, err := b.getInternalDataForBlock(ctxInternal, head.Hash, bbh.Height, body.Transactions) // ctxInternal allows cancellation on log errors. + internalCh <- internalResult{data: data, contracts: contracts, err: err} // Send result without shared state. + }() + logsRes := <-logsCh + if logsRes.err != nil { + // Short-circuit on log failure to preserve existing error behavior. + return nil, logsRes.err + } + internalRes := <-internalCh + // Rebind results to keep downstream logic unchanged. + logs := logsRes.logs + ens := logsRes.ens + internalData := internalRes.data + contracts := internalRes.contracts + internalErr := internalRes.err // error fetching internal data does not stop the block processing var blockSpecificData *bchain.EthereumBlockSpecificData - internalData, contracts, err := b.getInternalDataForBlock(head.Hash, bbh.Height, body.Transactions) // pass internalData error and ENS records in blockSpecificData to be stored - if err != nil || len(ens) > 0 || len(contracts) > 0 { + if internalErr != nil || len(ens) > 0 || len(contracts) > 0 { blockSpecificData = &bchain.EthereumBlockSpecificData{} - if err != nil { - blockSpecificData.InternalDataError = err.Error() - // glog.Info("InternalDataError ", bbh.Height, ": ", err.Error()) + if internalErr != nil { + blockSpecificData.InternalDataError = internalErr.Error() + // glog.Info("InternalDataError ", bbh.Height, ": ", internalErr.Error()) } if len(ens) > 0 { blockSpecificData.AddressAliasRecords = ens @@ -742,14 +1331,12 @@ func (b *EthereumRPC) GetBlock(hash string, height uint32) (*bchain.Block, error btxs := make([]bchain.Tx, len(body.Transactions)) for i := range body.Transactions { tx := &body.Transactions[i] - btx, err := b.Parser.ethTxToTx(tx, &bchain.RpcReceipt{Logs: logs[tx.Hash]}, &internalData[i], bbh.Time, uint32(bbh.Confirmations), true) + btx, err := b.Parser.EthTxToTx(tx, &bchain.RpcReceipt{Logs: logs[tx.Hash]}, &internalData[i], bbh.Time, uint32(bbh.Confirmations), true) if err != nil { return nil, errors.Annotatef(err, "hash %v, height %v, txid %v", hash, height, tx.Hash) } btxs[i] = *btx - if b.mempoolInitialized { - b.Mempool.RemoveTransactionFromMempool(tx.Hash) - } + b.removeTransactionFromMempool(tx.Hash) } bbk := bchain.Block{ BlockHeader: *bbh, @@ -791,25 +1378,43 @@ func (b *EthereumRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) return b.GetTransaction(txid) } +func (b *EthereumRPC) removeTransactionFromMempool(txid string) { + // remove tx from mempool + if b.mempoolInitialized { + b.Mempool.RemoveTransactionFromMempool(txid) + } + // remove tx from mempool txs fetched by alternative method + if b.alternativeSendTxProvider != nil { + b.alternativeSendTxProvider.RemoveTransaction(txid) + } +} + // GetTransaction returns a transaction by the transaction ID. func (b *EthereumRPC) GetTransaction(txid string) (*bchain.Tx, error) { ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() var tx *bchain.RpcTransaction + var txFound bool + var err error hash := ethcommon.HexToHash(txid) - err := b.RPC.CallContext(ctx, &tx, "eth_getTransactionByHash", hash) - if err != nil { - return nil, err - } else if tx == nil { - if b.mempoolInitialized { - b.Mempool.RemoveTransactionFromMempool(txid) + if b.alternativeSendTxProvider != nil { + tx, txFound = b.alternativeSendTxProvider.GetTransaction(txid) + } + if !txFound { + tx = &bchain.RpcTransaction{} + err = b.RPC.CallContext(ctx, tx, "eth_getTransactionByHash", hash) + if err != nil { + return nil, err } + } + if *tx == (bchain.RpcTransaction{}) { + b.removeTransactionFromMempool(txid) return nil, bchain.ErrTxNotFound } var btx *bchain.Tx if tx.BlockNumber == "" { // mempool tx - btx, err = b.Parser.ethTxToTx(tx, nil, nil, 0, 0, true) + btx, err = b.Parser.EthTxToTx(tx, nil, nil, 0, 0, true) if err != nil { return nil, errors.Annotatef(err, "txid %v", txid) } @@ -820,7 +1425,8 @@ func (b *EthereumRPC) GetTransaction(txid string) (*bchain.Tx, error) { return nil, err } var ht struct { - Time string `json:"timestamp"` + Time string `json:"timestamp"` + BaseFeePerGas string `json:"baseFeePerGas"` } if err := json.Unmarshal(raw, &ht); err != nil { return nil, errors.Annotatef(err, "hash %v", hash) @@ -829,8 +1435,8 @@ func (b *EthereumRPC) GetTransaction(txid string) (*bchain.Tx, error) { if time, err = ethNumber(ht.Time); err != nil { return nil, errors.Annotatef(err, "txid %v", txid) } - var receipt bchain.RpcReceipt - err = b.RPC.CallContext(ctx, &receipt, "eth_getTransactionReceipt", hash) + tx.BaseFeePerGas = ht.BaseFeePerGas + receipt, err := b.EthereumTypeGetTransactionReceipt(txid) if err != nil { return nil, errors.Annotatef(err, "txid %v", txid) } @@ -842,14 +1448,11 @@ func (b *EthereumRPC) GetTransaction(txid string) (*bchain.Tx, error) { if err != nil { return nil, errors.Annotatef(err, "txid %v", txid) } - btx, err = b.Parser.ethTxToTx(tx, &receipt, nil, time, confirmations, true) + btx, err = b.Parser.EthTxToTx(tx, receipt, nil, time, confirmations, true) if err != nil { return nil, errors.Annotatef(err, "txid %v", txid) } - // remove tx from mempool if it is there - if b.mempoolInitialized { - b.Mempool.RemoveTransactionFromMempool(txid) - } + b.removeTransactionFromMempool(txid) } return btx, nil } @@ -938,30 +1541,155 @@ func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (ui if s, ok := GetStringFromMap("gasPrice", params); ok && len(s) > 0 { msg.GasPrice, _ = hexutil.DecodeBig(s) } + + if b.alternativeSendTxProvider != nil { + result, err := b.alternativeSendTxProvider.callHttpStringResult( + b.alternativeSendTxProvider.urls[0], + "eth_estimateGas", + params, + ) + if err == nil { + return hexutil.DecodeUint64(result) + } + } return b.Client.EstimateGas(ctx, msg) } +// EthereumTypeGetEip1559Fees retrieves Eip1559Fees, if supported +func (b *EthereumRPC) EthereumTypeGetEip1559Fees() (*bchain.Eip1559Fees, error) { + if !b.ChainConfig.Eip1559Fees { + return nil, nil + } + // if there is an alternative provider, use it + if b.alternativeFeeProvider != nil { + return b.alternativeFeeProvider.GetEip1559Fees() + } + + // otherwise use algorithm from here https://docs.alchemy.com/docs/how-to-build-a-gas-fee-estimator-using-eip-1559 + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + var maxPriorityFeePerGas hexutil.Big + err := b.RPC.CallContext(ctx, &maxPriorityFeePerGas, "eth_maxPriorityFeePerGas") + if err != nil { + return nil, err + } + + var fees bchain.Eip1559Fees + + type history struct { + OldestBlock string `json:"oldestBlock"` + Reward [][]string `json:"reward"` + BaseFeePerGas []string `json:"baseFeePerGas"` + GasUsedRatio []float64 `json:"gasUsedRatio"` + } + var h history + percentiles := []int{ + 20, // low + 70, // medium + 90, // high + 99, // instant + } + blocks := 4 + + err = b.RPC.CallContext(ctx, &h, "eth_feeHistory", blocks, "pending", percentiles) + if err != nil { + return nil, err + } + if len(h.BaseFeePerGas) < blocks { + return nil, nil + } + + hs, _ := json.Marshal(h) + baseFee, _ := hexutil.DecodeUint64(h.BaseFeePerGas[blocks-1]) + fees.BaseFeePerGas = big.NewInt(int64(baseFee)) + maxBasePriorityFee := maxPriorityFeePerGas.ToInt().Int64() + glog.Info("eth_maxPriorityFeePerGas ", maxPriorityFeePerGas) + glog.Info("eth_feeHistory ", string(hs)) + + for i := 0; i < 4; i++ { + var f bchain.Eip1559Fee + priorityFee := int64(0) + for j := 0; j < len(h.Reward); j++ { + p, _ := hexutil.DecodeUint64(h.Reward[j][i]) + priorityFee += int64(p) + } + priorityFee = priorityFee / int64(len(h.Reward)) + f.MaxFeePerGas = big.NewInt(priorityFee) + f.MaxPriorityFeePerGas = big.NewInt(maxBasePriorityFee) + maxBasePriorityFee *= 2 + switch i { + case 0: + fees.Low = &f + case 1: + fees.Medium = &f + case 2: + fees.High = &f + default: + fees.Instant = &f + } + } + return &fees, err +} + // SendRawTransaction sends raw transaction -func (b *EthereumRPC) SendRawTransaction(hex string) (string, error) { +func (b *EthereumRPC) SendRawTransaction(hex string, disableAlternativeRPC bool) (string, error) { + var txid string + var retErr error + + if !disableAlternativeRPC && b.alternativeSendTxProvider != nil { + txid, retErr = b.alternativeSendTxProvider.SendRawTransaction(hex) + if retErr == nil { + return txid, nil + } + if b.alternativeSendTxProvider.UseOnlyAlternativeProvider() { + return txid, retErr + } + } + + txid, retErr = b.callRpcStringResult("eth_sendRawTransaction", hex) + if b.ChainConfig.DisableMempoolSync { + // add transactions submitted by us to mempool if sync is disabled + b.Mempool.AddTransactionToMempool(txid) + } + return txid, retErr +} + +// EthereumTypeGetRawTransaction gets raw transaction in hex format +func (b *EthereumRPC) EthereumTypeGetRawTransaction(txid string) (string, error) { + return b.callRpcStringResult("eth_getRawTransactionByHash", txid) +} + +// Helper function for calling ETH RPC with parameters and getting string result +func (b *EthereumRPC) callRpcStringResult(rpcMethod string, args ...interface{}) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() var raw json.RawMessage - err := b.RPC.CallContext(ctx, &raw, "eth_sendRawTransaction", hex) + err := b.RPC.CallContext(ctx, &raw, rpcMethod, args...) if err != nil { return "", err } else if len(raw) == 0 { - return "", errors.New("SendRawTransaction: failed") + return "", errors.New(rpcMethod + " : failed") } var result string if err := json.Unmarshal(raw, &result); err != nil { return "", errors.Annotatef(err, "raw result %v", raw) } if result == "" { - return "", errors.New("SendRawTransaction: failed, empty result") + return "", errors.New(rpcMethod + " : failed, empty result") } return result, nil } +// EthereumTypeGetTransactionReceipt returns the transaction receipt by the transaction ID. +func (b *EthereumRPC) EthereumTypeGetTransactionReceipt(txid string) (*bchain.RpcReceipt, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + var r *bchain.RpcReceipt + err := b.RPC.CallContext(ctx, &r, "eth_getTransactionReceipt", ethcommon.HexToHash(txid)) + return r, err +} + // EthereumTypeGetBalance returns current balance of an address func (b *EthereumRPC) EthereumTypeGetBalance(addrDesc bchain.AddressDescriptor) (*big.Int, error) { ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) @@ -971,12 +1699,224 @@ func (b *EthereumRPC) EthereumTypeGetBalance(addrDesc bchain.AddressDescriptor) // EthereumTypeGetNonce returns current balance of an address func (b *EthereumRPC) EthereumTypeGetNonce(addrDesc bchain.AddressDescriptor) (uint64, error) { - ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) - defer cancel() - return b.Client.NonceAt(ctx, addrDesc, nil) + var result string + var err error + var usedAlternative bool + + ethAddress := ethcommon.BytesToAddress(addrDesc) + + if b.alternativeSendTxProvider != nil { + result, err = b.alternativeSendTxProvider.callHttpStringResult( + b.alternativeSendTxProvider.urls[0], + "eth_getTransactionCount", + ethAddress, + "pending", + ) + if err == nil && result != "" { + usedAlternative = true + } else { + glog.Errorf("Alternative provider failed for eth_getTransactionCount: %v, falling back to primary RPC", err) + } + } + + if !usedAlternative { + result, err = b.callRpcStringResult("eth_getTransactionCount", ethAddress, "pending") + if err != nil { + glog.Errorf("Primary RPC failed for eth_getTransactionCount: %v", err) + return 0, err + } + } + + nonce, err := hexutil.DecodeUint64(result) + if err != nil { + glog.Errorf("Failed to parse nonce result '%s': %v", result, err) + return 0, err + } + + return nonce, nil } // GetChainParser returns ethereum BlockChainParser func (b *EthereumRPC) GetChainParser() bchain.BlockChainParser { return b.Parser } + +// ENS helper: namehash per ENS spec +func ensNameHash(name string) string { + node := make([]byte, 32) + if name != "" { + labels := strings.Split(name, ".") + for i := len(labels) - 1; i >= 0; i-- { + labelHash := keccak256([]byte(labels[i])) + node = keccak256(append(node, labelHash...)) + } + } + return "0x" + hex.EncodeToString(node) +} + +func keccak256(data []byte) []byte { + hash := sha3.NewLegacyKeccak256() + hash.Write(data) + return hash.Sum(nil) +} + +func parseENSAddressFromResult(result string) (string, error) { + if len(result) < 2 || result[:2] != "0x" { + return "", errors.New("invalid hex result") + } + hexData := result[2:] + if len(hexData) < 64 { + return "", errors.New("result too short") + } + addressHex := hexData[len(hexData)-EthereumAddressHexLength:] + return "0x" + addressHex, nil +} + +func (b *EthereumRPC) ensContracts() (string, string, error) { + if b.Testnet || b.MainNetChainID != MainNet { + // ENS contracts are mainnet-only here; avoid calling empty/uninitialized addresses on other networks. + return "", "", errors.New("ENS contracts not configured for this network") + } + return ENSRegistryAddress, ENSBaseRegistrarAddress, nil +} + +// ResolveENS resolves ENS domain name to Ethereum address +func (b *EthereumRPC) ResolveENS(name string) (*bchain.ENSResolution, error) { + glog.Infof("ResolveENS: Starting resolution for %s", name) + + name = strings.ToLower(strings.TrimSpace(name)) + if !strings.HasSuffix(name, ".eth") { + glog.Errorf("ResolveENS: Invalid ENS name %s", name) + return &bchain.ENSResolution{Name: name, Error: "invalid ENS name"}, errors.New("invalid ENS name") + } + + // Calculate the namehash for this domain + node := ensNameHash(name) + glog.Infof("ResolveENS: Generated node hash %s for %s", node, name) + + registry, _, err := b.ensContracts() + if err != nil { + // This avoids empty eth_call targets on L2s while keeping mainnet behavior unchanged + return &bchain.ENSResolution{Name: name, Error: "ENS not supported on this network"}, err + } + + // Call resolver(bytes32) on the ENS registry + callData := map[string]string{ + "to": registry, + "data": ENSResolverFunctionSelector + node[2:], + } + // Call the resolver function on the ENS registry + result, err := b.callRpcStringResult("eth_call", callData, "latest") + if err != nil { + glog.Errorf("ResolveENS: Registry call failed: %v", err) + return &bchain.ENSResolution{Name: name, Error: "failed to query ENS registry"}, err + } + glog.Infof("ResolveENS: Registry result: %s", result) + + // Parse the resolver address from the result + //The result is ABI-encoded, we need to extract the address from the last 40 hex characters + resolverAddr, err := parseENSAddressFromResult(result) + if err != nil { + glog.Errorf("ResolveENS: Failed to parse resolver address: %v", err) + return &bchain.ENSResolution{Name: name, Error: "failed to parse resolver"}, err + } + glog.Infof("ResolveENS: Resolver address: %s", resolverAddr) + + if resolverAddr == EthereumZeroAddress { + glog.Errorf("ResolveENS: No resolver set for %s", name) + return &bchain.ENSResolution{Name: name, Error: "no resolver set"}, errors.New("no resolver set") + } + + // Call the addr(bytes32) function on the resolver + callData = map[string]string{ + "to": resolverAddr, + "data": ENSAddrFunctionSelector + node[2:], + } + + result, err = b.callRpcStringResult("eth_call", callData, "latest") + if err != nil { + glog.Errorf("ResolveENS: Resolver call failed: %v", err) + return &bchain.ENSResolution{Name: name, Error: "failed to query resolver"}, err + } + glog.Infof("ResolveENS: Resolver result: %s", result) + + address, err := parseENSAddressFromResult(result) + if err != nil { + glog.Errorf("ResolveENS: Failed to parse address: %v", err) + return &bchain.ENSResolution{Name: name, Error: "failed to parse address"}, err + } + + if address == EthereumZeroAddress { + glog.Errorf("ResolveENS: ENS name %s not found", name) + return &bchain.ENSResolution{Name: name, Error: "ENS name not found"}, errors.New("ENS name not found") + } + + glog.Infof("ResolveENS: Successfully resolved %s to %s", name, address) + return &bchain.ENSResolution{Name: name, Address: address}, nil +} + +// CheckENSExpiration checks if an ENS domain is expired +func (b *EthereumRPC) CheckENSExpiration(name string) (bool, error) { + name = strings.ToLower(strings.TrimSpace(name)) + + // Only check expiration for .eth domains + if !strings.HasSuffix(name, ".eth") { + glog.Infof("CheckENSExpiration: %s is not a .eth domain, skipping expiration check", name) + return false, nil + } + + // Extract the label (part before .eth) + label := strings.TrimSuffix(name, ".eth") + if strings.Contains(label, ".") { + // Base Registrar tracks only second-level .eth names; for subdomains, check the parent label. + parts := strings.Split(label, ".") + label = parts[len(parts)-1] + } + + _, registrar, err := b.ensContracts() + if err != nil { + return false, err + } + + // Calculate token ID: keccak256(label) + labelHash := keccak256([]byte(label)) + tokenID := new(big.Int).SetBytes(labelHash) + + glog.Infof("CheckENSExpiration: Checking expiration for %s (label: %s, tokenID: %s)", name, label, tokenID.String()) + + // Pad token ID to 32 bytes (64 hex chars) with leading zeros + tokenIDHex := hex.EncodeToString(tokenID.Bytes()) + tokenIDPadded := strings.Repeat("0", 64-len(tokenIDHex)) + tokenIDHex + + // Call nameExpires(uint256 id) on the Base Registrar + callData := map[string]string{ + "to": registrar, + "data": ENSExpirationFunctionSelector + tokenIDPadded, + } + + result, err := b.callRpcStringResult("eth_call", callData, "latest") + if err != nil { + glog.Errorf("CheckENSExpiration: RPC call failed for %s: %v", name, err) + return false, err + } + + // Parse the expiration timestamp from the result + if len(result) < 2 || result[:2] != "0x" { + return false, errors.New("invalid hex result") + } + + expiration, err := hexutil.DecodeBig(result) + if err != nil { + glog.Errorf("CheckENSExpiration: Failed to decode expiration for %s: %v", name, err) + return false, err + } + + // Check if expired (current timestamp > expiration timestamp) + currentTime := big.NewInt(time.Now().Unix()) + isExpired := currentTime.Cmp(expiration) > 0 + + expirationTime := time.Unix(expiration.Int64(), 0) + glog.Infof("CheckENSExpiration: %s expires at %s (expired: %v)", name, expirationTime.String(), isExpired) + + return isExpired, nil +} diff --git a/bchain/coins/eth/ethrpc_average_block_time_test.go b/bchain/coins/eth/ethrpc_average_block_time_test.go new file mode 100644 index 0000000000..4b4760a902 --- /dev/null +++ b/bchain/coins/eth/ethrpc_average_block_time_test.go @@ -0,0 +1,108 @@ +package eth + +import ( + "encoding/json" + "testing" + "time" +) + +func TestConfigurationAverageBlockTimeDuration(t *testing.T) { + tests := []struct { + name string + config Configuration + want time.Duration + wantErr bool + }{ + { + name: "ethereum mainnet 12s slot", + config: Configuration{AverageBlockTimeMs: 12000}, + want: 12 * time.Second, + }, + { + name: "arbitrum sub-second", + config: Configuration{AverageBlockTimeMs: 250}, + want: 250 * time.Millisecond, + }, + { + name: "unset is rejected", + config: Configuration{}, + wantErr: true, + }, + { + name: "negative is rejected", + config: Configuration{AverageBlockTimeMs: -1}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.config.AverageBlockTimeDuration() + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("AverageBlockTimeDuration() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestNewEthereumRPCRequiresAverageBlockTimeMs(t *testing.T) { + tests := []struct { + name string + config string + wantErr bool + }{ + { + name: "missing averageBlockTimeMs is rejected", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "block_addresses_to_keep":600 + }`, + wantErr: true, + }, + { + name: "zero averageBlockTimeMs is rejected", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "block_addresses_to_keep":600, + "averageBlockTimeMs":0 + }`, + wantErr: true, + }, + { + name: "positive averageBlockTimeMs passes validation", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "block_addresses_to_keep":600, + "averageBlockTimeMs":12000 + }`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewEthereumRPC(json.RawMessage(tt.config), nil) + if tt.wantErr { + if err == nil { + t.Fatal("expected averageBlockTimeMs configuration error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/bchain/coins/eth/ethrpc_mempool_timeout_test.go b/bchain/coins/eth/ethrpc_mempool_timeout_test.go new file mode 100644 index 0000000000..afb2bc9e0e --- /dev/null +++ b/bchain/coins/eth/ethrpc_mempool_timeout_test.go @@ -0,0 +1,193 @@ +package eth + +import ( + "encoding/json" + "testing" + "time" +) + +func TestConfigurationMempoolTxTimeoutDuration(t *testing.T) { + tests := []struct { + name string + config Configuration + alternativeProviderEnabled bool + want time.Duration + }{ + { + name: "legacy hours without alternative provider", + config: Configuration{ + MempoolTxTimeoutHours: 12, + }, + want: 12 * time.Hour, + }, + { + name: "alternative provider default", + config: Configuration{ + MempoolTxTimeoutHours: 12, + }, + alternativeProviderEnabled: true, + want: 10 * time.Minute, + }, + { + name: "explicit duration overrides alternative provider default", + config: Configuration{ + MempoolTxTimeoutHours: 12, + MempoolTxTimeout: "15m", + }, + alternativeProviderEnabled: true, + want: 15 * time.Minute, + }, + { + name: "legacy zero is preserved", + config: Configuration{ + MempoolTxTimeoutHours: 0, + }, + want: 0, + }, + { + name: "explicit zero duration is preserved", + config: Configuration{ + MempoolTxTimeoutHours: 12, + MempoolTxTimeout: "0s", + }, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.config.MempoolTxTimeoutDuration(tt.alternativeProviderEnabled) + if err != nil { + t.Fatalf("MempoolTxTimeoutDuration() error = %v", err) + } + if got != tt.want { + t.Fatalf("MempoolTxTimeoutDuration() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestConfigurationAlternativeMempoolTxTimeoutDuration(t *testing.T) { + tests := []struct { + name string + config Configuration + want time.Duration + }{ + { + name: "default", + want: 5 * time.Minute, + }, + { + name: "explicit duration", + config: Configuration{ + AlternativeMempoolTxTimeout: "7m", + }, + want: 7 * time.Minute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.config.AlternativeMempoolTxTimeoutDuration() + if err != nil { + t.Fatalf("AlternativeMempoolTxTimeoutDuration() error = %v", err) + } + if got != tt.want { + t.Fatalf("AlternativeMempoolTxTimeoutDuration() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestNewEthereumRPCRejectsInvalidMempoolTimeouts(t *testing.T) { + tests := []struct { + name string + config string + }{ + { + name: "invalid blockbook mempool timeout", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "mempoolTxTimeout":"not-a-duration", + "block_addresses_to_keep":600 + }`, + }, + { + name: "zero alternative mempool timeout", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "alternativeMempoolTxTimeout":"0s", + "block_addresses_to_keep":600 + }`, + }, + { + name: "negative blockbook mempool timeout", + config: `{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "mempoolTxTimeout":"-1s", + "block_addresses_to_keep":600 + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewEthereumRPC(json.RawMessage(tt.config), nil) + if err == nil { + t.Fatal("expected timeout configuration error") + } + }) + } +} + +func TestInitAlternativeProvidersUsesAlternativeMempoolTxTimeout(t *testing.T) { + t.Setenv("ETH_ALTERNATIVE_SENDTX_URLS", "http://localhost:8545") + + tests := []struct { + name string + config Configuration + want time.Duration + }{ + { + name: "default", + config: Configuration{ + CoinShortcut: "eth", + RPCTimeout: 1, + }, + want: 5 * time.Minute, + }, + { + name: "explicit duration", + config: Configuration{ + CoinShortcut: "eth", + RPCTimeout: 1, + AlternativeMempoolTxTimeout: "7m", + }, + want: 7 * time.Minute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &EthereumRPC{ + ChainConfig: &tt.config, + } + if err := b.InitAlternativeProviders(); err != nil { + t.Fatalf("InitAlternativeProviders() error = %v", err) + } + + if b.alternativeSendTxProvider == nil { + t.Fatal("alternativeSendTxProvider is nil") + } + if got := b.alternativeSendTxProvider.mempoolTxsTimeout; got != tt.want { + t.Fatalf("mempoolTxsTimeout = %s, want %s", got, tt.want) + } + }) + } +} diff --git a/bchain/coins/eth/ethrpc_trace_test.go b/bchain/coins/eth/ethrpc_trace_test.go new file mode 100644 index 0000000000..72c1d50ce2 --- /dev/null +++ b/bchain/coins/eth/ethrpc_trace_test.go @@ -0,0 +1,105 @@ +package eth + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +type mockTraceRPC struct { + method string + args []interface{} +} + +func (m *mockTraceRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} + +func (m *mockTraceRPC) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + m.method = method + m.args = append([]interface{}{}, args...) + if out, ok := result.(*[]rpcTraceResult); ok { + *out = []rpcTraceResult{} + } + return nil +} + +func (m *mockTraceRPC) Close() {} + +func TestNewEthereumRPCRejectsInvalidTraceTimeout(t *testing.T) { + _, err := NewEthereumRPC(json.RawMessage(`{ + "coin_name":"Ethereum", + "coin_shortcut":"ETH", + "rpc_timeout":25, + "trace_timeout":"not-a-duration", + "block_addresses_to_keep":600 + }`), nil) + if err == nil { + t.Fatal("expected invalid trace_timeout error") + } +} + +func TestGetInternalDataForBlockIncludesTraceTimeout(t *testing.T) { + rpcClient := &mockTraceRPC{} + b := &EthereumRPC{ + RPC: rpcClient, + ChainConfig: &Configuration{ + ProcessInternalTransactions: true, + TraceTimeout: "20s", + }, + } + bchain.ProcessInternalTransactions = true + t.Cleanup(func() { + bchain.ProcessInternalTransactions = false + }) + + _, _, err := b.getInternalDataForBlock(context.Background(), "0xabc", 1, nil) + if err != nil { + t.Fatalf("getInternalDataForBlock() error = %v", err) + } + if rpcClient.method != "debug_traceBlockByHash" { + t.Fatalf("method = %q, want %q", rpcClient.method, "debug_traceBlockByHash") + } + if len(rpcClient.args) != 2 { + t.Fatalf("args len = %d, want 2", len(rpcClient.args)) + } + traceConfig, ok := rpcClient.args[1].(map[string]interface{}) + if !ok { + t.Fatalf("trace config type = %T, want map[string]interface{}", rpcClient.args[1]) + } + if got := traceConfig["tracer"]; got != "callTracer" { + t.Fatalf("tracer = %#v, want %q", got, "callTracer") + } + if got := traceConfig["timeout"]; got != "20s" { + t.Fatalf("timeout = %#v, want %q", got, "20s") + } +} + +func TestGetInternalDataForBlockOmitsTraceTimeoutWhenUnset(t *testing.T) { + rpcClient := &mockTraceRPC{} + b := &EthereumRPC{ + RPC: rpcClient, + ChainConfig: &Configuration{ + ProcessInternalTransactions: true, + }, + } + bchain.ProcessInternalTransactions = true + t.Cleanup(func() { + bchain.ProcessInternalTransactions = false + }) + + _, _, err := b.getInternalDataForBlock(context.Background(), "0xabc", 1, nil) + if err != nil { + t.Fatalf("getInternalDataForBlock() error = %v", err) + } + traceConfig, ok := rpcClient.args[1].(map[string]interface{}) + if !ok { + t.Fatalf("trace config type = %T, want map[string]interface{}", rpcClient.args[1]) + } + if _, ok := traceConfig["timeout"]; ok { + t.Fatalf("timeout should be omitted when unset, config = %#v", traceConfig) + } +} diff --git a/bchain/coins/eth/ethtx.pb.go b/bchain/coins/eth/ethtx.pb.go index 6023a259b5..17aed2493e 100644 --- a/bchain/coins/eth/ethtx.pb.go +++ b/bchain/coins/eth/ethtx.pb.go @@ -1,261 +1,516 @@ // Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v3.21.12 // source: bchain/coins/eth/ethtx.proto -/* -Package eth is a generated protocol buffer package. +package eth -It is generated from these files: - bchain/coins/eth/ethtx.proto +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) -It has these top-level messages: - ProtoCompleteTransaction -*/ -package eth +type ProtoCompleteTransaction struct { + state protoimpl.MessageState `protogen:"open.v1"` + BlockNumber uint32 `protobuf:"varint,1,opt,name=BlockNumber,proto3" json:"BlockNumber,omitempty"` + BlockTime uint64 `protobuf:"varint,2,opt,name=BlockTime,proto3" json:"BlockTime,omitempty"` + Tx *ProtoCompleteTransaction_TxType `protobuf:"bytes,3,opt,name=Tx,proto3" json:"Tx,omitempty"` + Receipt *ProtoCompleteTransaction_ReceiptType `protobuf:"bytes,4,opt,name=Receipt,proto3" json:"Receipt,omitempty"` + ChainExtraData []byte `protobuf:"bytes,5,opt,name=ChainExtraData,proto3" json:"ChainExtraData,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} -import proto "github.com/golang/protobuf/proto" -import fmt "fmt" -import math "math" +func (x *ProtoCompleteTransaction) Reset() { + *x = ProtoCompleteTransaction{} + mi := &file_bchain_coins_eth_ethtx_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} -// Reference imports to suppress errors if they are not otherwise used. -var _ = proto.Marshal -var _ = fmt.Errorf -var _ = math.Inf +func (x *ProtoCompleteTransaction) String() string { + return protoimpl.X.MessageStringOf(x) +} -// This is a compile-time assertion to ensure that this generated file -// is compatible with the proto package it is being compiled against. -// A compilation error at this line likely means your copy of the -// proto package needs to be updated. -const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package +func (*ProtoCompleteTransaction) ProtoMessage() {} -type ProtoCompleteTransaction struct { - BlockNumber uint32 `protobuf:"varint,1,opt,name=BlockNumber" json:"BlockNumber,omitempty"` - BlockTime uint64 `protobuf:"varint,2,opt,name=BlockTime" json:"BlockTime,omitempty"` - Tx *ProtoCompleteTransaction_TxType `protobuf:"bytes,3,opt,name=Tx" json:"Tx,omitempty"` - Receipt *ProtoCompleteTransaction_ReceiptType `protobuf:"bytes,4,opt,name=Receipt" json:"Receipt,omitempty"` +func (x *ProtoCompleteTransaction) ProtoReflect() protoreflect.Message { + mi := &file_bchain_coins_eth_ethtx_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) } -func (m *ProtoCompleteTransaction) Reset() { *m = ProtoCompleteTransaction{} } -func (m *ProtoCompleteTransaction) String() string { return proto.CompactTextString(m) } -func (*ProtoCompleteTransaction) ProtoMessage() {} -func (*ProtoCompleteTransaction) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } +// Deprecated: Use ProtoCompleteTransaction.ProtoReflect.Descriptor instead. +func (*ProtoCompleteTransaction) Descriptor() ([]byte, []int) { + return file_bchain_coins_eth_ethtx_proto_rawDescGZIP(), []int{0} +} -func (m *ProtoCompleteTransaction) GetBlockNumber() uint32 { - if m != nil { - return m.BlockNumber +func (x *ProtoCompleteTransaction) GetBlockNumber() uint32 { + if x != nil { + return x.BlockNumber } return 0 } -func (m *ProtoCompleteTransaction) GetBlockTime() uint64 { - if m != nil { - return m.BlockTime +func (x *ProtoCompleteTransaction) GetBlockTime() uint64 { + if x != nil { + return x.BlockTime } return 0 } -func (m *ProtoCompleteTransaction) GetTx() *ProtoCompleteTransaction_TxType { - if m != nil { - return m.Tx +func (x *ProtoCompleteTransaction) GetTx() *ProtoCompleteTransaction_TxType { + if x != nil { + return x.Tx } return nil } -func (m *ProtoCompleteTransaction) GetReceipt() *ProtoCompleteTransaction_ReceiptType { - if m != nil { - return m.Receipt +func (x *ProtoCompleteTransaction) GetReceipt() *ProtoCompleteTransaction_ReceiptType { + if x != nil { + return x.Receipt + } + return nil +} + +func (x *ProtoCompleteTransaction) GetChainExtraData() []byte { + if x != nil { + return x.ChainExtraData } return nil } type ProtoCompleteTransaction_TxType struct { - AccountNonce uint64 `protobuf:"varint,1,opt,name=AccountNonce" json:"AccountNonce,omitempty"` - GasPrice []byte `protobuf:"bytes,2,opt,name=GasPrice,proto3" json:"GasPrice,omitempty"` - GasLimit uint64 `protobuf:"varint,3,opt,name=GasLimit" json:"GasLimit,omitempty"` - Value []byte `protobuf:"bytes,4,opt,name=Value,proto3" json:"Value,omitempty"` - Payload []byte `protobuf:"bytes,5,opt,name=Payload,proto3" json:"Payload,omitempty"` - Hash []byte `protobuf:"bytes,6,opt,name=Hash,proto3" json:"Hash,omitempty"` - To []byte `protobuf:"bytes,7,opt,name=To,proto3" json:"To,omitempty"` - From []byte `protobuf:"bytes,8,opt,name=From,proto3" json:"From,omitempty"` - TransactionIndex uint32 `protobuf:"varint,9,opt,name=TransactionIndex" json:"TransactionIndex,omitempty"` -} - -func (m *ProtoCompleteTransaction_TxType) Reset() { *m = ProtoCompleteTransaction_TxType{} } -func (m *ProtoCompleteTransaction_TxType) String() string { return proto.CompactTextString(m) } -func (*ProtoCompleteTransaction_TxType) ProtoMessage() {} + state protoimpl.MessageState `protogen:"open.v1"` + AccountNonce uint64 `protobuf:"varint,1,opt,name=AccountNonce,proto3" json:"AccountNonce,omitempty"` + GasPrice []byte `protobuf:"bytes,2,opt,name=GasPrice,proto3" json:"GasPrice,omitempty"` + GasLimit uint64 `protobuf:"varint,3,opt,name=GasLimit,proto3" json:"GasLimit,omitempty"` + Value []byte `protobuf:"bytes,4,opt,name=Value,proto3" json:"Value,omitempty"` + Payload []byte `protobuf:"bytes,5,opt,name=Payload,proto3" json:"Payload,omitempty"` + Hash []byte `protobuf:"bytes,6,opt,name=Hash,proto3" json:"Hash,omitempty"` + To []byte `protobuf:"bytes,7,opt,name=To,proto3" json:"To,omitempty"` + From []byte `protobuf:"bytes,8,opt,name=From,proto3" json:"From,omitempty"` + TransactionIndex uint32 `protobuf:"varint,9,opt,name=TransactionIndex,proto3" json:"TransactionIndex,omitempty"` + MaxPriorityFeePerGas []byte `protobuf:"bytes,10,opt,name=MaxPriorityFeePerGas,proto3,oneof" json:"MaxPriorityFeePerGas,omitempty"` + MaxFeePerGas []byte `protobuf:"bytes,11,opt,name=MaxFeePerGas,proto3,oneof" json:"MaxFeePerGas,omitempty"` + BaseFeePerGas []byte `protobuf:"bytes,12,opt,name=BaseFeePerGas,proto3,oneof" json:"BaseFeePerGas,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProtoCompleteTransaction_TxType) Reset() { + *x = ProtoCompleteTransaction_TxType{} + mi := &file_bchain_coins_eth_ethtx_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProtoCompleteTransaction_TxType) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtoCompleteTransaction_TxType) ProtoMessage() {} + +func (x *ProtoCompleteTransaction_TxType) ProtoReflect() protoreflect.Message { + mi := &file_bchain_coins_eth_ethtx_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtoCompleteTransaction_TxType.ProtoReflect.Descriptor instead. func (*ProtoCompleteTransaction_TxType) Descriptor() ([]byte, []int) { - return fileDescriptor0, []int{0, 0} + return file_bchain_coins_eth_ethtx_proto_rawDescGZIP(), []int{0, 0} } -func (m *ProtoCompleteTransaction_TxType) GetAccountNonce() uint64 { - if m != nil { - return m.AccountNonce +func (x *ProtoCompleteTransaction_TxType) GetAccountNonce() uint64 { + if x != nil { + return x.AccountNonce } return 0 } -func (m *ProtoCompleteTransaction_TxType) GetGasPrice() []byte { - if m != nil { - return m.GasPrice +func (x *ProtoCompleteTransaction_TxType) GetGasPrice() []byte { + if x != nil { + return x.GasPrice } return nil } -func (m *ProtoCompleteTransaction_TxType) GetGasLimit() uint64 { - if m != nil { - return m.GasLimit +func (x *ProtoCompleteTransaction_TxType) GetGasLimit() uint64 { + if x != nil { + return x.GasLimit } return 0 } -func (m *ProtoCompleteTransaction_TxType) GetValue() []byte { - if m != nil { - return m.Value +func (x *ProtoCompleteTransaction_TxType) GetValue() []byte { + if x != nil { + return x.Value } return nil } -func (m *ProtoCompleteTransaction_TxType) GetPayload() []byte { - if m != nil { - return m.Payload +func (x *ProtoCompleteTransaction_TxType) GetPayload() []byte { + if x != nil { + return x.Payload } return nil } -func (m *ProtoCompleteTransaction_TxType) GetHash() []byte { - if m != nil { - return m.Hash +func (x *ProtoCompleteTransaction_TxType) GetHash() []byte { + if x != nil { + return x.Hash } return nil } -func (m *ProtoCompleteTransaction_TxType) GetTo() []byte { - if m != nil { - return m.To +func (x *ProtoCompleteTransaction_TxType) GetTo() []byte { + if x != nil { + return x.To } return nil } -func (m *ProtoCompleteTransaction_TxType) GetFrom() []byte { - if m != nil { - return m.From +func (x *ProtoCompleteTransaction_TxType) GetFrom() []byte { + if x != nil { + return x.From } return nil } -func (m *ProtoCompleteTransaction_TxType) GetTransactionIndex() uint32 { - if m != nil { - return m.TransactionIndex +func (x *ProtoCompleteTransaction_TxType) GetTransactionIndex() uint32 { + if x != nil { + return x.TransactionIndex } return 0 } +func (x *ProtoCompleteTransaction_TxType) GetMaxPriorityFeePerGas() []byte { + if x != nil { + return x.MaxPriorityFeePerGas + } + return nil +} + +func (x *ProtoCompleteTransaction_TxType) GetMaxFeePerGas() []byte { + if x != nil { + return x.MaxFeePerGas + } + return nil +} + +func (x *ProtoCompleteTransaction_TxType) GetBaseFeePerGas() []byte { + if x != nil { + return x.BaseFeePerGas + } + return nil +} + type ProtoCompleteTransaction_ReceiptType struct { - GasUsed []byte `protobuf:"bytes,1,opt,name=GasUsed,proto3" json:"GasUsed,omitempty"` - Status []byte `protobuf:"bytes,2,opt,name=Status,proto3" json:"Status,omitempty"` - Log []*ProtoCompleteTransaction_ReceiptType_LogType `protobuf:"bytes,3,rep,name=Log" json:"Log,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + GasUsed []byte `protobuf:"bytes,1,opt,name=GasUsed,proto3" json:"GasUsed,omitempty"` + Status []byte `protobuf:"bytes,2,opt,name=Status,proto3" json:"Status,omitempty"` + Log []*ProtoCompleteTransaction_ReceiptType_LogType `protobuf:"bytes,3,rep,name=Log,proto3" json:"Log,omitempty"` + L1Fee []byte `protobuf:"bytes,4,opt,name=L1Fee,proto3,oneof" json:"L1Fee,omitempty"` + L1FeeScalar []byte `protobuf:"bytes,5,opt,name=L1FeeScalar,proto3,oneof" json:"L1FeeScalar,omitempty"` + L1GasPrice []byte `protobuf:"bytes,6,opt,name=L1GasPrice,proto3,oneof" json:"L1GasPrice,omitempty"` + L1GasUsed []byte `protobuf:"bytes,7,opt,name=L1GasUsed,proto3,oneof" json:"L1GasUsed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (m *ProtoCompleteTransaction_ReceiptType) Reset() { *m = ProtoCompleteTransaction_ReceiptType{} } -func (m *ProtoCompleteTransaction_ReceiptType) String() string { return proto.CompactTextString(m) } -func (*ProtoCompleteTransaction_ReceiptType) ProtoMessage() {} +func (x *ProtoCompleteTransaction_ReceiptType) Reset() { + *x = ProtoCompleteTransaction_ReceiptType{} + mi := &file_bchain_coins_eth_ethtx_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProtoCompleteTransaction_ReceiptType) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtoCompleteTransaction_ReceiptType) ProtoMessage() {} + +func (x *ProtoCompleteTransaction_ReceiptType) ProtoReflect() protoreflect.Message { + mi := &file_bchain_coins_eth_ethtx_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtoCompleteTransaction_ReceiptType.ProtoReflect.Descriptor instead. func (*ProtoCompleteTransaction_ReceiptType) Descriptor() ([]byte, []int) { - return fileDescriptor0, []int{0, 1} + return file_bchain_coins_eth_ethtx_proto_rawDescGZIP(), []int{0, 1} +} + +func (x *ProtoCompleteTransaction_ReceiptType) GetGasUsed() []byte { + if x != nil { + return x.GasUsed + } + return nil } -func (m *ProtoCompleteTransaction_ReceiptType) GetGasUsed() []byte { - if m != nil { - return m.GasUsed +func (x *ProtoCompleteTransaction_ReceiptType) GetStatus() []byte { + if x != nil { + return x.Status } return nil } -func (m *ProtoCompleteTransaction_ReceiptType) GetStatus() []byte { - if m != nil { - return m.Status +func (x *ProtoCompleteTransaction_ReceiptType) GetLog() []*ProtoCompleteTransaction_ReceiptType_LogType { + if x != nil { + return x.Log } return nil } -func (m *ProtoCompleteTransaction_ReceiptType) GetLog() []*ProtoCompleteTransaction_ReceiptType_LogType { - if m != nil { - return m.Log +func (x *ProtoCompleteTransaction_ReceiptType) GetL1Fee() []byte { + if x != nil { + return x.L1Fee + } + return nil +} + +func (x *ProtoCompleteTransaction_ReceiptType) GetL1FeeScalar() []byte { + if x != nil { + return x.L1FeeScalar + } + return nil +} + +func (x *ProtoCompleteTransaction_ReceiptType) GetL1GasPrice() []byte { + if x != nil { + return x.L1GasPrice + } + return nil +} + +func (x *ProtoCompleteTransaction_ReceiptType) GetL1GasUsed() []byte { + if x != nil { + return x.L1GasUsed } return nil } type ProtoCompleteTransaction_ReceiptType_LogType struct { - Address []byte `protobuf:"bytes,1,opt,name=Address,proto3" json:"Address,omitempty"` - Data []byte `protobuf:"bytes,2,opt,name=Data,proto3" json:"Data,omitempty"` - Topics [][]byte `protobuf:"bytes,3,rep,name=Topics,proto3" json:"Topics,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Address []byte `protobuf:"bytes,1,opt,name=Address,proto3" json:"Address,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=Data,proto3" json:"Data,omitempty"` + Topics [][]byte `protobuf:"bytes,3,rep,name=Topics,proto3" json:"Topics,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (m *ProtoCompleteTransaction_ReceiptType_LogType) Reset() { - *m = ProtoCompleteTransaction_ReceiptType_LogType{} +func (x *ProtoCompleteTransaction_ReceiptType_LogType) Reset() { + *x = ProtoCompleteTransaction_ReceiptType_LogType{} + mi := &file_bchain_coins_eth_ethtx_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (m *ProtoCompleteTransaction_ReceiptType_LogType) String() string { - return proto.CompactTextString(m) + +func (x *ProtoCompleteTransaction_ReceiptType_LogType) String() string { + return protoimpl.X.MessageStringOf(x) } + func (*ProtoCompleteTransaction_ReceiptType_LogType) ProtoMessage() {} + +func (x *ProtoCompleteTransaction_ReceiptType_LogType) ProtoReflect() protoreflect.Message { + mi := &file_bchain_coins_eth_ethtx_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtoCompleteTransaction_ReceiptType_LogType.ProtoReflect.Descriptor instead. func (*ProtoCompleteTransaction_ReceiptType_LogType) Descriptor() ([]byte, []int) { - return fileDescriptor0, []int{0, 1, 0} + return file_bchain_coins_eth_ethtx_proto_rawDescGZIP(), []int{0, 1, 0} } -func (m *ProtoCompleteTransaction_ReceiptType_LogType) GetAddress() []byte { - if m != nil { - return m.Address +func (x *ProtoCompleteTransaction_ReceiptType_LogType) GetAddress() []byte { + if x != nil { + return x.Address } return nil } -func (m *ProtoCompleteTransaction_ReceiptType_LogType) GetData() []byte { - if m != nil { - return m.Data +func (x *ProtoCompleteTransaction_ReceiptType_LogType) GetData() []byte { + if x != nil { + return x.Data } return nil } -func (m *ProtoCompleteTransaction_ReceiptType_LogType) GetTopics() [][]byte { - if m != nil { - return m.Topics +func (x *ProtoCompleteTransaction_ReceiptType_LogType) GetTopics() [][]byte { + if x != nil { + return x.Topics } return nil } -func init() { - proto.RegisterType((*ProtoCompleteTransaction)(nil), "eth.ProtoCompleteTransaction") - proto.RegisterType((*ProtoCompleteTransaction_TxType)(nil), "eth.ProtoCompleteTransaction.TxType") - proto.RegisterType((*ProtoCompleteTransaction_ReceiptType)(nil), "eth.ProtoCompleteTransaction.ReceiptType") - proto.RegisterType((*ProtoCompleteTransaction_ReceiptType_LogType)(nil), "eth.ProtoCompleteTransaction.ReceiptType.LogType") -} - -func init() { proto.RegisterFile("bchain/coins/eth/ethtx.proto", fileDescriptor0) } - -var fileDescriptor0 = []byte{ - // 409 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x92, 0xdf, 0x8a, 0xd4, 0x30, - 0x18, 0xc5, 0xe9, 0x9f, 0x99, 0xd9, 0xfd, 0xa6, 0x8a, 0x04, 0x91, 0x30, 0xec, 0x45, 0x59, 0xbc, - 0x18, 0xbd, 0xe8, 0xe2, 0xea, 0x0b, 0xac, 0x23, 0xae, 0xc2, 0xb0, 0x0e, 0x31, 0x7a, 0x9f, 0x49, - 0xc3, 0x36, 0x38, 0x6d, 0x4a, 0x93, 0x42, 0xf7, 0x8d, 0x7c, 0x21, 0xdf, 0xc5, 0x4b, 0xc9, 0xd7, - 0x74, 0x1d, 0x11, 0x65, 0x2f, 0x0a, 0xf9, 0x9d, 0x7e, 0xa7, 0x39, 0x27, 0x29, 0x9c, 0xed, 0x65, - 0x25, 0x74, 0x73, 0x21, 0x8d, 0x6e, 0xec, 0x85, 0x72, 0x95, 0x7f, 0xdc, 0x50, 0xb4, 0x9d, 0x71, - 0x86, 0x24, 0xca, 0x55, 0xe7, 0xdf, 0x67, 0x40, 0x77, 0x1e, 0x37, 0xa6, 0x6e, 0x0f, 0xca, 0x29, - 0xde, 0x89, 0xc6, 0x0a, 0xe9, 0xb4, 0x69, 0x48, 0x0e, 0xcb, 0xb7, 0x07, 0x23, 0xbf, 0xdd, 0xf4, - 0xf5, 0x5e, 0x75, 0x34, 0xca, 0xa3, 0xf5, 0x23, 0x76, 0x2c, 0x91, 0x33, 0x38, 0x45, 0xe4, 0xba, - 0x56, 0x34, 0xce, 0xa3, 0x75, 0xca, 0x7e, 0x0b, 0xe4, 0x0d, 0xc4, 0x7c, 0xa0, 0x49, 0x1e, 0xad, - 0x97, 0x97, 0xcf, 0x0b, 0xe5, 0xaa, 0xe2, 0x5f, 0x5b, 0x15, 0x7c, 0xe0, 0x77, 0xad, 0x62, 0x31, - 0x1f, 0xc8, 0x06, 0x16, 0x4c, 0x49, 0xa5, 0x5b, 0x47, 0x53, 0xb4, 0xbe, 0xf8, 0xbf, 0x35, 0x0c, - 0xa3, 0x7f, 0x72, 0xae, 0x7e, 0x46, 0x30, 0x1f, 0xbf, 0x49, 0xce, 0x21, 0xbb, 0x92, 0xd2, 0xf4, - 0x8d, 0xbb, 0x31, 0x8d, 0x54, 0x58, 0x23, 0x65, 0x7f, 0x68, 0x64, 0x05, 0x27, 0xd7, 0xc2, 0xee, - 0x3a, 0x2d, 0xc7, 0x1a, 0x19, 0xbb, 0xe7, 0xf0, 0x6e, 0xab, 0x6b, 0xed, 0xb0, 0x4b, 0xca, 0xee, - 0x99, 0x3c, 0x85, 0xd9, 0x57, 0x71, 0xe8, 0x15, 0x26, 0xcd, 0xd8, 0x08, 0x84, 0xc2, 0x62, 0x27, - 0xee, 0x0e, 0x46, 0x94, 0x74, 0x86, 0xfa, 0x84, 0x84, 0x40, 0xfa, 0x41, 0xd8, 0x8a, 0xce, 0x51, - 0xc6, 0x35, 0x79, 0x0c, 0x31, 0x37, 0x74, 0x81, 0x4a, 0xcc, 0x8d, 0x9f, 0x79, 0xdf, 0x99, 0x9a, - 0x9e, 0x8c, 0x33, 0x7e, 0x4d, 0x5e, 0xc2, 0x93, 0xa3, 0xca, 0x1f, 0x9b, 0x52, 0x0d, 0xf4, 0x14, - 0xaf, 0xe3, 0x2f, 0x7d, 0xf5, 0x23, 0x82, 0xe5, 0xd1, 0x99, 0xf8, 0x34, 0xd7, 0xc2, 0x7e, 0xb1, - 0xaa, 0xc4, 0xea, 0x19, 0x9b, 0x90, 0x3c, 0x83, 0xf9, 0x67, 0x27, 0x5c, 0x6f, 0x43, 0xe7, 0x40, - 0x64, 0x03, 0xc9, 0xd6, 0xdc, 0xd2, 0x24, 0x4f, 0xd6, 0xcb, 0xcb, 0x57, 0x0f, 0x3e, 0xfd, 0x62, - 0x6b, 0x6e, 0xf1, 0x16, 0xbc, 0x7b, 0xf5, 0x09, 0x16, 0x81, 0x7d, 0x82, 0xab, 0xb2, 0xec, 0x94, - 0xb5, 0x53, 0x82, 0x80, 0xbe, 0xeb, 0x3b, 0xe1, 0x44, 0xd8, 0x1f, 0xd7, 0x3e, 0x15, 0x37, 0xad, - 0x96, 0x16, 0x03, 0x64, 0x2c, 0xd0, 0x7e, 0x8e, 0xbf, 0xed, 0xeb, 0x5f, 0x01, 0x00, 0x00, 0xff, - 0xff, 0xc2, 0x69, 0x8d, 0xdf, 0xd6, 0x02, 0x00, 0x00, +var File_bchain_coins_eth_ethtx_proto protoreflect.FileDescriptor + +var file_bchain_coins_eth_ethtx_proto_rawDesc = string([]byte{ + 0x0a, 0x1c, 0x62, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x2f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x2f, 0x65, + 0x74, 0x68, 0x2f, 0x65, 0x74, 0x68, 0x74, 0x78, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xce, + 0x08, 0x0a, 0x18, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, + 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x42, + 0x6c, 0x6f, 0x63, 0x6b, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x0b, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x1c, 0x0a, + 0x09, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x09, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x30, 0x0a, 0x02, 0x54, + 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x43, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x54, 0x78, 0x54, 0x79, 0x70, 0x65, 0x52, 0x02, 0x54, 0x78, 0x12, 0x3f, 0x0a, + 0x07, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, + 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x72, + 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, + 0x74, 0x54, 0x79, 0x70, 0x65, 0x52, 0x07, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x12, 0x26, + 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x45, 0x78, 0x74, 0x72, 0x61, 0x44, 0x61, 0x74, 0x61, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x43, 0x68, 0x61, 0x69, 0x6e, 0x45, 0x78, 0x74, + 0x72, 0x61, 0x44, 0x61, 0x74, 0x61, 0x1a, 0xc1, 0x03, 0x0a, 0x06, 0x54, 0x78, 0x54, 0x79, 0x70, + 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x6f, 0x6e, 0x63, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x4e, 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x47, 0x61, 0x73, 0x50, 0x72, 0x69, 0x63, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x47, 0x61, 0x73, 0x50, 0x72, 0x69, 0x63, + 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x47, 0x61, 0x73, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x08, 0x47, 0x61, 0x73, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x14, 0x0a, + 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x12, 0x0a, + 0x04, 0x48, 0x61, 0x73, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x48, 0x61, 0x73, + 0x68, 0x12, 0x0e, 0x0a, 0x02, 0x54, 0x6f, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x54, + 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x46, 0x72, 0x6f, 0x6d, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x04, 0x46, 0x72, 0x6f, 0x6d, 0x12, 0x2a, 0x0a, 0x10, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x10, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x64, 0x65, + 0x78, 0x12, 0x37, 0x0a, 0x14, 0x4d, 0x61, 0x78, 0x50, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, + 0x46, 0x65, 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x48, + 0x00, 0x52, 0x14, 0x4d, 0x61, 0x78, 0x50, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x46, 0x65, + 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x4d, 0x61, + 0x78, 0x46, 0x65, 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, + 0x48, 0x01, 0x52, 0x0c, 0x4d, 0x61, 0x78, 0x46, 0x65, 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, + 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x42, 0x61, 0x73, 0x65, 0x46, 0x65, 0x65, 0x50, 0x65, + 0x72, 0x47, 0x61, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x42, 0x61, + 0x73, 0x65, 0x46, 0x65, 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, 0x88, 0x01, 0x01, 0x42, 0x17, + 0x0a, 0x15, 0x5f, 0x4d, 0x61, 0x78, 0x50, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x46, 0x65, + 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x4d, 0x61, 0x78, 0x46, + 0x65, 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x42, 0x61, 0x73, + 0x65, 0x46, 0x65, 0x65, 0x50, 0x65, 0x72, 0x47, 0x61, 0x73, 0x1a, 0x92, 0x03, 0x0a, 0x0b, 0x52, + 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x47, 0x61, + 0x73, 0x55, 0x73, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x47, 0x61, 0x73, + 0x55, 0x73, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3f, 0x0a, 0x03, + 0x4c, 0x6f, 0x67, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x50, 0x72, 0x6f, 0x74, + 0x6f, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x54, 0x79, 0x70, 0x65, + 0x2e, 0x4c, 0x6f, 0x67, 0x54, 0x79, 0x70, 0x65, 0x52, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x19, 0x0a, + 0x05, 0x4c, 0x31, 0x46, 0x65, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x05, + 0x4c, 0x31, 0x46, 0x65, 0x65, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x4c, 0x31, 0x46, 0x65, + 0x65, 0x53, 0x63, 0x61, 0x6c, 0x61, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x01, 0x52, + 0x0b, 0x4c, 0x31, 0x46, 0x65, 0x65, 0x53, 0x63, 0x61, 0x6c, 0x61, 0x72, 0x88, 0x01, 0x01, 0x12, + 0x23, 0x0a, 0x0a, 0x4c, 0x31, 0x47, 0x61, 0x73, 0x50, 0x72, 0x69, 0x63, 0x65, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0a, 0x4c, 0x31, 0x47, 0x61, 0x73, 0x50, 0x72, 0x69, 0x63, + 0x65, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x4c, 0x31, 0x47, 0x61, 0x73, 0x55, 0x73, 0x65, + 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x03, 0x52, 0x09, 0x4c, 0x31, 0x47, 0x61, 0x73, + 0x55, 0x73, 0x65, 0x64, 0x88, 0x01, 0x01, 0x1a, 0x4f, 0x0a, 0x07, 0x4c, 0x6f, 0x67, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x07, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x12, 0x0a, 0x04, + 0x44, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x44, 0x61, 0x74, 0x61, + 0x12, 0x16, 0x0a, 0x06, 0x54, 0x6f, 0x70, 0x69, 0x63, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0c, + 0x52, 0x06, 0x54, 0x6f, 0x70, 0x69, 0x63, 0x73, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x4c, 0x31, 0x46, + 0x65, 0x65, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x4c, 0x31, 0x46, 0x65, 0x65, 0x53, 0x63, 0x61, 0x6c, + 0x61, 0x72, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x4c, 0x31, 0x47, 0x61, 0x73, 0x50, 0x72, 0x69, 0x63, + 0x65, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x4c, 0x31, 0x47, 0x61, 0x73, 0x55, 0x73, 0x65, 0x64, 0x42, + 0x12, 0x5a, 0x10, 0x62, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x2f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x2f, + 0x65, 0x74, 0x68, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_bchain_coins_eth_ethtx_proto_rawDescOnce sync.Once + file_bchain_coins_eth_ethtx_proto_rawDescData []byte +) + +func file_bchain_coins_eth_ethtx_proto_rawDescGZIP() []byte { + file_bchain_coins_eth_ethtx_proto_rawDescOnce.Do(func() { + file_bchain_coins_eth_ethtx_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_bchain_coins_eth_ethtx_proto_rawDesc), len(file_bchain_coins_eth_ethtx_proto_rawDesc))) + }) + return file_bchain_coins_eth_ethtx_proto_rawDescData +} + +var file_bchain_coins_eth_ethtx_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_bchain_coins_eth_ethtx_proto_goTypes = []any{ + (*ProtoCompleteTransaction)(nil), // 0: ProtoCompleteTransaction + (*ProtoCompleteTransaction_TxType)(nil), // 1: ProtoCompleteTransaction.TxType + (*ProtoCompleteTransaction_ReceiptType)(nil), // 2: ProtoCompleteTransaction.ReceiptType + (*ProtoCompleteTransaction_ReceiptType_LogType)(nil), // 3: ProtoCompleteTransaction.ReceiptType.LogType +} +var file_bchain_coins_eth_ethtx_proto_depIdxs = []int32{ + 1, // 0: ProtoCompleteTransaction.Tx:type_name -> ProtoCompleteTransaction.TxType + 2, // 1: ProtoCompleteTransaction.Receipt:type_name -> ProtoCompleteTransaction.ReceiptType + 3, // 2: ProtoCompleteTransaction.ReceiptType.Log:type_name -> ProtoCompleteTransaction.ReceiptType.LogType + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_bchain_coins_eth_ethtx_proto_init() } +func file_bchain_coins_eth_ethtx_proto_init() { + if File_bchain_coins_eth_ethtx_proto != nil { + return + } + file_bchain_coins_eth_ethtx_proto_msgTypes[1].OneofWrappers = []any{} + file_bchain_coins_eth_ethtx_proto_msgTypes[2].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_bchain_coins_eth_ethtx_proto_rawDesc), len(file_bchain_coins_eth_ethtx_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_bchain_coins_eth_ethtx_proto_goTypes, + DependencyIndexes: file_bchain_coins_eth_ethtx_proto_depIdxs, + MessageInfos: file_bchain_coins_eth_ethtx_proto_msgTypes, + }.Build() + File_bchain_coins_eth_ethtx_proto = out.File + file_bchain_coins_eth_ethtx_proto_goTypes = nil + file_bchain_coins_eth_ethtx_proto_depIdxs = nil } diff --git a/bchain/coins/eth/ethtx.proto b/bchain/coins/eth/ethtx.proto index ef7c4ce09d..fabd421030 100644 --- a/bchain/coins/eth/ethtx.proto +++ b/bchain/coins/eth/ethtx.proto @@ -1,30 +1,38 @@ syntax = "proto3"; - package eth; - - message ProtoCompleteTransaction { - message TxType { - uint64 AccountNonce = 1; - bytes GasPrice = 2; - uint64 GasLimit = 3; - bytes Value = 4; - bytes Payload = 5; - bytes Hash = 6; - bytes To = 7; - bytes From = 8; - uint32 TransactionIndex = 9; - } - message ReceiptType { - message LogType { - bytes Address = 1; - bytes Data = 2; - repeated bytes Topics = 3; - } - bytes GasUsed = 1; - bytes Status = 2; - repeated LogType Log = 3; - } - uint32 BlockNumber = 1; - uint64 BlockTime = 2; - TxType Tx = 3; - ReceiptType Receipt = 4; - } \ No newline at end of file +option go_package = "bchain/coins/eth"; + +message ProtoCompleteTransaction { + message TxType { + uint64 AccountNonce = 1; + bytes GasPrice = 2; + uint64 GasLimit = 3; + bytes Value = 4; + bytes Payload = 5; + bytes Hash = 6; + bytes To = 7; + bytes From = 8; + uint32 TransactionIndex = 9; + optional bytes MaxPriorityFeePerGas = 10; + optional bytes MaxFeePerGas = 11; + optional bytes BaseFeePerGas = 12; + } + message ReceiptType { + message LogType { + bytes Address = 1; + bytes Data = 2; + repeated bytes Topics = 3; + } + bytes GasUsed = 1; + bytes Status = 2; + repeated LogType Log = 3; + optional bytes L1Fee = 4; + optional bytes L1FeeScalar = 5; + optional bytes L1GasPrice = 6; + optional bytes L1GasUsed = 7; + } + uint32 BlockNumber = 1; + uint64 BlockTime = 2; + TxType Tx = 3; + ReceiptType Receipt = 4; + bytes ChainExtraData = 5; +} diff --git a/bchain/coins/eth/evm.go b/bchain/coins/eth/evm.go index 1304b24325..3accfa8956 100644 --- a/bchain/coins/eth/evm.go +++ b/bchain/coins/eth/evm.go @@ -47,6 +47,41 @@ type EthereumRPCClient struct { *rpc.Client } +// DualRPCClient routes calls over HTTP and subscriptions over WebSocket. +type DualRPCClient struct { + CallClient *rpc.Client + SubClient *rpc.Client +} + +// CallContext forwards JSON-RPC calls to the HTTP client. +func (c *DualRPCClient) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + return c.CallClient.CallContext(ctx, result, method, args...) +} + +// BatchCallContext forwards batch JSON-RPC calls to the HTTP client. +func (c *DualRPCClient) BatchCallContext(ctx context.Context, batch []rpc.BatchElem) error { + return c.CallClient.BatchCallContext(ctx, batch) +} + +// EthSubscribe forwards subscriptions to the WebSocket client. +func (c *DualRPCClient) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + sub, err := c.SubClient.EthSubscribe(ctx, channel, args...) + if err != nil { + return nil, err + } + return &EthereumClientSubscription{ClientSubscription: sub}, nil +} + +// Close shuts down both underlying clients. +func (c *DualRPCClient) Close() { + if c.SubClient != nil { + c.SubClient.Close() + } + if c.CallClient != nil && c.CallClient != c.SubClient { + c.CallClient.Close() + } +} + // EthSubscribe subscribes to events and returns a client subscription that implements the EVMClientSubscription interface func (c *EthereumRPCClient) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { sub, err := c.Client.EthSubscribe(ctx, channel, args...) @@ -92,6 +127,11 @@ type EthereumNewBlock struct { channel chan *types.Header } +// NewEthereumNewBlock returns an initialized EthereumNewBlock struct +func NewEthereumNewBlock() *EthereumNewBlock { + return &EthereumNewBlock{channel: make(chan *types.Header)} +} + // Channel returns the underlying channel as an empty interface func (s *EthereumNewBlock) Channel() interface{} { return s.channel @@ -113,6 +153,11 @@ type EthereumNewTx struct { channel chan common.Hash } +// NewEthereumNewTx returns an initialized EthereumNewTx struct +func NewEthereumNewTx() *EthereumNewTx { + return &EthereumNewTx{channel: make(chan common.Hash)} +} + // Channel returns the underlying channel as an empty interface func (s *EthereumNewTx) Channel() interface{} { return s.channel diff --git a/bchain/coins/eth/infurafees.go b/bchain/coins/eth/infurafees.go new file mode 100644 index 0000000000..a81b5940aa --- /dev/null +++ b/bchain/coins/eth/infurafees.go @@ -0,0 +1,206 @@ +package eth + +import ( + "bytes" + "encoding/json" + "math/big" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// https://gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees returns +// { +// "low": { +// "suggestedMaxPriorityFeePerGas": "0.01128", +// "suggestedMaxFeePerGas": "9.919888552", +// "minWaitTimeEstimate": 15000, +// "maxWaitTimeEstimate": 60000 +// }, +// "medium": { +// "suggestedMaxPriorityFeePerGas": "1.148315423", +// "suggestedMaxFeePerGas": "15.317625653", +// "minWaitTimeEstimate": 15000, +// "maxWaitTimeEstimate": 45000 +// }, +// "high": { +// "suggestedMaxPriorityFeePerGas": "2", +// "suggestedMaxFeePerGas": "24.78979967", +// "minWaitTimeEstimate": 15000, +// "maxWaitTimeEstimate": 30000 +// }, +// "estimatedBaseFee": "9.908608552", +// "networkCongestion": 0.004, +// "latestPriorityFeeRange": [ +// "0.05", +// "4" +// ], +// "historicalPriorityFeeRange": [ +// "0.006381976", +// "155.777346207" +// ], +// "historicalBaseFeeRange": [ +// "9.243163495", +// "16.734915363" +// ], +// "priorityFeeTrend": "up", +// "baseFeeTrend": "up", +// "version": "0.0.1" +// } + +type infuraFeeResult struct { + MaxPriorityFeePerGas string `json:"suggestedMaxPriorityFeePerGas"` + MaxFeePerGas string `json:"suggestedMaxFeePerGas"` + MinWaitTimeEstimate int `json:"minWaitTimeEstimate"` + MaxWaitTimeEstimate int `json:"maxWaitTimeEstimate"` +} + +type infuraFeesResult struct { + BaseFee string `json:"estimatedBaseFee"` + Low infuraFeeResult `json:"low"` + Medium infuraFeeResult `json:"medium"` + High infuraFeeResult `json:"high"` + NetworkCongestion float64 `json:"networkCongestion"` + LatestPriorityFeeRange []string `json:"latestPriorityFeeRange"` + HistoricalPriorityFeeRange []string `json:"historicalPriorityFeeRange"` + HistoricalBaseFeeRange []string `json:"historicalBaseFeeRange"` + PriorityFeeTrend string `json:"priorityFeeTrend"` + BaseFeeTrend string `json:"baseFeeTrend"` +} + +type infuraFeeParams struct { + URL string `json:"url"` + PeriodSeconds int `json:"periodSeconds"` +} + +type infuraFeeProvider struct { + *alternativeFeeProvider + params infuraFeeParams + apiKey string +} + +const infuraFeeStalePeriods = 30 + +// NewInfuraFeesProvider initializes https://gas.api.infura.io provider +func NewInfuraFeesProvider(chain bchain.BlockChain, params string, metrics *common.Metrics) (alternativeFeeProviderInterface, error) { + p := &infuraFeeProvider{alternativeFeeProvider: &alternativeFeeProvider{metrics: metrics, name: "infura"}} + err := json.Unmarshal([]byte(params), &p.params) + if err != nil { + return nil, err + } + if p.params.URL == "" || p.params.PeriodSeconds <= 0 { + return nil, errors.New("NewInfuraFeesProvider: missing config parameters 'url' or 'periodSeconds'.") + } + p.apiKey = os.Getenv("INFURA_API_KEY") + if p.apiKey == "" { + return nil, errors.New("NewInfuraFeesProvider: missing INFURA_API_KEY env variable.") + } + p.params.URL = strings.Replace(p.params.URL, "${api_key}", p.apiKey, -1) + p.chain = chain + // Keep cached Infura fees through throttling bursts. + // Current archive configs poll every 60s, which gives a 30-minute window. + p.staleSyncDuration = infuraFeeStaleDuration(p.params.PeriodSeconds) + go p.FeeDownloader() + return p, nil +} + +func infuraFeeStaleDuration(periodSeconds int) time.Duration { + return time.Duration(periodSeconds*infuraFeeStalePeriods) * time.Second +} + +func (p *infuraFeeProvider) FeeDownloader() { + period := time.Duration(p.params.PeriodSeconds) * time.Second + timer := time.NewTimer(period) + for { + var data infuraFeesResult + err := p.getData(&data) + if err != nil { + glog.Error("infuraFeeProvider.FeeDownloader ", err) + } else { + p.processData(&data) + } + <-timer.C + timer.Reset(period) + } +} + +func bigIntFromFloatString(s string) *big.Int { + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil + } + return big.NewInt(int64(f * 1e9)) +} + +func infuraFeesFromResult(result *infuraFeeResult) *bchain.Eip1559Fee { + fee := bchain.Eip1559Fee{} + fee.MaxFeePerGas = bigIntFromFloatString(result.MaxFeePerGas) + fee.MaxPriorityFeePerGas = bigIntFromFloatString(result.MaxPriorityFeePerGas) + fee.MinWaitTimeEstimate = result.MinWaitTimeEstimate + fee.MaxWaitTimeEstimate = result.MaxWaitTimeEstimate + return &fee +} + +func rangeFromString(feeRange []string) []*big.Int { + if feeRange == nil { + return nil + } + result := make([]*big.Int, len(feeRange)) + for i := range feeRange { + result[i] = bigIntFromFloatString(feeRange[i]) + } + return result +} + +func (p *infuraFeeProvider) processData(data *infuraFeesResult) bool { + fees := bchain.Eip1559Fees{} + fees.BaseFeePerGas = bigIntFromFloatString(data.BaseFee) + fees.High = infuraFeesFromResult(&data.High) + fees.Medium = infuraFeesFromResult(&data.Medium) + fees.Low = infuraFeesFromResult(&data.Low) + fees.NetworkCongestion = data.NetworkCongestion + fees.LatestPriorityFeeRange = rangeFromString(data.LatestPriorityFeeRange) + fees.HistoricalPriorityFeeRange = rangeFromString(data.HistoricalPriorityFeeRange) + fees.HistoricalBaseFeeRange = rangeFromString(data.HistoricalBaseFeeRange) + fees.PriorityFeeTrend = data.PriorityFeeTrend + fees.BaseFeeTrend = data.BaseFeeTrend + p.mux.Lock() + defer p.mux.Unlock() + p.lastSync = time.Now() + p.eip1559Fees = &fees + return true +} + +func (p *infuraFeeProvider) getData(res interface{}) error { + var httpData []byte + httpReq, err := http.NewRequest("GET", p.params.URL, bytes.NewBuffer(httpData)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + httpRes, err := http.DefaultClient.Do(httpReq) + if httpRes != nil { + defer httpRes.Body.Close() + } + if err != nil { + p.observeRequest("network_error") + return err + } + if httpRes.StatusCode != http.StatusOK { + p.observeRequest("http_" + strconv.Itoa(httpRes.StatusCode)) + return errors.New(p.params.URL + " returned status " + strconv.Itoa(httpRes.StatusCode)) + } + if err := common.SafeDecodeResponseFromReader(httpRes.Body, res); err != nil { + p.observeRequest("decode_error") + return err + } + p.observeRequest("ok") + return nil +} diff --git a/bchain/coins/eth/infurafees_test.go b/bchain/coins/eth/infurafees_test.go new file mode 100644 index 0000000000..f1a51fa5c7 --- /dev/null +++ b/bchain/coins/eth/infurafees_test.go @@ -0,0 +1,62 @@ +package eth + +import ( + "testing" + "time" +) + +func TestInfuraFeeStaleDuration(t *testing.T) { + got := infuraFeeStaleDuration(60) + want := 30 * time.Minute + if got != want { + t.Fatalf("infuraFeeStaleDuration(60) = %s, want %s", got, want) + } +} + +func TestInfuraFeeProviderUsesCachedFeesDuringStaleWindow(t *testing.T) { + provider := &infuraFeeProvider{ + alternativeFeeProvider: &alternativeFeeProvider{ + staleSyncDuration: infuraFeeStaleDuration(60), + }, + } + + provider.processData(&infuraFeesResult{ + BaseFee: "10", + Low: infuraFeeResult{ + MaxPriorityFeePerGas: "1", + MaxFeePerGas: "11", + }, + Medium: infuraFeeResult{ + MaxPriorityFeePerGas: "2", + MaxFeePerGas: "12", + }, + High: infuraFeeResult{ + MaxPriorityFeePerGas: "3", + MaxFeePerGas: "13", + }, + }) + + provider.mux.Lock() + provider.lastSync = time.Now().Add(-29 * time.Minute) + provider.mux.Unlock() + + fees, err := provider.GetEip1559Fees() + if err != nil { + t.Fatalf("GetEip1559Fees() error = %v", err) + } + if fees == nil { + t.Fatal("GetEip1559Fees() returned nil fees inside stale window") + } + + provider.mux.Lock() + provider.lastSync = time.Now().Add(-31 * time.Minute) + provider.mux.Unlock() + + fees, err = provider.GetEip1559Fees() + if err != nil { + t.Fatalf("GetEip1559Fees() error = %v", err) + } + if fees != nil { + t.Fatal("GetEip1559Fees() returned fees after stale window") + } +} diff --git a/bchain/coins/eth/multicall.go b/bchain/coins/eth/multicall.go new file mode 100644 index 0000000000..3e00e6ebbc --- /dev/null +++ b/bchain/coins/eth/multicall.go @@ -0,0 +1,324 @@ +package eth + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" +) + +// Canonical Multicall3 deployment, identical address on every major EVM chain. +// See https://github.com/mds1/multicall. +const multicall3Address = "0xcA11bde05977b3631167028862bE2a173976CA11" + +// Function selector for aggregate3((address,bool,bytes)[]). +// Verified: keccak256("aggregate3((address,bool,bytes)[])")[:4]. +const multicall3Aggregate3Signature = "0x82ad56cb" + +// multicall3Probe states; Unprobed is the zero value. +const ( + multicall3Unprobed int32 = 0 + multicall3Deployed int32 = 1 + multicall3NotDeployed int32 = 2 +) + +// errMulticall3NotDeployed is returned on chains without the canonical +// Multicall3 deployment; the answer is cached for the process lifetime. +var errMulticall3NotDeployed = errors.New("multicall3 not deployed at canonical address on this chain") + +// EthereumTypeMulticallAggregate3 issues an aggregate3 batch as one eth_call, +// observing all sub-calls at the same block (pinned to blockNumber, or +// "latest" if nil). The first call probes deployment with one eth_getCode; +// the deterministic result is cached. +func (b *EthereumRPC) EthereumTypeMulticallAggregate3(calls []bchain.EthereumMulticallCall, blockNumber *big.Int) ([]bchain.EthereumMulticallResult, error) { + if len(calls) == 0 { + return nil, nil + } + deployed, err := b.probeMulticall3() + if err != nil { + // Transient probe failure — surface as-is so callers can retry rather + // than treat the chain as permanently unsupported. + return nil, fmt.Errorf("multicall3 probe: %w", err) + } + if !deployed { + return nil, errMulticall3NotDeployed + } + encoded, err := encodeAggregate3(calls) + if err != nil { + return nil, fmt.Errorf("multicall3 encode: %w", err) + } + resp, err := b.EthereumTypeRpcCallAtBlock(encoded, multicall3Address, "", blockNumber) + if err != nil { + return nil, err + } + return decodeAggregate3Result(resp) +} + +// probeMulticall3 reports whether Multicall3 is deployed at the canonical +// address. Three outcomes: +// +// - (true, nil) — deployed; deterministic, cached for the process lifetime. +// - (false, nil) — not deployed; deterministic, cached. +// - (false, err) — transient probe failure (RPC down, timeout). NOT cached; +// the next call retries. Returned to callers so they can distinguish +// "this chain has no Multicall3" from "RPC is having a moment." +// +// Concurrent probers are collapsed via singleflight, so a thundering herd +// at process start performs at most one eth_getCode. +func (b *EthereumRPC) probeMulticall3() (bool, error) { + // The probe is set exactly once per process to either multicall3Deployed + // or multicall3NotDeployed and is never cleared back to the zero value, + // so any other observed state is multicall3Unprobed and falls through to + // the singleflight below. The Do callback re-checks the state under + // singleflight, so no correctness depends on the invariant above. + switch b.multicall3Probe.Load() { + case multicall3Deployed: + return true, nil + case multicall3NotDeployed: + return false, nil + } + + type probeResult struct { + deployed bool + err error + } + v, _, _ := b.multicall3ProbeSF.Do("multicall3", func() (interface{}, error) { + // Re-check: a peer may have completed before we entered Do. + if state := b.multicall3Probe.Load(); state != multicall3Unprobed { + return probeResult{deployed: state == multicall3Deployed}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + var code string + if err := b.RPC.CallContext(ctx, &code, "eth_getCode", multicall3Address, "latest"); err != nil { + glog.Warningf("multicall3 probe at %s failed: %v (will retry on next call)", multicall3Address, err) + return probeResult{err: err}, nil + } + // "0x" means no code at the address. + if len(code) <= 2 { + glog.Infof("multicall3 not deployed at %s on this chain; multicall enrichments will be disabled", multicall3Address) + b.multicall3Probe.Store(multicall3NotDeployed) + return probeResult{}, nil + } + b.multicall3Probe.Store(multicall3Deployed) + return probeResult{deployed: true}, nil + }) + r := v.(probeResult) + return r.deployed, r.err +} + +// encodeAggregate3 hand-rolls the ABI encoding for aggregate3((address,bool,bytes)[]). +// Layout (after the 4-byte selector): +// +// 0x20 <- offset to outer array +// N <- array length +// headOff[0..N-1] <- N words; offsets to each tuple, relative to start of heads +// tail[0..N-1] <- per-tuple encoding +// +// Each tuple `(address,bool,bytes)` is itself dynamic and encodes as: +// +// address (32 bytes, left-padded) +// bool (32 bytes) +// 0x60 <- offset to bytes data within the tuple +// bytesLen (32 bytes) +// bytesData (padded up to 32-byte boundary) +func encodeAggregate3(calls []bchain.EthereumMulticallCall) (string, error) { + type tuple struct { + target []byte // 20 bytes + bool32 byte // 0 or 1 + payload []byte + } + tuples := make([]tuple, len(calls)) + for i, c := range calls { + addr, err := hexToAddressBytes(c.Target) + if err != nil { + return "", fmt.Errorf("call %d target: %w", i, err) + } + payload, err := hexToBytes(c.CallData) + if err != nil { + return "", fmt.Errorf("call %d callData: %w", i, err) + } + tuples[i].target = addr + if c.AllowFailure { + tuples[i].bool32 = 1 + } + tuples[i].payload = payload + } + + // Per-tuple encoded size: 3 head words (address, bool, bytes-offset) + 1 length word + padded data. + tupleSize := func(t tuple) int { + return 32*4 + paddedLen(len(t.payload)) + } + + // Compute offset words first (relative to the start of the heads block). + n := len(tuples) + headBytes := n * 32 + offsets := make([]int, n) + cursor := headBytes + for i, t := range tuples { + offsets[i] = cursor + cursor += tupleSize(t) + } + + // Total payload size after the selector: 0x20 word + length word + heads + tails. + totalAfterSelector := 32 + 32 + cursor + out := make([]byte, 0, 4+totalAfterSelector) + + // Selector. + sel, err := hexToBytes(multicall3Aggregate3Signature) + if err != nil { + return "", err + } + out = append(out, sel...) + // Outer offset: array starts immediately after this word. + out = append(out, padLeftWord(big.NewInt(0x20))...) + // Array length. + out = append(out, padLeftWord(big.NewInt(int64(n)))...) + // Heads. + for _, off := range offsets { + out = append(out, padLeftWord(big.NewInt(int64(off)))...) + } + // Tails. + for _, t := range tuples { + // address + word := make([]byte, 32) + copy(word[12:], t.target) + out = append(out, word...) + // bool + word = make([]byte, 32) + word[31] = t.bool32 + out = append(out, word...) + // offset to bytes within tuple = 0x60 (3 head words) + out = append(out, padLeftWord(big.NewInt(0x60))...) + // bytes length + out = append(out, padLeftWord(big.NewInt(int64(len(t.payload))))...) + // bytes data, padded to 32 bytes + padded := make([]byte, paddedLen(len(t.payload))) + copy(padded, t.payload) + out = append(out, padded...) + } + + return "0x" + hex.EncodeToString(out), nil +} + +// decodeAggregate3Result inverts encodeAggregate3's return encoding for (bool,bytes)[]. +// Layout: +// +// 0x20 <- outer offset to array +// N <- array length +// headOff[0..N-1] <- offsets to tuples, relative to heads start +// tail[0..N-1] <- per-tuple (bool, bytes-offset, bytesLen, bytesData) +func decodeAggregate3Result(data string) ([]bchain.EthereumMulticallResult, error) { + raw, err := hexToBytes(data) + if err != nil { + return nil, fmt.Errorf("decode hex: %w", err) + } + if len(raw) < 64 { + return nil, fmt.Errorf("multicall3 response too short: %d bytes", len(raw)) + } + // Top-level offset word; in well-formed responses always 0x20. + if v := bigUintAt(raw, 0); v.Cmp(big.NewInt(0x20)) != 0 { + return nil, fmt.Errorf("multicall3 unexpected outer offset: %s", v) + } + headsStart := 64 + length := bigUintAt(raw, 32) + if !length.IsUint64() { + return nil, fmt.Errorf("multicall3 array length out of range") + } + n := int(length.Uint64()) + if n == 0 { + // Degenerate: encoder short-circuits empty input upstream, so a + // well-formed n==0 response can only arise from a malformed batch + // or unusual node behavior. nil matches encodeAggregate3's empty + // case and the caller's nil-means-no-results contract. + return nil, nil + } + if len(raw) < headsStart+n*32 { + return nil, fmt.Errorf("multicall3 response truncated in heads") + } + + results := make([]bchain.EthereumMulticallResult, n) + for i := 0; i < n; i++ { + offset := bigUintAt(raw, headsStart+i*32) + if !offset.IsUint64() { + return nil, fmt.Errorf("multicall3 element %d offset out of range", i) + } + tupleStart := headsStart + int(offset.Uint64()) + // Tuple shape: bool (32) | bytesOffsetInTuple (32) | bytesLen (32) | bytesData... + if len(raw) < tupleStart+96 { + return nil, fmt.Errorf("multicall3 element %d truncated", i) + } + successWord := raw[tupleStart : tupleStart+32] + // success is rightmost byte of the bool word. + results[i].Success = successWord[31] == 1 + + bytesOffset := bigUintAt(raw, tupleStart+32) + if !bytesOffset.IsUint64() { + return nil, fmt.Errorf("multicall3 element %d bytes offset out of range", i) + } + bytesPos := tupleStart + int(bytesOffset.Uint64()) + if len(raw) < bytesPos+32 { + return nil, fmt.Errorf("multicall3 element %d truncated at bytes length", i) + } + bytesLen := bigUintAt(raw, bytesPos) + if !bytesLen.IsUint64() { + return nil, fmt.Errorf("multicall3 element %d bytes length out of range", i) + } + bl := int(bytesLen.Uint64()) + if len(raw) < bytesPos+32+bl { + return nil, fmt.Errorf("multicall3 element %d truncated at bytes data", i) + } + results[i].Data = "0x" + hex.EncodeToString(raw[bytesPos+32:bytesPos+32+bl]) + } + return results, nil +} + +// hexToBytes accepts either a "0x"-prefixed or bare hex string and returns its bytes. +// Empty input is allowed and yields an empty slice (callers may pass empty calldata). +func hexToBytes(s string) ([]byte, error) { + s = strings.TrimSpace(s) + if has0xPrefix(s) { + s = s[2:] + } + if s == "" { + return nil, nil + } + return hex.DecodeString(s) +} + +// hexToAddressBytes decodes an EIP-55 / lowercase hex address into 20 bytes. +func hexToAddressBytes(s string) ([]byte, error) { + addr, err := hexutil.Decode(s) + if err != nil { + return nil, err + } + if len(addr) != 20 { + return nil, fmt.Errorf("address must be 20 bytes, got %d", len(addr)) + } + return addr, nil +} + +func padLeftWord(v *big.Int) []byte { + word := make([]byte, 32) + v.FillBytes(word) + return word +} + +func bigUintAt(buf []byte, offset int) *big.Int { + return new(big.Int).SetBytes(buf[offset : offset+32]) +} + +// paddedLen rounds n up to the next 32-byte word boundary. +func paddedLen(n int) int { + if n == 0 { + return 0 + } + return (n + 31) &^ 31 +} diff --git a/bchain/coins/eth/multicall_test.go b/bchain/coins/eth/multicall_test.go new file mode 100644 index 0000000000..3d5a615491 --- /dev/null +++ b/bchain/coins/eth/multicall_test.go @@ -0,0 +1,616 @@ +package eth + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/rpc" + "github.com/trezor/blockbook/bchain" +) + +// padHex32 left-pads `s` (a hex string without 0x) with zeros to 64 chars (32 bytes). +func padHex32(s string) string { + if len(s) >= 64 { + return s + } + return strings.Repeat("0", 64-len(s)) + s +} + +// rightPadHex pads `s` (hex without 0x) with zeros on the right to a multiple of 64 hex chars. +func rightPadHex(s string) string { + rem := len(s) % 64 + if rem == 0 { + return s + } + return s + strings.Repeat("0", 64-rem) +} + +func TestEncodeAggregate3KnownLayout(t *testing.T) { + calls := []bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000aa", CallData: "0x06fdde03", AllowFailure: false}, + {Target: "0x00000000000000000000000000000000000000bb", CallData: "0x95d89b41", AllowFailure: true}, + } + + encoded, err := encodeAggregate3(calls) + if err != nil { + t.Fatalf("encode error: %v", err) + } + raw, err := hex.DecodeString(strings.TrimPrefix(encoded, "0x")) + if err != nil { + t.Fatalf("decode hex: %v", err) + } + + // Selector: 0x82ad56cb. + if got := hex.EncodeToString(raw[:4]); got != "82ad56cb" { + t.Fatalf("selector mismatch: got %s want 82ad56cb", got) + } + // Outer offset to array = 0x20. + if v := bigUintAt(raw, 4); v.Cmp(big.NewInt(0x20)) != 0 { + t.Fatalf("outer offset wrong: %s", v) + } + // Array length = 2 at byte 4+32 = 36. + if v := bigUintAt(raw, 4+32); v.Cmp(big.NewInt(2)) != 0 { + t.Fatalf("array length wrong: %s", v) + } + // Heads start at byte 4+64 = 68. Two offsets follow. + if v := bigUintAt(raw, 4+64); v.Cmp(big.NewInt(64)) != 0 { + t.Fatalf("first head offset wrong: %s, want 64", v) + } + // Each tuple: 32(address)+32(bool)+32(0x60)+32(len)+32(data padded) = 160 bytes. + if v := bigUintAt(raw, 4+96); v.Cmp(big.NewInt(64+160)) != 0 { + t.Fatalf("second head offset wrong: %s, want %d", v, 64+160) + } + // Total encoded size = selector(4) + outer(32) + len(32) + heads(64) + tuples(2*160) = 452. + if got, want := len(raw), 4+32+32+64+2*160; got != want { + t.Fatalf("total size: got %d want %d", got, want) + } + + // Spot-check tuple 0's bool byte (false → 0) and tuple 1's bool byte (true → 1). + tuple0Start := 4 + 32 + 32 + 64 // start of tuple 0 + if raw[tuple0Start+32+31] != 0 { + t.Fatalf("tuple 0 bool byte should be 0") + } + tuple1Start := tuple0Start + 160 + if raw[tuple1Start+32+31] != 1 { + t.Fatalf("tuple 1 bool byte should be 1") + } +} + +// TestEncodeAggregate3MatchesCanonicalABI locks the hand-rolled encoder against +// the byte-for-byte output of go-ethereum's accounts/abi package for a small +// fixture. If this drifts, the encoder has gone non-canonical. +func TestEncodeAggregate3MatchesCanonicalABI(t *testing.T) { + calls := []bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000aa", CallData: "0x06fdde03", AllowFailure: false}, + {Target: "0x00000000000000000000000000000000000000bb", CallData: "0x95d89b41", AllowFailure: true}, + } + const expected = "0x82ad56cb" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "0000000000000000000000000000000000000000000000000000000000000002" + + "0000000000000000000000000000000000000000000000000000000000000040" + + "00000000000000000000000000000000000000000000000000000000000000e0" + + "00000000000000000000000000000000000000000000000000000000000000aa" + + "0000000000000000000000000000000000000000000000000000000000000000" + + "0000000000000000000000000000000000000000000000000000000000000060" + + "0000000000000000000000000000000000000000000000000000000000000004" + + "06fdde0300000000000000000000000000000000000000000000000000000000" + + "00000000000000000000000000000000000000000000000000000000000000bb" + + "0000000000000000000000000000000000000000000000000000000000000001" + + "0000000000000000000000000000000000000000000000000000000000000060" + + "0000000000000000000000000000000000000000000000000000000000000004" + + "95d89b4100000000000000000000000000000000000000000000000000000000" + got, err := encodeAggregate3(calls) + if err != nil { + t.Fatalf("encode error: %v", err) + } + if !strings.EqualFold(got, expected) { + t.Fatalf("encoder drift:\n got: %s\nwant: %s", got, expected) + } +} + +func TestEncodeAggregate3EmptyAndPadding(t *testing.T) { + // Empty CallData should produce a tuple with 0 length bytes and no data words. + encoded, err := encodeAggregate3([]bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000ee", CallData: "0x", AllowFailure: false}, + }) + if err != nil { + t.Fatalf("encode empty calldata: %v", err) + } + raw, _ := hex.DecodeString(strings.TrimPrefix(encoded, "0x")) + // selector(4) + outer(32) + len(32) + heads(32) + tuple_size(128) = 228. + // tuple_size when payload is empty: 32(addr)+32(bool)+32(0x60)+32(len=0)+0 = 128. + if got, want := len(raw), 4+32+32+32+128; got != want { + t.Fatalf("empty-payload size: got %d want %d", got, want) + } + // 5-byte payload should pad up to 32 bytes. + encoded2, err := encodeAggregate3([]bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000ee", CallData: "0x1234567890"}, + }) + if err != nil { + t.Fatalf("encode 5-byte calldata: %v", err) + } + raw2, _ := hex.DecodeString(strings.TrimPrefix(encoded2, "0x")) + if got, want := len(raw2), 4+32+32+32+160; got != want { + t.Fatalf("5-byte-padded size: got %d want %d", got, want) + } +} + +func TestEncodeAggregate3RejectsBadInput(t *testing.T) { + if _, err := encodeAggregate3([]bchain.EthereumMulticallCall{{Target: "0xnothex"}}); err == nil { + t.Fatal("expected error for invalid target") + } + if _, err := encodeAggregate3([]bchain.EthereumMulticallCall{{Target: "0x1234"}}); err == nil { + t.Fatal("expected error for too-short address") + } + if _, err := encodeAggregate3([]bchain.EthereumMulticallCall{{Target: "0x00000000000000000000000000000000000000aa", CallData: "zz"}}); err == nil { + t.Fatal("expected error for invalid calldata hex") + } +} + +// fixtureAggregate3Result builds a canonical aggregate3 return payload by hand for +// a small number of (success, data) tuples. Used to verify the decoder against bytes +// the test author can fully reason about. +func fixtureAggregate3Result(results []bchain.EthereumMulticallResult) string { + type encoded struct { + successByte byte + data []byte + } + enc := make([]encoded, len(results)) + for i, r := range results { + var b byte + if r.Success { + b = 1 + } + raw, _ := hex.DecodeString(strings.TrimPrefix(r.Data, "0x")) + enc[i] = encoded{successByte: b, data: raw} + } + + headBytes := len(enc) * 32 + cursor := headBytes + offsets := make([]int, len(enc)) + for i, e := range enc { + offsets[i] = cursor + // (bool, offset, len, data padded) + cursor += 32*3 + paddedLen(len(e.data)) + } + + var out strings.Builder + // outer offset 0x20 + out.WriteString(padHex32("20")) + // length + out.WriteString(padHex32(fmt.Sprintf("%x", len(enc)))) + for _, off := range offsets { + out.WriteString(padHex32(fmt.Sprintf("%x", off))) + } + for _, e := range enc { + // success + bword := "00" + if e.successByte == 1 { + bword = "01" + } + out.WriteString(padHex32(bword)) + // bytes offset within tuple = 0x40 (2 head words) + out.WriteString(padHex32("40")) + // bytes length + out.WriteString(padHex32(fmt.Sprintf("%x", len(e.data)))) + // padded data + dataHex := hex.EncodeToString(e.data) + out.WriteString(rightPadHex(dataHex)) + } + return "0x" + out.String() +} + +func TestDecodeAggregate3RoundTripFixture(t *testing.T) { + expected := []bchain.EthereumMulticallResult{ + {Success: true, Data: "0x1234567890"}, + {Success: false, Data: "0x"}, + {Success: true, Data: "0x" + strings.Repeat("ab", 64)}, // 64 bytes, exactly two padded words + } + got, err := decodeAggregate3Result(fixtureAggregate3Result(expected)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != len(expected) { + t.Fatalf("length: got %d want %d", len(got), len(expected)) + } + for i := range expected { + if got[i].Success != expected[i].Success { + t.Fatalf("[%d] success: got %v want %v", i, got[i].Success, expected[i].Success) + } + if !strings.EqualFold(got[i].Data, expected[i].Data) { + t.Fatalf("[%d] data: got %s want %s", i, got[i].Data, expected[i].Data) + } + } +} + +func TestDecodeAggregate3Rejects(t *testing.T) { + cases := []struct { + name string + hex string + }{ + {"empty", "0x"}, + {"too short for header", "0x" + padHex32("20")}, + {"bad outer offset", "0x" + padHex32("21") + padHex32("0")}, + {"truncated heads", "0x" + padHex32("20") + padHex32("2") + padHex32("40")}, // declares 2 elements but only 1 head word + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := decodeAggregate3Result(tc.hex); err == nil { + t.Fatalf("expected error for %q", tc.name) + } + }) + } +} + +// mockMulticallRPC routes eth_call and eth_getCode through hand-written +// handlers so MulticallAggregate3 (and the deployment probe in front of it) +// can be exercised end-to-end without a chain. +type mockMulticallRPC struct { + mu sync.Mutex + // eth_call handler. Required for tests that exercise the multicall path. + handler func(callData string) (string, error) + // eth_getCode handler for the deployment probe. When nil, the probe is + // answered with a stub "deployed" bytecode so existing multicall tests + // don't need to care about the probe. + getCodeHandler func(address string) (string, error) + + ethCallCalls int + getCodeCalls int +} + +func (m *mockMulticallRPC) callCounts() (ethCall, getCode int) { + m.mu.Lock() + defer m.mu.Unlock() + return m.ethCallCalls, m.getCodeCalls +} + +func (m *mockMulticallRPC) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} +func (m *mockMulticallRPC) Close() {} +func (m *mockMulticallRPC) BatchCallContext(ctx context.Context, batch []rpc.BatchElem) error { + return errors.New("not implemented") +} +func (m *mockMulticallRPC) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + out, ok := result.(*string) + if !ok { + return errors.New("bad result type") + } + switch method { + case "eth_getCode": + m.mu.Lock() + m.getCodeCalls++ + m.mu.Unlock() + if len(args) < 2 { + return errors.New("eth_getCode: missing args") + } + addr, _ := args[0].(string) + if !strings.EqualFold(addr, multicall3Address) { + return fmt.Errorf("unexpected eth_getCode target: %s", addr) + } + if m.getCodeHandler == nil { + // Default: report deployed with stub bytecode. Lets unrelated + // tests proceed straight to the eth_call handler. + *out = "0x6080604052" + return nil + } + s, err := m.getCodeHandler(addr) + if err != nil { + return err + } + *out = s + return nil + case "eth_call": + m.mu.Lock() + m.ethCallCalls++ + m.mu.Unlock() + if len(args) < 2 { + return errors.New("eth_call: missing args") + } + argMap, ok := args[0].(map[string]interface{}) + if !ok { + return errors.New("eth_call: bad args") + } + to, _ := argMap["to"].(string) + if !strings.EqualFold(to, multicall3Address) { + return fmt.Errorf("unexpected eth_call target: %s", to) + } + data, _ := argMap["data"].(string) + if m.handler == nil { + return errors.New("no eth_call handler installed") + } + resp, err := m.handler(data) + if err != nil { + return err + } + *out = resp + return nil + default: + return fmt.Errorf("unexpected method: %s", method) + } +} + +func TestMulticallAggregate3EndToEnd(t *testing.T) { + expected := []bchain.EthereumMulticallResult{ + {Success: true, Data: "0xdeadbeef"}, + {Success: true, Data: "0xcafebabe"}, + } + mock := &mockMulticallRPC{ + handler: func(_ string) (string, error) { + return fixtureAggregate3Result(expected), nil + }, + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + + got, err := rpcClient.EthereumTypeMulticallAggregate3([]bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000aa", CallData: "0x06fdde03"}, + {Target: "0x00000000000000000000000000000000000000bb", CallData: "0x95d89b41"}, + }, nil) + if err != nil { + t.Fatalf("MulticallAggregate3 error: %v", err) + } + if len(got) != len(expected) { + t.Fatalf("len mismatch: got %d want %d", len(got), len(expected)) + } + for i := range expected { + if got[i].Success != expected[i].Success || !strings.EqualFold(got[i].Data, expected[i].Data) { + t.Fatalf("[%d] mismatch: got %+v want %+v", i, got[i], expected[i]) + } + } +} + +func TestMulticallAggregate3EmptyCalls(t *testing.T) { + mock := &mockMulticallRPC{ + handler: func(string) (string, error) { + t.Fatal("eth_call should not be issued for empty input") + return "", nil + }, + getCodeHandler: func(string) (string, error) { + t.Fatal("eth_getCode probe should not fire for empty input") + return "", nil + }, + } + rpcClient := &EthereumRPC{RPC: mock, Timeout: time.Second} + got, err := rpcClient.EthereumTypeMulticallAggregate3(nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Fatalf("expected nil result, got %v", got) + } +} + +// --- Multicall3 deployment probe --- + +func TestProbeMulticall3_DetectsDeployedAndCachesResult(t *testing.T) { + mock := &mockMulticallRPC{ + // Any non-empty bytecode counts as deployed. + getCodeHandler: func(string) (string, error) { return "0x6080604052348015", nil }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} + + if got, err := rpc.probeMulticall3(); err != nil || !got { + t.Fatalf("probe should report deployed for non-empty bytecode, got=%v err=%v", got, err) + } + if got, err := rpc.probeMulticall3(); err != nil || !got { + t.Fatalf("probe should still report deployed on second call, got=%v err=%v", got, err) + } + if _, getCode := mock.callCounts(); getCode != 1 { + t.Fatalf("expected 1 eth_getCode call (cached on 2nd), got %d", getCode) + } + if state := rpc.multicall3Probe.Load(); state != multicall3Deployed { + t.Fatalf("expected state=Deployed, got %d", state) + } +} + +func TestProbeMulticall3_DetectsNotDeployedAndCachesResult(t *testing.T) { + mock := &mockMulticallRPC{ + getCodeHandler: func(string) (string, error) { return "0x", nil }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} + + if got, err := rpc.probeMulticall3(); err != nil || got { + t.Fatalf("probe should report not-deployed for '0x', got=%v err=%v", got, err) + } + if got, err := rpc.probeMulticall3(); err != nil || got { + t.Fatalf("probe should still report not-deployed on second call, got=%v err=%v", got, err) + } + if _, getCode := mock.callCounts(); getCode != 1 { + t.Fatalf("expected 1 eth_getCode call (cached on 2nd), got %d", getCode) + } + if state := rpc.multicall3Probe.Load(); state != multicall3NotDeployed { + t.Fatalf("expected state=NotDeployed, got %d", state) + } +} + +func TestProbeMulticall3_TransientErrorRetriesNextCall(t *testing.T) { + // First eth_getCode errors (RPC blip); second succeeds. The probe must + // retry rather than caching the transient failure. + var attempt atomic.Int32 + mock := &mockMulticallRPC{ + getCodeHandler: func(string) (string, error) { + n := attempt.Add(1) + if n == 1 { + return "", errors.New("rpc down") + } + return "0x6080604052", nil + }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} + + got, err := rpc.probeMulticall3() + if err == nil { + t.Fatal("first probe should propagate the transient RPC error") + } + if got { + t.Fatalf("first probe should report deployed=false on transient error, got=%v", got) + } + if state := rpc.multicall3Probe.Load(); state != multicall3Unprobed { + t.Fatalf("transient error must NOT cache state, got %d", state) + } + if got, err := rpc.probeMulticall3(); err != nil || !got { + t.Fatalf("second probe should detect deployed (transient error not cached), got=%v err=%v", got, err) + } + if _, getCode := mock.callCounts(); getCode != 2 { + t.Fatalf("expected 2 eth_getCode calls (no caching after transient), got %d", getCode) + } + if state := rpc.multicall3Probe.Load(); state != multicall3Deployed { + t.Fatalf("expected state=Deployed after recovery, got %d", state) + } +} + +func TestProbeMulticall3_ConcurrentFirstCallsCollapseToOneRPC(t *testing.T) { + // 32 concurrent first-time probes against a slow eth_getCode must result + // in exactly one upstream RPC and a deployed verdict for every caller. + const concurrency = 32 + gate := make(chan struct{}) + mock := &mockMulticallRPC{ + getCodeHandler: func(string) (string, error) { + <-gate + return "0x6080", nil + }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} + + type probeOutcome struct { + deployed bool + err error + } + results := make([]probeOutcome, concurrency) + var wg sync.WaitGroup + wg.Add(concurrency) + for i := 0; i < concurrency; i++ { + i := i + go func() { + defer wg.Done() + deployed, err := rpc.probeMulticall3() + results[i] = probeOutcome{deployed: deployed, err: err} + }() + } + // Wait for the in-flight probe to register one eth_getCode call before + // releasing it; concurrent peers will join singleflight in the meantime. + deadline := time.Now().Add(2 * time.Second) + for { + if _, gc := mock.callCounts(); gc >= 1 { + break + } + if time.Now().After(deadline) { + close(gate) + wg.Wait() + t.Fatal("timed out waiting for first eth_getCode") + } + time.Sleep(time.Millisecond) + } + close(gate) + wg.Wait() + + if _, gc := mock.callCounts(); gc != 1 { + t.Fatalf("singleflight must collapse concurrent probes to 1 RPC, got %d", gc) + } + for i, r := range results { + if r.err != nil || !r.deployed { + t.Fatalf("result[%d]: expected deployed=true, got=%+v", i, r) + } + } +} + +func TestEthereumTypeMulticallAggregate3_NotDeployed_ShortCircuits(t *testing.T) { + // With probe state pre-set to NotDeployed, MulticallAggregate3 must return + // errMulticall3NotDeployed without issuing any eth_call. + mock := &mockMulticallRPC{ + handler: func(string) (string, error) { + t.Fatal("eth_call must not be issued when multicall3 is known absent") + return "", nil + }, + getCodeHandler: func(string) (string, error) { + t.Fatal("eth_getCode must not be issued when probe state is already known") + return "", nil + }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} + rpc.multicall3Probe.Store(multicall3NotDeployed) + + got, err := rpc.EthereumTypeMulticallAggregate3([]bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000aa", CallData: "0x06fdde03"}, + }, nil) + if got != nil { + t.Fatalf("expected nil result, got %+v", got) + } + if !errors.Is(err, errMulticall3NotDeployed) { + t.Fatalf("expected errMulticall3NotDeployed, got %v", err) + } +} + +// A transient probe failure must surface as a real error to callers rather +// than being collapsed to errMulticall3NotDeployed — otherwise an RPC blip +// during the first request would look indistinguishable from "this chain +// has no Multicall3" in caller telemetry and short-circuit logic. +func TestEthereumTypeMulticallAggregate3_TransientProbeError_PropagatesAndIsDistinct(t *testing.T) { + probeErr := errors.New("rpc down") + mock := &mockMulticallRPC{ + handler: func(string) (string, error) { + t.Fatal("eth_call must not be issued when probe failed transiently") + return "", nil + }, + getCodeHandler: func(string) (string, error) { return "", probeErr }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} + + got, err := rpc.EthereumTypeMulticallAggregate3([]bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000aa", CallData: "0x06fdde03"}, + }, nil) + if got != nil { + t.Fatalf("expected nil result, got %+v", got) + } + if err == nil { + t.Fatal("expected non-nil error from transient probe failure") + } + if errors.Is(err, errMulticall3NotDeployed) { + t.Fatalf("transient error must be distinguishable from errMulticall3NotDeployed, got %v", err) + } + if !errors.Is(err, probeErr) { + t.Fatalf("expected wrapped probe error, got %v", err) + } + // Probe state must remain unprobed so the next call retries. + if state := rpc.multicall3Probe.Load(); state != multicall3Unprobed { + t.Fatalf("transient probe failure must not cache state, got %d", state) + } +} + +func TestEthereumTypeMulticallAggregate3_ProbesOnFirstCall(t *testing.T) { + // First call on a fresh EthereumRPC must probe via eth_getCode then + // proceed to eth_call. Subsequent calls must skip the probe. + expected := []bchain.EthereumMulticallResult{{Success: true, Data: "0xdead"}} + mock := &mockMulticallRPC{ + handler: func(string) (string, error) { return fixtureAggregate3Result(expected), nil }, + getCodeHandler: func(string) (string, error) { return "0x6080", nil }, + } + rpc := &EthereumRPC{RPC: mock, Timeout: time.Second} + + for i := 0; i < 3; i++ { + got, err := rpc.EthereumTypeMulticallAggregate3([]bchain.EthereumMulticallCall{ + {Target: "0x00000000000000000000000000000000000000aa", CallData: "0x06fdde03"}, + }, nil) + if err != nil { + t.Fatalf("call %d: unexpected error %v", i, err) + } + if len(got) != 1 || !strings.EqualFold(got[0].Data, expected[0].Data) { + t.Fatalf("call %d: unexpected result %+v", i, got) + } + } + ethCall, getCode := mock.callCounts() + if getCode != 1 { + t.Fatalf("expected exactly 1 eth_getCode (probe runs once), got %d", getCode) + } + if ethCall != 3 { + t.Fatalf("expected 3 eth_call (one per request), got %d", ethCall) + } +} diff --git a/bchain/coins/eth/oneinchfees.go b/bchain/coins/eth/oneinchfees.go new file mode 100644 index 0000000000..e42f5a6652 --- /dev/null +++ b/bchain/coins/eth/oneinchfees.go @@ -0,0 +1,151 @@ +package eth + +import ( + "bytes" + "encoding/json" + "math/big" + "net/http" + "os" + "strconv" + "time" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// https://api.1inch.dev/gas-price/v1.5/1 returns +// { +// "baseFee": "12456587953", +// "low": { +// "maxPriorityFeePerGas": "1000000", +// "maxFeePerGas": "14948905543" +// }, +// "medium": { +// "maxPriorityFeePerGas": "2000000", +// "maxFeePerGas": "14949905543" +// }, +// "high": { +// "maxPriorityFeePerGas": "5000000", +// "maxFeePerGas": "14952905543" +// }, +// "instant": { +// "maxPriorityFeePerGas": "10000000", +// "maxFeePerGas": "29905811086" +// } +// } + +type oneInchFeeFeeResult struct { + MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas"` + MaxFeePerGas string `json:"maxFeePerGas"` +} + +type oneInchFeeFeesResult struct { + BaseFee string `json:"baseFee"` + Low oneInchFeeFeeResult `json:"low"` + Medium oneInchFeeFeeResult `json:"medium"` + High oneInchFeeFeeResult `json:"high"` + Instant oneInchFeeFeeResult `json:"instant"` +} + +type oneInchFeeParams struct { + URL string `json:"url"` + PeriodSeconds int `json:"periodSeconds"` +} + +type oneInchFeeProvider struct { + *alternativeFeeProvider + params oneInchFeeParams + apiKey string +} + +// NewOneInchFeesProvider initializes https://api.1inch.dev provider +func NewOneInchFeesProvider(chain bchain.BlockChain, params string, metrics *common.Metrics) (alternativeFeeProviderInterface, error) { + p := &oneInchFeeProvider{alternativeFeeProvider: &alternativeFeeProvider{metrics: metrics, name: "1inch"}} + err := json.Unmarshal([]byte(params), &p.params) + if err != nil { + return nil, err + } + if p.params.URL == "" || p.params.PeriodSeconds == 0 { + return nil, errors.New("NewOneInchFeesProvider: missing config parameters 'url' or 'periodSeconds'.") + } + p.apiKey = os.Getenv("ONE_INCH_API_KEY") + if p.apiKey == "" { + return nil, errors.New("NewOneInchFeesProvider: missing ONE_INCH_API_KEY env variable.") + } + p.chain = chain + go p.FeeDownloader() + return p, nil +} + +func (p *oneInchFeeProvider) FeeDownloader() { + period := time.Duration(p.params.PeriodSeconds) * time.Second + timer := time.NewTimer(period) + for { + var data oneInchFeeFeesResult + err := p.getData(&data) + if err != nil { + glog.Error("oneInchFeeProvider.FeeDownloader", err) + } else { + p.processData(&data) + } + <-timer.C + timer.Reset(period) + } +} + +func bigIntFromString(s string) *big.Int { + b := big.NewInt(0) + b, _ = b.SetString(s, 10) + return b +} + +func oneInchFeesFromResult(result *oneInchFeeFeeResult) *bchain.Eip1559Fee { + fee := bchain.Eip1559Fee{} + fee.MaxFeePerGas = bigIntFromString(result.MaxFeePerGas) + fee.MaxPriorityFeePerGas = bigIntFromString(result.MaxPriorityFeePerGas) + return &fee +} + +func (p *oneInchFeeProvider) processData(data *oneInchFeeFeesResult) bool { + fees := bchain.Eip1559Fees{} + fees.BaseFeePerGas = bigIntFromString(data.BaseFee) + fees.Instant = oneInchFeesFromResult(&data.Instant) + fees.High = oneInchFeesFromResult(&data.High) + fees.Medium = oneInchFeesFromResult(&data.Medium) + fees.Low = oneInchFeesFromResult(&data.Low) + p.mux.Lock() + defer p.mux.Unlock() + p.lastSync = time.Now() + p.eip1559Fees = &fees + return true +} + +func (p *oneInchFeeProvider) getData(res interface{}) error { + var httpData []byte + httpReq, err := http.NewRequest("GET", p.params.URL, bytes.NewBuffer(httpData)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", " Bearer "+p.apiKey) + httpRes, err := http.DefaultClient.Do(httpReq) + if httpRes != nil { + defer httpRes.Body.Close() + } + if err != nil { + p.observeRequest("network_error") + return err + } + if httpRes.StatusCode != http.StatusOK { + p.observeRequest("http_" + strconv.Itoa(httpRes.StatusCode)) + return errors.New(p.params.URL + " returned status " + strconv.Itoa(httpRes.StatusCode)) + } + if err := common.SafeDecodeResponseFromReader(httpRes.Body, res); err != nil { + p.observeRequest("decode_error") + return err + } + p.observeRequest("ok") + return nil +} diff --git a/bchain/coins/eth/stakingpool.go b/bchain/coins/eth/stakingpool.go new file mode 100644 index 0000000000..da34d053eb --- /dev/null +++ b/bchain/coins/eth/stakingpool.go @@ -0,0 +1,152 @@ +package eth + +import ( + "math/big" + "os" + "strings" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" +) + +func (b *EthereumRPC) initStakingPools() error { + network := b.ChainConfig.Network + if network == "" { + network = b.ChainConfig.CoinShortcut + } + // for now only single staking pool + envVar := strings.ToUpper(network) + "_STAKING_POOL_CONTRACT" + envValue := os.Getenv(envVar) + if envValue != "" { + parts := strings.Split(envValue, "/") + if len(parts) != 2 { + glog.Errorf("Wrong format of environment variable %s=%s, expecting value '/', staking pools not enabled", envVar, envValue) + return nil + } + b.supportedStakingPools = []string{envValue} + b.stakingPoolNames = []string{parts[0]} + b.stakingPoolContracts = []string{parts[1]} + glog.Info("Support of staking pools enabled with these pools: ", b.supportedStakingPools) + } + return nil +} + +func (b *EthereumRPC) EthereumTypeGetSupportedStakingPools() []string { + return b.supportedStakingPools +} + +func (b *EthereumRPC) EthereumTypeGetStakingPoolsData(addrDesc bchain.AddressDescriptor) ([]bchain.StakingPoolData, error) { + // for now only single staking pool - Everstake + addr := hexutil.Encode(addrDesc)[2:] + if len(b.supportedStakingPools) == 1 { + data, err := b.everstakePoolData(addr, b.stakingPoolContracts[0], b.stakingPoolNames[0]) + if err != nil { + return nil, err + } + if data != nil { + return []bchain.StakingPoolData{*data}, nil + } + } + return nil, nil +} + +const everstakePendingBalanceOfMethodSignature = "0x59b8c763" // pendingBalanceOf(address) +const everstakePendingDepositedBalanceOfMethodSignature = "0x80f14ecc" // pendingDepositedBalanceOf(address) +const everstakeDepositedBalanceOfMethodSignature = "0x68b48254" // depositedBalanceOf(address) +const everstakeWithdrawRequestMethodSignature = "0x14cbc46a" // withdrawRequest(address) +const everstakeRestakedRewardOfMethodSignature = "0x0c98929a" // restakedRewardOf(address) +const everstakeAutocompoundBalanceOfMethodSignature = "0x2fec7966" // autocompoundBalanceOf(address) + +func isZeroBigInt(b *big.Int) bool { + return len(b.Bits()) == 0 +} + +func (b *EthereumRPC) everstakeBalanceTypeContractCall(signature, addr, contract string) (string, error) { + req := signature + "0000000000000000000000000000000000000000000000000000000000000000"[len(addr):] + addr + return b.EthereumTypeRpcCall(req, contract, "") +} + +func (b *EthereumRPC) everstakeContractCallSimpleNumeric(signature, addr, contract string) (*big.Int, error) { + data, err := b.everstakeBalanceTypeContractCall(signature, addr, contract) + if err != nil { + return nil, err + } + r := parseSimpleNumericProperty(data) + if r == nil { + return nil, errors.New("Invalid balance") + } + return r, nil +} + +func (b *EthereumRPC) everstakePoolData(addr, contract, name string) (*bchain.StakingPoolData, error) { + poolData := bchain.StakingPoolData{ + Contract: contract, + Name: name, + } + allZeros := true + + value, err := b.everstakeContractCallSimpleNumeric(everstakePendingBalanceOfMethodSignature, addr, contract) + b.observeEthCallStakingPool("pending_balance") + if err != nil { + return nil, err + } + poolData.PendingBalance = *value + allZeros = allZeros && isZeroBigInt(value) + + value, err = b.everstakeContractCallSimpleNumeric(everstakePendingDepositedBalanceOfMethodSignature, addr, contract) + b.observeEthCallStakingPool("pending_deposited_balance") + if err != nil { + return nil, err + } + poolData.PendingDepositedBalance = *value + allZeros = allZeros && isZeroBigInt(value) + + value, err = b.everstakeContractCallSimpleNumeric(everstakeDepositedBalanceOfMethodSignature, addr, contract) + b.observeEthCallStakingPool("deposited_balance") + if err != nil { + return nil, err + } + poolData.DepositedBalance = *value + allZeros = allZeros && isZeroBigInt(value) + + data, err := b.everstakeBalanceTypeContractCall(everstakeWithdrawRequestMethodSignature, addr, contract) + b.observeEthCallStakingPool("withdraw_request") + if err != nil { + return nil, err + } + value = parseSimpleNumericProperty(data) + if value == nil { + return nil, errors.New("Invalid balance") + } + poolData.WithdrawTotalAmount = *value + allZeros = allZeros && isZeroBigInt(value) + value = parseSimpleNumericProperty(data[64+2:]) + if value == nil { + return nil, errors.New("Invalid balance") + } + poolData.ClaimableAmount = *value + allZeros = allZeros && isZeroBigInt(value) + + value, err = b.everstakeContractCallSimpleNumeric(everstakeRestakedRewardOfMethodSignature, addr, contract) + b.observeEthCallStakingPool("restaked_reward") + if err != nil { + return nil, err + } + poolData.RestakedReward = *value + allZeros = allZeros && isZeroBigInt(value) + + value, err = b.everstakeContractCallSimpleNumeric(everstakeAutocompoundBalanceOfMethodSignature, addr, contract) + b.observeEthCallStakingPool("autocompound_balance") + if err != nil { + return nil, err + } + poolData.AutocompoundBalance = *value + allZeros = allZeros && isZeroBigInt(value) + + if allZeros { + return nil, nil + } + return &poolData, nil +} diff --git a/bchain/coins/firo/firoparser.go b/bchain/coins/firo/firoparser.go index 4bb800e2df..742cb467d8 100644 --- a/bchain/coins/firo/firoparser.go +++ b/bchain/coins/firo/firoparser.go @@ -14,13 +14,14 @@ import ( ) const ( - OpZeroCoinMint = 0xc1 - OpZeroCoinSpend = 0xc2 - OpSigmaMint = 0xc3 - OpSigmaSpend = 0xc4 - OpLelantusMint = 0xc5 - OpLelantusJMint = 0xc6 - OpLelantusJoinSplit = 0xc7 + OpZeroCoinMint = 0xc1 + OpZeroCoinSpend = 0xc2 + OpSigmaMint = 0xc3 + OpSigmaSpend = 0xc4 + OpLelantusMint = 0xc5 + OpLelantusJMint = 0xc6 + OpLelantusJoinSplit = 0xc7 + OpLelantusJoinSplitPayload = 0xc9 MainnetMagic wire.BitcoinNet = 0xe3d9fef1 TestnetMagic wire.BitcoinNet = 0xcffcbeea @@ -122,6 +123,8 @@ func (p *FiroParser) GetAddressesFromAddrDesc(addrDesc bchain.AddressDescriptor) return []string{"LelantusJMint"}, false, nil case OpLelantusJoinSplit: return []string{"LelantusJoinSplit"}, false, nil + case OpLelantusJoinSplitPayload: + return []string{"LelantusJoinSplit"}, false, nil } } @@ -170,7 +173,7 @@ func (p *FiroParser) ParseBlock(b []byte) (*bchain.Block, error) { } else { if isMTP(header) { mtpHeader := MTPBlockHeader{} - mtpHashData := MTPHashData{} + mtpHashDataRoot := MTPHashDataRoot{} // header err = binary.Read(reader, binary.LittleEndian, &mtpHeader) @@ -178,28 +181,45 @@ func (p *FiroParser) ParseBlock(b []byte) (*bchain.Block, error) { return nil, err } - // hash data - err = binary.Read(reader, binary.LittleEndian, &mtpHashData) + // hash data root + err = binary.Read(reader, binary.LittleEndian, &mtpHashDataRoot) if err != nil { return nil, err } - // proof - for i := 0; i < MTPL*3; i++ { - var numberProofBlocks uint8 + isAllZero := true + for i := 0; i < 16; i++ { + if mtpHashDataRoot.HashRootMTP[i] != 0 { + isAllZero = false + break + } + } - err = binary.Read(reader, binary.LittleEndian, &numberProofBlocks) + if !isAllZero { + // hash data + mtpHashData := MTPHashData{} + err = binary.Read(reader, binary.LittleEndian, &mtpHashData) if err != nil { return nil, err } - for j := uint8(0); j < numberProofBlocks; j++ { - var mtpData [16]uint8 + // proof + for i := 0; i < MTPL*3; i++ { + var numberProofBlocks uint8 - err = binary.Read(reader, binary.LittleEndian, mtpData[:]) + err = binary.Read(reader, binary.LittleEndian, &numberProofBlocks) if err != nil { return nil, err } + + for j := uint8(0); j < numberProofBlocks; j++ { + var mtpData [16]uint8 + + err = binary.Read(reader, binary.LittleEndian, mtpData[:]) + if err != nil { + return nil, err + } + } } } } @@ -251,6 +271,7 @@ func (p *FiroParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: header.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: header.Timestamp.Unix(), }, @@ -318,9 +339,12 @@ func isProgPow(h *wire.BlockHeader, isTestNet bool) bool { return isTestNet && epoch >= SwitchToProgPowBlockHeaderTestnet || !isTestNet && epoch >= SwitchToProgPowBlockHeaderMainnet } -type MTPHashData struct { +type MTPHashDataRoot struct { HashRootMTP [16]uint8 - BlockMTP [128][128]uint64 +} + +type MTPHashData struct { + BlockMTP [128][128]uint64 } type MTPBlockHeader struct { diff --git a/bchain/coins/firo/firoparser_test.go b/bchain/coins/firo/firoparser_test.go index e4efd1327f..253323df25 100644 --- a/bchain/coins/firo/firoparser_test.go +++ b/bchain/coins/firo/firoparser_test.go @@ -731,6 +731,7 @@ func TestParseBlock(t *testing.T) { }, want: &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: "a3b419a943bdc31aba65d40fc71f12ceb4ef2edcf1c8bd6d83b839261387e0d9", Size: 200286, Time: 1547120622, }, @@ -746,6 +747,7 @@ func TestParseBlock(t *testing.T) { }, want: &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: "0fb6e382a25a9e298a533237f359cb6cd86a99afb8d98e3d981e650fd5012c00", Size: 25298, Time: 1482107572, }, @@ -761,6 +763,7 @@ func TestParseBlock(t *testing.T) { }, want: &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: "12c117c25e52f71e8863eadd0ccc7cd7d45e7ef907cfadf99ca4b4d390cb1a0a", Size: 200062, Time: 1591752749, }, diff --git a/bchain/coins/monetaryunit/monetaryunitparser.go b/bchain/coins/monetaryunit/monetaryunitparser.go index 045eba0370..d83dafb3bd 100644 --- a/bchain/coins/monetaryunit/monetaryunitparser.go +++ b/bchain/coins/monetaryunit/monetaryunitparser.go @@ -105,6 +105,7 @@ func (p *MonetaryUnitParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/myriad/myriadparser.go b/bchain/coins/myriad/myriadparser.go index 9cc50593ff..9783d1977b 100644 --- a/bchain/coins/myriad/myriadparser.go +++ b/bchain/coins/myriad/myriadparser.go @@ -85,6 +85,7 @@ func (p *MyriadParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/namecoin/namecoinparser.go b/bchain/coins/namecoin/namecoinparser.go index 9cf89e335e..30e1f705b5 100644 --- a/bchain/coins/namecoin/namecoinparser.go +++ b/bchain/coins/namecoin/namecoinparser.go @@ -82,6 +82,7 @@ func (p *NamecoinParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/nuls/nulsrpc.go b/bchain/coins/nuls/nulsrpc.go index 001cb6dfd9..3c73bed8bc 100644 --- a/bchain/coins/nuls/nulsrpc.go +++ b/bchain/coins/nuls/nulsrpc.go @@ -471,7 +471,7 @@ func (n *NulsRPC) EstimateFee(blocks int) (big.Int, error) { return *big.NewInt(100000), nil } -func (n *NulsRPC) SendRawTransaction(tx string) (string, error) { +func (n *NulsRPC) SendRawTransaction(tx string, alternativeRPC bool) (string, error) { broadcast := CmdTxBroadcast{} req := struct { TxHex string `json:"txHex"` diff --git a/bchain/coins/omotenashicoin/omotenashicoinparser.go b/bchain/coins/omotenashicoin/omotenashicoinparser.go index dd179fab76..3b823a33ea 100644 --- a/bchain/coins/omotenashicoin/omotenashicoinparser.go +++ b/bchain/coins/omotenashicoin/omotenashicoinparser.go @@ -112,6 +112,7 @@ func (p *OmotenashiCoinParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/optimism/evm.go b/bchain/coins/optimism/evm.go new file mode 100644 index 0000000000..d03390aa21 --- /dev/null +++ b/bchain/coins/optimism/evm.go @@ -0,0 +1,50 @@ +package optimism + +import ( + "context" + + "github.com/ethereum/go-ethereum/rpc" + "github.com/trezor/blockbook/bchain" +) + +// OptimismRPCClient wraps an rpc client to implement the EVMRPCClient interface +type OptimismRPCClient struct { + *rpc.Client +} + +// EthSubscribe subscribes to events and returns a client subscription that implements the EVMClientSubscription interface +func (c *OptimismRPCClient) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + sub, err := c.Client.EthSubscribe(ctx, channel, args...) + if err != nil { + return nil, err + } + + return &OptimismClientSubscription{ClientSubscription: sub}, nil +} + +// CallContext performs a JSON-RPC call with the given arguments +func (c *OptimismRPCClient) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + if err := c.Client.CallContext(ctx, result, method, args...); err != nil { + return err + } + + // special case to handle empty gas price for a valid rpc transaction + // (https://goerli-optimism.etherscan.io/tx/0x9b62094073147508471e3371920b68070979beea32100acdc49c721350b69cb9) + if r, ok := result.(*bchain.RpcTransaction); ok { + if *r != (bchain.RpcTransaction{}) && r.GasPrice == "" { + r.GasPrice = "0x0" + } + } + + return nil +} + +// BatchCallContext forwards batch JSON-RPC calls to the underlying client. +func (c *OptimismRPCClient) BatchCallContext(ctx context.Context, batch []rpc.BatchElem) error { + return c.Client.BatchCallContext(ctx, batch) +} + +// OptimismClientSubscription wraps a client subcription to implement the EVMClientSubscription interface +type OptimismClientSubscription struct { + *rpc.ClientSubscription +} diff --git a/bchain/coins/optimism/optimismrpc.go b/bchain/coins/optimism/optimismrpc.go new file mode 100644 index 0000000000..2512037c74 --- /dev/null +++ b/bchain/coins/optimism/optimismrpc.go @@ -0,0 +1,81 @@ +package optimism + +import ( + "context" + "encoding/json" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +const ( + // MainNet is production network + MainNet eth.Network = 10 +) + +// OptimismRPC is an interface to JSON-RPC optimism service. +type OptimismRPC struct { + *eth.EthereumRPC +} + +// NewOptimismRPC returns new OptimismRPC instance. +func NewOptimismRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + c, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &OptimismRPC{ + EthereumRPC: c.(*eth.EthereumRPC), + } + + return s, nil +} + +// Initialize bnb smart chain rpc interface +func (b *OptimismRPC) Initialize() error { + b.OpenRPC = eth.OpenRPC + + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) + if err != nil { + return err + } + + // set chain specific + b.Client = ec + b.RPC = rc + b.MainNetChainID = MainNet + b.NewBlock = eth.NewEthereumNewBlock() + b.NewTx = eth.NewEthereumNewTx() + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + id, err := b.Client.NetworkID(ctx) + if err != nil { + return err + } + + // parameters for getInfo request + switch eth.Network(id.Uint64()) { + case MainNet: + b.Testnet = false + b.Network = "livenet" + default: + return errors.Errorf("Unknown network id %v", id) + } + + if err = b.InitAlternativeProviders(); err != nil { + return err + } + + glog.Info("rpc: block chain ", b.Network) + + return nil +} + +func (b *OptimismRPC) ResolveENS(name string) (*bchain.ENSResolution, error) { + return b.EthereumRPC.ResolveENS(name) +} diff --git a/bchain/coins/pivx/pivxparser.go b/bchain/coins/pivx/pivxparser.go index 4dc92943f7..57acff4463 100644 --- a/bchain/coins/pivx/pivxparser.go +++ b/bchain/coins/pivx/pivxparser.go @@ -2,8 +2,10 @@ package pivx import ( "bytes" + "encoding/binary" "encoding/hex" "encoding/json" + "fmt" "io" "math/big" @@ -13,7 +15,6 @@ import ( "github.com/martinboehm/btcutil/chaincfg" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/btc" - "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -100,7 +101,12 @@ func (p *PivXParser) ParseBlock(b []byte) (*bchain.Block, error) { r.Seek(32, io.SeekCurrent) } - err = utils.DecodeTransactions(r, 0, wire.WitnessEncoding, &w) + if h.Version > 7 { + // Skip new hashFinalSaplingRoot (block version 8 or newer) + r.Seek(32, io.SeekCurrent) + } + + err = p.PivxDecodeTransactions(r, 0, &w) if err != nil { return nil, errors.Annotatef(err, "DecodeTransactions") } @@ -112,6 +118,7 @@ func (p *PivXParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, @@ -255,6 +262,90 @@ func (p *PivXParser) GetAddrDescForUnknownInput(tx *bchain.Tx, input int) bchain return s } +func (p *PivXParser) PivxDecodeTransactions(r *bytes.Reader, pver uint32, blk *wire.MsgBlock) error { + maxTxPerBlock := uint64((wire.MaxBlockPayload / 10) + 1) + + txCount, err := wire.ReadVarInt(r, pver) + if err != nil { + return err + } + + // Prevent more transactions than could possibly fit into a block. + // It would be possible to cause memory exhaustion and panics without + // a sane upper bound on this count. + if txCount > maxTxPerBlock { + str := fmt.Sprintf("too many transactions to fit into a block "+ + "[count %d, max %d]", txCount, maxTxPerBlock) + return &wire.MessageError{Func: "utils.decodeTransactions", Description: str} + } + + blk.Transactions = make([]*wire.MsgTx, 0, txCount) + for i := uint64(0); i < txCount; i++ { + tx := wire.MsgTx{} + + // read version & seek back to original state + var version uint32 = 0 + if err = binary.Read(r, binary.LittleEndian, &version); err != nil { + return err + } + if _, err = r.Seek(-4, io.SeekCurrent); err != nil { + return err + } + + txVersion := version & 0xffff + enc := wire.WitnessEncoding + + // shielded transactions + if txVersion >= 3 { + enc = wire.BaseEncoding + } + + err := p.PivxDecode(&tx, r, pver, enc) + if err != nil { + return err + } + blk.Transactions = append(blk.Transactions, &tx) + } + + return nil +} + +func (p *PivXParser) PivxDecode(MsgTx *wire.MsgTx, r *bytes.Reader, pver uint32, enc wire.MessageEncoding) error { + if err := MsgTx.BtcDecode(r, pver, enc); err != nil { + return err + } + + // extra + version := uint32(MsgTx.Version) + txVersion := version & 0xffff + + if txVersion >= 3 { + // valueBalance + r.Seek(9, io.SeekCurrent) + + vShieldedSpend, err := wire.ReadVarInt(r, 0) + if err != nil { + return err + } + if vShieldedSpend > 0 { + r.Seek(int64(vShieldedSpend*384), io.SeekCurrent) + } + + vShieldOutput, err := wire.ReadVarInt(r, 0) + if err != nil { + return err + } + if vShieldOutput > 0 { + r.Seek(int64(vShieldOutput*948), io.SeekCurrent) + } + + // bindingSig + r.Seek(64, io.SeekCurrent) + } + + return nil +} + // Checks if script is OP_ZEROCOINMINT func isZeroCoinMintScript(signatureScript []byte) bool { return len(signatureScript) > 1 && signatureScript[0] == OP_ZEROCOINMINT diff --git a/bchain/coins/polygon/polygonrpc.go b/bchain/coins/polygon/polygonrpc.go new file mode 100644 index 0000000000..b3e103bac4 --- /dev/null +++ b/bchain/coins/polygon/polygonrpc.go @@ -0,0 +1,81 @@ +package polygon + +import ( + "context" + "encoding/json" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +const ( + // MainNet is production network + MainNet eth.Network = 137 +) + +// PolygonRPC is an interface to JSON-RPC polygon service. +type PolygonRPC struct { + *eth.EthereumRPC +} + +// NewPolygonRPC returns new PolygonRPC instance. +func NewPolygonRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + c, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &PolygonRPC{ + EthereumRPC: c.(*eth.EthereumRPC), + } + + return s, nil +} + +// Initialize polygon rpc interface +func (b *PolygonRPC) Initialize() error { + b.OpenRPC = eth.OpenRPC + + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) + if err != nil { + return err + } + + // set chain specific + b.Client = ec + b.RPC = rc + b.MainNetChainID = MainNet + b.NewBlock = eth.NewEthereumNewBlock() + b.NewTx = eth.NewEthereumNewTx() + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + id, err := b.Client.NetworkID(ctx) + if err != nil { + return err + } + + // parameters for getInfo request + switch eth.Network(id.Uint64()) { + case MainNet: + b.Testnet = false + b.Network = "livenet" + default: + return errors.Errorf("Unknown network id %v", id) + } + + if err = b.InitAlternativeProviders(); err != nil { + return err + } + + glog.Info("rpc: block chain ", b.Network) + + return nil +} + +func (b *PolygonRPC) ResolveENS(name string) (*bchain.ENSResolution, error) { + return b.EthereumRPC.ResolveENS(name) +} diff --git a/bchain/coins/qtum/qtumparser.go b/bchain/coins/qtum/qtumparser.go index 8bc2d5e943..da3f0aee88 100644 --- a/bchain/coins/qtum/qtumparser.go +++ b/bchain/coins/qtum/qtumparser.go @@ -124,6 +124,7 @@ func (p *QtumParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/ritocoin/ritocoinparser.go b/bchain/coins/ritocoin/ritocoinparser.go index de134b6e6e..ea1e3cfc30 100644 --- a/bchain/coins/ritocoin/ritocoinparser.go +++ b/bchain/coins/ritocoin/ritocoinparser.go @@ -85,6 +85,7 @@ func (p *RitocoinParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/tron/contract_test.go b/bchain/coins/tron/contract_test.go new file mode 100644 index 0000000000..4ca44a26dc --- /dev/null +++ b/bchain/coins/tron/contract_test.go @@ -0,0 +1,244 @@ +// //go:build unittest +package tron + +import ( + "math/big" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +// Receipt != nil so we are testing getting transfers from og +func TestTronParser_EthereumTypeGetTokenTransfersFromLog(t *testing.T) { + parser := NewTronParser(1, false) + + tests := []struct { + name string + tx *bchain.Tx + expected bchain.TokenTransfers + }{ + { + name: "TRC20 transfer", + tx: &bchain.Tx{ + Txid: "0xtesttxid", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + From: "0xc88bb5a4636463d7eb2af02ccabb8b790fb200a9", + To: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c", // contract + Payload: "0xa9059cbb0000000000000000000000418da98894069283ddf2379e0b27bfea76fc9b73990000000000000000000000000000000000000000000000000000000022eda680", // transfer(address,uint256) + }, + Receipt: &bchain.RpcReceipt{ + Logs: []*bchain.RpcLog{ + { + Address: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c", // USDT + Topics: []string{ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000c88bb5a4636463d7eb2af02ccabb8b790fb200a9", + "0x0000000000000000000000008da98894069283ddf2379e0b27bfea76fc9b7399", + }, + Data: "0x0000000000000000000000000000000000000000000000000000000022eda680", + }, + }, + }, + }, + }, + expected: bchain.TokenTransfers{ + { + Standard: bchain.FungibleToken, + Contract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + From: "TUFbWcZzvLy2LbxkxFAraojZRTB8vewjsz", + To: "TNtFNW4EoQJanSczatPpU2kETN3WbVFVHR", + Value: *big.NewInt(586000000), + }, + }, + }, + { + name: "TRC721 transfer", + tx: &bchain.Tx{ + Txid: "0x49ced31cd0fd6d8e1126775f53ade165fe7ca43e9cc968d64a9ce1aff597423c", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + From: "0x34627862d50389c8d7a1ab5ef074b84ab4ddb9e9", + To: "0x0b17822171ee88e98d4a61029f97c9f8edc15fcd", + Payload: "0x23b872dd00000000000000000000000034627862d50389c8d7a1ab5ef074b84ab4ddb9e90000000000000000000000000cecca0e53477d2b6c562ab68c3452fc99f7817e000000000000000000000000000000000000000000000000000000000000067f", + }, + Receipt: &bchain.RpcReceipt{ + Logs: []*bchain.RpcLog{ + { + Address: "0x0b17822171ee88e98d4a61029f97c9f8edc15fcd", + Topics: []string{ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x00000000000000000000000034627862d50389c8d7a1ab5ef074b84ab4ddb9e9", + "0x0000000000000000000000000cecca0e53477d2b6c562ab68c3452fc99f7817e", + "0x000000000000000000000000000000000000000000000000000000000000067f", + }, + Data: "0x", + }, + }, + }, + }, + }, + expected: bchain.TokenTransfers{ + { + Standard: bchain.NonFungibleToken, + Contract: "TAyrbZCme4jVBnHnALvoKbE6ewLd2VGD77", + From: "TEkC6sH3rPjwXzXm58p9dRVVMHiz2wTcub", + To: "TB9YmmXyQuhZ4dvG4T2EAzeksVme6RSvWA", + Value: *big.NewInt(1663), + }, + }, + }, + { + name: "TRC1155 transfer", + tx: &bchain.Tx{ + Txid: "0x1c5273ced427e4dcad8f6ad7441a0e247dadec0d7e24583ba0f292feeba463b1", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + From: "0x46f67edfe3080971e39c7e099d50ec5d86f2cb06", + To: "0xec3dc0f7b89a6463eb05527fdaf3634db481fe61", + Payload: "0xf242432a00000000000000000000000046f67edfe3080971e39c7e099d50ec5d86f2cb060000000000000000000000008227ecc55945f98c3dd10a8f461a4d7db126fdba000000000000000000000000000000000000000019efcdb92505463d0bebd400000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000", + }, + Receipt: &bchain.RpcReceipt{ + Logs: []*bchain.RpcLog{ + { + Address: "0xec3dc0f7b89a6463eb05527fdaf3634db481fe61", + Topics: []string{ + "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62", + "0x00000000000000000000000046f67edfe3080971e39c7e099d50ec5d86f2cb06", + "0x00000000000000000000000046f67edfe3080971e39c7e099d50ec5d86f2cb06", + "0x0000000000000000000000008227ecc55945f98c3dd10a8f461a4d7db126fdba", + }, + Data: "0x000000000000000000000000000000000000000019efcdb92505463d0bebd4000000000000000000000000000000000000000000000000000000000000000001", + }, + }, + }, + }, + }, + expected: bchain.TokenTransfers{ + { + Standard: bchain.MultiToken, + Contract: "TXWLT4N9vDcmNHDnSuKv2odhBtizYuEMKJ", + From: "TGSRbJTwpyNtjnefQJG1ZwVF1CSDaGYGDy", + To: "TMqQg2W2UEEB8cdR35AvpEfU7QbVMihiRn", + MultiTokenValues: []bchain.MultiTokenValue{ + { + Id: bi("802703001686578058670400000"), + Value: *big.NewInt(1), + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transfers, err := parser.EthereumTypeGetTokenTransfersFromTx(tt.tx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tt.expected) != len(transfers) { + t.Fatalf("expected %d transfers, got %d", len(tt.expected), len(transfers)) + } + + for i := range tt.expected { + if tt.expected[i].Contract != transfers[i].Contract || + tt.expected[i].Standard != transfers[i].Standard || + tt.expected[i].From != transfers[i].From || + tt.expected[i].To != transfers[i].To || + tt.expected[i].Value.Cmp(&transfers[i].Value) != 0 { + t.Errorf("transfer %d mismatch:\ngot %+v\nwant %+v", i, transfers[i], tt.expected[i]) + } + } + + }) + } +} + +func TestTronParser_EthereumTypeGetTokenTransfersFromTx(t *testing.T) { + parser := NewTronParser(1, false) + + tests := []struct { + name string + tx *bchain.Tx + expected bchain.TokenTransfers + }{ + { + name: "TRC20 transfer", + tx: &bchain.Tx{ + Txid: "0xtesttxid", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + From: "0xc88bb5a4636463d7eb2af02ccabb8b790fb200a9", + To: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c", // contract + Payload: "0xa9059cbb0000000000000000000000418da98894069283ddf2379e0b27bfea76fc9b73990000000000000000000000000000000000000000000000000000000022eda680", // transfer(address,uint256) + }, + }, + }, + expected: bchain.TokenTransfers{ + { + Standard: bchain.FungibleToken, + Contract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", // Base58 + From: "TUFbWcZzvLy2LbxkxFAraojZRTB8vewjsz", // Base58 + To: "TNtFNW4EoQJanSczatPpU2kETN3WbVFVHR", // Base58 + Value: *big.NewInt(586000000), + }, + }, + }, + { + name: "TRC721 transfer", + tx: &bchain.Tx{ + Txid: "0x49ced31cd0fd6d8e1126775f53ade165fe7ca43e9cc968d64a9ce1aff597423c", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + From: "0x34627862d50389c8d7a1ab5ef074b84ab4ddb9e9", + To: "0x0b17822171ee88e98d4a61029f97c9f8edc15fcd", + Payload: "0x23b872dd00000000000000000000000034627862d50389c8d7a1ab5ef074b84ab4ddb9e90000000000000000000000000cecca0e53477d2b6c562ab68c3452fc99f7817e000000000000000000000000000000000000000000000000000000000000067f", + }, + }, + }, + expected: bchain.TokenTransfers{ + { + Standard: bchain.NonFungibleToken, + Contract: "TAyrbZCme4jVBnHnALvoKbE6ewLd2VGD77", + From: "TEkC6sH3rPjwXzXm58p9dRVVMHiz2wTcub", + To: "TB9YmmXyQuhZ4dvG4T2EAzeksVme6RSvWA", + Value: *big.NewInt(1663), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transfers, err := parser.EthereumTypeGetTokenTransfersFromTx(tt.tx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tt.expected) != len(transfers) { + t.Fatalf("expected %d transfers, got %d", len(tt.expected), len(transfers)) + } + + for i := range tt.expected { + if tt.expected[i].Contract != transfers[i].Contract || + tt.expected[i].Standard != transfers[i].Standard || + tt.expected[i].From != transfers[i].From || + tt.expected[i].To != transfers[i].To || + tt.expected[i].Value.Cmp(&transfers[i].Value) != 0 { + t.Errorf("transfer %d mismatch:\ngot %+v\nwant %+v", i, transfers[i], tt.expected[i]) + } + } + + }) + } +} + +// convert number longer than uint64 to big.Int +func bi(s string) big.Int { + n := big.NewInt(0) + _, ok := n.SetString(s, 10) + if !ok { + panic("invalid big.Int string: " + s) + } + return *n +} diff --git a/bchain/coins/tron/dataparser_test.go b/bchain/coins/tron/dataparser_test.go new file mode 100644 index 0000000000..8e0a4284fd --- /dev/null +++ b/bchain/coins/tron/dataparser_test.go @@ -0,0 +1,118 @@ +//go:build unittest + +package tron + +import ( + "reflect" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +func TestParseInputData(t *testing.T) { + signatures := []bchain.FourByteSignature{ + { + Name: "safeTransferFrom", + Parameters: []string{"address", "address", "uint256", "uint256", "bytes"}, + }, + { + Name: "transfer", + Parameters: []string{"address", "uint256"}, + }, + } + tests := []struct { + name string + signatures *[]bchain.FourByteSignature + data string + want *bchain.EthereumParsedInputData + wantErr bool + }{ + { + name: "TRC 1155 transfer", + signatures: &signatures, + data: "0xf242432a00000000000000000000000046f67edfe3080971e39c7e099d50ec5d86f2cb060000000000000000000000008227ecc55945f98c3dd10a8f461a4d7db126fdba000000000000000000000000000000000000000019efcdb92505463d0bebd400000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xf242432a", + Name: "Safe Transfer From", + Function: "safeTransferFrom(address, address, uint256, uint256, bytes)", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "address", + Values: []string{"TGSRbJTwpyNtjnefQJG1ZwVF1CSDaGYGDy"}, + }, + { + Type: "address", + Values: []string{"TMqQg2W2UEEB8cdR35AvpEfU7QbVMihiRn"}, + }, + { + Type: "uint256", + Values: []string{"8027030016865780586704000000"}, + }, + { + Type: "uint256", + Values: []string{"1"}, + }, + { + Type: "bytes", + Values: []string{""}, + }, + }, + }, + }, + { + name: "TRC20 transfer", + signatures: &signatures, + data: "0xa9059cbb000000000000000000000000d54f9e3b484b372f83aecd67b3772368af4268be0000000000000000000000000000000000000000000000000000000000a7d8c0", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xa9059cbb", + Name: "Transfer", + Function: "transfer(address, uint256)", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "address", + Values: []string{"TVR6Jt3bTZhpsQer2DoH2RMDHoe5LS61Kz"}, + }, + { + Type: "uint256", + Values: []string{"11000000"}, + }, + }, + }, + }, + { + name: "Return Energy (dab0fe27)", + signatures: &[]bchain.FourByteSignature{ + { + Name: "returnEnergy", + Parameters: []string{"address", "uint256"}, + }, + }, + data: "0xdab0fe27000000000000000000000000e18657b3968394ae9a68f7dc93c110d84f2b079e000000000000000000000000000000000000000000000000000000016139cc53", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xdab0fe27", + Name: "Return Energy", + Function: "returnEnergy(address, uint256)", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "address", + Values: []string{"TWXfyWNZCeewDCpATk7i6E3X5CwGrFEkg6"}, + }, + { + Type: "uint256", + Values: []string{"5926145107"}, + }, + }, + }, + }, + } + parser := NewTronParser(1, false) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parser.ParseInputData(tt.signatures, tt.data) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseInputData() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/bchain/coins/tron/evm.go b/bchain/coins/tron/evm.go new file mode 100644 index 0000000000..bc4e12e173 --- /dev/null +++ b/bchain/coins/tron/evm.go @@ -0,0 +1,268 @@ +package tron + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/trezor/blockbook/bchain" +) + +const ( + MainnetGenesisHash = "0x2b6653dc" + NileTestnetGenesisHash = "0xcd8690dc" +) + +// TronClient wraps the original go-ethereum Client and adds Tron-specific methods +type TronClient struct { + *ethclient.Client + rpcClient *TronRPCClient +} + +// EstimateGas returns the current estimated gas cost for executing a transaction +func (c *TronClient) EstimateGas(ctx context.Context, msg interface{}) (uint64, error) { + return c.Client.EstimateGas(ctx, msg.(ethereum.CallMsg)) +} + +// BalanceAt returns the balance for the given account at a specific block, or latest known block if no block number is provided +// IMPORTANT: Tron RPC only supports 'latest' block parameter. The blockNumber parameter is ignored. +func (c *TronClient) BalanceAt(ctx context.Context, addrDesc bchain.AddressDescriptor, blockNumber *big.Int) (*big.Int, error) { + var result hexutil.Big + err := c.rpcClient.CallContext(ctx, &result, "eth_getBalance", common.BytesToAddress(addrDesc), "latest") + return (*big.Int)(&result), err +} + +// NonceAt is not supported by Tron RPC +func (c *TronClient) NonceAt(ctx context.Context, addrDesc bchain.AddressDescriptor, blockNumber *big.Int) (uint64, error) { + return 0, nil +} + +// TronHash wraps a transaction hash to implement the EVMHash interface +type TronHash struct { + common.Hash +} + +type TronClientSubscription struct { + *rpc.ClientSubscription +} + +// TronNewBlock wraps a block header channel to implement the EVMNewBlockSubscriber interface +type TronNewBlock struct { + channel chan *types.Header +} + +// Close the underlying channel +func (s *TronNewBlock) Close() { + close(s.channel) +} + +// Channel returns the underlying channel as an empty interface +func (s *TronNewBlock) Channel() interface{} { + return s.channel +} + +// Read from the underlying channel and return a block header that implements the EVMHeader interface +func (s *TronNewBlock) Read() (bchain.EVMHeader, bool) { + h, ok := <-s.channel + return &TronHeader{Header: h}, ok +} + +// TronNewTx wraps a transaction hash channel to conform with the EVMNewTxSubscriber interface +type TronNewTx struct { + channel chan common.Hash +} + +// Channel returns the underlying channel as an empty interface +func (s *TronNewTx) Channel() interface{} { + return s.channel +} + +// Read from the underlying channel and return a transaction hash that implements the EVMHash interface +func (s *TronNewTx) Read() (bchain.EVMHash, bool) { + h, ok := <-s.channel + return &TronHash{Hash: h}, ok +} + +// Close the underlying channel +func (s *TronNewTx) Close() { + close(s.channel) +} + +type TronHeader struct { + *types.Header // Embed the original Header + // use Hash of the block returned from RPC + HashBlock common.Hash `json:"hash" gencodec:"required"` +} + +func (h *TronHeader) Hash() string { + return h.HashBlock.Hex() +} + +func (h *TronHeader) Number() *big.Int { + return h.Header.Number +} + +func (h *TronHeader) Difficulty() *big.Int { + return h.Header.Difficulty +} + +func (t *TronHeader) MarshalJSON() ([]byte, error) { + type Alias TronHeader + return json.Marshal(&struct { + HashBlock common.Hash `json:"hash"` + *Alias + }{ + HashBlock: t.HashBlock, + Alias: (*Alias)(t), + }) +} + +func (t *TronHeader) UnmarshalJSON(data []byte) error { + // initialize Header + if t.Header == nil { + t.Header = &types.Header{} + } + + var hashData struct { + Hash string `json:"hash"` + } + if err := json.Unmarshal(data, &hashData); err != nil { + return fmt.Errorf("error unmarshalling hash: %w", err) + } + + // Decode the hash from hex string to `common.Hash` + hashBytes, err := hexutil.Decode(hashData.Hash) + if err != nil { + return fmt.Errorf("invalid hash hex format: %w", err) + } + copy(t.HashBlock[:], hashBytes) + + // Unmarshal remaining data from Header + if err := json.Unmarshal(data, t.Header); err != nil { + return fmt.Errorf("error unmarshalling Header: %w", err) + } + + return nil +} + +// TronRPCClient wraps an rpc client to implement the EVMRPCClient interface +type TronRPCClient struct { + *rpc.Client +} + +// EthSubscribe subscribes to events and returns a client subscription that implements the EVMClientSubscription interface +func (c *TronRPCClient) EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (bchain.EVMClientSubscription, error) { + sub, err := c.Client.EthSubscribe(ctx, channel, args...) + if err != nil { + return nil, err + } + + return &TronClientSubscription{ClientSubscription: sub}, nil +} + +func (c *TronClient) Close() { + c.Client.Close() +} + +func (c *TronClient) HeaderByNumber(ctx context.Context, number *big.Int) (bchain.EVMHeader, error) { + h, err := c.rpcClient.HeaderByNumber(ctx, number) + if err != nil { + return nil, err + } + + return h, nil +} + +// NetworkID returns the network ID for this client. +// Tron RPC returns genesis block +func (c *TronClient) NetworkID(ctx context.Context) (*big.Int, error) { + var ver string + + if err := c.rpcClient.CallContext(ctx, &ver, "net_version"); err != nil { + return nil, err + } + + switch ver { + case MainnetGenesisHash: + return big.NewInt(int64(MainNet)), nil + case NileTestnetGenesisHash: + return big.NewInt(int64(TestNetNile)), nil + default: + return nil, fmt.Errorf("invalid net_version result %q", ver) + } +} + +// HeaderByNumber returns a block header from the current canonical chain. If number is +// nil, the latest known header is returned. +// overwriten so it returns TronHeader with Hash +func (c *TronRPCClient) HeaderByNumber(ctx context.Context, number *big.Int) (*TronHeader, error) { + var head *TronHeader + err := c.CallContext(ctx, &head, "eth_getBlockByNumber", toBlockNumArg(number), false) + if err == nil && head == nil { + err = ethereum.NotFound + } + return head, err +} + +func toBlockNumArg(number *big.Int) string { + if number == nil { + return "latest" + } + if number.Sign() >= 0 { + return hexutil.EncodeBig(number) + } + // It's negative. + if number.IsInt64() { + return rpc.BlockNumber(number.Int64()).String() + } + // It's negative and large, which is invalid. + return fmt.Sprintf("", number) +} + +func (c *TronRPCClient) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + var rawData json.RawMessage + + if err := c.Client.CallContext(ctx, &rawData, method, args...); err != nil { + return err + } + + // Clean up the response for Tron-specific (Tron has wrong stateRoot as '0x') + // Skip when returning raw JSON to avoid an extra marshal/unmarshal cycle. + if method == "eth_getBlockByHash" || method == "eth_getBlockByNumber" { + if _, ok := result.(*json.RawMessage); !ok { + rawData = fixStateRoot(rawData) + } + } + + return json.Unmarshal(rawData, result) +} + +// fixStateRoot works around Tron JSON-RPC returning stateRoot in a format incompatible with go-ethereum +// Issue: Tron returns stateRoot as "0x" (empty) or with incorrect length, which causes go-ethereum +// deserialization to fail since it expects a valid 32-byte hash (66 chars: "0x" + 64 hex digits) +// +// This is likely because Tron uses a different state storage mechanism than Ethereum's MPT (Merkle Patricia Tree), +// but still tries to maintain API compatibility. The stateRoot field may not have the same meaning in Tron. +// +// Workaround: Replace invalid stateRoot with a zero hash to allow successful parsing by go-ethereum library +// Reference: https://github.com/tronprotocol/java-tron/issues/5518 +func fixStateRoot(data []byte) []byte { + const ( + stateRootBad = `"stateRoot":"0x"` + stateRootGood = `"stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000"` + ) + + if !bytes.Contains(data, []byte(stateRootBad)) { + return data + } + + return bytes.Replace(data, []byte(stateRootBad), []byte(stateRootGood), 1) +} diff --git a/bchain/coins/tron/normalization.go b/bchain/coins/tron/normalization.go new file mode 100644 index 0000000000..4697d5fd0e --- /dev/null +++ b/bchain/coins/tron/normalization.go @@ -0,0 +1,168 @@ +package tron + +import ( + "encoding/json" + "fmt" + "math" + "math/big" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/trezor/blockbook/bchain" +) + +const ( + tronResourceBandwidth tronResourceCode = 0 + tronResourceEnergy tronResourceCode = 1 + tronResourceVotePower tronResourceCode = 2 +) + +func (c *tronResourceCode) UnmarshalJSON(data []byte) error { + var n int64 + if err := json.Unmarshal(data, &n); err == nil { + *c = tronResourceCode(n) + return nil + } + + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + switch strings.ToUpper(strings.TrimSpace(s)) { + case "0", "BANDWIDTH": + *c = tronResourceBandwidth + case "1", "ENERGY": + *c = tronResourceEnergy + case "2", "VOTE_POWER", "VOTEPOWER", "TRON_POWER", "TRONPOWER": + *c = tronResourceVotePower + default: + return fmt.Errorf("unknown Tron resource code %q", s) + } + return nil +} + +func tronNumberToString(v interface{}) string { + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case json.Number: + return strings.TrimSpace(t.String()) + default: + return "" + } +} + +func has0xPrefix(s string) bool { + return len(s) >= 2 && s[0] == '0' && (s[1]|32) == 'x' +} + +func strip0xPrefix(s string) string { + if has0xPrefix(s) { + return s[2:] + } + return s +} + +func normalizeHexString(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if has0xPrefix(s) { + return s + } + return "0x" + s +} + +func tronResourceToString(v *tronResourceCode) string { + if v == nil { + return "" + } + switch *v { + case tronResourceEnergy: + return "energy" + case tronResourceBandwidth: + return "bandwidth" + case tronResourceVotePower: + return "votePower" + default: + return "" + } +} + +func tronInt64PtrToString(v *int64) string { + if v == nil { + return "" + } + return strconv.FormatInt(*v, 10) +} + +func tronInt64PtrToHexQuantity(v *int64) string { + if v == nil { + return "" + } + n := big.NewInt(*v) + if n.Sign() < 0 { + return "" + } + return "0x" + n.Text(16) +} + +func tronHexQuantityToInt64Ptr(v string) *int64 { + if strings.TrimSpace(v) == "" { + return nil + } + n, err := hexutil.DecodeUint64(v) + if err != nil || n > math.MaxInt64 { + return nil + } + value := int64(n) + return &value +} + +func tronUint64(v interface{}) (uint64, bool) { + s := strings.TrimSpace(tronNumberToString(v)) + if s == "" { + return 0, false + } + n, ok := new(big.Int).SetString(s, 0) + if !ok || n.Sign() < 0 || !n.IsUint64() { + return 0, false + } + return n.Uint64(), true +} + +func tronFirstAddress(values ...string) string { + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + return v + } + } + return "" +} + +func tronFirstInt64PtrToString(values ...*int64) string { + for _, v := range values { + if s := tronInt64PtrToString(v); s != "" { + return s + } + } + return "" +} + +func tronNormalizeLogs(logs []*bchain.RpcLog) []*bchain.RpcLog { + for _, l := range logs { + if l == nil { + continue + } + l.Address = normalizeHexString(l.Address) + l.Data = normalizeHexString(l.Data) + for i, t := range l.Topics { + l.Topics[i] = normalizeHexString(t) + } + } + return logs +} diff --git a/bchain/coins/tron/tronInternalDataProvider.go b/bchain/coins/tron/tronInternalDataProvider.go new file mode 100644 index 0000000000..94a2b2bb93 --- /dev/null +++ b/bchain/coins/tron/tronInternalDataProvider.go @@ -0,0 +1,264 @@ +package tron + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "time" + + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" +) + +type TronInternalDataProvider struct { + solidityNodeHTTP TronHTTP + timeout time.Duration +} + +type tronCallValueInfo struct { + CallValue int64 `json:"callValue"` + TokenID string `json:"tokenId,omitempty"` +} + +type tronInternalTransaction struct { + Hash string `json:"hash"` + CallerAddress string `json:"caller_address"` + TransferToAddress string `json:"transferTo_address"` + Note string `json:"note"` // "call", "create", "suicide", ... + Rejected bool `json:"rejected"` // true = fail + CallValueInfo []tronCallValueInfo `json:"callValueInfo"` +} + +type tronReceipt struct { + Result string `json:"result"` // "SUCCESS", "REVERT", ... +} + +type tronTxInfo struct { + ID string `json:"id"` + BlockNumber int64 `json:"blockNumber"` + ContractAddress string `json:"contract_address"` + InternalTransactions []tronInternalTransaction `json:"internal_transactions"` + Receipt tronReceipt `json:"receipt"` +} + +func NewTronInternalDataProvider(solidityNodeHTTP TronHTTP, timeout time.Duration) *TronInternalDataProvider { + return &TronInternalDataProvider{ + solidityNodeHTTP: solidityNodeHTTP, + timeout: timeout, + } +} + +func (p *TronInternalDataProvider) GetInternalDataForBlock( + blockHash string, + blockHeight uint32, + transactions []bchain.RpcTransaction, +) ([]bchain.EthereumInternalData, []bchain.ContractInfo, error) { + data := make([]bchain.EthereumInternalData, len(transactions)) + contracts := make([]bchain.ContractInfo, 0) + + if !bchain.ProcessInternalTransactions { + return data, contracts, nil + } + if len(transactions) == 0 { + return data, contracts, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), p.timeout) + defer cancel() + + responses, err := p.GetTransactionInfoByBlockNum(ctx, blockHeight) + if err != nil { + glog.Errorf("GetInternalDataForBlock: error calling gettransactioninfobyblocknum: %v", err) + return nil, nil, err + } + infos := tronTxInfosFromResponses(responses) + + return buildInternalDataFromTronInfos(infos, transactions, blockHeight) +} + +func (p *TronInternalDataProvider) GetTransactionInfoByBlockNum(ctx context.Context, blockNum uint32) ([]tronGetTransactionInfoByIDResponse, error) { + return p.requestTransactionInfoByBlockNumWithHTTP(ctx, p.solidityNodeHTTP, blockNum) +} + +func (p *TronInternalDataProvider) requestTransactionInfoByBlockNumWithHTTP(ctx context.Context, http TronHTTP, blockNum uint32) ([]tronGetTransactionInfoByIDResponse, error) { + if http == nil { + return nil, errors.New("Tron internal data provider missing solidity http client") + } + var raw json.RawMessage + if err := http.Request(ctx, "/walletsolidity/gettransactioninfobyblocknum", map[string]any{ + "num": blockNum, + }, &raw); err != nil { + return nil, err + } + if tronIsEmptyResponse(raw) { + return nil, nil + } + + var resp []tronGetTransactionInfoByIDResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func tronTxInfosFromResponses(responses []tronGetTransactionInfoByIDResponse) []tronTxInfo { + if len(responses) == 0 { + return nil + } + infos := make([]tronTxInfo, len(responses)) + for i := range responses { + r := &responses[i] + info := &infos[i] + info.ID = r.ID + info.ContractAddress = r.ContractAddr + info.InternalTransactions = r.InternalTransactions + if r.BlockNumber != nil { + info.BlockNumber = *r.BlockNumber + } + info.Receipt.Result = r.Receipt.Result + } + return infos +} + +// internal transaction format described at https://developers.tron.network/docs/tron-protocol-transaction#internal-transactions +func buildInternalDataFromTronInfos( + infos []tronTxInfo, + transactions []bchain.RpcTransaction, + blockHeight uint32, +) ([]bchain.EthereumInternalData, []bchain.ContractInfo, error) { + + data := make([]bchain.EthereumInternalData, len(transactions)) + contracts := make([]bchain.ContractInfo, 0) + + // make sure the tx order is correct + infoByID := make(map[string]*tronTxInfo, len(infos)) + for i := range infos { + id := infos[i].ID + infoByID[id] = &infos[i] + } + + for i := range transactions { + tx := &transactions[i] + key := strip0xPrefix(tx.Hash) + + info, ok := infoByID[key] + if !ok { + continue + } + + d := &data[i] + + topType, contractAddr, err := detectTopType(info.InternalTransactions) + if err != nil { + return data, contracts, err + } + + if topType == bchain.CALL && info.ContractAddress != "" { + topType = bchain.CREATE + contractAddr = ToTronAddressFromAddress(info.ContractAddress) + } + + d.Type = topType + if contractAddr != "" { + d.Contract = contractAddr + } + + if topType == bchain.CREATE && contractAddr != "" { + contracts = append(contracts, bchain.ContractInfo{ + Contract: contractAddr, + CreatedInBlock: blockHeight, + Standard: bchain.UnhandledTokenStandard, + }) + } else if topType == bchain.SELFDESTRUCT { + contracts = append(contracts, bchain.ContractInfo{ + Contract: contractAddr, + DestructedInBlock: blockHeight, + }) + } + + for _, itx := range info.InternalTransactions { + + t, err := tronNoteHexToInternalType(itx.Note) + if err != nil { + return data, contracts, err + } + + from := ToTronAddressFromAddress(itx.CallerAddress) + to := ToTronAddressFromAddress(itx.TransferToAddress) + + for _, cv := range itx.CallValueInfo { + // skip TRC-10 + if cv.CallValue <= 0 || cv.TokenID != "" { + continue + } + + val := *big.NewInt(cv.CallValue) + d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{ + Type: t, + From: from, + To: to, + Value: val, + }) + } + } + + if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" { + d.Error = info.Receipt.Result + } + + for _, itx := range info.InternalTransactions { + if itx.Rejected { + if d.Error == "" { + d.Error = "Internal transaction rejected" + } else { + d.Error += "; internal transaction rejected" + } + break + } + } + } + + return data, contracts, nil +} + +// we need to figure out the root type of the transaction +// CREATE > SELFDESTRUCT > CALL +func detectTopType(internalTxs []tronInternalTransaction) ( + bchain.EthereumInternalTransactionType, + string, + error, +) { + var createdContract string + var destructedContract string + + for _, itx := range internalTxs { + t, err := tronNoteHexToInternalType(itx.Note) + if err != nil { + return bchain.CALL, "", err + } + + switch t { + case bchain.CALL: + continue + case bchain.CREATE: + if createdContract == "" { + createdContract = ToTronAddressFromAddress(itx.TransferToAddress) + } + case bchain.SELFDESTRUCT: + if destructedContract == "" { + destructedContract = ToTronAddressFromAddress(itx.CallerAddress) + } + default: + glog.Warningf("Unknown Tron internal transaction type %v", t) + } + } + + if createdContract != "" { + return bchain.CREATE, createdContract, nil + } + if destructedContract != "" { + return bchain.SELFDESTRUCT, destructedContract, nil + } + return bchain.CALL, "", nil +} diff --git a/bchain/coins/tron/tronInternalDataProvider_test.go b/bchain/coins/tron/tronInternalDataProvider_test.go new file mode 100644 index 0000000000..63c39500a6 --- /dev/null +++ b/bchain/coins/tron/tronInternalDataProvider_test.go @@ -0,0 +1,255 @@ +//go:build unittest + +package tron + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" +) + +type MockTronHTTPClient struct { + Resp interface{} + Err error + + LastPath string + LastBody interface{} +} + +func (m *MockTronHTTPClient) Request(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error { + m.LastPath = path + m.LastBody = reqBody + + if m.Err != nil { + return m.Err + } + b, _ := json.Marshal(m.Resp) + return json.Unmarshal(b, respBody) +} + +func TestTronInternalDataProvider_GetInternalDataForBlock_Simple(t *testing.T) { + bchain.ProcessInternalTransactions = true + + // fake transaction info returned from the Tron HTTP API + fake := []tronTxInfo{ + { + ID: "abcd", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41da727d310b98700af4cec797e43991899668d6f3", + Note: "63616c6c", // "call" + CallValueInfo: []tronCallValueInfo{ + {CallValue: 123456}, + }, + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + } + + mockHTTP := &MockTronHTTPClient{ + Resp: fake, + } + + provider := NewTronInternalDataProvider(mockHTTP, time.Second) + + txs := []bchain.RpcTransaction{ + {Hash: "0xabcd"}, + } + + data, contracts, err := provider.GetInternalDataForBlock("", 99, txs) + + require.NoError(t, err) + + // verify HTTP call + require.Equal(t, "/walletsolidity/gettransactioninfobyblocknum", mockHTTP.LastPath) + require.Equal(t, map[string]any{"num": uint32(99)}, mockHTTP.LastBody) + + // verify parsed internal data + require.Len(t, data, 1) + require.Len(t, contracts, 0) + + d := data[0] + require.Equal(t, bchain.CALL, d.Type) + require.Len(t, d.Transfers, 1) + require.Equal(t, int64(123456), d.Transfers[0].Value.Int64()) + + require.Equal(t, "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", d.Transfers[0].From) + require.Equal(t, "TVtFTiSQmeMkdpusjefUcPcEeTPtqnhz3D", d.Transfers[0].To) +} + +func TestBuildInternalDataFromTronInfos(t *testing.T) { + + tests := []struct { + name string + infos []tronTxInfo + txs []bchain.RpcTransaction + wantType bchain.EthereumInternalTransactionType + wantTransfers int + wantContracts int + wantErrContains string // error return from function + wantDataErrSubstr string // d.Error (EthereumInternalData.Error) + wantContract string + wantFrom string + wantTo string + wantValue int64 + }{ + { + name: "CALL with TRX transfer", + infos: []tronTxInfo{ + { + ID: "abcd1234", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + TransferToAddress: "41da727d310b98700af4cec797e43991899668d6f3", + Note: "63616c6c", // "call" + CallValueInfo: []tronCallValueInfo{ + {CallValue: 700000}, + }, + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xabcd1234"}}, + + wantType: bchain.CALL, + wantTransfers: 1, + + wantFrom: "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", + wantTo: "TVtFTiSQmeMkdpusjefUcPcEeTPtqnhz3D", + wantValue: 700000, + }, + + { + name: "CREATE detected by internal note", + infos: []tronTxInfo{ + { + ID: "0544ab15ada7051af68b57ca29d69c753b64e6701cfebe5cdbe53a2a9127a88d", + ContractAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + InternalTransactions: []tronInternalTransaction{ + { + CallerAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510", + TransferToAddress: "41ed56e617db5eab11b61a9eaefc98c77a6798d257", + Note: "637265617465", // create + }, + }, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0x0544ab15ada7051af68b57ca29d69c753b64e6701cfebe5cdbe53a2a9127a88d"}}, + wantType: bchain.CREATE, + wantContracts: 1, + wantContract: "TXc9FMgWcKK7zGApKj9rArxDb49QkJZWXn", + }, + + { + name: "SELFDESTRUCT detected", + infos: []tronTxInfo{ + { + ID: "deadbeef", + InternalTransactions: []tronInternalTransaction{ + {Note: "73756963696465", CallerAddress: "4139dd12a54e2bab7c82aa14a1e158b34263d2d510"}, // suicide + }, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xdeadbeef"}}, + wantType: bchain.SELFDESTRUCT, + }, + + { + name: "Rejected internal call", + infos: []tronTxInfo{ + { + ID: "fail01", + InternalTransactions: []tronInternalTransaction{ + { + Note: "63616c6c", + Rejected: true, + }, + }, + Receipt: tronReceipt{Result: "SUCCESS"}, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xfail01"}}, + wantType: bchain.CALL, + wantDataErrSubstr: "rejected", + }, + + { + name: "Invalid hex in note", + infos: []tronTxInfo{ + { + ID: "bad1", + InternalTransactions: []tronInternalTransaction{ + {Note: "this-is-not-hex"}, + }, + }, + }, + txs: []bchain.RpcTransaction{{Hash: "0xbad1"}}, + wantErrContains: "invalid", + }, + + { + name: "No internal transactions", + infos: []tronTxInfo{ + {ID: "nointernal"}, + }, + txs: []bchain.RpcTransaction{{Hash: "0xnointernal"}}, + wantType: bchain.CALL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + data, contracts, err := buildInternalDataFromTronInfos(tt.infos, tt.txs, 12345) + + if tt.wantErrContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErrContains) + return + } + + require.NoError(t, err) + require.Len(t, data, 1) + + d := data[0] + + if tt.wantType != 0 { + require.Equal(t, tt.wantType, d.Type) + } + + require.Len(t, d.Transfers, tt.wantTransfers) + + if tt.wantTransfers > 0 { + tr := d.Transfers[0] + + require.Equal(t, tt.wantValue, tr.Value.Int64()) + + if tt.wantFrom != "" { + require.Equal(t, tt.wantFrom, tr.From) + } + if tt.wantTo != "" { + require.Equal(t, tt.wantTo, tr.To) + } + } + + if tt.wantContracts > 0 { + require.Len(t, contracts, tt.wantContracts) + if tt.wantContract != "" { + require.Equal(t, tt.wantContract, d.Contract) + } + } + + if tt.wantDataErrSubstr != "" { + require.Contains(t, d.Error, tt.wantDataErrSubstr) + } + }) + } +} diff --git a/bchain/coins/tron/tronhttp.go b/bchain/coins/tron/tronhttp.go new file mode 100644 index 0000000000..5172e37ce5 --- /dev/null +++ b/bchain/coins/tron/tronhttp.go @@ -0,0 +1,57 @@ +package tron + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type TronHTTP interface { + Request(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error +} + +type TronHTTPClient struct { + baseURL string + httpClient *http.Client +} + +func NewTronHTTPClient(baseURL string, timeout time.Duration) *TronHTTPClient { + return &TronHTTPClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +func (c *TronHTTPClient) Request(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error { + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to encode request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewBuffer(bodyBytes)) + if err != nil { + return fmt.Errorf("failed to create http request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("HTTP error calling Tron API %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return fmt.Errorf("Tron API returned status %d at path: %s %s", resp.StatusCode, c.baseURL, path) + } + + if respBody != nil { + return json.NewDecoder(resp.Body).Decode(respBody) + } + + return nil +} diff --git a/bchain/coins/tron/tronhttp_endpoints.go b/bchain/coins/tron/tronhttp_endpoints.go new file mode 100644 index 0000000000..4027240d11 --- /dev/null +++ b/bchain/coins/tron/tronhttp_endpoints.go @@ -0,0 +1,299 @@ +package tron + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" +) + +type tronBroadcastHexResponse struct { + Result bool `json:"result"` + TxID string `json:"txid"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type tronGetTransactionListFromPendingResponse struct { + TxID []string `json:"txId,omitempty"` +} + +type tronGetAccountResourceResponse struct { + FreeNetLimit int64 `json:"freeNetLimit"` + FreeNetUsed int64 `json:"freeNetUsed"` + NetLimit int64 `json:"NetLimit"` + NetUsed int64 `json:"NetUsed"` + EnergyLimit int64 `json:"EnergyLimit"` + EnergyUsed int64 `json:"EnergyUsed"` +} + +type tronGetBlockResponse struct { + Transactions []tronGetTransactionByIDResponse `json:"transactions,omitempty"` +} + +type tronGetBlockHeaderResponse struct { + BlockHeader struct { + RawData struct { + Number *uint64 `json:"number"` + } `json:"raw_data"` + } `json:"block_header"` +} + +func (b *TronRPC) getLookupHTTPClient(isSolidified bool) TronHTTP { + if isSolidified { + return b.solidityNodeHTTP + } + return b.fullNodeHTTP +} + +func (b *TronRPC) getTransactionByID(txid string, isSolidified bool) (*tronGetTransactionByIDResponse, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + return b.requestTransactionByID(ctx, txid, isSolidified) +} + +func (b *TronRPC) getTransactionInfoByID(txid string, isSolidified bool) (*tronGetTransactionInfoByIDResponse, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + return b.requestTransactionInfoByID(ctx, txid, isSolidified) +} + +func (b *TronRPC) GetMempoolTransactions() ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + txs, err := b.requestMempoolTransactions(ctx) + if err != nil { + return nil, err + } + b.reconcileMempoolWithPendingList(txs) + return txs, nil +} + +// GetAddressChainExtraData returns normalized Tron-specific account/address data. +func (b *TronRPC) GetAddressChainExtraData(addrDesc bchain.AddressDescriptor) (json.RawMessage, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + resp, err := b.requestAccountResource(ctx, ToTronAddressFromDesc(addrDesc)) + if err != nil { + return nil, err + } + + payload, err := json.Marshal(bchain.TronAccountExtraData{ + AvailableStakedBandwidth: tronAvailableResource(resp.NetLimit, resp.NetUsed), + TotalStakedBandwidth: resp.NetLimit, + AvailableFreeBandwidth: tronAvailableResource(resp.FreeNetLimit, resp.FreeNetUsed), + TotalFreeBandwidth: resp.FreeNetLimit, + AvailableEnergy: tronAvailableResource(resp.EnergyLimit, resp.EnergyUsed), + TotalEnergy: resp.EnergyLimit, + }) + if err != nil { + return nil, err + } + return payload, nil +} + +func (b *TronRPC) SendRawTransaction(tx string, disableAlternativeRPC bool) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + resp, err := b.requestBroadcastHex(ctx, strip0xPrefix(tx)) + if err != nil { + return "", err + } + if !resp.Result { + if resp.Code != "" || resp.Message != "" { + return "", errors.Errorf("Tron broadcasthex failed: %s %s", resp.Code, resp.Message) + } + return "", errors.New("Tron broadcasthex failed") + } + + txID := strip0xPrefix(resp.TxID) + if b.ChainConfig != nil && b.ChainConfig.DisableMempoolSync && b.Mempool != nil { + b.Mempool.AddTransactionToMempool(txID) + } + return txID, nil +} + +func (b *TronRPC) requestTransactionByID(ctx context.Context, txid string, isSolidified bool) (*tronGetTransactionByIDResponse, error) { + http := b.getLookupHTTPClient(isSolidified) + raw, err := requestRawMessage( + ctx, + http, + tronLookupPath(isSolidified, "/wallet/gettransactionbyid", "/walletsolidity/gettransactionbyid"), + map[string]string{"value": strip0xPrefix(txid)}, + ) + if err != nil { + return nil, err + } + if tronIsEmptyObject(raw) { + return nil, errors.Annotatef(bchain.ErrTxNotFound, "txid %v", txid) + } + + var resp tronGetTransactionByIDResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (b *TronRPC) requestTransactionInfoByID(ctx context.Context, txid string, isSolidified bool) (*tronGetTransactionInfoByIDResponse, error) { + http := b.getLookupHTTPClient(isSolidified) + raw, err := requestRawMessage( + ctx, + http, + tronLookupPath(isSolidified, "/wallet/gettransactioninfobyid", "/walletsolidity/gettransactioninfobyid"), + map[string]string{"value": strip0xPrefix(txid)}, + ) + if err != nil { + return nil, err + } + if tronIsEmptyObject(raw) { + return nil, errors.Annotatef(bchain.ErrTxNotFound, "txid %v", txid) + } + + var resp tronGetTransactionInfoByIDResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, err + } + + return &resp, nil +} + +func (b *TronRPC) requestMempoolTransactions(ctx context.Context) ([]string, error) { + var resp tronGetTransactionListFromPendingResponse + if err := b.fullNodeHTTP.Request(ctx, "/wallet/gettransactionlistfrompending", map[string]any{}, &resp); err != nil { + return nil, err + } + if len(resp.TxID) == 0 { + return []string{}, nil + } + return resp.TxID, nil +} + +func (b *TronRPC) requestAccountResource(ctx context.Context, address string) (*tronGetAccountResourceResponse, error) { + req := map[string]any{ + "address": address, + "visible": true, + } + var resp tronGetAccountResourceResponse + if err := b.fullNodeHTTP.Request(ctx, "/wallet/getaccountresource", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (b *TronRPC) requestBroadcastHex(ctx context.Context, tx string) (*tronBroadcastHexResponse, error) { + req := map[string]string{ + "transaction": tx, + } + http := b.fullNodeHTTP + if http == nil { + http = b.getLookupHTTPClient(false) + } + var resp tronBroadcastHexResponse + if err := http.Request(ctx, "/wallet/broadcasthex", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (b *TronRPC) requestTransactionInfoByBlockNum(ctx context.Context, blockNum uint32, isSolidified bool) ([]tronGetTransactionInfoByIDResponse, error) { + if isSolidified && b.internalDataProvider != nil { + return b.internalDataProvider.GetTransactionInfoByBlockNum(ctx, blockNum) + } + http := b.getLookupHTTPClient(isSolidified) + raw, err := requestRawMessage(ctx, http, tronLookupPath(isSolidified, "/wallet/gettransactioninfobyblocknum", "/walletsolidity/gettransactioninfobyblocknum"), map[string]any{ + "num": blockNum, + }) + if err != nil { + return nil, err + } + if tronIsEmptyResponse(raw) { + return nil, nil + } + + var resp []tronGetTransactionInfoByIDResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func (b *TronRPC) requestBlockByNum(ctx context.Context, blockNum uint32, isSolidified bool) (*tronGetBlockResponse, error) { + req := map[string]any{ + "num": blockNum, + } + http := b.getLookupHTTPClient(isSolidified) + var resp tronGetBlockResponse + if err := http.Request(ctx, tronLookupPath(isSolidified, "/wallet/getblockbynum", "/walletsolidity/getblockbynum"), req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (b *TronRPC) requestBlockByID(ctx context.Context, blockHash string, isSolidified bool) (*tronGetBlockResponse, error) { + req := map[string]string{ + "value": strip0xPrefix(blockHash), + } + http := b.getLookupHTTPClient(isSolidified) + var resp tronGetBlockResponse + if err := http.Request(ctx, tronLookupPath(isSolidified, "/wallet/getblockbyid", "/walletsolidity/getblockbyid"), req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (b *TronRPC) requestLatestSolidifiedBlockHeight(ctx context.Context) (uint64, error) { + http := b.solidityNodeHTTP + if http == nil { + http = b.getLookupHTTPClient(true) + } + var resp tronGetBlockHeaderResponse + if err := http.Request(ctx, "/walletsolidity/getblock", map[string]any{"detail": false}, &resp); err != nil { + return 0, err + } + if resp.BlockHeader.RawData.Number == nil { + return 0, errors.New("Tron /walletsolidity/getblock returned missing block_header.raw_data.number") + } + return *resp.BlockHeader.RawData.Number, nil +} + +func requestRawMessage(ctx context.Context, http TronHTTP, path string, reqBody interface{}) (json.RawMessage, error) { + var raw json.RawMessage + if err := http.Request(ctx, path, reqBody, &raw); err != nil { + return nil, err + } + return raw, nil +} + +func tronLookupPath(isSolidified bool, walletPath, walletSolidityPath string) string { + if isSolidified { + return walletSolidityPath + } + return walletPath +} + +func tronIsEmptyObject(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("{}")) +} + +func tronIsEmptyArray(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("[]")) +} + +func tronIsEmptyResponse(raw json.RawMessage) bool { + return tronIsEmptyObject(raw) || tronIsEmptyArray(raw) +} + +func tronAvailableResource(limit, used int64) int64 { + if used >= limit { + return 0 + } + return limit - used +} diff --git a/bchain/coins/tron/tronparser.go b/bchain/coins/tron/tronparser.go new file mode 100644 index 0000000000..51dfe5fe7b --- /dev/null +++ b/bchain/coins/tron/tronparser.go @@ -0,0 +1,356 @@ +package tron + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/decred/base58" + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +// TronTypeAddressDescriptorLen - the AddressDescriptor of TronType has fixed length +const TronTypeAddressDescriptorLen = 20 + +// TronAddressLen - length of Tron Base58 address +const TronAddressLen = 34 + +// TronAmountDecimalPoint defines number of decimal points in Tron amounts +// base unit is 'SUN', 1 TRX = 1,000,000 SUN +const TronAmountDecimalPoint = 6 + +// TronParser handle +type TronParser struct { + *eth.EthereumParser +} + +// NewTronParser returns a new instance of TronParser +func NewTronParser(b int, addressAliases bool) *TronParser { + ethParser := eth.NewEthereumParser(b, addressAliases) + ethParser.AmountDecimalPoint = TronAmountDecimalPoint + ethParser.FormatAddressFunc = ToTronAddressFromAddress + ethParser.FromDescToAddressFunc = ToTronAddressFromDesc + ethParser.EnsSuffix = ".trx" + return &TronParser{ + EthereumParser: ethParser, + } +} + +// GetAddrDescFromVout returns internal address representation of given transaction output +func (p *TronParser) GetAddrDescFromVout(output *bchain.Vout) (bchain.AddressDescriptor, error) { + if len(output.ScriptPubKey.Addresses) != 1 { + return nil, bchain.ErrAddressMissing + } + return p.GetAddrDescFromAddress(output.ScriptPubKey.Addresses[0]) +} + +func (p *TronParser) GetAddrDescFromAddress(address string) (bchain.AddressDescriptor, error) { + address = strip0xPrefix(address) + + if len(address) == TronAddressLen { + decoded := base58.Decode(address) + if len(decoded) != 25 || decoded[0] != 0x41 { + return nil, errors.New("invalid Tron base58 address") + } + payload := decoded[:21] + checksum := decoded[21:] + first := sha256.Sum256(payload) + second := sha256.Sum256(first[:]) + if !bytes.Equal(checksum, second[:4]) { + return nil, errors.New("invalid Tron base58 checksum") + } + return payload[1:], nil + } else if len(address) != TronTypeAddressDescriptorLen*2 { + glog.Infof("Invalid Tron address length: got %d chars: %q", len(address), address) + return nil, bchain.ErrAddressMissing + } + + return hex.DecodeString(address) +} + +// GetAddressesFromAddrDesc checks len and prefix and converts to base58 +func (p *TronParser) GetAddressesFromAddrDesc(desc bchain.AddressDescriptor) ([]string, bool, error) { + if len(desc) != TronTypeAddressDescriptorLen { + return nil, false, bchain.ErrAddressMissing + } + + return []string{ToTronAddressFromDesc(desc)}, true, nil +} + +func ToTronAddressFromDesc(addrDesc bchain.AddressDescriptor) string { + var withPrefix []byte + + // check if already prefixed with 0x41 + if len(addrDesc) == 1+TronTypeAddressDescriptorLen && addrDesc[0] == 0x41 { + withPrefix = addrDesc + } else { + withPrefix = append([]byte{0x41}, addrDesc...) + } + + firstSHA := sha256.Sum256(withPrefix) + secondSHA := sha256.Sum256(firstSHA[:]) + checksum := secondSHA[:4] + + fullAddress := append(withPrefix, checksum...) + + base58Addr := base58.Encode(fullAddress) + + return base58Addr +} + +func ToTronAddressFromAddress(address string) string { + address = strings.TrimSpace(address) + if address == "" { + return "" + } + if has0xPrefix(address) { + address = address[2:] + address = strings.TrimSpace(address) + if address == "" { + return "" + } + } + b, err := hex.DecodeString(address) + if err != nil { + return address + } + return ToTronAddressFromDesc(b) +} + +func (p *TronParser) FromTronAddressToHex(addr string) (string, error) { + desc, err := p.GetAddrDescFromAddress(addr) + if err != nil { + return "", fmt.Errorf("failed to convert Tron address %q: %w", addr, err) + } + return "0x" + hex.EncodeToString(desc), nil +} + +func (p *TronParser) ParseInputData(signatures *[]bchain.FourByteSignature, data string) *bchain.EthereumParsedInputData { + parsed := p.EthereumParser.ParseInputData(signatures, data) + + if parsed == nil { + return nil + } + + for i, param := range parsed.Params { + if param.Type == "address" || strings.HasPrefix(param.Type, "address[") { + for j, v := range param.Values { + parsed.Params[i].Values[j] = ToTronAddressFromAddress(v) + } + } + } + + return parsed +} + +func (p *TronParser) EthereumTypeGetTokenTransfersFromTx(tx *bchain.Tx) (bchain.TokenTransfers, error) { + var transfers bchain.TokenTransfers + var err error + transfers, err = p.EthereumParser.EthereumTypeGetTokenTransfersFromTx(tx) + + if err != nil { + return nil, err + } + + // Post-process the transfers to convert addresses to Tron format + for i, transfer := range transfers { + if transfer.Contract != "" { + contract := ToTronAddressFromAddress(transfer.Contract) + transfers[i].Contract = contract + } + + if transfer.From != "" { + from := ToTronAddressFromAddress(transfer.From) + transfers[i].From = from + } + + if transfer.To != "" { + to := ToTronAddressFromAddress(transfer.To) + transfers[i].To = to + } + + } + + return transfers, nil +} + +func (p *TronParser) GetEthereumTxData(tx *bchain.Tx) *bchain.EthereumTxData { + r := p.EthereumParser.GetEthereumTxData(tx) + // Tron reuses Ethereum-like data structure, but some fields are not + // semantically correct for Tron transactions and should not leak into API output. + r.Nonce = 0 + r.GasLimit = big.NewInt(0) + r.GasPrice = nil + r.GasUsed = nil + return r +} + +func (p *TronParser) GetChainExtraData(tx *bchain.Tx) (json.RawMessage, error) { + csd, _, err := parseTronExtra(tx) + if err != nil { + return nil, err + } + return csd.ChainExtraData, nil +} + +func (p *TronParser) GetChainExtraPayloadType() bchain.ChainExtraPayloadType { + return bchain.ChainExtraPayloadTypeTron +} + +func parseTronExtra(tx *bchain.Tx) (bchain.EthereumSpecificData, *bchain.TronChainExtraData, error) { + if tx == nil { + return bchain.EthereumSpecificData{}, nil, errors.New("tx is nil") + } + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok || len(csd.ChainExtraData) == 0 { + return bchain.EthereumSpecificData{}, nil, errors.New("missing ethereumSpecificData.chainExtraData") + } + var extra bchain.TronChainExtraData + if err := json.Unmarshal(csd.ChainExtraData, &extra); err != nil { + return bchain.EthereumSpecificData{}, nil, fmt.Errorf("invalid tron chainExtraData: %w", err) + } + return csd, &extra, nil +} + +func validateTronChainExtraData(chainExtraData json.RawMessage) error { + if len(chainExtraData) == 0 { + return nil + } + var extra bchain.TronChainExtraData + if err := json.Unmarshal(chainExtraData, &extra); err != nil { + return fmt.Errorf("invalid tron chainExtraData: %w", err) + } + return nil +} + +func (p *TronParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ([]byte, error) { + r, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + return nil, errors.New("missing CoinSpecificData") + } + if err := validateTronChainExtraData(r.ChainExtraData); err != nil { + return nil, err + } + r.Tx.AccountNonce = SanitizeHexUint64String(r.Tx.AccountNonce) + + var err error + + r.Tx.From, err = p.FromTronAddressToHex(r.Tx.From) + if err != nil { + return nil, fmt.Errorf("failed to convert 'from' address: %w", err) + } + + if r.Tx.To != "" { + r.Tx.To, err = p.FromTronAddressToHex(r.Tx.To) + if err != nil { + return nil, fmt.Errorf("failed to convert 'to' address: %w", err) + } + } + + if r.Receipt != nil { + for i, l := range r.Receipt.Logs { + addr, err := p.FromTronAddressToHex(l.Address) + if err != nil { + return nil, fmt.Errorf("failed to convert log[%d] address: %w", i, err) + } + l.Address = addr + } + } + + tx.CoinSpecificData = r + return p.EthereumParser.PackTx(tx, height, blockTime) +} + +func (p *TronParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { + tx, height, err := p.EthereumParser.UnpackTx(buf) + if err != nil { + return nil, 0, err + } + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + return nil, 0, errors.New("missing CoinSpecificData") + } + if err := validateTronChainExtraData(csd.ChainExtraData); err != nil { + return nil, 0, err + } + // Pending (unsolidified) Tron transactions are intentionally not served from + // persistent tx cache so they can transition to SUCCESS/FAILED on subsequent + // backend refreshes. + if csd.Receipt == nil { + return nil, 0, nil + } + if has0xPrefix(tx.Txid) { + tx.Txid = tx.Txid[2:] + } + return tx, height, nil +} + +// UnpackTxid unpacks byte array to txid in Tron format (without 0x prefix). +func (p *TronParser) UnpackTxid(buf []byte) (string, error) { + txid, err := p.EthereumParser.UnpackTxid(buf) + if err != nil { + return "", err + } + if has0xPrefix(txid) { + txid = txid[2:] + } + return txid, nil +} + +// UnpackBlockHash unpacks byte array to block hash in Tron format (without 0x prefix). +func (p *TronParser) UnpackBlockHash(buf []byte) (string, error) { + hash, err := p.EthereumParser.UnpackBlockHash(buf) + if err != nil { + return "", err + } + if has0xPrefix(hash) { + hash = hash[2:] + } + return hash, nil +} + +// SanitizeHexUint64String Java-Tron's JSON-RPC returns "nonce" in format that is unexpected for `hexutil.DecodeUint64` in PackTx +func SanitizeHexUint64String(s string) string { + if strings.HasPrefix(s, "0x") { + sanitized := strings.TrimLeft(s[2:], "0") + if sanitized == "" { + return "0x0" + } + return "0x" + sanitized + } + return s +} + +func tronNoteHexToInternalType(noteHex string) (bchain.EthereumInternalTransactionType, error) { + note, err := decodeNoteHex(noteHex) + if err != nil { + return bchain.CALL, err + } + + switch note { + case "create": + return bchain.CREATE, nil + case "suicide": + return bchain.SELFDESTRUCT, nil + case "call": + return bchain.CALL, nil + default: + // add others + return bchain.CALL, nil + } +} + +func decodeNoteHex(hexStr string) (string, error) { + decoded, err := hex.DecodeString(hexStr) + if err != nil { + return "", fmt.Errorf("invalid hex in note: %s", hexStr) + } + return string(decoded), nil +} diff --git a/bchain/coins/tron/tronparser_test.go b/bchain/coins/tron/tronparser_test.go new file mode 100644 index 0000000000..a199d12534 --- /dev/null +++ b/bchain/coins/tron/tronparser_test.go @@ -0,0 +1,407 @@ +//go:build unittest + +package tron + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "reflect" + "testing" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" +) + +func TestTronParser_GetAddrDescFromAddress(t *testing.T) { + type args struct { + address string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "Base58 Tron Address", + args: args{address: "TJngGWiRMLgNFScEybQxLEKQMNdB4nR6Vx"}, + want: "60bb513e91aa723a10a4020ae6fcce39bce7e240", // Hexadecimal format with prefix 41 + wantErr: false, + }, + { + name: "Hex Tron Address as from JSON-RPC", + args: args{address: "0xef51c82ea6336ba1544c4a182a7368e9fbe28274"}, + want: "ef51c82ea6336ba1544c4a182a7368e9fbe28274", // descriptor without prefix and checksum -> len = 20 + wantErr: false, + }, + { + name: "Invalid Tron Address", + args: args{address: "invalidAddress"}, + want: "", + wantErr: true, + }, + } + parser := NewTronParser(1, false) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parser.GetAddrDescFromAddress(tt.args.address) + if (err != nil) != tt.wantErr { + t.Errorf("GetAddrDescFromAddress() error = %v, wantErr %v", err, tt.wantErr) + return + } + h := hex.EncodeToString(got) + if h != tt.want { + t.Errorf("GetAddrDescFromAddress() = %v, want %v", h, tt.want) + } + }) + } +} + +func TestTronParser_GetAddressesFromAddrDesc(t *testing.T) { + type args struct { + desc string + } + tests := []struct { + name string + args args + want []string + wantErr bool + }{ + { + name: "Desc to Base58 Tron Address", + args: args{desc: "f3f1c189594e2642e5d42d7669b4ec60a69802a9"}, + want: []string{"TYD4pB7wGi1p8zK67rBTV3KdfEb9nvNDXh"}, + wantErr: false, + }, + { + name: "Desc to Base58 Tron Address 2", + args: args{desc: "ef51c82ea6336ba1544c4a182a7368e9fbe28274"}, + want: []string{"TXncUDXYkRCmwhFikxYMutwAy93fbhPbbv"}, + wantErr: false, + }, + { + name: "Invalid Hex Address", + args: args{desc: "invalidHex"}, + want: nil, + wantErr: true, + }, + } + parser := NewTronParser(1, false) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := hex.DecodeString(tt.args.desc) + if err != nil && !tt.wantErr { + t.Errorf("GetAddressesFromAddrDesc() error = %v", err) + return + } + + got, _, err := parser.GetAddressesFromAddrDesc(b) + if (err != nil) != tt.wantErr { + t.Errorf("GetAddressesFromAddrDesc() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetAddressesFromAddrDesc() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSanitizeHexUint64String(t *testing.T) { + tests := map[string]string{ + "0x0000000000000000": "0x0", + "0x0000000000000001": "0x1", + "0x": "0x0", + "0x01": "0x1", + "0xa": "0xa", + } + for input, expected := range tests { + got := SanitizeHexUint64String(input) + if got != expected { + t.Errorf("SanitizeHexUint64String(%q) = %q, want %q", input, got, expected) + } + } +} + +func TestFromTronAddressToHex(t *testing.T) { + parser := NewTronParser(1, false) + + tests := []struct { + name string + input string + expected string + expectError bool + }{ + { + name: "Valid Base58 Tron address", + input: "TJngGWiRMLgNFScEybQxLEKQMNdB4nR6Vx", + expected: "0x60bb513e91aa723a10a4020ae6fcce39bce7e240", + expectError: false, + }, + { + name: "Invalid Tron address", + input: "INVALID_ADDRESS", + expected: "", // should return empty string on error + expectError: true, + }, + { + name: "Already hex address", + input: "0x60bb513e91aa723a10a4020ae6fcce39bce7e240", + expected: "0x60bb513e91aa723a10a4020ae6fcce39bce7e240", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parser.FromTronAddressToHex(tt.input) + + if (err != nil) != tt.expectError { + t.Errorf("FromTronAddressToHex(%s) unexpected error state: got err=%v, wantError=%v", tt.input, err, tt.expectError) + return + } + + if result != tt.expected { + t.Errorf("FromTronAddressToHex(%s) = %s; want %s", tt.input, result, tt.expected) + } + }) + } +} + +func TestToTronAddressFromAddress(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "hex without 0x prefix", + input: "08e3448764a3b3014727070b32795dafbfcdd436", + expected: "TAnCfRsZkYJ3AZ1DRuMTAV6u7Mi7sNUMf9", + }, + { + name: "hex with 0x prefix", + input: "0x08e3448764a3b3014727070b32795dafbfcdd436", + expected: "TAnCfRsZkYJ3AZ1DRuMTAV6u7Mi7sNUMf9", + }, + { + name: "hex with tron 0x41 prefix", + input: "4160bb513e91aa723a10a4020ae6fcce39bce7e240", + expected: "TJngGWiRMLgNFScEybQxLEKQMNdB4nR6Vx", + }, + { + name: "invalid input is returned unchanged", + input: "not-a-hex-address", + expected: "not-a-hex-address", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ToTronAddressFromAddress(tt.input) + require.Equal(t, tt.expected, got) + }) + } +} + +func TestTronParser_PackUnpackRoundtrip(t *testing.T) { + original := &bchain.Tx{ + Txid: "a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302", + Vin: []bchain.Vin{ + { + Addresses: []string{ + "TZEZWXYQS44388xBoMhQdpL1HrBZFLfDpt", + }, + }, + }, + Vout: []bchain.Vout{ + { + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Addresses: []string{ + "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + }, + }, + }, + }, + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + AccountNonce: "0x0", + GasPrice: "0xd2", + GasLimit: "0x393a", + To: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + Value: "0x0", + Payload: "0xa9059cbb000000000000000000000000242aa579f130bf6fea5eac12aa6b846fb8b293ab0000000000000000000000000000000000000000000000000000000000ab604e", + Hash: "0xa431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302", + BlockNumber: "0x348d2a7", + BlockHash: "0x000000000348d2a70c64b102b21699f7f561fffbc67d50ed5f540db5ad631913", + From: "TZEZWXYQS44388xBoMhQdpL1HrBZFLfDpt", + TransactionIndex: "0x0", + }, + Receipt: &bchain.RpcReceipt{ + GasUsed: "0x393a", + Status: "0x1", + Logs: []*bchain.RpcLog{ + { + Address: "0xeca9bc828a3005b9a3b909f2cc5c2a54794de05f", + Topics: []string{ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ff324071970b2b08822caa310c1bb458e63a5033", + "0x000000000000000000000000242aa579f130bf6fea5eac12aa6b846fb8b293ab", + }, + Data: "0x0000000000000000000000000000000000000000000000000000000000ab604e", + }, + }, + }, + ChainExtraData: json.RawMessage(`{"operation":"contractCall","totalFee":"12345","energyUsageTotal":"14650"}`), + }, + } + + parser := NewTronParser(1, false) + + packed, err := parser.PackTx(original, original.BlockHeight, original.Blocktime) + require.NoError(t, err) + + unpacked, _, err := parser.UnpackTx(packed) + require.NoError(t, err) + + origJSON, err := json.Marshal(original) + require.NoError(t, err) + unpkJSON, err := json.Marshal(unpacked) + require.NoError(t, err) + + if !bytes.Equal(origJSON, unpkJSON) { + t.Errorf("Transactions are not equal \nOriginal: %s\nUnpacked: %s", origJSON, unpkJSON) + } + +} + +func TestTronParser_PackTx_InvalidChainExtraData(t *testing.T) { + parser := NewTronParser(1, false) + tx := &bchain.Tx{ + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + AccountNonce: "0x0", + GasPrice: "0x1", + GasLimit: "0x5208", + To: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + Value: "0x0", + Payload: "0x", + Hash: "0xa431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302", + BlockNumber: "0x1", + From: "TZEZWXYQS44388xBoMhQdpL1HrBZFLfDpt", + TransactionIndex: "0x0", + }, + Receipt: &bchain.RpcReceipt{ + GasUsed: "0x5208", + Status: "0x1", + Logs: []*bchain.RpcLog{}, + }, + ChainExtraData: []byte("{"), + }, + } + _, err := parser.PackTx(tx, 1, 1) + require.Error(t, err) +} + +func TestTronParser_UnpackTx_InvalidChainExtraData(t *testing.T) { + parser := NewTronParser(1, false) + tx := &bchain.Tx{ + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + AccountNonce: "0x0", + GasPrice: "0x1", + GasLimit: "0x5208", + To: "0x1111111111111111111111111111111111111111", + Value: "0x0", + Payload: "0x", + Hash: "0xa431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302", + BlockNumber: "0x1", + From: "0x2222222222222222222222222222222222222222", + TransactionIndex: "0x0", + }, + Receipt: &bchain.RpcReceipt{ + GasUsed: "0x5208", + Status: "0x1", + Logs: []*bchain.RpcLog{}, + }, + ChainExtraData: []byte("not-json"), + }, + } + packed, err := parser.EthereumParser.PackTx(tx, 1, 1) + require.NoError(t, err) + + _, _, err = parser.UnpackTx(packed) + require.Error(t, err) +} + +func TestTronParser_GetChainExtraData(t *testing.T) { + parser := NewTronParser(1, false) + valid := json.RawMessage(`{"operation":"contractCall","totalFee":"12345","energyUsageTotal":"14650"}`) + + t.Run("valid", func(t *testing.T) { + tx := &bchain.Tx{ + CoinSpecificData: bchain.EthereumSpecificData{ + ChainExtraData: valid, + }, + } + got, err := parser.GetChainExtraData(tx) + require.NoError(t, err) + require.JSONEq(t, string(valid), string(got)) + }) + + t.Run("nil tx", func(t *testing.T) { + _, err := parser.GetChainExtraData(nil) + require.Error(t, err) + }) + + t.Run("missing chain extra", func(t *testing.T) { + tx := &bchain.Tx{ + CoinSpecificData: bchain.EthereumSpecificData{}, + } + _, err := parser.GetChainExtraData(tx) + require.Error(t, err) + }) + + t.Run("invalid chain extra json", func(t *testing.T) { + tx := &bchain.Tx{ + CoinSpecificData: bchain.EthereumSpecificData{ + ChainExtraData: json.RawMessage("{"), + }, + } + _, err := parser.GetChainExtraData(tx) + require.Error(t, err) + }) +} + +func TestTronParser_UnpackTxid_NoPrefix(t *testing.T) { + parser := NewTronParser(1, false) + txidWithPrefix := "0xa431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302" + + packed, err := parser.PackTxid(txidWithPrefix) + require.NoError(t, err) + + unpacked, err := parser.UnpackTxid(packed) + require.NoError(t, err) + require.Equal(t, "a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302", unpacked) +} + +func TestTronParser_UnpackBlockHash_NoPrefix(t *testing.T) { + parser := NewTronParser(1, false) + blockHashWithPrefix := "0x000000000348d2a70c64b102b21699f7f561fffbc67d50ed5f540db5ad631913" + + packed, err := parser.PackBlockHash(blockHashWithPrefix) + require.NoError(t, err) + + unpacked, err := parser.UnpackBlockHash(packed) + require.NoError(t, err) + require.Equal(t, "000000000348d2a70c64b102b21699f7f561fffbc67d50ed5f540db5ad631913", unpacked) +} diff --git a/bchain/coins/tron/tronrpc.go b/bchain/coins/tron/tronrpc.go new file mode 100644 index 0000000000..9027b35484 --- /dev/null +++ b/bchain/coins/tron/tronrpc.go @@ -0,0 +1,1023 @@ +package tron + +import ( + "context" + "encoding/json" + "math/big" + "net" + "net/url" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rpc" + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +const ( + // MainNet is production network + MainNet eth.Network = 11111 + TestNetNile eth.Network = 201910292 + + tronDefaultFullNodeHTTPPort = "8090" + tronDefaultSolidityHTTPPort = "8091" + + TRC10TokenType bchain.TokenStandardName = "TRC10" + TRC20TokenType bchain.TokenStandardName = "TRC20" + TRC721TokenType bchain.TokenStandardName = "TRC721" + TRC1155TokenType bchain.TokenStandardName = "TRC1155" + + tronBestHeaderMaxAge = 30 * time.Second +) + +type TronConfiguration struct { + eth.Configuration + MessageQueueBinding string `json:"message_queue_binding"` + FullNodeHTTPURLTemplate string `json:"tron_fullnode_http_url_template"` + SolidityHTTPURLTemplate string `json:"tron_solidity_http_url_template"` +} + +type tronResourceCode int64 + +type tronTxContractValue struct { + OwnerAddress string `json:"owner_address,omitempty"` + ToAddress string `json:"to_address,omitempty"` + AccountAddress string `json:"account_address,omitempty"` + ContractAddress string `json:"contract_address,omitempty"` + ReceiverAddress string `json:"receiver_address,omitempty"` + Resource *tronResourceCode `json:"resource,omitempty"` + Amount *int64 `json:"amount,omitempty"` + CallValue *int64 `json:"call_value,omitempty"` + FrozenBalance *int64 `json:"frozen_balance,omitempty"` + UnfreezeBalance *int64 `json:"unfreeze_balance,omitempty"` + Balance *int64 `json:"balance,omitempty"` + Votes []tronTxVote `json:"votes,omitempty"` + Data string `json:"data,omitempty"` +} + +type tronTxVote struct { + VoteAddress string `json:"vote_address,omitempty"` + VoteCount *int64 `json:"vote_count,omitempty"` +} + +type tronTxContract struct { + Type string `json:"type"` + Parameter struct { + Value tronTxContractValue `json:"value"` + } `json:"parameter"` +} + +type tronGetTransactionByIDResponse struct { + TxID string `json:"txID,omitempty"` + RawDataHex string `json:"raw_data_hex"` + RawData struct { + Timestamp *int64 `json:"timestamp,omitempty"` + FeeLimit *int64 `json:"fee_limit,omitempty"` + Contract []tronTxContract `json:"contract"` + } `json:"raw_data"` +} + +type TronRPC struct { + *eth.EthereumRPC + Parser *TronParser + ChainConfig *TronConfiguration + mq *bchain.MQ + fullNodeHTTP TronHTTP + solidityNodeHTTP TronHTTP + internalDataProvider *TronInternalDataProvider + bestHeaderLock sync.Mutex + bestHeader bchain.EVMHeader + bestHeaderTime time.Time + bestSolidifiedHeight uint64 + hasSolidifiedHeight bool + newBlockNotifyCh chan struct{} + newBlockNotifyOnce sync.Once +} + +func NewTronRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + ethereumRPC, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + var cfg TronConfiguration + err = json.Unmarshal(config, &cfg) + if err != nil { + return nil, errors.Annotatef(err, "Invalid Tron configuration file") + } + + cfg.Eip1559Fees = false + + bchain.EthereumTokenStandardMap = []bchain.TokenStandardName{TRC20TokenType, TRC721TokenType, TRC1155TokenType} + + tronRpc := &TronRPC{ + EthereumRPC: ethereumRPC.(*eth.EthereumRPC), + Parser: NewTronParser(cfg.BlockAddressesToKeep, cfg.AddressAliases), + newBlockNotifyCh: make(chan struct{}, 1), + } + ethChainConfig := tronRpc.EthereumRPC.ChainConfig + + tronRpc.Parser.HotAddressMinContracts = ethChainConfig.HotAddressMinContracts + tronRpc.Parser.HotAddressLRUCacheSize = ethChainConfig.HotAddressLRUCacheSize + tronRpc.Parser.HotAddressMinHits = ethChainConfig.HotAddressMinHits + tronRpc.Parser.AddrContractsCacheMinSize = ethChainConfig.AddressContractsCacheMinSize + tronRpc.Parser.AddrContractsCacheMaxBytes = ethChainConfig.AddressContractsCacheMaxBytes + tronRpc.Parser.AddrContractsCacheBulkMaxBytes = ethChainConfig.AddressContractsCacheBulkMaxBytes + + tronRpc.EthereumRPC.Parser = tronRpc.Parser + tronRpc.ChainConfig = &cfg + tronRpc.PushHandler = pushHandler + + fullNodeURL, err := resolveTronHTTPURL(cfg.FullNodeHTTPURLTemplate, cfg.RPCURL, tronDefaultFullNodeHTTPPort) + if err != nil { + return nil, errors.Annotate(err, "resolve Tron full node HTTP URL") + } + solidityURL, err := resolveTronHTTPURL(cfg.SolidityHTTPURLTemplate, cfg.RPCURL, tronDefaultSolidityHTTPPort) + if err != nil { + return nil, errors.Annotate(err, "resolve Tron solidity node HTTP URL") + } + + timeout := time.Duration(cfg.RPCTimeout) * time.Second + tronRpc.fullNodeHTTP = NewTronHTTPClient(fullNodeURL, timeout) + tronRpc.solidityNodeHTTP = NewTronHTTPClient(solidityURL, timeout) + + internalProvider := NewTronInternalDataProvider( + tronRpc.solidityNodeHTTP, + timeout, + ) + + tronRpc.internalDataProvider = internalProvider + tronRpc.EthereumRPC.InternalDataProvider = internalProvider + + return tronRpc, nil +} + +func resolveTronHTTPURL(explicitURL, rpcURL, defaultPort string) (string, error) { + explicitURL = strings.TrimSpace(explicitURL) + if explicitURL != "" { + return explicitURL, nil + } + + parsed, err := url.Parse(strings.TrimSpace(rpcURL)) + if err != nil { + return "", errors.Annotate(err, "invalid rpc_url") + } + if parsed.Scheme == "" { + return "", errors.New("missing scheme in rpc_url") + } + + host := parsed.Hostname() + if host == "" { + return "", errors.New("missing host in rpc_url") + } + + parsed.Host = net.JoinHostPort(host, defaultPort) + parsed.Path = "" + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + +// OpenRPC opens an RPC connection to the Tron backend (wsURL is unused – Tron has no WS subscriptions) +var OpenRPC = func(url, _ string) (bchain.EVMRPCClient, bchain.EVMClient, error) { + opts := []rpc.ClientOption{} + opts = append(opts, rpc.WithWebsocketMessageSizeLimit(0)) + + r, err := rpc.DialOptions(context.Background(), url, opts...) + if err != nil { + return nil, nil, err + } + + rpcClient := &TronRPCClient{Client: r} + ethClient := ethclient.NewClient(r) // Ethereum client for compatibility + tc := &TronClient{ + Client: ethClient, + rpcClient: rpcClient, + } + + return rpcClient, tc, nil +} + +// Initialize Tron RPC +func (b *TronRPC) Initialize() error { + b.OpenRPC = OpenRPC + + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, "") + if err != nil { + return err + } + + b.Client = ec + b.RPC = rc + b.MainNetChainID = MainNet + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + id, err := b.Client.NetworkID(ctx) + if err != nil { + return err + } + + // parameters for getInfo request + switch eth.Network(id.Uint64()) { + case MainNet: + b.Testnet = false + b.Network = "mainnet" + case TestNetNile: + b.Testnet = true + b.Network = "nile" + default: + return errors.Errorf("Unknown network id %v", id) + } + + log.Info("TronRPC: initialized Tron blockchain: ", b.Network) + return nil +} + +// GetBestBlockHash returns hash of the tip of the best-block-chain +// need to overwrite this because the getBestHeader method in EthRpc is +// relying on the subscription +func (b *TronRPC) GetBestBlockHash() (string, error) { + var err error + var header bchain.EVMHeader + + header, err = b.getBestHeader() + if err != nil { + return "", err + } + + return strip0xPrefix(header.Hash()), nil +} + +// GetBlockHash returns block hash in Tron API format (without 0x prefix). +func (b *TronRPC) GetBlockHash(height uint32) (string, error) { + hash, err := b.EthereumRPC.GetBlockHash(height) + if err != nil { + return "", err + } + return strip0xPrefix(hash), nil +} + +// GetChainInfo returns information about connected backend with Tron-formatted IDs (without 0x). +func (b *TronRPC) GetChainInfo() (*bchain.ChainInfo, error) { + ci, err := b.EthereumRPC.GetChainInfo() + if err != nil { + return nil, err + } + ci.Bestblockhash = strip0xPrefix(ci.Bestblockhash) + return ci, nil +} + +// GetBestBlockHeight returns height of the tip of the best-block-chain +func (b *TronRPC) GetBestBlockHeight() (uint32, error) { + var err error + var header bchain.EVMHeader + + header, err = b.getBestHeader() + if err != nil { + return 0, err + } + + return uint32(header.Number().Uint64()), nil +} + +// GetBlockHeader returns block header with Tron-formatted hashes (without 0x). +func (b *TronRPC) GetBlockHeader(hash string) (*bchain.BlockHeader, error) { + ethHash := normalizeHexString(hash) + bh, err := b.EthereumRPC.GetBlockHeader(ethHash) + if err != nil { + return nil, err + } + bh.Hash = strip0xPrefix(bh.Hash) + bh.Prev = strip0xPrefix(bh.Prev) + bh.Next = strip0xPrefix(bh.Next) + return bh, nil +} + +// GetBlockInfo returns block info with Tron-formatted hashes and txids (without 0x). +func (b *TronRPC) GetBlockInfo(hash string) (*bchain.BlockInfo, error) { + ethHash := normalizeHexString(hash) + bi, err := b.EthereumRPC.GetBlockInfo(ethHash) + if err != nil { + return nil, err + } + bi.Hash = strip0xPrefix(bi.Hash) + bi.Prev = strip0xPrefix(bi.Prev) + bi.Next = strip0xPrefix(bi.Next) + for i := range bi.Txids { + bi.Txids[i] = strip0xPrefix(bi.Txids[i]) + } + return bi, nil +} + +func (b *TronRPC) getBestHeader() (bchain.EVMHeader, error) { + // During initial sync (before ZeroMQ is initialized) there is no push-based + // tip refresh, so always read the latest header from the backend. + if b.mq == nil { + _, err := b.refreshBestHeaderFromChain() + if err != nil { + return nil, err + } + b.bestHeaderLock.Lock() + defer b.bestHeaderLock.Unlock() + if b.bestHeader == nil || b.bestHeader.Number() == nil { + return nil, errors.New("best header is nil") + } + return b.bestHeader, nil + } + + b.bestHeaderLock.Lock() + cachedHeader := b.bestHeader + cachedAt := b.bestHeaderTime + b.bestHeaderLock.Unlock() + + if cachedHeader != nil && cachedAt.Add(tronBestHeaderMaxAge).After(time.Now()) { + return cachedHeader, nil + } + + _, err := b.refreshBestHeaderFromChain() + if err != nil { + return nil, err + } + + b.bestHeaderLock.Lock() + defer b.bestHeaderLock.Unlock() + if b.bestHeader == nil || b.bestHeader.Number() == nil { + return nil, errors.New("best header is nil") + } + return b.bestHeader, nil +} + +func (b *TronRPC) setBestHeader(h bchain.EVMHeader) bool { + if h == nil || h.Number() == nil { + return false + } + b.bestHeaderLock.Lock() + defer b.bestHeaderLock.Unlock() + changed := false + if b.bestHeader == nil || b.bestHeader.Number() == nil { + changed = true + } else { + prevNum := b.bestHeader.Number().Uint64() + newNum := h.Number().Uint64() + if prevNum != newNum || b.bestHeader.Hash() != h.Hash() { + changed = true + } + } + b.bestHeader = h + b.bestHeaderTime = time.Now() + b.UpdateBestHeader(h) + return changed +} + +func (b *TronRPC) setBestSolidifiedHeight(height uint64) { + b.bestHeaderLock.Lock() + defer b.bestHeaderLock.Unlock() + b.bestSolidifiedHeight = height + b.hasSolidifiedHeight = true +} + +func (b *TronRPC) getBestSolidifiedHeight() (uint64, bool) { + b.bestHeaderLock.Lock() + defer b.bestHeaderLock.Unlock() + return b.bestSolidifiedHeight, b.hasSolidifiedHeight +} + +func (b *TronRPC) isBlockSolidified(blockNumber uint64) bool { + bestSolidifiedHeight, ok := b.getBestSolidifiedHeight() + if !ok { + return false + } + return blockNumber <= bestSolidifiedHeight +} + +func (b *TronRPC) refreshBestHeaderFromChain() (bool, error) { + if b.Client == nil { + return false, errors.New("rpc client not initialized") + } + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + h, err := b.Client.HeaderByNumber(ctx, nil) + if err != nil { + return false, err + } + if h == nil || h.Number() == nil { + return false, errors.New("best header is nil") + } + updated := b.setBestHeader(h) + + solidifiedHeight, err := b.requestLatestSolidifiedBlockHeight(ctx) + if err != nil { + glog.V(1).Infof("TronRPC: failed to refresh solidified head: %v", err) + } else { + b.setBestSolidifiedHeight(solidifiedHeight) + } + + return updated, nil +} + +func (b *TronRPC) signalNewBlock() { + select { + case b.newBlockNotifyCh <- struct{}{}: + default: + } +} + +func (b *TronRPC) newBlockNotifier() { + for range b.newBlockNotifyCh { + updated, err := b.refreshBestHeaderFromChain() + if err != nil { + glog.Error("refreshBestHeaderFromChain ", err) + continue + } + if updated && b.PushHandler != nil { + b.PushHandler(bchain.NotificationNewBlock) + // Tron mempool is refreshed via periodic/backend resync rather than per-tx + // subscriptions, so a new block should also trigger a mempool refresh. + b.PushHandler(bchain.NotificationNewTx) + } + } +} + +func (b *TronRPC) handleMQNotification(nt bchain.NotificationType) { + if nt == bchain.NotificationNewBlock { + b.signalNewBlock() + return + } + if b.PushHandler != nil { + b.PushHandler(nt) + } +} + +// GetChainParser returns Tron-specific BlockChainParser +func (b *TronRPC) GetChainParser() bchain.BlockChainParser { + return b.Parser +} + +func (b *TronRPC) CreateMempool(chain bchain.BlockChain) (bchain.Mempool, error) { + if b.Mempool == nil { + mempoolTxTimeout, err := b.ChainConfig.MempoolTxTimeoutDuration(false) + if err != nil { + return nil, err + } + b.Mempool = bchain.NewMempoolEthereumType(chain, mempoolTxTimeout, b.ChainConfig.QueryBackendOnMempoolResync) + } + return b.Mempool, nil +} + +func (b *TronRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOutpointFunc, onNewTx bchain.OnNewTxFunc) error { + if b.Mempool == nil { + return errors.New("Tron Mempool not created") + } + b.Mempool.OnNewTx = onNewTx + b.newBlockNotifyOnce.Do(func() { + go b.newBlockNotifier() + }) + + if b.mq == nil { + tronTopics := bchain.SubscriptionTopics{ + BlockSubscribe: "block", + BlockReceive: "blockTrigger", + TxSubscribe: "", + TxReceive: "", + } + + mq, err := bchain.NewMQ(b.ChainConfig.MessageQueueBinding, b.handleMQNotification, tronTopics) + if err != nil { + return err + } + b.mq = mq + } + + return nil +} + +func (b *TronRPC) Shutdown(ctx context.Context) error { + if b.mq != nil { + if err := b.mq.Shutdown(ctx); err != nil { + return err + } + } + return nil +} + +func (b *TronRPC) computeConfirmationsFromBlockNumber(txid string, blockNumber uint64, hasBlockNumber bool) uint32 { + if !hasBlockNumber { + return 0 + } + confirmations, err := b.computeBlockConfirmations(blockNumber) + if err != nil { + glog.V(1).Infof("Tron eth_blockNumber tx %v: %v", txid, err) + return 0 + } + return confirmations +} + +func (b *TronRPC) computeBlockConfirmations(blockNumber uint64) (uint32, error) { + bh, err := b.getBestHeader() + if err != nil { + return 0, err + } + bestHeight := bh.Number().Uint64() + if bestHeight < blockNumber { + return 0, nil + } + return uint32(bestHeight - blockNumber + 1), nil +} + +func (b *TronRPC) buildTxFromHTTPData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse, blockTime int64, confirmations uint32, internalData *bchain.EthereumInternalData, isSolidified bool) (*bchain.Tx, error) { + csd := tronBuildEthereumSpecificData(txByID, txInfo) + csd.InternalData = internalData + + if !isSolidified { + csd.Receipt = nil // set to nil so it can be considered as pending + } + + tx, err := b.Parser.EthTxToTx(csd.Tx, csd.Receipt, csd.InternalData, blockTime, confirmations, true) + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txByID.TxID) + } + + if len(tx.Vout) > 0 && + tx.Vout[0].ScriptPubKey.Addresses == nil && + csd.Receipt != nil && + csd.Receipt.ContractAddress != "" { + tx.Vout = []bchain.Vout{{ + ValueSat: tx.Vout[0].ValueSat, + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Addresses: []string{ToTronAddressFromAddress(csd.Receipt.ContractAddress)}, + }, + }} + + contractAddress := ToTronAddressFromAddress(csd.Receipt.ContractAddress) + if csd.InternalData == nil { + csd.InternalData = &bchain.EthereumInternalData{ + Type: bchain.CREATE, + Contract: contractAddress, + } + } else if csd.InternalData.Contract == "" { + csd.InternalData.Type = bchain.CREATE + csd.InternalData.Contract = contractAddress + } + } + tx.Txid = strip0xPrefix(tx.Txid) + tx.CoinSpecificData = csd + return tx, nil +} + +func synthesizeGenesisTxByID(tx *bchain.RpcTransaction, blockHeight uint32) *tronGetTransactionByIDResponse { + if blockHeight != 0 || tx == nil { + return nil + } + + contract := tronTxContract{} + contract.Parameter.Value.OwnerAddress = strip0xPrefix(tx.From) + + if strings.TrimSpace(tx.Payload) != "" && tx.Payload != "0x" { + contract.Type = "TriggerSmartContract" + contract.Parameter.Value.ContractAddress = strip0xPrefix(tx.To) + contract.Parameter.Value.CallValue = tronHexQuantityToInt64Ptr(tx.Value) + contract.Parameter.Value.Data = strip0xPrefix(tx.Payload) + } else { + contract.Type = "TransferContract" + contract.Parameter.Value.ToAddress = strip0xPrefix(tx.To) + contract.Parameter.Value.Amount = tronHexQuantityToInt64Ptr(tx.Value) + } + + txByID := &tronGetTransactionByIDResponse{ + TxID: strip0xPrefix(tx.Hash), + } + txByID.RawData.FeeLimit = tronHexQuantityToInt64Ptr(tx.GasLimit) + txByID.RawData.Contract = []tronTxContract{contract} + return txByID +} + +func synthesizeGenesisTxInfo(txHash string, blockHeight uint32, blockTime int64) *tronGetTransactionInfoByIDResponse { + if blockHeight != 0 { + return nil + } + + blockNumber := int64(0) + txInfo := &tronGetTransactionInfoByIDResponse{ + ID: strip0xPrefix(txHash), + BlockNumber: &blockNumber, + } + if blockTime >= 0 { + blockTimestamp := blockTime * 1000 + txInfo.BlockTimeStamp = &blockTimestamp + } + return txInfo +} + +func (b *TronRPC) getTransactionByIDMapForBlockWithContext(ctx context.Context, hash string, blockHeight uint32, isSolidified bool) (map[string]*tronGetTransactionByIDResponse, error) { + var ( + blockResp *tronGetBlockResponse + err error + ) + if hash != "" && hash != "pending" { + blockResp, err = b.requestBlockByID(ctx, hash, isSolidified) + } else { + blockResp, err = b.requestBlockByNum(ctx, blockHeight, isSolidified) + } + if err != nil { + return nil, err + } + if blockResp == nil { + return nil, nil + } + return mapTransactionByID(blockResp.Transactions), nil +} + +type tronRPCBlockHeader struct { + Hash string `json:"hash"` + ParentHash string `json:"parentHash"` + Number string `json:"number"` + Time string `json:"timestamp"` + Size string `json:"size"` +} + +type tronRPCBlockWithTransactions struct { + tronRPCBlockHeader + Transactions []bchain.RpcTransaction `json:"transactions"` +} + +// GetBlock returns block with given hash or height, hash has precedence if both passed. +// Tron implementation enriches each tx with data from Tron HTTP endpoints and does not call EthereumRPC.GetBlock. +func (b *TronRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { + raw, err := b.EthereumRPC.GetBlockRawByHashOrHeight(hash, height, true) + if err != nil { + return nil, err + } + var block tronRPCBlockWithTransactions + if err := json.Unmarshal(raw, &block); err != nil { + return nil, errors.Annotatef(err, "hash %v, height %v", hash, height) + } + + blockNumber, ok := tronUint64(block.Number) + if !ok { + return nil, errors.Errorf("invalid block number %q", block.Number) + } + blockTime, ok := tronUint64(block.Time) + if !ok { + return nil, errors.Errorf("invalid block timestamp %q", block.Time) + } + blockSize, ok := tronUint64(block.Size) + if !ok { + return nil, errors.Errorf("invalid block size %q", block.Size) + } + + confirmations, err := b.computeBlockConfirmations(blockNumber) + if err != nil { + return nil, err + } + isSolidified := b.isBlockSolidified(blockNumber) + + bbh := bchain.BlockHeader{ + Hash: strip0xPrefix(block.Hash), + Prev: strip0xPrefix(block.ParentHash), + Height: uint32(blockNumber), + Confirmations: int(confirmations), + Time: int64(blockTime), + Size: int(blockSize), + } + + txInfosByID := map[string]*tronGetTransactionInfoByIDResponse{} + txByIDByID := map[string]*tronGetTransactionByIDResponse{} + internalData := make([]bchain.EthereumInternalData, len(block.Transactions)) + contracts := make([]bchain.ContractInfo, 0) + var internalErr error + + if len(block.Transactions) > 0 { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + type txInfosResult struct { + infos []tronGetTransactionInfoByIDResponse + err error + } + type txByIDResult struct { + txByID map[string]*tronGetTransactionByIDResponse + err error + } + + infosCh := make(chan txInfosResult, 1) + txByIDCh := make(chan txByIDResult, 1) + + go func() { + infos, err := b.requestTransactionInfoByBlockNum(ctx, bbh.Height, isSolidified) + infosCh <- txInfosResult{infos: infos, err: err} + }() + go func() { + txByID, err := b.getTransactionByIDMapForBlockWithContext(ctx, hash, bbh.Height, isSolidified) + txByIDCh <- txByIDResult{txByID: txByID, err: err} + }() + + infosRes := <-infosCh + if infosRes.err != nil { + return nil, errors.Annotatef(infosRes.err, "height %v", bbh.Height) + } + if m := mapTransactionInfoByID(infosRes.infos); m != nil { + txInfosByID = m + } + + txByIDRes := <-txByIDCh + if txByIDRes.err != nil { + return nil, errors.Annotatef(txByIDRes.err, "height %v", bbh.Height) + } + if txByIDRes.txByID != nil { + txByIDByID = txByIDRes.txByID + } + + if bchain.ProcessInternalTransactions { + internalData, contracts, internalErr = buildInternalDataFromTronInfos( + tronTxInfosFromResponses(infosRes.infos), + block.Transactions, + bbh.Height, + ) + } + } + + txs := make([]bchain.Tx, len(block.Transactions)) + for i := range block.Transactions { + tx := &block.Transactions[i] + txByID := txByIDByID[strip0xPrefix(tx.Hash)] + if txByID == nil { + txByID = synthesizeGenesisTxByID(tx, bbh.Height) + } + + if txByID == nil { // todo possibly can be deleted + b.ObserveChainDataFallback("tron_getblock", "missing_tx_by_id_map") + glog.V(1).Infof("Tron GetBlock fallback to gettransactionbyid for tx %s in block %d", tx.Hash, bbh.Height) + txByID, err = b.getTransactionByID(tx.Hash, isSolidified) + if err != nil { + return nil, err + } + } + + txInfo := txInfosByID[strip0xPrefix(tx.Hash)] + if txInfo == nil { + txInfo = synthesizeGenesisTxInfo(tx.Hash, bbh.Height, bbh.Time) + } + if txInfo == nil { + b.ObserveChainDataFallback("tron_getblock", "missing_tx_info_by_block") + glog.V(1).Infof("Tron GetBlock fallback to gettransactioninfobyid for tx %s in block %d", tx.Hash, bbh.Height) + txInfo, err = b.getTransactionInfoByID(tx.Hash, isSolidified) + if err != nil { + return nil, err + } + } + if txInfo == nil { + return nil, errors.Errorf("missing txInfo for tx %s in block %d", tx.Hash, bbh.Height) + } + + var txInternalData *bchain.EthereumInternalData + if i < len(internalData) { + txInternalData = &internalData[i] + } + + rebuiltTx, err := b.buildTxFromHTTPData(txByID, txInfo, bbh.Time, confirmations, txInternalData, isSolidified) + if err != nil { + return nil, err + } + txs[i] = *rebuiltTx + + if isSolidified && b.Mempool != nil { + b.Mempool.RemoveTransactionFromMempool(strip0xPrefix(tx.Hash)) + } + } + + var blockSpecificData *bchain.EthereumBlockSpecificData + if internalErr != nil || len(contracts) > 0 { + blockSpecificData = &bchain.EthereumBlockSpecificData{} + if internalErr != nil { + blockSpecificData.InternalDataError = internalErr.Error() + } + if len(contracts) > 0 { + blockSpecificData.Contracts = contracts + } + } + + return &bchain.Block{ + BlockHeader: bbh, + Txs: txs, + CoinSpecificData: blockSpecificData, + }, nil +} + +func isTronTxNotFound(err error) bool { + return errors.Cause(err) == bchain.ErrTxNotFound +} + +func reconcileTronMempoolWithPendingList(m bchain.Mempool, pendingTxids []string, removeTx func(string)) int { + if m == nil || removeTx == nil { + return 0 + } + + pendingSet := make(map[string]struct{}, len(pendingTxids)) + for _, txid := range pendingTxids { + pendingSet[strip0xPrefix(txid)] = struct{}{} + } + + removed := 0 + for _, entry := range m.GetAllEntries() { + txid := strip0xPrefix(entry.Txid) + if _, ok := pendingSet[txid]; ok { + continue + } + removeTx(txid) + removed++ + } + + return removed +} + +func (b *TronRPC) reconcileMempoolWithPendingList(pendingTxids []string) { + if b.Mempool == nil { + return + } + + removed := reconcileTronMempoolWithPendingList(b.Mempool, pendingTxids, b.Mempool.RemoveTransactionFromMempool) + if removed > 0 { + glog.V(1).Infof("Tron mempool reconcile removed %d stale tx(s)", removed) + } +} + +func (b *TronRPC) getTransactionByIDWithFallback(txid string) (*tronGetTransactionByIDResponse, bool, error) { + resp, err := b.getTransactionByID(txid, true) + if err == nil { + return resp, true, nil + } + if !isTronTxNotFound(err) { + return nil, false, err + } + resp, err = b.getTransactionByID(txid, false) + if err != nil { + return nil, false, err + } + return resp, false, nil +} + +func (b *TronRPC) getTransactionInfoByIDWithFallback(txid string) (*tronGetTransactionInfoByIDResponse, bool, error) { + resp, err := b.getTransactionInfoByID(txid, true) + if err == nil { + return resp, true, nil + } + if !isTronTxNotFound(err) { + return nil, false, err + } + resp, err = b.getTransactionInfoByID(txid, false) + if err != nil { + return nil, false, err + } + return resp, false, nil +} + +func (b *TronRPC) GetTransaction(txid string) (*bchain.Tx, error) { + txInfo, isSolidified, err := b.getTransactionInfoByIDWithFallback(txid) + if err != nil { + return nil, err + } + txByID, err := b.getTransactionByID(txid, isSolidified) + if err != nil { + return nil, err + } + + blockTime, blockNumber, hasBlockNumber := tronTxMeta(txInfo) + confirmations := b.computeConfirmationsFromBlockNumber(txid, blockNumber, hasBlockNumber) + tx, err := b.buildTxFromHTTPData(txByID, txInfo, blockTime, confirmations, nil, isSolidified) + if err != nil { + return nil, err + } + if isSolidified && b.Mempool != nil { + b.Mempool.RemoveTransactionFromMempool(strip0xPrefix(txid)) + } + return tx, nil +} + +// GetTransactionSpecific returns tx-specific JSON in Tron API format (without 0x in tx hash fields). +func (b *TronRPC) GetTransactionSpecific(tx *bchain.Tx) (json.RawMessage, error) { + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + ntx, err := b.GetTransaction(tx.Txid) + if err != nil { + return nil, err + } + csd, ok = ntx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + return nil, errors.New("Cannot get CoinSpecificData") + } + } + csdCopy := csd + if csd.Tx != nil { + txCopy := *csd.Tx + txCopy.Hash = strip0xPrefix(txCopy.Hash) + txCopy.BlockHash = strip0xPrefix(txCopy.BlockHash) + csdCopy.Tx = &txCopy + } + m, err := json.Marshal(&csdCopy) + if err != nil { + return nil, err + } + return m, nil +} + +func (b *TronRPC) EthereumTypeGetBalance(addrDesc bchain.AddressDescriptor) (*big.Int, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + return b.Client.BalanceAt(ctx, addrDesc, nil) +} + +// EthereumTypeEstimateGas supports both EVM hex and Tron Base58 in `from`/`to` +// and calls eth_estimateGas using Tron-compatible params: from, to, value, data. +func (b *TronRPC) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) { + req := make(map[string]interface{}, 4) + for _, field := range []string{"from", "to"} { + address, ok := eth.GetStringFromMap(field, params) + if !ok || address == "" { + continue + } + hexAddress, err := b.Parser.FromTronAddressToHex(address) + if err != nil { + return 0, err + } + req[field] = hexAddress + } + if value, ok := eth.GetStringFromMap("value", params); ok && value != "" { + req["value"] = value + } + if data, ok := eth.GetStringFromMap("data", params); ok && data != "" { + req["data"] = data + } + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + var result string + if err := b.RPC.CallContext(ctx, &result, "eth_estimateGas", req); err != nil { + return 0, err + } + return hexutil.DecodeUint64(result) +} + +// EthereumTypeRpcCall supports both EVM hex and Tron Base58 in `to`/`from`. +func (b *TronRPC) EthereumTypeRpcCall(data, to, from string) (string, error) { + normalizedTo := to + if to != "" { + hexAddress, err := b.Parser.FromTronAddressToHex(to) + if err != nil { + return "", err + } + normalizedTo = hexAddress + } + normalizedFrom := from + if from != "" { + hexAddress, err := b.Parser.FromTronAddressToHex(from) + if err != nil { + return "", err + } + normalizedFrom = hexAddress + } + return b.EthereumRPC.EthereumTypeRpcCall(data, normalizedTo, normalizedFrom) +} + +// EthereumTypeGetNonce returns current balance of an address +func (b *TronRPC) EthereumTypeGetNonce(addrDesc bchain.AddressDescriptor) (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + return b.Client.NonceAt(ctx, addrDesc, nil) +} + +// GetContractInfo returns information about a contract +func (b *TronRPC) GetContractInfo(contractDesc bchain.AddressDescriptor) (*bchain.ContractInfo, error) { + contract, err := b.EthereumRPC.GetContractInfo(contractDesc) + if err != nil { + return nil, err + } + if contract == nil { + return nil, nil + } + contract.Contract = ToTronAddressFromAddress(contract.Contract) + glog.Infof("Getting contract info for: %s", contract.Contract) + return contract, nil +} + +func (b *TronRPC) EthereumTypeGetRawTransaction(txid string) (string, error) { + resp, _, err := b.getTransactionByIDWithFallback(txid) + if err != nil { + return "", err + } + if resp.RawDataHex == "" { + return "", errors.Errorf("Tron gettransactionbyid returned empty raw_data_hex for %s", txid) + } + return normalizeHexString(resp.RawDataHex), nil +} diff --git a/bchain/coins/tron/tronrpc_test.go b/bchain/coins/tron/tronrpc_test.go new file mode 100644 index 0000000000..80eacdfe34 --- /dev/null +++ b/bchain/coins/tron/tronrpc_test.go @@ -0,0 +1,560 @@ +//go:build unittest + +package tron + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +func TestResolveTronHTTPURL_UsesExplicitURL(t *testing.T) { + got, err := resolveTronHTTPURL("http://fullnode.example:8090", "http://backend.example:8545/jsonrpc", tronDefaultFullNodeHTTPPort) + require.NoError(t, err) + require.Equal(t, "http://fullnode.example:8090", got) +} + +func TestResolveTronHTTPURL_DerivesFromRPCURL(t *testing.T) { + got, err := resolveTronHTTPURL("", "https://tron-node.example:8545/jsonrpc", tronDefaultFullNodeHTTPPort) + require.NoError(t, err) + require.Equal(t, "https://tron-node.example:8090", got) + + got, err = resolveTronHTTPURL("", "http://tron-node.example:8545/jsonrpc", tronDefaultSolidityHTTPPort) + require.NoError(t, err) + require.Equal(t, "http://tron-node.example:8091", got) +} + +func TestResolveTronHTTPURL_InvalidRPCURL(t *testing.T) { + _, err := resolveTronHTTPURL("", "://missing", tronDefaultFullNodeHTTPPort) + require.Error(t, err) +} + +type tronTestMempool struct { + txTimes map[string]uint32 +} + +func (m *tronTestMempool) Resync() (int, error) { + return 0, nil +} + +func (m *tronTestMempool) GetTransactions(address string) ([]bchain.Outpoint, error) { + return nil, nil +} + +func (m *tronTestMempool) GetAddrDescTransactions(addrDesc bchain.AddressDescriptor) ([]bchain.Outpoint, error) { + return nil, nil +} + +func (m *tronTestMempool) GetAllEntries() bchain.MempoolTxidEntries { + entries := make(bchain.MempoolTxidEntries, 0, len(m.txTimes)) + for txid, firstSeen := range m.txTimes { + entries = append(entries, bchain.MempoolTxidEntry{ + Txid: txid, + Time: firstSeen, + }) + } + return entries +} + +func (m *tronTestMempool) GetTransactionTime(txid string) uint32 { + return m.txTimes[txid] +} + +func (m *tronTestMempool) GetTxidFilterEntries(filterScripts string, fromTimestamp uint32) (bchain.MempoolTxidFilterEntries, error) { + return bchain.MempoolTxidFilterEntries{}, nil +} + +func TestTronRPC_EthereumTypeGetRawTransaction_Empty(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: tronGetTransactionByIDResponse{}, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + _, err := tronRPC.EthereumTypeGetRawTransaction("0xabc") + require.Error(t, err) +} + +func TestTronRPC_EthereumTypeGetRawTransaction_FallbackToFullNode(t *testing.T) { + solidityHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + fullNodeHTTP := &MockTronHTTPClient{ + Resp: tronGetTransactionByIDResponse{ + RawDataHex: "deadbeef", + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: fullNodeHTTP, + solidityNodeHTTP: solidityHTTP, + } + + rawHex, err := tronRPC.EthereumTypeGetRawTransaction("0xabc") + require.NoError(t, err) + require.Equal(t, "0xdeadbeef", rawHex) + require.Equal(t, "/walletsolidity/gettransactionbyid", solidityHTTP.LastPath) + require.Equal(t, map[string]string{"value": "abc"}, solidityHTTP.LastBody) + require.Equal(t, "/wallet/gettransactionbyid", fullNodeHTTP.LastPath) + require.Equal(t, map[string]string{"value": "abc"}, fullNodeHTTP.LastBody) +} + +func TestTronRPC_GetTransactionByIDWithFallback_FallbackToFullNode(t *testing.T) { + solidityHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + fullNodeHTTP := &MockTronHTTPClient{ + Resp: map[string]any{ + "txID": "tx1", + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: fullNodeHTTP, + solidityNodeHTTP: solidityHTTP, + } + + txByID, isSolidified, err := tronRPC.getTransactionByIDWithFallback("0x123") + require.NoError(t, err) + require.False(t, isSolidified) + require.NotNil(t, txByID) + require.Equal(t, "tx1", txByID.TxID) + require.Equal(t, "/walletsolidity/gettransactionbyid", solidityHTTP.LastPath) + require.Equal(t, "/wallet/gettransactionbyid", fullNodeHTTP.LastPath) +} + +func TestTronRPC_GetTransactionInfoByIDWithFallback_FallbackToFullNode(t *testing.T) { + solidityHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + fullNodeHTTP := &MockTronHTTPClient{ + Resp: map[string]any{ + "id": "tx1", + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: fullNodeHTTP, + solidityNodeHTTP: solidityHTTP, + } + + txInfo, isSolidified, err := tronRPC.getTransactionInfoByIDWithFallback("0x123") + require.NoError(t, err) + require.False(t, isSolidified) + require.NotNil(t, txInfo) + require.Equal(t, "tx1", txInfo.ID) + require.Equal(t, "/walletsolidity/gettransactioninfobyid", solidityHTTP.LastPath) + require.Equal(t, "/wallet/gettransactioninfobyid", fullNodeHTTP.LastPath) +} + +func TestTronRPC_GetTransaction_NilMempoolDoesNotPanic(t *testing.T) { + solidityHTTP := &MockTronHTTPClient{ + Resp: map[string]any{ + "id": "abc", + "txID": "abc", + }, + } + fullNodeHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + Parser: NewTronParser(1, false), + fullNodeHTTP: fullNodeHTTP, + solidityNodeHTTP: solidityHTTP, + } + + tx, err := tronRPC.GetTransaction("0xabc") + require.NoError(t, err) + require.NotNil(t, tx) + require.Equal(t, "abc", tx.Txid) + require.Equal(t, "/walletsolidity/gettransactionbyid", solidityHTTP.LastPath) + require.Equal(t, "", fullNodeHTTP.LastPath) + + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.NotNil(t, csd.Receipt) +} + +func TestTronRPC_GetTransaction_FallbackToFullNodeKeepsPendingEvenWithBlockNumber(t *testing.T) { + solidityHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + fullNodeHTTP := &MockTronHTTPClient{ + Resp: map[string]any{ + "id": "abc", + "txID": "abc", + "blockNumber": int64(123), + "blockTimeStamp": int64(1700000000000), + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + Parser: NewTronParser(1, false), + fullNodeHTTP: fullNodeHTTP, + solidityNodeHTTP: solidityHTTP, + } + + tx, err := tronRPC.GetTransaction("0xabc") + require.NoError(t, err) + require.NotNil(t, tx) + require.Equal(t, "abc", tx.Txid) + require.Equal(t, "/walletsolidity/gettransactioninfobyid", solidityHTTP.LastPath) + require.Equal(t, "/wallet/gettransactionbyid", fullNodeHTTP.LastPath) + + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.Nil(t, csd.Receipt) +} + +func TestTronRPC_ReconcileTronMempoolWithPendingList_RemovesMissingTxs(t *testing.T) { + m := &tronTestMempool{ + txTimes: map[string]uint32{ + "a1": 1, + "b2": 2, + "c3": 3, + }, + } + + removedTxs := make(map[string]struct{}) + removed := reconcileTronMempoolWithPendingList(m, []string{"0xa1", "c3"}, func(txid string) { + removedTxs[txid] = struct{}{} + delete(m.txTimes, txid) + }) + + require.Equal(t, 1, removed) + _, removedB2 := removedTxs["b2"] + require.True(t, removedB2) + require.Equal(t, map[string]uint32{ + "a1": 1, + "c3": 3, + }, m.txTimes) +} + +func TestTronRPC_GetTransactionByID_EmptyObjectMeansNotFound(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + tx, err := tronRPC.getTransactionByID("0x788b4d0ca432b3d07f895dffe80429bf58398d0e86222460b07f9db38e238803", true) + require.Error(t, err) + require.Nil(t, tx) + require.Equal(t, "/walletsolidity/gettransactionbyid", mockHTTP.LastPath) + require.Equal(t, map[string]string{"value": "788b4d0ca432b3d07f895dffe80429bf58398d0e86222460b07f9db38e238803"}, mockHTTP.LastBody) +} + +func TestTronRPC_GetTransactionInfoByID_EmptyObjectMeansNoData(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + txInfo, err := tronRPC.getTransactionInfoByID("0x788b4d0ca432b3d07f895dffe80429bf58398d0e86222460b07f9db38e238803", true) + require.Error(t, err) + require.Nil(t, txInfo) + require.Equal(t, "/walletsolidity/gettransactioninfobyid", mockHTTP.LastPath) + require.Equal(t, map[string]string{"value": "788b4d0ca432b3d07f895dffe80429bf58398d0e86222460b07f9db38e238803"}, mockHTTP.LastBody) +} + +func TestTronRPC_GetTransactionInfoByID_NonEmptyObjectReturned(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: map[string]any{ + "id": "tx1", + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + txInfo, err := tronRPC.getTransactionInfoByID("0x123", true) + require.NoError(t, err) + require.NotNil(t, txInfo) + require.Equal(t, "tx1", txInfo.ID) + require.Equal(t, "/walletsolidity/gettransactioninfobyid", mockHTTP.LastPath) +} + +func TestTronRPC_SendRawTransaction(t *testing.T) { + txID := "7c2d4206c03a883dd9066d620335dc1be272a8dc733cfa3f6d10308faa37facc" + txHex := "0xdeadbeef" + + mockHTTP := &MockTronHTTPClient{ + Resp: tronBroadcastHexResponse{ + Result: true, + TxID: txID, + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + gotTxID, err := tronRPC.SendRawTransaction(txHex, false) + require.NoError(t, err) + require.Equal(t, txID, gotTxID) + require.Equal(t, "/wallet/broadcasthex", mockHTTP.LastPath) + require.Equal(t, map[string]string{"transaction": "deadbeef"}, mockHTTP.LastBody) +} + +func TestTronRPC_SendRawTransaction_StripsPrefixFromResponse(t *testing.T) { + txHex := "deadbeef" + + mockHTTP := &MockTronHTTPClient{ + Resp: tronBroadcastHexResponse{ + Result: true, + TxID: "0x7c2d4206c03a883dd9066d620335dc1be272a8dc733cfa3f6d10308faa37facc", + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + gotTxID, err := tronRPC.SendRawTransaction(txHex, false) + require.NoError(t, err) + require.Equal(t, "7c2d4206c03a883dd9066d620335dc1be272a8dc733cfa3f6d10308faa37facc", gotTxID) +} + +func TestTronRPC_SendRawTransaction_Failed(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: tronBroadcastHexResponse{ + Result: false, + Code: "SIGERROR", + Message: "error", + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + _, err := tronRPC.SendRawTransaction("deadbeef", false) + require.Error(t, err) +} + +func TestTronRPC_GetMempoolTransactions(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: tronGetTransactionListFromPendingResponse{ + TxID: []string{ + "a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302", + "b431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b303", + }, + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + txs, err := tronRPC.GetMempoolTransactions() + require.NoError(t, err) + require.Equal(t, []string{ + "a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302", + "b431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b303", + }, txs) + require.Equal(t, "/wallet/gettransactionlistfrompending", mockHTTP.LastPath) + require.Equal(t, map[string]any{}, mockHTTP.LastBody) +} + +func TestTronRPC_GetMempoolTransactions_Error(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Err: errors.New("backend error"), + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + _, err := tronRPC.GetMempoolTransactions() + require.Error(t, err) +} + +func TestTronRPC_GetAddressChainExtraData(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: tronGetAccountResourceResponse{ + FreeNetLimit: 600, + FreeNetUsed: 100, + NetLimit: 400, + NetUsed: 250, + EnergyLimit: 9000, + EnergyUsed: 1234, + }, + } + parser := NewTronParser(1, false) + addrDesc, err := parser.GetAddrDescFromAddress("TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe") + require.NoError(t, err) + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + payload, err := tronRPC.GetAddressChainExtraData(addrDesc) + require.NoError(t, err) + require.JSONEq(t, `{ + "availableStakedBandwidth":150, + "totalStakedBandwidth":400, + "availableFreeBandwidth":500, + "totalFreeBandwidth":600, + "availableEnergy":7766, + "totalEnergy":9000 + }`, string(payload)) + require.Equal(t, "/wallet/getaccountresource", mockHTTP.LastPath) + require.Equal(t, map[string]any{ + "address": "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", + "visible": true, + }, mockHTTP.LastBody) +} + +func TestTronRPC_GetAddressChainExtraData_MissingFieldsClampToZero(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: map[string]any{ + "freeNetLimit": int64(100), + "freeNetUsed": int64(150), + "NetLimit": int64(50), + "NetUsed": int64(10), + "EnergyUsed": int64(20), + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + payload, err := tronRPC.GetAddressChainExtraData(bchain.AddressDescriptor{ + 0x73, 0x4c, 0x2f, 0x23, 0xab, 0x41, 0xc5, 0x23, 0x08, 0xd1, + 0x20, 0x6c, 0x4e, 0xb5, 0xfe, 0x8e, 0x12, 0x4e, 0x68, 0x98, + }) + require.NoError(t, err) + + var extra bchain.TronAccountExtraData + require.NoError(t, json.Unmarshal(payload, &extra)) + require.Equal(t, bchain.TronAccountExtraData{ + AvailableStakedBandwidth: 40, + TotalStakedBandwidth: 50, + AvailableFreeBandwidth: 0, + TotalFreeBandwidth: 100, + AvailableEnergy: 0, + TotalEnergy: 0, + }, extra) +} + +func TestTronRPC_RequestLatestSolidifiedBlockHeight(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: map[string]any{ + "block_header": map[string]any{ + "raw_data": map[string]any{ + "number": uint64(123456), + }, + }, + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + height, err := tronRPC.requestLatestSolidifiedBlockHeight(context.Background()) + require.NoError(t, err) + require.Equal(t, uint64(123456), height) + require.Equal(t, "/walletsolidity/getblock", mockHTTP.LastPath) + require.Equal(t, map[string]any{"detail": false}, mockHTTP.LastBody) +} + +func TestTronRPC_RequestLatestSolidifiedBlockHeight_MissingNumber(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + Resp: map[string]any{ + "block_header": map[string]any{ + "raw_data": map[string]any{}, + }, + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + fullNodeHTTP: mockHTTP, + solidityNodeHTTP: mockHTTP, + } + + _, err := tronRPC.requestLatestSolidifiedBlockHeight(context.Background()) + require.Error(t, err) +} diff --git a/bchain/coins/tron/txextra.go b/bchain/coins/tron/txextra.go new file mode 100644 index 0000000000..e56162ce65 --- /dev/null +++ b/bchain/coins/tron/txextra.go @@ -0,0 +1,276 @@ +package tron + +import ( + "encoding/json" + "strings" + + "github.com/trezor/blockbook/bchain" +) + +type tronGetTransactionInfoByIDResponse struct { + ID string `json:"id,omitempty"` + Fee *int64 `json:"fee,omitempty"` + BlockNumber *int64 `json:"blockNumber,omitempty"` + BlockTimeStamp *int64 `json:"blockTimeStamp,omitempty"` + ContractResult []string `json:"contractResult,omitempty"` + ContractAddr string `json:"contract_address,omitempty"` + Result string `json:"result,omitempty"` // omitted on success, FAILED on error + ResMessage string `json:"resMessage,omitempty"` + AssetIssueID string `json:"assetIssueID,omitempty"` + WithdrawAmount *int64 `json:"withdraw_amount,omitempty"` // rewards from voting, super representatives rewards + UnfreezeAmount *int64 `json:"unfreeze_amount,omitempty"` + InternalTransactions []tronInternalTransaction `json:"internal_transactions,omitempty"` + WithdrawExpireAmount *int64 `json:"withdraw_expire_amount,omitempty"` // stake 2.0 withdraw of TRX after unfreeze + Receipt struct { + Result string `json:"result"` + EnergyUsage *int64 `json:"energy_usage,omitempty"` + EnergyUsageTotal *int64 `json:"energy_usage_total,omitempty"` + EnergyFee *int64 `json:"energy_fee,omitempty"` + OriginEnergyUsage *int64 `json:"origin_energy_usage,omitempty"` + NetUsage *int64 `json:"net_usage,omitempty"` + NetFee *int64 `json:"net_fee,omitempty"` + EnergyPenaltyTotal *int64 `json:"energy_penalty_total,omitempty"` + } `json:"receipt"` + Log []*bchain.RpcLog `json:"log,omitempty"` +} + +func tronOperationFromContractType(contractType string) string { + switch contractType { + case "AccountCreateContract": + return "activateAccount" + case "VoteWitnessContract": + return "vote" + case "FreezeBalanceContract", "FreezeBalanceV2Contract": + return "freeze" + case "UnfreezeBalanceContract", "UnfreezeBalanceV2Contract": + return "unfreeze" + case "WithdrawExpireUnfreezeContract": + return "withdraw" + case "WithdrawBalanceContract": + return "voteRewardAmount" + case "DelegateResourceContract": + return "delegate" + case "UnDelegateResourceContract": + return "undelegate" + case "TransferContract": + return "transfer" + case "TransferAssetContract": + return "trc10Transfer" + case "TriggerSmartContract": + return "contractCall" + default: + return "" + } +} + +func tronFirstContract(txByID *tronGetTransactionByIDResponse) *tronTxContract { + if txByID == nil || len(txByID.RawData.Contract) == 0 { + return nil + } + return &txByID.RawData.Contract[0] +} + +func tronBuildExtraData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse) bchain.TronChainExtraData { + extra := bchain.TronChainExtraData{} + extra.FeeLimit = tronInt64PtrToString(txByID.RawData.FeeLimit) + + if c := tronFirstContract(txByID); c != nil { + extra.ContractType = c.Type + extra.Operation = tronOperationFromContractType(c.Type) + v := c.Parameter.Value + extra.Resource = tronResourceToString(v.Resource) + switch c.Type { + case "VoteWitnessContract": + if len(v.Votes) > 0 { + extra.Votes = make([]bchain.TronVoteExtra, 0, len(v.Votes)) + for _, vote := range v.Votes { + if count := tronInt64PtrToString(vote.VoteCount); count != "" { + extra.Votes = append(extra.Votes, bchain.TronVoteExtra{ + Address: ToTronAddressFromAddress(vote.VoteAddress), + Count: count, + }) + } + } + } + case "FreezeBalanceContract", "FreezeBalanceV2Contract": + extra.StakeAmount = tronInt64PtrToString(v.FrozenBalance) + case "UnfreezeBalanceContract": + extra.UnstakeAmount = tronInt64PtrToString(txInfo.UnfreezeAmount) + case "UnfreezeBalanceV2Contract": + extra.UnstakeAmount = tronInt64PtrToString(v.UnfreezeBalance) + case "WithdrawExpireUnfreezeContract": + extra.UnstakeAmount = tronInt64PtrToString(txInfo.WithdrawExpireAmount) + case "WithdrawBalanceContract": + extra.ClaimedVoteReward = tronInt64PtrToString(txInfo.WithdrawAmount) + case "DelegateResourceContract", "UnDelegateResourceContract": + extra.DelegateAmount = tronInt64PtrToString(v.Balance) + extra.DelegateTo = ToTronAddressFromAddress(v.ReceiverAddress) + } + } + + extra.AssetIssueID = strings.TrimSpace(txInfo.AssetIssueID) + extra.TotalFee = tronInt64PtrToString(txInfo.Fee) + extra.EnergyUsage = tronInt64PtrToString(txInfo.Receipt.EnergyUsage) + extra.EnergyUsageTotal = tronInt64PtrToString(txInfo.Receipt.EnergyUsageTotal) + extra.EnergyFee = tronInt64PtrToString(txInfo.Receipt.EnergyFee) + extra.BandwidthUsage = tronInt64PtrToString(txInfo.Receipt.NetUsage) + extra.BandwidthFee = tronInt64PtrToString(txInfo.Receipt.NetFee) + if extra.BandwidthUsage == "" { + extra.BandwidthUsage = "0" + } + extra.Result = strings.TrimSpace(txInfo.Receipt.Result) + if extra.Result == "" { + extra.Result = strings.TrimSpace(txInfo.Result) + } + + return extra +} + +func tronBuildRpcReceipt(txInfo *tronGetTransactionInfoByIDResponse) *bchain.RpcReceipt { + receipt := &bchain.RpcReceipt{} + if strings.TrimSpace(txInfo.Result) == "" { + receipt.Status = "0x1" // success + } else { + receipt.Status = "0x0" // failed + } + + if gasUsed := tronInt64PtrToHexQuantity(txInfo.Receipt.EnergyUsageTotal); gasUsed != "" { + receipt.GasUsed = gasUsed + } + if txInfo.ContractAddr != "" { + receipt.ContractAddress = normalizeHexString(txInfo.ContractAddr) + } + logs := txInfo.Log + if len(logs) > 0 { + receipt.Logs = tronNormalizeLogs(logs) + } + + if receipt.Status == "" && receipt.GasUsed == "" && len(receipt.Logs) == 0 && receipt.ContractAddress == "" { + return nil + } + return receipt +} + +func tronBuildRpcTransaction(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse) *bchain.RpcTransaction { + tx := &bchain.RpcTransaction{ + AccountNonce: "0x0", + GasPrice: "0x0", + GasLimit: "0x0", + Value: "0x0", + Payload: "0x", + Hash: normalizeHexString(txByID.TxID), + TransactionIndex: "0x0", + } + if gasLimit := tronInt64PtrToHexQuantity(txByID.RawData.FeeLimit); gasLimit != "" { + tx.GasLimit = gasLimit + } + if c := tronFirstContract(txByID); c != nil { + v := c.Parameter.Value + tx.From = ToTronAddressFromAddress(v.OwnerAddress) + switch c.Type { + case "AccountCreateContract": + tx.To = strings.TrimSpace(v.AccountAddress) + case "TransferContract": // TRX transfer + tx.To = strings.TrimSpace(v.ToAddress) + tx.Value = tronInt64PtrToHexQuantity(v.Amount) + case "TransferAssetContract": // TRC-10 transfer + tx.To = strings.TrimSpace(v.ToAddress) + case "TriggerSmartContract": + tx.To = strings.TrimSpace(v.ContractAddress) + tx.Value = tronInt64PtrToHexQuantity(v.CallValue) + if data := normalizeHexString(v.Data); data != "" { + tx.Payload = data + } + case "FreezeBalanceContract", "FreezeBalanceV2Contract": + tx.To = tronFirstAddress(v.ReceiverAddress, v.OwnerAddress) + tx.Value = tronInt64PtrToHexQuantity(v.FrozenBalance) + case "UnfreezeBalanceContract": + tx.To = tronFirstAddress(v.ReceiverAddress, v.OwnerAddress) + case "WithdrawExpireUnfreezeContract": + tx.To = tronFirstAddress(v.ReceiverAddress, v.OwnerAddress) + tx.Value = tronInt64PtrToHexQuantity(txInfo.WithdrawExpireAmount) + case "UnfreezeBalanceV2Contract": + tx.To = tronFirstAddress(v.ReceiverAddress, v.OwnerAddress) + tx.Value = tronInt64PtrToHexQuantity(v.UnfreezeBalance) + case "DelegateResourceContract", "UnDelegateResourceContract": + tx.To = tronFirstAddress(v.ReceiverAddress, v.ContractAddress, v.ToAddress) + default: + tx.To = tronFirstAddress(v.ToAddress, v.ContractAddress, v.ReceiverAddress) + if tx.Payload == "0x" { + if data := normalizeHexString(v.Data); data != "" { + tx.Payload = data + } + } + } + } + + if bn := tronInt64PtrToHexQuantity(txInfo.BlockNumber); bn != "" { + tx.BlockNumber = bn + } + + if tx.Value == "" { + tx.Value = "0x0" + } + return tx +} + +func tronBuildEthereumSpecificData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse) bchain.EthereumSpecificData { + csd := bchain.EthereumSpecificData{ + Tx: tronBuildRpcTransaction(txByID, txInfo), + Receipt: tronBuildRpcReceipt(txInfo), + } + extra := tronBuildExtraData(txByID, txInfo) + if m, err := json.Marshal(extra); err == nil { + csd.ChainExtraData = m + } + return csd +} + +func tronTxMeta(txInfo *tronGetTransactionInfoByIDResponse) (int64, uint64, bool) { + var ( + blockTime int64 + blockNumber uint64 + hasBlockNumber bool + ) + if txInfo.BlockNumber != nil && *txInfo.BlockNumber >= 0 { + blockNumber = uint64(*txInfo.BlockNumber) + hasBlockNumber = true + } + if txInfo.BlockTimeStamp != nil && *txInfo.BlockTimeStamp >= 0 { + blockTime = *txInfo.BlockTimeStamp / 1000 + } + + return blockTime, blockNumber, hasBlockNumber +} + +func mapTransactionInfoByID(infos []tronGetTransactionInfoByIDResponse) map[string]*tronGetTransactionInfoByIDResponse { + if len(infos) == 0 { + return nil + } + r := make(map[string]*tronGetTransactionInfoByIDResponse, len(infos)) + for i := range infos { + txInfo := &infos[i] + id := txInfo.ID + if id == "" { + continue + } + r[id] = txInfo + } + return r +} + +func mapTransactionByID(txs []tronGetTransactionByIDResponse) map[string]*tronGetTransactionByIDResponse { + if len(txs) == 0 { + return nil + } + r := make(map[string]*tronGetTransactionByIDResponse, len(txs)) + for i := range txs { + txByID := &txs[i] + id := txByID.TxID + if id == "" { + continue + } + r[id] = txByID + } + return r +} diff --git a/bchain/coins/tron/txextra_test.go b/bchain/coins/tron/txextra_test.go new file mode 100644 index 0000000000..00aa22c163 --- /dev/null +++ b/bchain/coins/tron/txextra_test.go @@ -0,0 +1,426 @@ +//go:build unittest + +package tron + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" +) + +func int64Ptr(v int64) *int64 { + return &v +} + +func resourceCodePtr(v tronResourceCode) *tronResourceCode { + return &v +} + +func TestTronBuildExtraData_VoteWitness(t *testing.T) { + contract := tronTxContract{Type: "VoteWitnessContract"} + contract.Parameter.Value.Votes = []tronTxVote{ + { + VoteAddress: "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + VoteCount: int64Ptr(17), + }, + { + VoteAddress: "41da727d310b98700af4cec797e43991899668d6f3", + VoteCount: int64Ptr(3), + }, + } + + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "VoteWitnessContract", extra.ContractType) + require.Equal(t, "vote", extra.Operation) + require.Len(t, extra.Votes, 2) + require.Equal(t, ToTronAddressFromAddress(contract.Parameter.Value.Votes[0].VoteAddress), extra.Votes[0].Address) + require.Equal(t, "17", extra.Votes[0].Count) + require.Equal(t, ToTronAddressFromAddress(contract.Parameter.Value.Votes[1].VoteAddress), extra.Votes[1].Address) + require.Equal(t, "3", extra.Votes[1].Count) +} + +func TestTronBuildExtraData_AccountCreateOperation(t *testing.T) { + contract := tronTxContract{Type: "AccountCreateContract"} + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "AccountCreateContract", extra.ContractType) + require.Equal(t, "activateAccount", extra.Operation) +} + +func TestTronBuildExtraData_StakeAndDelegateDetails(t *testing.T) { + t.Run("stake amount", func(t *testing.T) { + contract := tronTxContract{Type: "FreezeBalanceV2Contract"} + contract.Parameter.Value.FrozenBalance = int64Ptr(125000000) + contract.Parameter.Value.Resource = resourceCodePtr(tronResourceEnergy) + + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "freeze", extra.Operation) + require.Equal(t, "125000000", extra.StakeAmount) + require.Equal(t, "energy", extra.Resource) + }) + + t.Run("unstake amount uses contract unfreeze balance for stake 2.0", func(t *testing.T) { + contract := tronTxContract{Type: "UnfreezeBalanceV2Contract"} + contract.Parameter.Value.UnfreezeBalance = int64Ptr(99000000) + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "unfreeze", extra.Operation) + require.Equal(t, "99000000", extra.UnstakeAmount) + }) + + t.Run("unstake amount uses txInfo unfreeze amount for stake 1.0", func(t *testing.T) { + contract := tronTxContract{Type: "UnfreezeBalanceContract"} + contract.Parameter.Value.UnfreezeBalance = int64Ptr(11111111) + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + + txInfo := &tronGetTransactionInfoByIDResponse{ + UnfreezeAmount: int64Ptr(88000000), + } + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "unfreeze", extra.Operation) + require.Equal(t, "88000000", extra.UnstakeAmount) + }) + + t.Run("delegate amount and receiver", func(t *testing.T) { + contract := tronTxContract{Type: "DelegateResourceContract"} + contract.Parameter.Value.Balance = int64Ptr(42000000) + contract.Parameter.Value.ReceiverAddress = "41da727d310b98700af4cec797e43991899668d6f3" + contract.Parameter.Value.Resource = resourceCodePtr(tronResourceBandwidth) + + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "delegate", extra.Operation) + require.Equal(t, "42000000", extra.DelegateAmount) + require.Equal(t, ToTronAddressFromAddress(contract.Parameter.Value.ReceiverAddress), extra.DelegateTo) + require.Equal(t, "bandwidth", extra.Resource) + }) + + t.Run("vote power resource", func(t *testing.T) { + contract := tronTxContract{Type: "UnfreezeBalanceV2Contract"} + contract.Parameter.Value.Resource = resourceCodePtr(tronResourceVotePower) + + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "votePower", extra.Resource) + }) + + t.Run("withdraw balance contract uses vote reward amount", func(t *testing.T) { + contract := tronTxContract{Type: "WithdrawBalanceContract"} + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txInfo := &tronGetTransactionInfoByIDResponse{ + WithdrawAmount: int64Ptr(6500000), + } + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "voteRewardAmount", extra.Operation) + require.Equal(t, "6500000", extra.ClaimedVoteReward) + }) + +} + +func TestTronBuildExtraData_AssetIssueID(t *testing.T) { + contract := tronTxContract{Type: "TransferAssetContract"} + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txByID.RawData.FeeLimit = int64Ptr(999000000) + + txInfo := &tronGetTransactionInfoByIDResponse{ + AssetIssueID: "1000047", + } + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "trc10Transfer", extra.Operation) + require.Equal(t, "1000047", extra.AssetIssueID) + require.Equal(t, "999000000", extra.FeeLimit) +} + +func TestTronBuildRpcTransaction_ValueIsEthereumHexQuantity(t *testing.T) { + tests := []struct { + name string + contract tronTxContract + want int64 + }{ + { + name: "transfer amount", + contract: tronTxContract{Type: "TransferContract"}, + want: 586000000, + }, + { + name: "trigger smart contract call value", + contract: tronTxContract{Type: "TriggerSmartContract"}, + want: 12345, + }, + { + name: "freeze balance integer", + contract: tronTxContract{Type: "FreezeBalanceContract"}, + want: 42000000, + }, + { + name: "unfreeze balance v2", + contract: tronTxContract{Type: "UnfreezeBalanceV2Contract"}, + want: 77000000, + }, + { + name: "unfreeze balance v1 has no tx value", + contract: tronTxContract{Type: "UnfreezeBalanceContract"}, + want: 0, + }, + { + name: "trc10 transfer has no trx tx value", + contract: tronTxContract{Type: "TransferAssetContract"}, + want: 0, + }, + { + name: "delegate resource has no trx tx value", + contract: tronTxContract{Type: "DelegateResourceContract"}, + want: 0, + }, + { + name: "undelegate resource has no trx tx value", + contract: tronTxContract{Type: "UnDelegateResourceContract"}, + want: 0, + }, + } + + tests[0].contract.Parameter.Value.Amount = int64Ptr(586000000) + tests[1].contract.Parameter.Value.CallValue = int64Ptr(12345) + tests[2].contract.Parameter.Value.FrozenBalance = int64Ptr(42000000) + tests[3].contract.Parameter.Value.UnfreezeBalance = int64Ptr(77000000) + tests[5].contract.Parameter.Value.Balance = int64Ptr(88000000) + tests[6].contract.Parameter.Value.Balance = int64Ptr(99000000) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{tt.contract} + txByID.TxID = "25b18a55f86afb10e7aca38d0073d04c80397c6636069193953fdefaea0b8369" + txInfo := &tronGetTransactionInfoByIDResponse{ + BlockNumber: int64Ptr(1), + } + + tx := tronBuildRpcTransaction(txByID, txInfo) + value, err := hexutil.DecodeBig(tx.Value) + + require.NoError(t, err) + require.Equal(t, tt.want, value.Int64()) + }) + } +} + +func TestTronBuildRpcTransaction_AccountCreateContractSetsToAddress(t *testing.T) { + contract := tronTxContract{Type: "AccountCreateContract"} + contract.Parameter.Value.OwnerAddress = "41508b7b8057fc9170398a65bbc89ff3ccfcc0f4a5" + contract.Parameter.Value.AccountAddress = "41da79e32a568680fccedadcab18a6e1bc231c0476" + + txByID := &tronGetTransactionByIDResponse{ + TxID: "e5babca390bfb5ba2e26151f031893f5b01237536fbd700f5f563423a1dc1b7d", + } + txByID.RawData.Contract = []tronTxContract{contract} + + txInfo := &tronGetTransactionInfoByIDResponse{ + BlockNumber: int64Ptr(1), + } + + tx := tronBuildRpcTransaction(txByID, txInfo) + + require.Equal(t, ToTronAddressFromAddress(contract.Parameter.Value.OwnerAddress), tx.From) + require.Equal(t, contract.Parameter.Value.AccountAddress, tx.To) + require.Equal(t, "0x0", tx.Value) +} + +func TestTronBuildRpcTransaction_WithdrawExpireUnfreezeSetsToAndValue(t *testing.T) { + contract := tronTxContract{Type: "WithdrawExpireUnfreezeContract"} + contract.Parameter.Value.OwnerAddress = "41da727d310b98700af4cec797e43991899668d6f3" + contract.Parameter.Value.ReceiverAddress = "41734c2f23ab41c52308d1206c4eb5fe8e124e6898" + + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Contract = []tronTxContract{contract} + txByID.TxID = "25b18a55f86afb10e7aca38d0073d04c80397c6636069193953fdefaea0b8369" + txInfo := &tronGetTransactionInfoByIDResponse{ + BlockNumber: int64Ptr(1), + WithdrawExpireAmount: int64Ptr(88000000), + } + + tx := tronBuildRpcTransaction(txByID, txInfo) + value, err := hexutil.DecodeBig(tx.Value) + + require.NoError(t, err) + require.Equal(t, int64(88000000), value.Int64()) + require.Equal(t, contract.Parameter.Value.ReceiverAddress, tx.To) +} + +func TestTronGetTransactionInfoByIDResponse_IgnoresCancelUnfreezeV2AmountShape(t *testing.T) { + raw := []byte(`[ + { + "id":"tx1", + "fee":123, + "cancel_unfreezeV2_amount":[] + }, + { + "id":"tx2", + "fee":456, + "cancel_unfreezeV2_amount":{"ENERGY":100} + } + ]`) + + var resp []tronGetTransactionInfoByIDResponse + err := json.Unmarshal(raw, &resp) + require.NoError(t, err) + require.Len(t, resp, 2) + require.Equal(t, "tx1", resp[0].ID) + require.Equal(t, int64(123), *resp[0].Fee) + require.Equal(t, "tx2", resp[1].ID) + require.Equal(t, int64(456), *resp[1].Fee) +} + +func TestTronBuildRpcReceipt_UsesTopLevelResultOmittedAsSuccess(t *testing.T) { + txInfo := &tronGetTransactionInfoByIDResponse{ + ID: "tx1", + } + receipt := tronBuildRpcReceipt(txInfo) + require.NotNil(t, receipt) + require.Equal(t, "0x1", receipt.Status) + + txInfo.Result = "FAILED" + receipt = tronBuildRpcReceipt(txInfo) + require.NotNil(t, receipt) + require.Equal(t, "0x0", receipt.Status) +} + +func TestTronBuildExtraData_ResultRequiresTransactionInfo(t *testing.T) { + txByID := &tronGetTransactionByIDResponse{} + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "", extra.Result) + + txInfo.Receipt.Result = "SUCCESS" + extra = tronBuildExtraData(txByID, txInfo) + require.Equal(t, "SUCCESS", extra.Result) +} + +func TestTronBuildExtraData_BandwidthUsageDefaultsToZero(t *testing.T) { + txByID := &tronGetTransactionByIDResponse{} + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, "0", extra.BandwidthUsage) + + txInfo.Receipt.NetUsage = int64Ptr(42) + extra = tronBuildExtraData(txByID, txInfo) + require.Equal(t, "42", extra.BandwidthUsage) +} + +func TestTronTxMeta_GetCorrectTxMeta(t *testing.T) { + txInfo := &tronGetTransactionInfoByIDResponse{ + BlockNumber: int64Ptr(12345), + BlockTimeStamp: int64Ptr(1700000000000), + } + blockTime, blockNumber, hasBlockNumber := tronTxMeta(txInfo) + require.True(t, hasBlockNumber) + require.Equal(t, uint64(12345), blockNumber) + require.Equal(t, int64(1700000000), blockTime) +} + +func TestSynthesizeGenesisTxInfo(t *testing.T) { + txInfo := synthesizeGenesisTxInfo("0x1fdaa5bb76e3c1a5430f7d8920fe2cebc8120a14c87b3a9cba36e0a11b68b57e", 0, 1234) + require.NotNil(t, txInfo) + require.Equal(t, "1fdaa5bb76e3c1a5430f7d8920fe2cebc8120a14c87b3a9cba36e0a11b68b57e", txInfo.ID) + require.NotNil(t, txInfo.BlockNumber) + require.Equal(t, int64(0), *txInfo.BlockNumber) + require.NotNil(t, txInfo.BlockTimeStamp) + require.Equal(t, int64(1234000), *txInfo.BlockTimeStamp) + + txInfo = synthesizeGenesisTxInfo("0xabc", 1, 1234) + require.Nil(t, txInfo) +} + +func TestSynthesizeGenesisTxByID(t *testing.T) { + rpcTx := &bchain.RpcTransaction{ + Hash: "0x1fdaa5bb76e3c1a5430f7d8920fe2cebc8120a14c87b3a9cba36e0a11b68b57e", + From: "0x0000000000000000000000000000000000000000", + To: "0x7e95e45f5a60cc45f2d0afe37ee9f77fb8ce9fff", + Value: "0x15fb7f9b8c38000", + Payload: "0x", + GasLimit: "0x0", + BlockHash: "0x0000000000000000d698d4192c56cb6be724a558448e2684802de4d6cd8690dc", + } + + txByID := synthesizeGenesisTxByID(rpcTx, 0) + require.NotNil(t, txByID) + require.Equal(t, "1fdaa5bb76e3c1a5430f7d8920fe2cebc8120a14c87b3a9cba36e0a11b68b57e", txByID.TxID) + require.NotNil(t, txByID.RawData.FeeLimit) + require.Equal(t, int64(0), *txByID.RawData.FeeLimit) + require.Len(t, txByID.RawData.Contract, 1) + require.Equal(t, "TransferContract", txByID.RawData.Contract[0].Type) + require.Equal(t, "0000000000000000000000000000000000000000", txByID.RawData.Contract[0].Parameter.Value.OwnerAddress) + require.Equal(t, "7e95e45f5a60cc45f2d0afe37ee9f77fb8ce9fff", txByID.RawData.Contract[0].Parameter.Value.ToAddress) + require.NotNil(t, txByID.RawData.Contract[0].Parameter.Value.Amount) + require.Equal(t, int64(99000000000000000), *txByID.RawData.Contract[0].Parameter.Value.Amount) + + txByID = synthesizeGenesisTxByID(rpcTx, 1) + require.Nil(t, txByID) +} + +func TestTronHexQuantityToInt64Ptr(t *testing.T) { + value := tronHexQuantityToInt64Ptr("0x15fb7f9b8c38000") + require.NotNil(t, value) + require.Equal(t, int64(99000000000000000), *value) + + require.Nil(t, tronHexQuantityToInt64Ptr("")) + require.Nil(t, tronHexQuantityToInt64Ptr("not-a-quantity")) + require.Nil(t, tronHexQuantityToInt64Ptr("0x8000000000000000")) +} + +func TestTronBuildTxFromHTTPData_WithSynthesizedGenesisData(t *testing.T) { + rpcTx := &bchain.RpcTransaction{ + Hash: "0x1fdaa5bb76e3c1a5430f7d8920fe2cebc8120a14c87b3a9cba36e0a11b68b57e", + From: "0x0000000000000000000000000000000000000000", + To: "0x7e95e45f5a60cc45f2d0afe37ee9f77fb8ce9fff", + Value: "0x15fb7f9b8c38000", + Payload: "0x", + GasLimit: "0x0", + } + txByID := synthesizeGenesisTxByID(rpcTx, 0) + txInfo := synthesizeGenesisTxInfo(txByID.TxID, 0, 0) + tronRPC := &TronRPC{ + Parser: NewTronParser(1, false), + } + tx, err := tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 1, nil, true) + require.NoError(t, err) + require.NotNil(t, tx) + require.Equal(t, "1fdaa5bb76e3c1a5430f7d8920fe2cebc8120a14c87b3a9cba36e0a11b68b57e", tx.Txid) + require.Len(t, tx.Vout, 1) + require.Equal(t, ToTronAddressFromAddress("7e95e45f5a60cc45f2d0afe37ee9f77fb8ce9fff"), tx.Vout[0].ScriptPubKey.Addresses[0]) + + receipt := tronBuildRpcReceipt(txInfo) + require.NotNil(t, receipt) + require.Equal(t, "0x1", receipt.Status) +} diff --git a/bchain/coins/unobtanium/unobtaniumparser.go b/bchain/coins/unobtanium/unobtaniumparser.go index 3e803ff8bc..500c25f74a 100644 --- a/bchain/coins/unobtanium/unobtaniumparser.go +++ b/bchain/coins/unobtanium/unobtaniumparser.go @@ -82,6 +82,7 @@ func (p *UnobtaniumParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/viacoin/viacoinparser.go b/bchain/coins/viacoin/viacoinparser.go index 369d45dea5..071cb09e53 100644 --- a/bchain/coins/viacoin/viacoinparser.go +++ b/bchain/coins/viacoin/viacoinparser.go @@ -99,6 +99,7 @@ func (p *ViacoinParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/vipstarcoin/vipstarcoinparser.go b/bchain/coins/vipstarcoin/vipstarcoinparser.go index 005cab8f40..8751ed85bd 100644 --- a/bchain/coins/vipstarcoin/vipstarcoinparser.go +++ b/bchain/coins/vipstarcoin/vipstarcoinparser.go @@ -120,6 +120,7 @@ func (p *VIPSTARCOINParser) ParseBlock(b []byte) (*bchain.Block, error) { return &bchain.Block{ BlockHeader: bchain.BlockHeader{ + Prev: h.PrevBlock.String(), // needed for fork detection when parsing raw blocks Size: len(b), Time: h.Timestamp.Unix(), }, diff --git a/bchain/coins/vipstarcoin/vipstarcoinparser_test.go b/bchain/coins/vipstarcoin/vipstarcoinparser_test.go index 5ab6403182..83f215724f 100644 --- a/bchain/coins/vipstarcoin/vipstarcoinparser_test.go +++ b/bchain/coins/vipstarcoin/vipstarcoinparser_test.go @@ -294,6 +294,10 @@ func Test_UnpackTx(t *testing.T) { t.Errorf("unpackTx() error = %v, wantErr %v", err, tt.wantErr) return } + // ignore witness unpacking + for i := range got.Vin { + got.Vin[i].Witness = nil + } if !reflect.DeepEqual(got, tt.want) { t.Errorf("unpackTx() got = %v, want %v", got, tt.want) } diff --git a/bchain/coins/zec/zcashrpc.go b/bchain/coins/zec/zcashrpc.go index c7f40566de..c1952c6c4e 100644 --- a/bchain/coins/zec/zcashrpc.go +++ b/bchain/coins/zec/zcashrpc.go @@ -1,7 +1,10 @@ package zec import ( + "bytes" "encoding/json" + "reflect" + "strings" "github.com/golang/glog" "github.com/juju/errors" @@ -42,7 +45,7 @@ func NewZCashRPC(config json.RawMessage, pushHandler func(bchain.NotificationTyp z := &ZCashRPC{ BitcoinRPC: b.(*btc.BitcoinRPC), } - z.RPCMarshaler = btc.JSONMarshalerV1{} + z.RPCMarshaler = JSONMarshalerV1Zebra{} z.ChainConfig.SupportsEstimateSmartFee = false return z, nil } @@ -111,6 +114,19 @@ func (z *ZCashRPC) GetChainInfo() (*bchain.ChainInfo, error) { // GetBlock returns block with given hash. func (z *ZCashRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { + type rpcBlock struct { + bchain.BlockHeader + Txs []bchain.Tx `json:"tx"` + } + type resGetBlockV1 struct { + Error *bchain.RPCError `json:"error"` + Result bchain.BlockInfo `json:"result"` + } + type resGetBlockV2 struct { + Error *bchain.RPCError `json:"error"` + Result rpcBlock `json:"result"` + } + var err error if hash == "" && height > 0 { hash, err = z.GetBlockHash(height) @@ -119,40 +135,138 @@ func (z *ZCashRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { } } - glog.V(1).Info("rpc: getblock (verbosity=1) ", hash) - - res := btc.ResGetBlockThin{} + var rawResponse json.RawMessage + resV2 := resGetBlockV2{} req := btc.CmdGetBlock{Method: "getblock"} req.Params.BlockHash = hash + req.Params.Verbosity = 2 + err = z.Call(&req, &rawResponse) + if err != nil { + // Check if it's a memory error and fall back + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "memory capacity exceeded") || strings.Contains(errStr, "response is too big") { + glog.Warningf("getblock verbosity=2 failed for block %v, falling back to individual tx fetches", hash) + return z.getBlockWithFallback(hash) + } + return nil, errors.Annotatef(err, "hash %v", hash) + } + // hack for ZCash, where the field "valueZat" is used instead of "valueSat" + rawResponse = bytes.ReplaceAll(rawResponse, []byte(`"valueZat"`), []byte(`"valueSat"`)) + err = json.Unmarshal(rawResponse, &resV2) + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + + // Check if verbosity=2 returned an RPC error + if resV2.Error != nil { + // Check if error is memory-related (case-insensitive) + errorMsg := strings.ToLower(resV2.Error.Message) + if strings.Contains(errorMsg, "memory capacity exceeded") || strings.Contains(errorMsg, "response is too big") { + glog.Warningf("getblock verbosity=2 returned memory error for block %v, falling back to verbosity=1 + individual tx fetches", hash) + return z.getBlockWithFallback(hash) + } + return nil, errors.Annotatef(resV2.Error, "hash %v", hash) + } + + block := &bchain.Block{ + BlockHeader: resV2.Result.BlockHeader, + Txs: resV2.Result.Txs, + } + + // transactions fetched in block with verbosity 2 do not contain txids, so we need to get it separately + resV1 := resGetBlockV1{} req.Params.Verbosity = 1 - err = z.Call(&req, &res) + err = z.Call(&req, &resV1) + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + if resV1.Error != nil { + return nil, errors.Annotatef(resV1.Error, "hash %v", hash) + } + for i := range resV1.Result.Txids { + block.Txs[i].Txid = resV1.Result.Txids[i] + } + return block, nil +} + +// getBlockWithFallback fetches block using verbosity=1 and then fetches each transaction individually +func (z *ZCashRPC) getBlockWithFallback(hash string) (*bchain.Block, error) { + type resGetBlockV1 struct { + Error *bchain.RPCError `json:"error"` + Result bchain.BlockInfo `json:"result"` + } + // Get block header and txids using verbosity=1 + resV1 := resGetBlockV1{} + req := btc.CmdGetBlock{Method: "getblock"} + req.Params.BlockHash = hash + req.Params.Verbosity = 1 + err := z.Call(&req, &resV1) if err != nil { return nil, errors.Annotatef(err, "hash %v", hash) } - if res.Error != nil { - return nil, errors.Annotatef(res.Error, "hash %v", hash) + if resV1.Error != nil { + return nil, errors.Annotatef(resV1.Error, "hash %v", hash) } - txs := make([]bchain.Tx, 0, len(res.Result.Txids)) - for _, txid := range res.Result.Txids { + // Create block with header from verbosity=1 response + block := &bchain.Block{ + BlockHeader: resV1.Result.BlockHeader, + Txs: make([]bchain.Tx, 0, len(resV1.Result.Txids)), + } + + // Fetch each transaction individually + for _, txid := range resV1.Result.Txids { tx, err := z.GetTransaction(txid) if err != nil { - if err == bchain.ErrTxNotFound { - glog.Errorf("rpc: getblock: skipping transanction in block %s due error: %s", hash, err) - continue - } - return nil, err + return nil, errors.Annotatef(err, "failed to fetch tx %v for block %v", txid, hash) } - txs = append(txs, *tx) - } - block := &bchain.Block{ - BlockHeader: res.Result.BlockHeader, - Txs: txs, + block.Txs = append(block.Txs, *tx) } + return block, nil } +// GetTransaction returns a transaction by the transaction ID +func (z *ZCashRPC) GetTransaction(txid string) (*bchain.Tx, error) { + r, err := z.getRawTransaction(txid) + if err != nil { + return nil, err + } + // hack for ZCash, where the field "valueZat" is used instead of "valueSat" + r = bytes.ReplaceAll(r, []byte(`"valueZat"`), []byte(`"valueSat"`)) + tx, err := z.Parser.ParseTxFromJson(r) + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } + tx.Blocktime = tx.Time + tx.Txid = txid + tx.CoinSpecificData = r + return tx, nil +} + +// getRawTransaction returns json as returned by backend, with all coin specific data +func (z *ZCashRPC) getRawTransaction(txid string) (json.RawMessage, error) { + glog.V(1).Info("rpc: getrawtransaction ", txid) + + res := btc.ResGetRawTransaction{} + req := btc.CmdGetRawTransaction{Method: "getrawtransaction"} + req.Params.Txid = txid + req.Params.Verbose = true + err := z.Call(&req, &res) + + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } + if res.Error != nil { + if btc.IsMissingTx(res.Error) { + return nil, bchain.ErrTxNotFound + } + return nil, errors.Annotatef(res.Error, "txid %v", txid) + } + return res.Result, nil +} + // GetTransactionForMempool returns a transaction by the transaction ID. // It could be optimized for mempool, i.e. without block time and confirmations func (z *ZCashRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { @@ -168,3 +282,72 @@ func (z *ZCashRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) { func (z *ZCashRPC) GetBlockRaw(hash string) (string, error) { return "", errors.New("GetBlockRaw: not supported") } + +// JSONMarshalerV1 is used for marshalling requests to legacy Bitcoin Type RPC interfaces +type JSONMarshalerV1Zebra struct{} + +// Marshal converts struct passed by parameter to JSON +func (JSONMarshalerV1Zebra) Marshal(v interface{}) ([]byte, error) { + u := cmdUntypedParams{} + + switch v := v.(type) { + case *btc.CmdGetBlock: + u.Method = v.Method + u.Params = append(u.Params, v.Params.BlockHash) + u.Params = append(u.Params, v.Params.Verbosity) + case *btc.CmdGetRawTransaction: + var n int + if v.Params.Verbose { + n = 1 + } + u.Method = v.Method + u.Params = append(u.Params, v.Params.Txid) + u.Params = append(u.Params, n) + default: + { + v := reflect.ValueOf(v).Elem() + + f := v.FieldByName("Method") + if !f.IsValid() || f.Kind() != reflect.String { + return nil, btc.ErrInvalidValue + } + u.Method = f.String() + + f = v.FieldByName("Params") + if f.IsValid() { + var arr []interface{} + switch f.Kind() { + case reflect.Slice: + arr = make([]interface{}, f.Len()) + for i := 0; i < f.Len(); i++ { + arr[i] = f.Index(i).Interface() + } + case reflect.Struct: + arr = make([]interface{}, f.NumField()) + for i := 0; i < f.NumField(); i++ { + arr[i] = f.Field(i).Interface() + } + default: + return nil, btc.ErrInvalidValue + } + u.Params = arr + } + } + } + u.Id = "-" + if u.Params == nil { + u.Params = make([]interface{}, 0) + } + d, err := json.Marshal(u) + if err != nil { + return nil, err + } + + return d, nil +} + +type cmdUntypedParams struct { + Method string `json:"method"` + Id string `json:"id"` + Params []interface{} `json:"params"` +} diff --git a/bchain/config_loader.go b/bchain/config_loader.go new file mode 100644 index 0000000000..32d9ad96b0 --- /dev/null +++ b/bchain/config_loader.go @@ -0,0 +1,157 @@ +//go:build integration + +package bchain + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + + buildcfg "github.com/trezor/blockbook/build/tools" +) + +const ( + testBuildEnvVar = "BB_BUILD_ENV" + testBuildEnvDev = "dev" +) + +var testEnvMu sync.Mutex + +// BlockchainCfg contains fields read from blockbook's blockchaincfg.json after being rendered from templates. +type BlockchainCfg struct { + // more fields can be added later as needed + RpcUrl string `json:"rpc_url"` + RpcUrlWs string `json:"rpc_url_ws"` + RpcUser string `json:"rpc_user"` + RpcPass string `json:"rpc_pass"` + RpcTimeout int `json:"rpc_timeout"` + TraceTimeout string `json:"trace_timeout"` + Parse bool `json:"parse"` +} + +// LoadBlockchainCfg returns the resolved blockchaincfg.json (env overrides are honored in tests) +func LoadBlockchainCfg(t *testing.T, coinAlias string) BlockchainCfg { + t.Helper() + + rawCfg, err := loadBlockchainCfgBytes(coinAlias) + if err != nil { + t.Fatalf("%v", err) + } + + var blockchainCfg BlockchainCfg + if err := json.Unmarshal(rawCfg, &blockchainCfg); err != nil { + t.Fatalf("unmarshal blockchain config for %s: %v", coinAlias, err) + } + if blockchainCfg.RpcUrl == "" { + t.Fatalf("empty rpc_url for %s", coinAlias) + } + return blockchainCfg +} + +// LoadBlockchainCfgRaw returns the rendered blockchaincfg.json payload for integration tests. +func LoadBlockchainCfgRaw(coinAlias string) (json.RawMessage, error) { + rawCfg, err := loadBlockchainCfgBytes(coinAlias) + if err != nil { + return nil, err + } + return json.RawMessage(rawCfg), nil +} + +func loadBlockchainCfgBytes(coinAlias string) ([]byte, error) { + configsDir, err := repoConfigsDir() + if err != nil { + return nil, fmt.Errorf("integration config path error: %w", err) + } + templatesDir, err := repoTemplatesDir(configsDir) + if err != nil { + return nil, fmt.Errorf("integration templates path error: %w", err) + } + + var config *buildcfg.Config + err = withDefaultTestBuildEnv(func() error { + var loadErr error + config, loadErr = buildcfg.LoadConfig(configsDir, coinAlias) + return loadErr + }) + if err != nil { + return nil, fmt.Errorf("load config for %s: %w", coinAlias, err) + } + + outputDir, err := os.MkdirTemp("", "integration_blockchaincfg") + if err != nil { + return nil, fmt.Errorf("integration temp dir error: %w", err) + } + defer func() { + _ = os.RemoveAll(outputDir) + }() + + // Render templates so tests read the same generated blockchaincfg.json as packaging. + if err := buildcfg.GeneratePackageDefinitions(config, templatesDir, outputDir); err != nil { + return nil, fmt.Errorf("generate package definitions for %s: %w", coinAlias, err) + } + + blockchainCfgPath := filepath.Join(outputDir, "blockbook", "blockchaincfg.json") + rawCfg, err := os.ReadFile(blockchainCfgPath) + if err != nil { + return nil, fmt.Errorf("read blockchain config for %s: %w", coinAlias, err) + } + return rawCfg, nil +} + +func withDefaultTestBuildEnv(fn func() error) error { + testEnvMu.Lock() + defer testEnvMu.Unlock() + + if _, ok := os.LookupEnv(testBuildEnvVar); ok { + return fn() + } + if err := os.Setenv(testBuildEnvVar, testBuildEnvDev); err != nil { + return err + } + defer func() { + _ = os.Unsetenv(testBuildEnvVar) + }() + return fn() +} + +// repoTemplatesDir locates build/templates relative to the repo root. +func repoTemplatesDir(configsDir string) (string, error) { + repoRoot := filepath.Dir(configsDir) + templatesDir := filepath.Join(repoRoot, "build", "templates") + if _, err := os.Stat(templatesDir); err == nil { + return templatesDir, nil + } else if os.IsNotExist(err) { + return "", fmt.Errorf("build/templates not found near %s", configsDir) + } else { + return "", err + } +} + +// repoConfigsDir finds configs/coins from the caller path so tests can run from any subdir. +func repoConfigsDir() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", errors.New("unable to resolve caller path") + } + dir := filepath.Dir(file) + // Walk up so tests can run from any subdir while still locating configs. + for i := 0; i < 3; i++ { + configsDir := filepath.Join(dir, "configs") + if _, err := os.Stat(filepath.Join(configsDir, "coins")); err == nil { + return configsDir, nil + } else if !os.IsNotExist(err) { + return "", err + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", errors.New("configs/coins not found from caller path") +} diff --git a/bchain/erc20_batch_integration.go b/bchain/erc20_batch_integration.go new file mode 100644 index 0000000000..6fa236c36f --- /dev/null +++ b/bchain/erc20_batch_integration.go @@ -0,0 +1,146 @@ +//go:build integration + +package bchain + +import ( + "context" + "errors" + "fmt" + "math/big" + "net" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +const defaultBatchSize = 100 + +type ERC20BatchCase struct { + Name string + RPCURL string + RPCURLWS string + Addr common.Address + Contracts []common.Address + BatchSize int + SkipUnavailable bool + NewClient ERC20BatchClientFactory +} + +// RunERC20BatchBalanceTest validates batch balanceOf results against single calls. +func RunERC20BatchBalanceTest(t *testing.T, tc ERC20BatchCase) { + t.Helper() + if tc.BatchSize <= 0 { + tc.BatchSize = defaultBatchSize + } + if tc.NewClient == nil { + t.Fatalf("NewClient is required for ERC20 batch integration test") + } + rpcClient, closeFn, err := tc.NewClient(tc.RPCURL, tc.RPCURLWS, tc.BatchSize) + if err != nil { + handleRPCError(t, tc, fmt.Errorf("rpc dial error: %w", err)) + return + } + if closeFn != nil { + t.Cleanup(closeFn) + } + if err := verifyBatchBalances(rpcClient, tc.Addr, tc.Contracts); err != nil { + handleRPCError(t, tc, err) + return + } + chunkedContracts := expandContracts(tc.Contracts, tc.BatchSize+1) + if err := verifyBatchBalances(rpcClient, tc.Addr, chunkedContracts); err != nil { + handleRPCError(t, tc, err) + return + } +} + +func handleRPCError(t *testing.T, tc ERC20BatchCase, err error) { + t.Helper() + if tc.SkipUnavailable && isRPCUnavailable(err) { + t.Skipf("WARN: %s RPC not available: %v", tc.Name, err) + return + } + t.Fatalf("%v", err) +} + +func expandContracts(contracts []common.Address, minLen int) []common.Address { + if len(contracts) >= minLen { + return contracts + } + out := make([]common.Address, 0, minLen) + for len(out) < minLen { + out = append(out, contracts...) + } + if len(out) > minLen { + out = out[:minLen] + } + return out +} + +type ERC20BatchClient interface { + EthereumTypeGetErc20ContractBalancesAtBlock(addrDesc AddressDescriptor, contractDescs []AddressDescriptor, blockNumber *big.Int) ([]*big.Int, error) + EthereumTypeGetErc20ContractBalanceAtBlock(addrDesc, contractDesc AddressDescriptor, blockNumber *big.Int) (*big.Int, error) + GetBestBlockHeight() (uint32, error) +} + +type ERC20BatchClientFactory func(rpcURL, rpcURLWS string, batchSize int) (ERC20BatchClient, func(), error) + +func verifyBatchBalances(rpcClient ERC20BatchClient, addr common.Address, contracts []common.Address) error { + if len(contracts) == 0 { + return errors.New("no contracts to query") + } + contractDescs := make([]AddressDescriptor, len(contracts)) + for i, c := range contracts { + contractDescs[i] = AddressDescriptor(c.Bytes()) + } + addrDesc := AddressDescriptor(addr.Bytes()) + height, err := rpcClient.GetBestBlockHeight() + if err != nil { + return fmt.Errorf("best block height error: %w", err) + } + blockNumber := new(big.Int).SetUint64(uint64(height)) + balances, err := rpcClient.EthereumTypeGetErc20ContractBalancesAtBlock(addrDesc, contractDescs, blockNumber) + if err != nil { + return fmt.Errorf("batch balances error: %w", err) + } + if len(balances) != len(contractDescs) { + return fmt.Errorf("expected %d balances, got %d", len(contractDescs), len(balances)) + } + for i, contractDesc := range contractDescs { + single, err := rpcClient.EthereumTypeGetErc20ContractBalanceAtBlock(addrDesc, contractDesc, blockNumber) + if err != nil { + return fmt.Errorf("single balance error for %s: %w", contracts[i].Hex(), err) + } + if balances[i] == nil { + return fmt.Errorf("batch balance missing for %s", contracts[i].Hex()) + } + if balances[i].Cmp(single) != 0 { + return fmt.Errorf("balance mismatch for %s: batch=%s single=%s", contracts[i].Hex(), balances[i].String(), single.String()) + } + } + return nil +} + +func isRPCUnavailable(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + var netErr net.Error + if errors.As(err, &netErr) { + return true + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "context deadline exceeded"), + strings.Contains(msg, "connection refused"), + strings.Contains(msg, "no such host"), + strings.Contains(msg, "i/o timeout"), + strings.Contains(msg, "timeout"): + return true + } + return false +} diff --git a/bchain/evm_interface.go b/bchain/evm_interface.go index 1338b01290..8eb94f54a1 100644 --- a/bchain/evm_interface.go +++ b/bchain/evm_interface.go @@ -2,7 +2,11 @@ package bchain import ( "context" + "fmt" "math/big" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" ) // EVMClient provides the necessary client functionality for evm chain sync @@ -57,3 +61,21 @@ type EVMNewTxSubscriber interface { EVMSubscriber Read() (EVMHash, bool) } + +// ToBlockNumArg converts a big.Int to an appropriate string representation of the number if possible +// - valid return values: (hex string, "latest", "pending", "earliest", "finalized", or "safe") +// - invalid return value: "invalid" +func ToBlockNumArg(number *big.Int) string { + if number == nil { + return "latest" + } + if number.Sign() >= 0 { + return hexutil.EncodeBig(number) + } + // It's negative. + if number.IsInt64() { + return rpc.BlockNumber(number.Int64()).String() + } + // It's negative and large, which is invalid. + return fmt.Sprintf("", number) +} diff --git a/bchain/golomb.go b/bchain/golomb.go new file mode 100644 index 0000000000..c0d38e303c --- /dev/null +++ b/bchain/golomb.go @@ -0,0 +1,217 @@ +package bchain + +import ( + "bytes" + "encoding/hex" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/martinboehm/btcutil/gcs" +) + +type FilterScriptsType int + +const ( + FilterScriptsInvalid = FilterScriptsType(iota) + FilterScriptsAll + FilterScriptsTaproot + FilterScriptsTaprootNoOrdinals +) + +// GolombFilter is computing golomb filter of address descriptors +type GolombFilter struct { + Enabled bool + UseZeroedKey bool + p uint8 + key string + filterScripts string + filterScriptsType FilterScriptsType + filterData [][]byte + uniqueData map[string]struct{} + // All the unique txids that contain ordinal data + ordinalTxIds map[string]struct{} + // Mapping of txid to address descriptors - only used in case of taproot-noordinals + allAddressDescriptors map[string][]AddressDescriptor +} + +// NewGolombFilter initializes the GolombFilter handler +func NewGolombFilter(p uint8, filterScripts string, key string, useZeroedKey bool) (*GolombFilter, error) { + if p == 0 { + return &GolombFilter{Enabled: false}, nil + } + gf := GolombFilter{ + Enabled: true, + UseZeroedKey: useZeroedKey, + p: p, + key: key, + filterScripts: filterScripts, + filterScriptsType: filterScriptsToScriptsType(filterScripts), + filterData: make([][]byte, 0), + uniqueData: make(map[string]struct{}), + } + // reject invalid filterScripts + if gf.filterScriptsType == FilterScriptsInvalid { + return nil, errors.Errorf("Invalid/unsupported filterScripts parameter %s", filterScripts) + } + // set ordinal-related fields if needed + if gf.ignoreOrdinals() { + gf.ordinalTxIds = make(map[string]struct{}) + gf.allAddressDescriptors = make(map[string][]AddressDescriptor) + } + return &gf, nil +} + +// Gets the M parameter that we are using for the filter +// Currently it relies on P parameter, but that can change +func GetGolombParamM(p uint8) uint64 { + return uint64(1 << uint64(p)) +} + +// Checks whether this input contains ordinal data +func isInputOrdinal(vin Vin) bool { + byte_pattern := []byte{ + 0x00, // OP_0, OP_FALSE + 0x63, // OP_IF + 0x03, // OP_PUSHBYTES_3 + 0x6f, // "o" + 0x72, // "r" + 0x64, // "d" + 0x01, // OP_PUSHBYTES_1 + } + // Witness needs to have at least 3 items and the second one needs to contain certain pattern + return len(vin.Witness) > 2 && bytes.Contains(vin.Witness[1], byte_pattern) +} + +// Whether a transaction contains any ordinal data +func txContainsOrdinal(tx *Tx) bool { + for _, vin := range tx.Vin { + if isInputOrdinal(vin) { + return true + } + } + return false +} + +// Saving all the ordinal-related txIds so we can later ignore their address descriptors +func (f *GolombFilter) markTxAndParentsAsOrdinals(tx *Tx) { + f.ordinalTxIds[tx.Txid] = struct{}{} + for _, vin := range tx.Vin { + f.ordinalTxIds[vin.Txid] = struct{}{} + } +} + +// Adding a new address descriptor mapped to a txid +func (f *GolombFilter) addTxIdMapping(ad AddressDescriptor, tx *Tx) { + f.allAddressDescriptors[tx.Txid] = append(f.allAddressDescriptors[tx.Txid], ad) +} + +// AddAddrDesc adds taproot address descriptor to the data for the filter +func (f *GolombFilter) AddAddrDesc(ad AddressDescriptor, tx *Tx) { + if f.ignoreNonTaproot() && !ad.IsTaproot() { + return + } + if f.ignoreOrdinals() && tx != nil && txContainsOrdinal(tx) { + f.markTxAndParentsAsOrdinals(tx) + return + } + if len(ad) == 0 { + return + } + // When ignoring ordinals, we need to save all the address descriptors before + // filtering out the "invalid" ones. + if f.ignoreOrdinals() && tx != nil { + f.addTxIdMapping(ad, tx) + return + } + f.includeAddrDesc(ad) +} + +// Private function to be called with descriptors that were already validated +func (f *GolombFilter) includeAddrDesc(ad AddressDescriptor) { + s := string(ad) + if _, found := f.uniqueData[s]; !found { + f.filterData = append(f.filterData, ad) + f.uniqueData[s] = struct{}{} + } +} + +// Including all the address descriptors from non-ordinal transactions +func (f *GolombFilter) includeAllAddressDescriptorsOrdinals() { + for txid, ads := range f.allAddressDescriptors { + // Ignoring the txids that contain ordinal data + if _, found := f.ordinalTxIds[txid]; found { + continue + } + for _, ad := range ads { + f.includeAddrDesc(ad) + } + } +} + +// Compute computes golomb filter from the data +func (f *GolombFilter) Compute() []byte { + m := GetGolombParamM(f.p) + + // In case of ignoring the ordinals, we still need to assemble the filter data + if f.ignoreOrdinals() { + f.includeAllAddressDescriptorsOrdinals() + } + + if len(f.filterData) == 0 { + return nil + } + + // Used key is possibly just zeroes, otherwise get it from the supplied key + var key [gcs.KeySize]byte + if f.UseZeroedKey { + key = [gcs.KeySize]byte{} + } else { + b, _ := hex.DecodeString(f.key) + if len(b) < gcs.KeySize { + return nil + } + copy(key[:], b[:gcs.KeySize]) + } + + filter, err := gcs.BuildGCSFilter(f.p, m, key, f.filterData) + if err != nil { + glog.Error("Cannot create golomb filter for ", f.key, ", ", err) + return nil + } + + fb, err := filter.NBytes() + if err != nil { + glog.Error("Error getting NBytes from golomb filter for ", f.key, ", ", err) + return nil + } + + return fb +} + +func (f *GolombFilter) ignoreNonTaproot() bool { + switch f.filterScriptsType { + case FilterScriptsTaproot, FilterScriptsTaprootNoOrdinals: + return true + } + return false +} + +func (f *GolombFilter) ignoreOrdinals() bool { + switch f.filterScriptsType { + case FilterScriptsTaprootNoOrdinals: + return true + } + return false +} + +func filterScriptsToScriptsType(filterScripts string) FilterScriptsType { + switch filterScripts { + case "": + return FilterScriptsAll + case "taproot": + return FilterScriptsTaproot + case "taproot-noordinals": + return FilterScriptsTaprootNoOrdinals + } + return FilterScriptsInvalid +} diff --git a/bchain/golomb_test.go b/bchain/golomb_test.go new file mode 100644 index 0000000000..cd9ddd4689 --- /dev/null +++ b/bchain/golomb_test.go @@ -0,0 +1,282 @@ +// //go:build unittest + +package bchain + +import ( + "encoding/hex" + "testing" +) + +func getCommonAddressDescriptors() []AddressDescriptor { + return []AddressDescriptor{ + // bc1pgeqrcq5capal83ypxczmypjdhk4d9wwcea4k66c7ghe07p2qt97sqh8sy5 + hexToBytes("512046403c0298e87bf3c4813605b2064dbdaad2b9d8cf6b6d6b1e45f2ff0540597d"), + // bc1p7en40zu9hmf9d3luh8evmfyg655pu5k2gtna6j7zr623f9tz7z0stfnwav + hexToBytes("5120f667578b85bed256c7fcb9f2cda488d5281e52ca42e7dd4bc21e95149562f09f"), + // 39ECUF8YaFRX7XfttfAiLa5ir43bsrQUZJ + hexToBytes("a91452ae9441d9920d9eb4a3c0a877ca8d8de547ce6587"), + } +} + +func TestGolombFilter(t *testing.T) { + tests := []struct { + name string + p uint8 + useZeroedKey bool + filterScripts string + key string + addressDescriptors []AddressDescriptor + wantError bool + wantEnabled bool + want string + }{ + { + name: "taproot", + p: 20, + useZeroedKey: false, + filterScripts: "taproot", + key: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + addressDescriptors: getCommonAddressDescriptors(), + wantEnabled: true, + wantError: false, + want: "0235dddcce5d60", + }, + { + name: "taproot-zeroed-key", + p: 20, + useZeroedKey: true, + filterScripts: "taproot", + key: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + addressDescriptors: getCommonAddressDescriptors(), + wantEnabled: true, + wantError: false, + want: "0218c23a013600", + }, + { + name: "taproot p=21", + p: 21, + useZeroedKey: false, + filterScripts: "taproot", + key: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + addressDescriptors: getCommonAddressDescriptors(), + wantEnabled: true, + wantError: false, + want: "0235ddda672eb0", + }, + { + name: "all", + p: 20, + useZeroedKey: false, + filterScripts: "", + key: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + addressDescriptors: getCommonAddressDescriptors(), + wantEnabled: true, + wantError: false, + want: "0350ccc61ac611976c80", + }, + { + name: "taproot-noordinals", + p: 20, + useZeroedKey: false, + filterScripts: "taproot-noordinals", + key: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + addressDescriptors: getCommonAddressDescriptors(), + wantEnabled: true, + wantError: false, + want: "0235dddcce5d60", + }, + { + name: "not supported filter", + p: 20, + useZeroedKey: false, + filterScripts: "notsupported", + wantEnabled: false, + wantError: true, + want: "", + }, + { + name: "not enabled", + p: 0, + useZeroedKey: false, + filterScripts: "", + wantEnabled: false, + wantError: false, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gf, err := NewGolombFilter(tt.p, tt.filterScripts, tt.key, tt.useZeroedKey) + if err != nil && !tt.wantError { + t.Errorf("TestGolombFilter.NewGolombFilter() got unexpected error '%v'", err) + return + } + if err == nil && tt.wantError { + t.Errorf("TestGolombFilter.NewGolombFilter() wanted error, got none") + return + } + if gf == nil && tt.wantError { + return + } + if gf.Enabled != tt.wantEnabled { + t.Errorf("TestGolombFilter.NewGolombFilter() got gf.Enabled %v, want %v", gf.Enabled, tt.wantEnabled) + return + } + for _, ad := range tt.addressDescriptors { + gf.AddAddrDesc(ad, nil) + } + f := gf.Compute() + got := hex.EncodeToString(f) + if got != tt.want { + t.Errorf("TestGolombFilter Compute() got %v, want %v", got, tt.want) + } + }) + } +} + +// Preparation transaction, locking BTC redeemable by ordinal witness - parent of the reveal transaction +func getOrdinalCommitTx() (Tx, []AddressDescriptor) { + tx := Tx{ + // https://mempool.space/tx/11111c17cbe86aebab146ee039d4e354cb55a9fb226ebdd2e30948630e7710ad + Txid: "11111c17cbe86aebab146ee039d4e354cb55a9fb226ebdd2e30948630e7710ad", + Vin: []Vin{ + { + // https://mempool.space/tx/c4cae52a6e681b66c85c12feafb42f3617f34977032df1ee139eae07370863ef + Txid: "c163fe1fdc21269cb05621adec38045e46a65289a356f9354df6010bce064916", + Vout: 0, + Witness: [][]byte{ + hexToBytes("0371633164dd16345c02e80c9963042f9a502aa2c8109c0f61da333ac1503c3ce2a1b79895359bbdee5979ab2cb44f3395892e1c419c3a8f67d31d33d7e764c9"), + }, + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "51206a711358bac6ca8f7ddfdf8f733546e658208122939f0bf7a3727f8143dfbbff", + Addresses: []string{ + "bc1pdfc3xk96cm9g7lwlm78hxd2xuevzpqfzjw0shaarwflczs7lh0lstksdn0", + }, + }, + }, + { + ScriptPubKey: ScriptPubKey{ + Hex: "a9144390d0b3d2b6d48b8c205ffbe40b2d84c40de07f87", + Addresses: []string{ + "37rGgLSLX6C6LS9am4KWd6GT1QCEP4H4py", + }, + }, + }, + { + ScriptPubKey: ScriptPubKey{ + Hex: "76a914ba6b046dd832aa8bc41c158232bcc18211387c4388ac", + Addresses: []string{ + "1HzgtNdRCXszf95rFYemsDSHJQBbs9rbZf", + }, + }, + }, + }, + } + addressDescriptors := []AddressDescriptor{ + // bc1pdfc3xk96cm9g7lwlm78hxd2xuevzpqfzjw0shaarwflczs7lh0lstksdn0 + hexToBytes("51206a711358bac6ca8f7ddfdf8f733546e658208122939f0bf7a3727f8143dfbbff"), + // 37rGgLSLX6C6LS9am4KWd6GT1QCEP4H4py + hexToBytes("a9144390d0b3d2b6d48b8c205ffbe40b2d84c40de07f87"), + // 1HzgtNdRCXszf95rFYemsDSHJQBbs9rbZf + hexToBytes("76a914ba6b046dd832aa8bc41c158232bcc18211387c4388ac"), + } + return tx, addressDescriptors +} + +// Transaction containing the actual ordinal data in witness - child of the commit transaction +func getOrdinalRevealTx() (Tx, []AddressDescriptor) { + tx := Tx{ + // https://mempool.space/tx/c4cae52a6e681b66c85c12feafb42f3617f34977032df1ee139eae07370863ef + Txid: "c4cae52a6e681b66c85c12feafb42f3617f34977032df1ee139eae07370863ef", + Vin: []Vin{ + { + Txid: "11111c17cbe86aebab146ee039d4e354cb55a9fb226ebdd2e30948630e7710ad", + Vout: 0, + Witness: [][]byte{ + hexToBytes("737ad2835962e3d147cd74a578f1109e9314eac9d00c9fad304ce2050b78fac21a2d124fd886d1d646cf1de5d5c9754b0415b960b1319526fa25e36ca1f650ce"), + hexToBytes("2029f34532e043fade4471779b4955005db8fa9b64c9e8d0a2dae4a38bbca23328ac0063036f726401010a696d6167652f77656270004d08025249464650020000574542505650384c440200002f57c2950067a026009086939b7785a104699656f4f53388355445b6415d22f8924000fd83bd31d346ca69f8fcfed6d8d18231846083f90f00ffbf203883666c36463c6ba8662257d789935e002192245bd15ac00216b080052cac85b380052c60e1593859f33a7a7abff7ed88feb361db3692341bc83553aef7aec75669ffb1ffd87fec3ff61ffb8ffdc736f20a96a0fba34071d4fdf111c435381df667728f95c4e82b6872d82471bfdc1665107bb80fd46df1686425bcd2e27eb59adc9d17b54b997ee96776a7c37ca2b57b9551bcffeb71d88768765af7384c2e3ba031ca3f19c9ddb0c6ec55223fbfe3731a1e8d7bb010de8532d53293bbbb6145597ee53559a612e6de4f8fc66936ef463eea7498555643ac0dafad6627575f2733b9fb352e411e7d9df8fc80fde75f5f66f5c5381a46b9a697d9c97555c4bf41a4909b9dd071557c3dfe0bfcd6459e06514266c65756ce9f25705230df63d30fef6076b797e1f49d00b41e87b5ccecb1c237f419e4b3ca6876053c14fc979a629459a62f78d735fb078bfa0e7a1fc69ad379447d817e06b3d7f1de820f28534f85fa20469cd6f93ddc6c5f2a94878fc64a98ac336294c99d27d11742268ae1a34cd61f31e2e4aee94b0ff496f55068fa727ace6ad2ec1e6e3f59e6a8bd154f287f652fbfaa05cac067951de1bfacc0e330c3bf6dd2efde4c509646566836eb71986154731daf722a6ff585001e87f9479559a61265d6e330f3682bf87ab2598fc3fca36da778e59cee71584594ef175e6d7d5f70d6deb02c4b371e5063c35669ffb1ffd87ffe0e730068"), + hexToBytes("c129f34532e043fade4471779b4955005db8fa9b64c9e8d0a2dae4a38bbca23328"), + }, + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "51206850b179630df0f7012ae2b111bafa52ebb9b54e1435fc4f98fbe0af6f95076a", + Addresses: []string{ + "bc1pdpgtz7trphc0wqf2u2c3rwh62t4mnd2wzs6lcnucl0s27mu4qa4q4md9ta", + }, + }, + }, + }, + } + addressDescriptors := []AddressDescriptor{ + // bc1pdpgtz7trphc0wqf2u2c3rwh62t4mnd2wzs6lcnucl0s27mu4qa4q4md9ta + hexToBytes("51206850b179630df0f7012ae2b111bafa52ebb9b54e1435fc4f98fbe0af6f95076a"), + } + return tx, addressDescriptors +} + +func TestGolombIsOrdinal(t *testing.T) { + revealTx, _ := getOrdinalRevealTx() + if txContainsOrdinal(&revealTx) != true { + t.Error("Ordinal not found in reveal Tx") + } + commitTx, _ := getOrdinalCommitTx() + if txContainsOrdinal(&commitTx) != false { + t.Error("Ordinal found in commit Tx, but should not be there") + } +} + +func TestGolombOrdinalTransactions(t *testing.T) { + tests := []struct { + name string + filterScripts string + want string + }{ + { + name: "all", + filterScripts: "", + want: "04256e660160e42ff40ee320", // take all four descriptors + }, + { + name: "taproot", + filterScripts: "taproot", + want: "0212b734c2ebe0", // filter out two non-taproot ones + }, + { + name: "taproot-noordinals", + filterScripts: "taproot-noordinals", + want: "", // ignore everything + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gf, err := NewGolombFilter(20, tt.filterScripts, "", true) + if err != nil { + t.Errorf("TestGolombOrdinalTransactions.NewGolombFilter() got unexpected error '%v'", err) + return + } + + commitTx, addressDescriptorsCommit := getOrdinalCommitTx() + revealTx, addressDescriptorsReveal := getOrdinalRevealTx() + + for _, ad := range addressDescriptorsCommit { + gf.AddAddrDesc(ad, &commitTx) + } + for _, ad := range addressDescriptorsReveal { + gf.AddAddrDesc(ad, &revealTx) + } + + f := gf.Compute() + got := hex.EncodeToString(f) + if got != tt.want { + t.Errorf("TestGolombOrdinalTransactions Compute() got %v, want %v", got, tt.want) + } + }) + } +} diff --git a/bchain/mempool_bitcoin_type.go b/bchain/mempool_bitcoin_type.go index 1063059dc6..7a4427b570 100644 --- a/bchain/mempool_bitcoin_type.go +++ b/bchain/mempool_bitcoin_type.go @@ -1,10 +1,16 @@ package bchain import ( + "context" + "encoding/hex" + "fmt" "math/big" + "sync" + "sync/atomic" "time" "github.com/golang/glog" + "github.com/juju/errors" ) type chanInputPayload struct { @@ -12,26 +18,104 @@ type chanInputPayload struct { index int } +type txPayload struct { + txid string + tx *Tx +} + +type resyncOutpointCache struct { + mu sync.RWMutex + entries map[Outpoint]outpointInfo + // hits/misses track cache effectiveness without impacting read paths with extra locks. + hits uint64 + misses uint64 +} + +type outpointInfo struct { + addrDesc AddressDescriptor + value *big.Int +} + +func newResyncOutpointCache(sizeHint int) *resyncOutpointCache { + return &resyncOutpointCache{entries: make(map[Outpoint]outpointInfo, sizeHint)} +} + +func (c *resyncOutpointCache) get(outpoint Outpoint) (AddressDescriptor, *big.Int, bool) { + c.mu.RLock() + entry, ok := c.entries[outpoint] + c.mu.RUnlock() + if !ok { + // Use atomics to avoid lock contention on hot lookup paths. + atomic.AddUint64(&c.misses, 1) + return nil, nil, false + } + atomic.AddUint64(&c.hits, 1) + return entry.addrDesc, entry.value, true +} + +func (c *resyncOutpointCache) set(outpoint Outpoint, addrDesc AddressDescriptor, value *big.Int) { + if len(addrDesc) == 0 || value == nil { + return + } + // Copy to keep cached values independent of the transaction object lifetime. + valueCopy := new(big.Int).Set(value) + addrCopy := append(AddressDescriptor(nil), addrDesc...) + c.mu.Lock() + c.entries[outpoint] = outpointInfo{addrDesc: addrCopy, value: valueCopy} + c.mu.Unlock() +} + +func (c *resyncOutpointCache) len() int { + c.mu.RLock() + n := len(c.entries) + c.mu.RUnlock() + return n +} + +func (c *resyncOutpointCache) stats() (uint64, uint64) { + return atomic.LoadUint64(&c.hits), atomic.LoadUint64(&c.misses) +} + // MempoolBitcoinType is mempool handle. type MempoolBitcoinType struct { BaseMempool - chanTxid chan string + chanTx chan txPayload chanAddrIndex chan txidio AddrDescForOutpoint AddrDescForOutpointFunc + golombFilterP uint8 + filterScripts string + useZeroedKey bool + resyncBatchSize int + // resyncBatchWorkers controls how many batch RPCs can be in flight during resync. + resyncBatchWorkers int + // resyncOutpoints caches mempool outputs during resync to avoid extra RPC lookups for parents. + resyncOutpoints atomic.Value } // NewMempoolBitcoinType creates new mempool handler. // For now there is no cleanup of sync routines, the expectation is that the mempool is created only once per process -func NewMempoolBitcoinType(chain BlockChain, workers int, subworkers int) *MempoolBitcoinType { +func NewMempoolBitcoinType(chain BlockChain, workers int, subworkers int, golombFilterP uint8, filterScripts string, useZeroedKey bool, resyncBatchSize int) *MempoolBitcoinType { + if resyncBatchSize < 1 { + resyncBatchSize = 1 + } + if workers < 1 { + workers = 1 + } m := &MempoolBitcoinType{ BaseMempool: BaseMempool{ chain: chain, txEntries: make(map[string]txEntry), addrDescToTx: make(map[string][]Outpoint), }, - chanTxid: make(chan string, 1), - chanAddrIndex: make(chan txidio, 1), + chanTx: make(chan txPayload, 1), + chanAddrIndex: make(chan txidio, 1), + golombFilterP: golombFilterP, + filterScripts: filterScripts, + useZeroedKey: useZeroedKey, + resyncBatchSize: resyncBatchSize, + resyncBatchWorkers: workers, } + m.resyncOutpoints.Store((*resyncOutpointCache)(nil)) for i := 0; i < workers; i++ { go func(i int) { chanInput := make(chan chanInputPayload, 1) @@ -44,12 +128,12 @@ func NewMempoolBitcoinType(chain BlockChain, workers int, subworkers int) *Mempo } }(j) } - for txid := range m.chanTxid { - io, ok := m.getTxAddrs(txid, chanInput, chanResult) + for payload := range m.chanTx { + io, golombFilter, ok := m.getTxAddrs(payload.txid, payload.tx, chanInput, chanResult) if !ok { io = []addrIndex{} } - m.chanAddrIndex <- txidio{txid, io} + m.chanAddrIndex <- txidio{payload.txid, io, golombFilter} } }(i) } @@ -57,6 +141,18 @@ func NewMempoolBitcoinType(chain BlockChain, workers int, subworkers int) *Mempo return m } +func (m *MempoolBitcoinType) getResyncOutpointCache() *resyncOutpointCache { + cache, _ := m.resyncOutpoints.Load().(*resyncOutpointCache) + return cache +} + +func roundDuration(d time.Duration, unit time.Duration) time.Duration { + if unit <= 0 { + return d + } + return d.Round(unit) +} + func (m *MempoolBitcoinType) getInputAddress(payload *chanInputPayload) *addrIndex { var addrDesc AddressDescriptor var value *big.Int @@ -65,8 +161,18 @@ func (m *MempoolBitcoinType) getInputAddress(payload *chanInputPayload) *addrInd // cannot get address from empty input txid (for example in Litecoin mweb) return nil } + outpoint := Outpoint{vin.Txid, int32(vin.Vout)} + cache := m.getResyncOutpointCache() if m.AddrDescForOutpoint != nil { - addrDesc, value = m.AddrDescForOutpoint(Outpoint{vin.Txid, int32(vin.Vout)}) + addrDesc, value = m.AddrDescForOutpoint(outpoint) + } + if addrDesc == nil { + if cache != nil { + if cachedDesc, cachedValue, ok := cache.get(outpoint); ok { + addrDesc = cachedDesc + value = cachedValue + } + } } if addrDesc == nil { itx, err := m.chain.GetTransactionForMempool(vin.Txid) @@ -78,12 +184,39 @@ func (m *MempoolBitcoinType) getInputAddress(payload *chanInputPayload) *addrInd glog.Error("Vout len in transaction ", vin.Txid, " ", len(itx.Vout), " input.Vout=", vin.Vout) return nil } - addrDesc, err = m.chain.GetChainParser().GetAddrDescFromVout(&itx.Vout[vin.Vout]) - if err != nil { - glog.Error("error in addrDesc in ", vin.Txid, " ", vin.Vout, ": ", err) - return nil + parser := m.chain.GetChainParser() + if cache != nil { + // Cache all outputs for this parent so other inputs can skip another RPC. + found := false + for i := range itx.Vout { + output := &itx.Vout[i] + outDesc, outErr := parser.GetAddrDescFromVout(output) + if outErr != nil { + if output.N == vin.Vout { + glog.Error("error in addrDesc in ", vin.Txid, " ", vin.Vout, ": ", outErr) + return nil + } + continue + } + cache.set(Outpoint{vin.Txid, int32(output.N)}, outDesc, &output.ValueSat) + if output.N == vin.Vout { + found = true + addrDesc = outDesc + value = &output.ValueSat + } + } + if !found { + glog.Error("Vout not found in transaction ", vin.Txid, " input.Vout=", vin.Vout) + return nil + } + } else { + addrDesc, err = parser.GetAddrDescFromVout(&itx.Vout[vin.Vout]) + if err != nil { + glog.Error("error in addrDesc in ", vin.Txid, " ", vin.Vout, ": ", err) + return nil + } + value = &itx.Vout[vin.Vout].ValueSat } - value = &itx.Vout[vin.Vout].ValueSat } vin.AddrDesc = addrDesc vin.ValueSat = *value @@ -91,27 +224,49 @@ func (m *MempoolBitcoinType) getInputAddress(payload *chanInputPayload) *addrInd } -func (m *MempoolBitcoinType) getTxAddrs(txid string, chanInput chan chanInputPayload, chanResult chan *addrIndex) ([]addrIndex, bool) { - tx, err := m.chain.GetTransactionForMempool(txid) - if err != nil { - glog.Error("cannot get transaction ", txid, ": ", err) - return nil, false +func (m *MempoolBitcoinType) computeGolombFilter(mtx *MempoolTx, tx *Tx) string { + gf, _ := NewGolombFilter(m.golombFilterP, m.filterScripts, mtx.Txid, m.useZeroedKey) + if gf == nil || !gf.Enabled { + return "" + } + for _, vin := range mtx.Vin { + gf.AddAddrDesc(vin.AddrDesc, tx) + } + for _, vout := range mtx.Vout { + b, err := hex.DecodeString(vout.ScriptPubKey.Hex) + if err == nil { + gf.AddAddrDesc(b, tx) + } + } + fb := gf.Compute() + return hex.EncodeToString(fb) +} + +func (m *MempoolBitcoinType) getTxAddrs(txid string, tx *Tx, chanInput chan chanInputPayload, chanResult chan *addrIndex) ([]addrIndex, string, bool) { + if tx == nil { + var err error + tx, err = m.chain.GetTransactionForMempool(txid) + if err != nil { + glog.Error("cannot get transaction ", txid, ": ", err) + return nil, "", false + } } glog.V(2).Info("mempool: gettxaddrs ", txid, ", ", len(tx.Vin), " inputs") mtx := m.txToMempoolTx(tx) io := make([]addrIndex, 0, len(tx.Vout)+len(tx.Vin)) + cache := m.getResyncOutpointCache() for _, output := range tx.Vout { addrDesc, err := m.chain.GetChainParser().GetAddrDescFromVout(&output) if err != nil { glog.Error("error in addrDesc in ", txid, " ", output.N, ": ", err) continue } + if cache != nil { + cache.set(Outpoint{txid, int32(output.N)}, addrDesc, &output.ValueSat) + } if len(addrDesc) > 0 { io = append(io, addrIndex{string(addrDesc), int32(output.N)}) } - if m.OnNewTxAddr != nil { - m.OnNewTxAddr(tx, addrDesc) - } } dispatched := 0 for i := range tx.Vin { @@ -142,22 +297,200 @@ func (m *MempoolBitcoinType) getTxAddrs(txid string, chanInput chan chanInputPay io = append(io, *ai) } } + var golombFilter string + if m.golombFilterP > 0 { + golombFilter = m.computeGolombFilter(mtx, tx) + } if m.OnNewTx != nil { m.OnNewTx(mtx) } - return io, true + return io, golombFilter, true +} + +func (m *MempoolBitcoinType) dispatchResyncPayloads(txids []string, cache map[string]*Tx, txTime uint32, onNewEntry func(txid string, entry txEntry)) { + dispatched := 0 + for _, txid := range txids { + var tx *Tx + if cache != nil { + tx = cache[txid] + } + sendLoop: + for { + select { + // store as many processed transactions as possible + case tio := <-m.chanAddrIndex: + onNewEntry(tio.txid, txEntry{tio.io, txTime, tio.filter}) + dispatched-- + // send transaction to be processed + case m.chanTx <- txPayload{txid: txid, tx: tx}: + dispatched++ + break sendLoop + } + } + } + for i := 0; i < dispatched; i++ { + tio := <-m.chanAddrIndex + onNewEntry(tio.txid, txEntry{tio.io, txTime, tio.filter}) + } +} + +func (m *MempoolBitcoinType) resyncBatchedMissing(missing []string, batcher MempoolBatcher, batchSize int, txTime uint32, onNewEntry func(txid string, entry txEntry)) (int, error) { + if len(missing) == 0 { + return 0, nil + } + type batchResult struct { + txids []string + cache map[string]*Tx + err error + } + batchCount := (len(missing) + batchSize - 1) / batchSize + batchWorkers := m.resyncBatchWorkers + if batchWorkers < 1 { + batchWorkers = 1 + } + if batchWorkers > batchCount { + batchWorkers = batchCount + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + batchJobs := make(chan []string) + // Buffer results so up to batchWorkers RPC calls can run in parallel. + batchResults := make(chan batchResult, batchWorkers) + var wg sync.WaitGroup + for i := 0; i < batchWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case batch, ok := <-batchJobs: + if !ok { + return + } + cache, err := batcher.GetRawTransactionsForMempoolBatch(batch) + select { + case <-ctx.Done(): + return + case batchResults <- batchResult{txids: batch, cache: cache, err: err}: + } + if err != nil { + return + } + } + } + }() + } + + go func() { + defer close(batchJobs) + for start := 0; start < len(missing); start += batchSize { + end := start + batchSize + if end > len(missing) { + end = len(missing) + } + batch := missing[start:end] + select { + case <-ctx.Done(): + return + case batchJobs <- batch: + } + } + }() + + go func() { + wg.Wait() + close(batchResults) + }() + + var batchErr error + for batch := range batchResults { + if batch.err != nil { + if batchErr == nil { + // Fail fast to avoid mixing partial batch results with per-tx fetches. + batchErr = batch.err + cancel() + } + continue + } + if batchErr != nil { + // Drain remaining results after failure to let fetchers exit cleanly. + continue + } + m.dispatchResyncPayloads(batch.txids, batch.cache, txTime, onNewEntry) + } + if batchErr != nil { + return batchWorkers, batchErr + } + return batchWorkers, nil } // Resync gets mempool transactions and maps outputs to transactions. // Resync is not reentrant, it should be called from a single thread. // Read operations (GetTransactions) are safe. -func (m *MempoolBitcoinType) Resync() (int, error) { +func (m *MempoolBitcoinType) Resync() (count int, err error) { start := time.Now() + var ( + mempoolSize int + missingCount int + outpointCacheEntries int + batchSize int + batchWorkers int + listDuration time.Duration + processDuration time.Duration + processStart time.Time + ) + // Log metrics on every exit path to make bottlenecks visible even on errors. + defer func() { + if !processStart.IsZero() && processDuration == 0 { + processDuration = time.Since(processStart) + } + totalDuration := time.Since(start) + avgPerTx := time.Duration(0) + if mempoolSize > 0 { + avgPerTx = totalDuration / time.Duration(mempoolSize) + } + throughput := 0.0 + if seconds := totalDuration.Seconds(); seconds > 0 { + throughput = float64(mempoolSize) / seconds + } + var cacheHits uint64 + var cacheMisses uint64 + var cacheHitRate float64 + if cache := m.getResyncOutpointCache(); cache != nil { + outpointCacheEntries = cache.len() + cacheHits, cacheMisses = cache.stats() + total := cacheHits + cacheMisses + if total > 0 { + cacheHitRate = float64(cacheHits) / float64(total) + } + } + listDurationRounded := roundDuration(listDuration, time.Millisecond) + processDurationRounded := roundDuration(processDuration, time.Millisecond) + totalDurationRounded := roundDuration(totalDuration, time.Millisecond) + avgPerTxRounded := roundDuration(avgPerTx, time.Microsecond) + hitRateText := fmt.Sprintf("%.3f", cacheHitRate) + throughputText := fmt.Sprintf("%.3f", throughput) + if err != nil { + glog.Warning("mempool: resync failed size=", mempoolSize, " missing=", missingCount, " outpoint_cache_entries=", outpointCacheEntries, " outpoint_cache_hits=", cacheHits, " outpoint_cache_misses=", cacheMisses, " outpoint_cache_hit_rate=", hitRateText, " batch_size=", batchSize, " batch_workers=", batchWorkers, " list_duration=", listDurationRounded, " process_duration=", processDurationRounded, " duration=", totalDurationRounded, " avg_per_tx=", avgPerTxRounded, " throughput_txs_per_second=", throughputText, " err=", err) + } else { + glog.Info("mempool: resync finished size=", mempoolSize, " missing=", missingCount, " outpoint_cache_entries=", outpointCacheEntries, " outpoint_cache_hits=", cacheHits, " outpoint_cache_misses=", cacheMisses, " outpoint_cache_hit_rate=", hitRateText, " batch_size=", batchSize, " batch_workers=", batchWorkers, " list_duration=", listDurationRounded, " process_duration=", processDurationRounded, " duration=", totalDurationRounded, " avg_per_tx=", avgPerTxRounded, " throughput_txs_per_second=", throughputText) + } + m.resyncOutpoints.Store((*resyncOutpointCache)(nil)) + }() + glog.V(1).Info("mempool: resync") + listStart := time.Now() txs, err := m.chain.GetMempoolTransactions() + listDuration = time.Since(listStart) if err != nil { return 0, err } + mempoolSize = len(txs) + m.resyncOutpoints.Store(newResyncOutpointCache(mempoolSize)) glog.V(2).Info("mempool: resync ", len(txs), " txs") onNewEntry := func(txid string, entry txEntry) { if len(entry.addrIndexes) > 0 { @@ -170,31 +503,41 @@ func (m *MempoolBitcoinType) Resync() (int, error) { } } txsMap := make(map[string]struct{}, len(txs)) - dispatched := 0 txTime := uint32(time.Now().Unix()) - // get transaction in parallel using goroutines created in NewUTXOMempool + missing := make([]string, 0, len(txs)) for _, txid := range txs { txsMap[txid] = struct{}{} _, exists := m.txEntries[txid] if !exists { - loop: - for { - select { - // store as many processed transactions as possible - case tio := <-m.chanAddrIndex: - onNewEntry(tio.txid, txEntry{tio.io, txTime}) - dispatched-- - // send transaction to be processed - case m.chanTxid <- txid: - dispatched++ - break loop - } - } + missing = append(missing, txid) } } - for i := 0; i < dispatched; i++ { - tio := <-m.chanAddrIndex - onNewEntry(tio.txid, txEntry{tio.io, txTime}) + missingCount = len(missing) + + batchSize = m.resyncBatchSize + if batchSize < 1 { + batchSize = 1 + } + var batcher MempoolBatcher + if batchSize > 1 { + var ok bool + batcher, ok = m.chain.(MempoolBatcher) + if !ok { + // Fail fast so operators notice unsupported batch backends early. + return 0, errors.New("mempool: batch resync requested but backend does not support batch fetch") + } + } + + processStart = time.Now() + if batchSize == 1 { + // get transaction in parallel using goroutines created in NewUTXOMempool + m.dispatchResyncPayloads(missing, nil, txTime, onNewEntry) + } else { + var batchErr error + batchWorkers, batchErr = m.resyncBatchedMissing(missing, batcher, batchSize, txTime, onNewEntry) + if batchErr != nil { + return 0, batchErr + } } for txid, entry := range m.txEntries { @@ -204,6 +547,23 @@ func (m *MempoolBitcoinType) Resync() (int, error) { m.mux.Unlock() } } - glog.Info("mempool: resync finished in ", time.Since(start), ", ", len(m.txEntries), " transactions in mempool") - return len(m.txEntries), nil + processDuration = time.Since(processStart) + count = len(m.txEntries) + return count, nil +} + +// GetTxidFilterEntries returns all mempool entries with golomb filter from +func (m *MempoolBitcoinType) GetTxidFilterEntries(filterScripts string, fromTimestamp uint32) (MempoolTxidFilterEntries, error) { + if m.filterScripts != filterScripts { + return MempoolTxidFilterEntries{}, errors.Errorf("Unsupported script filter %s", filterScripts) + } + m.mux.Lock() + entries := make(map[string]string) + for txid, entry := range m.txEntries { + if entry.filter != "" && entry.time >= fromTimestamp { + entries[txid] = entry.filter + } + } + m.mux.Unlock() + return MempoolTxidFilterEntries{entries, m.useZeroedKey}, nil } diff --git a/bchain/mempool_bitcoin_type_test.go b/bchain/mempool_bitcoin_type_test.go new file mode 100644 index 0000000000..ddbe428f3c --- /dev/null +++ b/bchain/mempool_bitcoin_type_test.go @@ -0,0 +1,352 @@ +package bchain + +import ( + "encoding/hex" + "testing" + + "github.com/martinboehm/btcutil/gcs" +) + +func hexToBytes(h string) []byte { + b, _ := hex.DecodeString(h) + return b +} + +func TestMempoolBitcoinType_computeGolombFilter_taproot(t *testing.T) { + randomScript := hexToBytes("a914ff074800343a81ada8fe86c2d5d5a0e55b93dd7a87") + m := &MempoolBitcoinType{ + golombFilterP: 20, + filterScripts: "taproot", + } + golombFilterM := GetGolombParamM(m.golombFilterP) + tests := []struct { + name string + mtx MempoolTx + want string + }{ + { + name: "taproot", + mtx: MempoolTx{ + Txid: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + Vin: []MempoolVin{ + { + // bc1pgeqrcq5capal83ypxczmypjdhk4d9wwcea4k66c7ghe07p2qt97sqh8sy5 + AddrDesc: hexToBytes("512046403c0298e87bf3c4813605b2064dbdaad2b9d8cf6b6d6b1e45f2ff0540597d"), + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "5120f667578b85bed256c7fcb9f2cda488d5281e52ca42e7dd4bc21e95149562f09f", + Addresses: []string{ + "bc1p7en40zu9hmf9d3luh8evmfyg655pu5k2gtna6j7zr623f9tz7z0stfnwav", + }, + }, + }, + }, + }, + want: "0235dddcce5d60", + }, + { + name: "taproot multiple", + mtx: MempoolTx{ + Txid: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + Vin: []MempoolVin{ + { + // bc1pp3752xgfy39w30kggy8vvn0u68x8afwqmq6p96jzr8ffrcvjxgrqrny93y + AddrDesc: hexToBytes("51200c7d451909244ae8bec8410ec64dfcd1cc7ea5c0d83412ea4219d291e1923206"), + }, + { + // bc1p5ldsz3zxnjxrwf4xluf4qu7u839c204ptacwe2k0vzfk8s63mwts3njuwr + AddrDesc: hexToBytes("5120a7db0144469c8c3726a6ff135073dc3c4b853ea15f70ecaacf609363c351db97"), + }, + { + // bc1pgeqrcq5capal83ypxczmypjdhk4d9wwcea4k66c7ghe07p2qt97sqh8sy5 + AddrDesc: hexToBytes("512046403c0298e87bf3c4813605b2064dbdaad2b9d8cf6b6d6b1e45f2ff0540597d"), + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "51209ab20580f77e7cd676f896fc1794f7e8061efc1ce7494f2bb16205262aa12bdb", + Addresses: []string{ + "bc1pn2eqtq8h0e7dvahcjm7p098haqrpalquuay572a3vgzjv24p90dszxzg40", + }, + }, + }, + { + ScriptPubKey: ScriptPubKey{ + Hex: "5120f667578b85bed256c7fcb9f2cda488d5281e52ca42e7dd4bc21e95149562f09f", + Addresses: []string{ + "bc1p7en40zu9hmf9d3luh8evmfyg655pu5k2gtna6j7zr623f9tz7z0stfnwav", + }, + }, + }, + { + ScriptPubKey: ScriptPubKey{ + Hex: "51201341e5a58314d89bcf5add2b2a68f109add5efb1ae774fa33c612da311f25904", + Addresses: []string{ + "bc1pzdq7tfvrznvfhn66m54j5683pxkatma34em5lgeuvyk6xy0jtyzqjt48z3", + }, + }, + }, + { + ScriptPubKey: ScriptPubKey{ + Hex: "512042b2d5c032b68220bfd6d4e26bc015129e168e87e22af743ffdc736708b7d342", + Addresses: []string{ + "bc1pg2edtspjk6pzp07k6n3xhsq4z20pdr58ug40wsllm3ekwz9h6dpq77lhu9", + }, + }, + }, + }, + }, + want: "071143e4ad12730965a5247ac15db8c81c89b0bc", + }, + { + name: "taproot duplicities", + mtx: MempoolTx{ + Txid: "33a03f983b47725bbdd6045f2d5ee0d95dce08eaaf7104759758aabd8af27d34", + Vin: []MempoolVin{ + { + // bc1px2k5tu5mfq23ekkwncz5apx6ccw2nr0rne25r8t8zk7nu035ryxqn9ge8p + AddrDesc: hexToBytes("512032ad45f29b48151cdace9e054e84dac61ca98de39e55419d6715bd3e3e34190c"), + }, + { + // bc1px2k5tu5mfq23ekkwncz5apx6ccw2nr0rne25r8t8zk7nu035ryxqn9ge8p + AddrDesc: hexToBytes("512032ad45f29b48151cdace9e054e84dac61ca98de39e55419d6715bd3e3e34190c"), + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "512032ad45f29b48151cdace9e054e84dac61ca98de39e55419d6715bd3e3e34190c", + Addresses: []string{ + "bc1px2k5tu5mfq23ekkwncz5apx6ccw2nr0rne25r8t8zk7nu035ryxqn9ge8p", + }, + }, + }, + { + ScriptPubKey: ScriptPubKey{ + Hex: "512032ad45f29b48151cdace9e054e84dac61ca98de39e55419d6715bd3e3e34190c", + Addresses: []string{ + "bc1px2k5tu5mfq23ekkwncz5apx6ccw2nr0rne25r8t8zk7nu035ryxqn9ge8p", + }, + }, + }, + { + ScriptPubKey: ScriptPubKey{ + Hex: "512032ad45f29b48151cdace9e054e84dac61ca98de39e55419d6715bd3e3e34190c", + Addresses: []string{ + "bc1px2k5tu5mfq23ekkwncz5apx6ccw2nr0rne25r8t8zk7nu035ryxqn9ge8p", + }, + }, + }, + }, + }, + want: "01778db0", + }, + { + name: "partial taproot", + mtx: MempoolTx{ + Txid: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + Vin: []MempoolVin{ + { + // bc1pgeqrcq5capal83ypxczmypjdhk4d9wwcea4k66c7ghe07p2qt97sqh8sy5 + AddrDesc: hexToBytes("512046403c0298e87bf3c4813605b2064dbdaad2b9d8cf6b6d6b1e45f2ff0540597d"), + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "00145f997834e1135e893b7707ba1b12bcb8d74b821d", + Addresses: []string{ + "bc1qt7vhsd8pzd0gjwmhq7apky4uhrt5hqsa2y58nl", + }, + }, + }, + }, + }, + want: "011aeee8", + }, + { + name: "no taproot", + mtx: MempoolTx{ + Txid: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + Vin: []MempoolVin{ + { + // 39ECUF8YaFRX7XfttfAiLa5ir43bsrQUZJ + AddrDesc: hexToBytes("a91452ae9441d9920d9eb4a3c0a877ca8d8de547ce6587"), + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "00145f997834e1135e893b7707ba1b12bcb8d74b821d", + Addresses: []string{ + "bc1qt7vhsd8pzd0gjwmhq7apky4uhrt5hqsa2y58nl", + }, + }, + }, + }, + }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := m.computeGolombFilter(&tt.mtx, nil) + if got != tt.want { + t.Errorf("MempoolBitcoinType.computeGolombFilter() = %v, want %v", got, tt.want) + } + if got != "" { + // build the filter from computed value + filter, err := gcs.FromNBytes(m.golombFilterP, golombFilterM, hexToBytes(got)) + if err != nil { + t.Errorf("gcs.BuildGCSFilter() unexpected error %v", err) + } + // check that the vin scripts match the filter + b, _ := hex.DecodeString(tt.mtx.Txid) + for i := range tt.mtx.Vin { + match, err := filter.Match(*(*[gcs.KeySize]byte)(b[:gcs.KeySize]), tt.mtx.Vin[i].AddrDesc) + if err != nil { + t.Errorf("filter.Match vin[%d] unexpected error %v", i, err) + } + if match != tt.mtx.Vin[i].AddrDesc.IsTaproot() { + t.Errorf("filter.Match vin[%d] got %v, want %v", i, match, tt.mtx.Vin[i].AddrDesc.IsTaproot()) + } + } + // check that the vout scripts match the filter + for i := range tt.mtx.Vout { + s := hexToBytes(tt.mtx.Vout[i].ScriptPubKey.Hex) + match, err := filter.Match(*(*[gcs.KeySize]byte)(b[:gcs.KeySize]), s) + if err != nil { + t.Errorf("filter.Match vout[%d] unexpected error %v", i, err) + } + if match != AddressDescriptor(s).IsTaproot() { + t.Errorf("filter.Match vout[%d] got %v, want %v", i, match, AddressDescriptor(s).IsTaproot()) + } + } + // check that a random script does not match the filter + match, err := filter.Match(*(*[gcs.KeySize]byte)(b[:gcs.KeySize]), randomScript) + if err != nil { + t.Errorf("filter.Match randomScript unexpected error %v", err) + } + if match != false { + t.Errorf("filter.Match randomScript got true, want false") + } + } + }) + } +} + +func TestMempoolBitcoinType_computeGolombFilter_taproot_noordinals(t *testing.T) { + m := &MempoolBitcoinType{ + golombFilterP: 20, + filterScripts: "taproot-noordinals", + } + tests := []struct { + name string + mtx MempoolTx + tx Tx + want string + }{ + { + name: "taproot-no-ordinals normal taproot tx", + mtx: MempoolTx{ + Txid: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + Vin: []MempoolVin{ + { + // bc1pdfc3xk96cm9g7lwlm78hxd2xuevzpqfzjw0shaarwflczs7lh0lstksdn0 + AddrDesc: hexToBytes("51206a711358bac6ca8f7ddfdf8f733546e658208122939f0bf7a3727f8143dfbbff"), + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "51206850b179630df0f7012ae2b111bafa52ebb9b54e1435fc4f98fbe0af6f95076a", + Addresses: []string{ + "bc1pdpgtz7trphc0wqf2u2c3rwh62t4mnd2wzs6lcnucl0s27mu4qa4q4md9ta", + }, + }, + }, + }, + }, + tx: Tx{ + Vin: []Vin{ + { + Witness: [][]byte{ + hexToBytes("737ad2835962e3d147cd74a578f1109e9314eac9d00c9fad304ce2050b78fac21a2d124fd886d1d646cf1de5d5c9754b0415b960b1319526fa25e36ca1f650ce"), + }, + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "51206850b179630df0f7012ae2b111bafa52ebb9b54e1435fc4f98fbe0af6f95076a", + Addresses: []string{ + "bc1pdpgtz7trphc0wqf2u2c3rwh62t4mnd2wzs6lcnucl0s27mu4qa4q4md9ta", + }, + }, + }, + }, + }, + want: "02899e8c952b40", + }, + { + name: "taproot-no-ordinals ordinal tx", + mtx: MempoolTx{ + Txid: "86336c62a63f509a278624e3f400cdd50838d035a44e0af8a7d6d133c04cc2d2", + Vin: []MempoolVin{ + { + // bc1pdfc3xk96cm9g7lwlm78hxd2xuevzpqfzjw0shaarwflczs7lh0lstksdn0 + AddrDesc: hexToBytes("51206a711358bac6ca8f7ddfdf8f733546e658208122939f0bf7a3727f8143dfbbff"), + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "51206850b179630df0f7012ae2b111bafa52ebb9b54e1435fc4f98fbe0af6f95076a", + Addresses: []string{ + "bc1pdpgtz7trphc0wqf2u2c3rwh62t4mnd2wzs6lcnucl0s27mu4qa4q4md9ta", + }, + }, + }, + }, + }, + tx: Tx{ + // https://mempool.space/tx/c4cae52a6e681b66c85c12feafb42f3617f34977032df1ee139eae07370863ef + Txid: "c4cae52a6e681b66c85c12feafb42f3617f34977032df1ee139eae07370863ef", + Vin: []Vin{ + { + Txid: "11111c17cbe86aebab146ee039d4e354cb55a9fb226ebdd2e30948630e7710ad", + Vout: 0, + Witness: [][]byte{ + hexToBytes("737ad2835962e3d147cd74a578f1109e9314eac9d00c9fad304ce2050b78fac21a2d124fd886d1d646cf1de5d5c9754b0415b960b1319526fa25e36ca1f650ce"), + hexToBytes("2029f34532e043fade4471779b4955005db8fa9b64c9e8d0a2dae4a38bbca23328ac0063036f726401010a696d6167652f77656270004d08025249464650020000574542505650384c440200002f57c2950067a026009086939b7785a104699656f4f53388355445b6415d22f8924000fd83bd31d346ca69f8fcfed6d8d18231846083f90f00ffbf203883666c36463c6ba8662257d789935e002192245bd15ac00216b080052cac85b380052c60e1593859f33a7a7abff7ed88feb361db3692341bc83553aef7aec75669ffb1ffd87fec3ff61ffb8ffdc736f20a96a0fba34071d4fdf111c435381df667728f95c4e82b6872d82471bfdc1665107bb80fd46df1686425bcd2e27eb59adc9d17b54b997ee96776a7c37ca2b57b9551bcffeb71d88768765af7384c2e3ba031ca3f19c9ddb0c6ec55223fbfe3731a1e8d7bb010de8532d53293bbbb6145597ee53559a612e6de4f8fc66936ef463eea7498555643ac0dafad6627575f2733b9fb352e411e7d9df8fc80fde75f5f66f5c5381a46b9a697d9c97555c4bf41a4909b9dd071557c3dfe0bfcd6459e06514266c65756ce9f25705230df63d30fef6076b797e1f49d00b41e87b5ccecb1c237f419e4b3ca6876053c14fc979a629459a62f78d735fb078bfa0e7a1fc69ad379447d817e06b3d7f1de820f28534f85fa20469cd6f93ddc6c5f2a94878fc64a98ac336294c99d27d11742268ae1a34cd61f31e2e4aee94b0ff496f55068fa727ace6ad2ec1e6e3f59e6a8bd154f287f652fbfaa05cac067951de1bfacc0e330c3bf6dd2efde4c509646566836eb71986154731daf722a6ff585001e87f9479559a61265d6e330f3682bf87ab2598fc3fca36da778e59cee71584594ef175e6d7d5f70d6deb02c4b371e5063c35669ffb1ffd87ffe0e730068"), + hexToBytes("c129f34532e043fade4471779b4955005db8fa9b64c9e8d0a2dae4a38bbca23328"), + }, + }, + }, + Vout: []Vout{ + { + ScriptPubKey: ScriptPubKey{ + Hex: "51206850b179630df0f7012ae2b111bafa52ebb9b54e1435fc4f98fbe0af6f95076a", + Addresses: []string{ + "bc1pdpgtz7trphc0wqf2u2c3rwh62t4mnd2wzs6lcnucl0s27mu4qa4q4md9ta", + }, + }, + }, + }, + }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := m.computeGolombFilter(&tt.mtx, &tt.tx) + if got != tt.want { + t.Errorf("MempoolBitcoinType.computeGolombFilter() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/bchain/mempool_ethereum_type.go b/bchain/mempool_ethereum_type.go index 23d333fd32..f54874900c 100644 --- a/bchain/mempool_ethereum_type.go +++ b/bchain/mempool_ethereum_type.go @@ -1,6 +1,7 @@ package bchain import ( + "errors" "time" "github.com/golang/glog" @@ -17,8 +18,7 @@ type MempoolEthereumType struct { } // NewMempoolEthereumType creates new mempool handler. -func NewMempoolEthereumType(chain BlockChain, mempoolTxTimeoutHours int, queryBackendOnResync bool) *MempoolEthereumType { - mempoolTimeoutTime := time.Duration(mempoolTxTimeoutHours) * time.Hour +func NewMempoolEthereumType(chain BlockChain, mempoolTimeoutTime time.Duration, queryBackendOnResync bool) *MempoolEthereumType { return &MempoolEthereumType{ BaseMempool: BaseMempool{ chain: chain, @@ -84,15 +84,6 @@ func (m *MempoolEthereumType) createTxEntry(txid string, txTime uint32) (txEntry addrIndexes, _ = appendAddress(addrIndexes, int32(i+1), t[i].To, parser) } } - if m.OnNewTxAddr != nil { - sent := make(map[string]struct{}) - for _, si := range addrIndexes { - if _, found := sent[si.addrDesc]; !found { - m.OnNewTxAddr(tx, AddressDescriptor(si.addrDesc)) - sent[si.addrDesc] = struct{}{} - } - } - } if m.OnNewTx != nil { m.OnNewTx(mtx) } @@ -102,14 +93,22 @@ func (m *MempoolEthereumType) createTxEntry(txid string, txTime uint32) (txEntry // Resync ethereum type removes timed out transactions and returns number of transactions in mempool. // Transactions are added/removed by AddTransactionToMempool/RemoveTransactionFromMempool methods func (m *MempoolEthereumType) Resync() (int, error) { + start := time.Now() + processedTxs := 0 + backendRemoved := 0 if m.queryBackendOnResync { + backendSnapshotTime := uint32(time.Now().Unix()) txs, err := m.chain.GetMempoolTransactions() if err != nil { return 0, err } + processedTxs = len(txs) + backendTxs := make(map[string]struct{}, len(txs)) for _, txid := range txs { + backendTxs[txid] = struct{}{} m.AddTransactionToMempool(txid) } + backendRemoved = m.removeTransactionsMissingFromBackend(backendTxs, backendSnapshotTime) } m.mux.Lock() entries := len(m.txEntries) @@ -127,12 +126,39 @@ func (m *MempoolEthereumType) Resync() (int, error) { m.nextTimeoutRun = now.Add(mempoolTimeoutRunPeriod) } m.mux.Unlock() - glog.Info("Mempool: resync ", entries, " transactions in mempool") + duration := time.Since(start) + durationRounded := duration.Round(time.Millisecond) + if durationRounded == 0 { + durationRounded = duration + } + if m.queryBackendOnResync { + throughput := 0.0 + if seconds := duration.Seconds(); seconds > 0 { + throughput = float64(processedTxs) / seconds + } + glog.Infof("Mempool: resync complete, mempool size %d txs, processed %d txs, removed %d stale txs, duration %s, throughput %.2f tx/s", entries, processedTxs, backendRemoved, durationRounded, throughput) + } else { + glog.Infof("Mempool: resync complete, mempool size %d txs, duration %s", entries, durationRounded) + } return entries, nil } -// AddTransactionToMempool adds transactions to mempool -func (m *MempoolEthereumType) AddTransactionToMempool(txid string) { +func (m *MempoolEthereumType) removeTransactionsMissingFromBackend(backendTxs map[string]struct{}, backendSnapshotTime uint32) int { + removed := 0 + m.mux.Lock() + defer m.mux.Unlock() + for txid, entry := range m.txEntries { + if _, exists := backendTxs[txid]; exists || entry.time >= backendSnapshotTime { + continue + } + m.removeEntryFromMempool(txid, entry) + removed++ + } + return removed +} + +// AddTransactionToMempool adds transactions to mempool, returns true if tx added to mempool, false if not added (for example duplicate call) +func (m *MempoolEthereumType) AddTransactionToMempool(txid string) bool { m.mux.Lock() _, exists := m.txEntries[txid] m.mux.Unlock() @@ -142,7 +168,7 @@ func (m *MempoolEthereumType) AddTransactionToMempool(txid string) { if !exists { entry, ok := m.createTxEntry(txid, uint32(time.Now().Unix())) if !ok { - return + return false } m.mux.Lock() m.txEntries[txid] = entry @@ -151,6 +177,7 @@ func (m *MempoolEthereumType) AddTransactionToMempool(txid string) { } m.mux.Unlock() } + return !exists } // RemoveTransactionFromMempool removes transaction from mempool @@ -165,3 +192,8 @@ func (m *MempoolEthereumType) RemoveTransactionFromMempool(txid string) { } m.mux.Unlock() } + +// GetTxidFilterEntries returns all mempool entries with golomb filter from +func (m *MempoolEthereumType) GetTxidFilterEntries(filterScripts string, fromTimestamp uint32) (MempoolTxidFilterEntries, error) { + return MempoolTxidFilterEntries{}, errors.New("Not supported") +} diff --git a/bchain/mempool_ethereum_type_test.go b/bchain/mempool_ethereum_type_test.go new file mode 100644 index 0000000000..9a5ae5067e --- /dev/null +++ b/bchain/mempool_ethereum_type_test.go @@ -0,0 +1,62 @@ +package bchain + +import ( + "reflect" + "testing" + "time" +) + +func TestMempoolEthereumType_removeTransactionsMissingFromBackend(t *testing.T) { + snapshotTime := uint32(time.Now().Unix()) + m := &MempoolEthereumType{ + BaseMempool: BaseMempool{ + txEntries: map[string]txEntry{ + "kept": { + addrIndexes: []addrIndex{{addrDesc: "addr1"}}, + time: snapshotTime - 1, + }, + "removed": { + addrIndexes: []addrIndex{{addrDesc: "addr1"}, {addrDesc: "addr2"}}, + time: snapshotTime - 1, + }, + "new": { + addrIndexes: []addrIndex{{addrDesc: "addr2"}}, + time: snapshotTime, + }, + }, + addrDescToTx: map[string][]Outpoint{ + "addr1": {{Txid: "kept"}, {Txid: "removed"}}, + "addr2": {{Txid: "removed"}, {Txid: "new"}}, + }, + }, + } + + removed := m.removeTransactionsMissingFromBackend(map[string]struct{}{"kept": {}}, snapshotTime) + if removed != 1 { + t.Fatalf("removeTransactionsMissingFromBackend() = %d, want 1", removed) + } + if _, found := m.txEntries["removed"]; found { + t.Fatal("expected tx missing from backend snapshot to be removed") + } + if _, found := m.txEntries["kept"]; !found { + t.Fatal("expected backend tx to remain in mempool") + } + if _, found := m.txEntries["new"]; !found { + t.Fatal("expected tx added at snapshot time to remain in mempool") + } + + wantAddrDescToTx := map[string][]Outpoint{ + "addr1": {{Txid: "kept"}}, + "addr2": {{Txid: "new"}}, + } + if !reflect.DeepEqual(m.addrDescToTx, wantAddrDescToTx) { + t.Fatalf("addrDescToTx = %+v, want %+v", m.addrDescToTx, wantAddrDescToTx) + } +} + +func TestNewMempoolEthereumTypeUsesDuration(t *testing.T) { + m := NewMempoolEthereumType(nil, 10*time.Minute, false) + if m.mempoolTimeoutTime != 10*time.Minute { + t.Fatalf("mempoolTimeoutTime = %s, want %s", m.mempoolTimeoutTime, 10*time.Minute) + } +} diff --git a/bchain/mq.go b/bchain/mq.go index 5f91920914..8f0bd6787b 100644 --- a/bchain/mq.go +++ b/bchain/mq.go @@ -9,6 +9,13 @@ import ( zmq "github.com/pebbe/zmq4" ) +type SubscriptionTopics struct { + BlockSubscribe string + BlockReceive string + TxSubscribe string + TxReceive string +} + // MQ is message queue listener handle type MQ struct { context *zmq.Context @@ -16,6 +23,7 @@ type MQ struct { isRunning bool finished chan error binding string + subs SubscriptionTopics } // NotificationType is type of notification @@ -32,7 +40,7 @@ const ( // NewMQ creates new Bitcoind ZeroMQ listener // callback function receives messages -func NewMQ(binding string, callback func(NotificationType)) (*MQ, error) { +func NewMQ(binding string, callback func(NotificationType), subs SubscriptionTopics) (*MQ, error) { context, err := zmq.NewContext() if err != nil { return nil, err @@ -41,13 +49,15 @@ func NewMQ(binding string, callback func(NotificationType)) (*MQ, error) { if err != nil { return nil, err } - err = socket.SetSubscribe("hashblock") - if err != nil { - return nil, err + if subs.BlockSubscribe != "" { + if err := socket.SetSubscribe(subs.BlockSubscribe); err != nil { + return nil, err + } } - err = socket.SetSubscribe("hashtx") - if err != nil { - return nil, err + if subs.TxSubscribe != "" { + if err := socket.SetSubscribe(subs.TxSubscribe); err != nil { + return nil, err + } } // for now do not use raw subscriptions - we would have to handle skipped/lost notifications from zeromq // on each notification we do sync or syncmempool respectively @@ -58,7 +68,7 @@ func NewMQ(binding string, callback func(NotificationType)) (*MQ, error) { return nil, err } glog.Info("MQ listening to ", binding) - mq := &MQ{context, socket, true, make(chan error), binding} + mq := &MQ{context, socket, true, make(chan error), binding, subs} go mq.run(callback) return mq, nil } @@ -92,12 +102,13 @@ func (mq *MQ) run(callback func(NotificationType)) { } else { repeatedError = false } - if len(msg) >= 3 { + + if len(msg) >= 2 { // we received at least topic and payload var nt NotificationType switch string(msg[0]) { - case "hashblock": + case mq.subs.BlockReceive: nt = NotificationNewBlock - case "hashtx": + case mq.subs.TxReceive: nt = NotificationNewTx default: nt = NotificationUnknown @@ -121,13 +132,17 @@ func (mq *MQ) Shutdown(ctx context.Context) error { if mq.isRunning { go func() { // if errors in the closing sequence, let it close ungracefully - if err := mq.socket.SetUnsubscribe("hashtx"); err != nil { - mq.finished <- err - return + if mq.subs.BlockSubscribe != "" { + if err := mq.socket.SetUnsubscribe(mq.subs.BlockSubscribe); err != nil { + mq.finished <- err + return + } } - if err := mq.socket.SetUnsubscribe("hashblock"); err != nil { - mq.finished <- err - return + if mq.subs.TxSubscribe != "" { + if err := mq.socket.SetUnsubscribe(mq.subs.TxSubscribe); err != nil { + mq.finished <- err + return + } } if err := mq.socket.Unbind(mq.binding); err != nil { mq.finished <- err diff --git a/bchain/types.go b/bchain/types.go index cdabf93531..5253f67c19 100644 --- a/bchain/types.go +++ b/bchain/types.go @@ -39,101 +39,103 @@ var ( // Outpoint is txid together with output (or input) index type Outpoint struct { - Txid string - Vout int32 + Txid string `ts_doc:"Transaction ID of the referenced outpoint."` + Vout int32 `ts_doc:"Index of the specific output in the transaction."` } // ScriptSig contains data about input script type ScriptSig struct { // Asm string `json:"asm"` - Hex string `json:"hex"` + Hex string `json:"hex" ts_doc:"Hex-encoded representation of the scriptSig."` } // Vin contains data about tx input type Vin struct { - Coinbase string `json:"coinbase"` - Txid string `json:"txid"` - Vout uint32 `json:"vout"` - ScriptSig ScriptSig `json:"scriptSig"` - Sequence uint32 `json:"sequence"` - Addresses []string `json:"addresses"` + Coinbase string `json:"coinbase" ts_doc:"Coinbase data if this is a coinbase input."` + Txid string `json:"txid" ts_doc:"Transaction ID of the input being spent."` + Vout uint32 `json:"vout" ts_doc:"Output index in the referenced transaction."` + ScriptSig ScriptSig `json:"scriptSig" ts_doc:"scriptSig object containing the spending script data."` + Sequence uint32 `json:"sequence" ts_doc:"Sequence number for the input."` + Addresses []string `json:"addresses" ts_doc:"Addresses derived from this input's script (if known)."` + Witness [][]byte `json:"-" ts_doc:"Witness data for SegWit inputs (not exposed via JSON)."` } // ScriptPubKey contains data about output script type ScriptPubKey struct { // Asm string `json:"asm"` - Hex string `json:"hex,omitempty"` + Hex string `json:"hex,omitempty" ts_doc:"Hex-encoded representation of the scriptPubKey."` // Type string `json:"type"` - Addresses []string `json:"addresses"` + Addresses []string `json:"addresses" ts_doc:"Addresses derived from this output's script (if known)."` } // Vout contains data about tx output type Vout struct { - ValueSat big.Int - JsonValue common.JSONNumber `json:"value"` - N uint32 `json:"n"` - ScriptPubKey ScriptPubKey `json:"scriptPubKey"` + ValueSat big.Int `ts_doc:"Amount (in satoshi or base unit) for this output."` + JsonValue common.JSONNumber `json:"value" ts_doc:"String-based amount for JSON usage."` + N uint32 `json:"n" ts_doc:"Index of this output in the transaction."` + ScriptPubKey ScriptPubKey `json:"scriptPubKey" ts_doc:"scriptPubKey object containing the output script data."` } // Tx is blockchain transaction // unnecessary fields are commented out to avoid overhead type Tx struct { - Hex string `json:"hex"` - Txid string `json:"txid"` - Version int32 `json:"version"` - LockTime uint32 `json:"locktime"` - VSize int64 `json:"vsize,omitempty"` - Vin []Vin `json:"vin"` - Vout []Vout `json:"vout"` - BlockHeight uint32 `json:"blockHeight,omitempty"` + Hex string `json:"hex" ts_doc:"Hex-encoded transaction data."` + Txid string `json:"txid" ts_doc:"Transaction ID (hash)."` + Version int32 `json:"version" ts_doc:"Transaction version number."` + LockTime uint32 `json:"locktime" ts_doc:"Locktime specifying earliest time/block a tx can be mined."` + VSize int64 `json:"vsize,omitempty" ts_doc:"Virtual size of the transaction (for SegWit-based networks)."` + Vin []Vin `json:"vin" ts_doc:"List of inputs."` + Vout []Vout `json:"vout" ts_doc:"List of outputs."` + BlockHeight uint32 `json:"blockHeight,omitempty" ts_doc:"Block height in which this transaction was included."` // BlockHash string `json:"blockhash,omitempty"` - Confirmations uint32 `json:"confirmations,omitempty"` - Time int64 `json:"time,omitempty"` - Blocktime int64 `json:"blocktime,omitempty"` - CoinSpecificData interface{} `json:"-"` + Confirmations uint32 `json:"confirmations,omitempty" ts_doc:"Number of confirmations the transaction has."` + Time int64 `json:"time,omitempty" ts_doc:"Timestamp when the transaction was broadcast or included in a block."` + Blocktime int64 `json:"blocktime,omitempty" ts_doc:"Timestamp of the block in which the transaction was mined."` + CoinSpecificData interface{} `json:"-" ts_doc:"Additional chain-specific data (not exposed via JSON)."` } -// MempoolVin contains data about tx input +// MempoolVin contains data about tx input specifically in mempool type MempoolVin struct { Vin - AddrDesc AddressDescriptor `json:"-"` - ValueSat big.Int + AddrDesc AddressDescriptor `json:"-" ts_doc:"Internal descriptor for the input address (not exposed)."` + ValueSat big.Int `ts_doc:"Amount (in satoshi or base unit) of the input."` } // MempoolTx is blockchain transaction in mempool // optimized for onNewTx notification type MempoolTx struct { - Hex string `json:"hex"` - Txid string `json:"txid"` - Version int32 `json:"version"` - LockTime uint32 `json:"locktime"` - VSize int64 `json:"vsize,omitempty"` - Vin []MempoolVin `json:"vin"` - Vout []Vout `json:"vout"` - Blocktime int64 `json:"blocktime,omitempty"` - TokenTransfers TokenTransfers `json:"-"` - CoinSpecificData interface{} `json:"-"` + Hex string `json:"hex" ts_doc:"Hex-encoded transaction data."` + Txid string `json:"txid" ts_doc:"Transaction ID (hash)."` + Version int32 `json:"version" ts_doc:"Transaction version number."` + LockTime uint32 `json:"locktime" ts_doc:"Locktime specifying earliest time/block a tx can be mined."` + VSize int64 `json:"vsize,omitempty" ts_doc:"Virtual size of the transaction (if applicable)."` + Vin []MempoolVin `json:"vin" ts_doc:"List of inputs in this mempool transaction."` + Vout []Vout `json:"vout" ts_doc:"List of outputs in this mempool transaction."` + Blocktime int64 `json:"blocktime,omitempty" ts_doc:"Timestamp for the block in which tx might eventually be mined, if known."` + TokenTransfers TokenTransfers `json:"-" ts_doc:"Token transfers discovered in this mempool transaction (not exposed by default)."` + CoinSpecificData interface{} `json:"-" ts_doc:"Additional chain-specific data (not exposed via JSON)."` } -// TokenType - type of token -type TokenType int +// TokenStandard - standard of token +type TokenStandard int -// TokenType enumeration +// TokenStandard enumeration const ( - FungibleToken = TokenType(iota) // ERC20 - NonFungibleToken // ERC721 - MultiToken // ERC1155 + FungibleToken = TokenStandard(iota) // ERC20/BEP20 + NonFungibleToken // ERC721/BEP721 + MultiToken // ERC1155/BEP1155 ) -// TokenTypeName specifies type of token -type TokenTypeName string +// TokenStandardName specifies standard of token +type TokenStandardName string -// Token types +// Token standards const ( - UnknownTokenType TokenTypeName = "" + UnknownTokenStandard TokenStandardName = "" + UnhandledTokenStandard TokenStandardName = "-" - // XPUBAddressTokenType is address derived from xpub - XPUBAddressTokenType TokenTypeName = "XPUBAddress" + // XPUBAddressStandard is address derived from xpub + XPUBAddressStandard TokenStandardName = "XPUBAddress" ) // TokenTransfers is array of TokenTransfer @@ -142,77 +144,83 @@ type TokenTransfers []*TokenTransfer func (a TokenTransfers) Len() int { return len(a) } func (a TokenTransfers) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a TokenTransfers) Less(i, j int) bool { - return a[i].Type < a[j].Type + return a[i].Standard < a[j].Standard } // Block is block header and list of transactions type Block struct { BlockHeader - Txs []Tx `json:"tx"` - CoinSpecificData interface{} `json:"-"` + Txs []Tx `json:"tx" ts_doc:"List of full transactions included in this block."` + CoinSpecificData interface{} `json:"-" ts_doc:"Additional chain-specific data (not exposed via JSON)."` } // BlockHeader contains limited data (as needed for indexing) from backend block header type BlockHeader struct { - Hash string `json:"hash"` - Prev string `json:"previousblockhash"` - Next string `json:"nextblockhash"` - Height uint32 `json:"height"` - Confirmations int `json:"confirmations"` - Size int `json:"size"` - Time int64 `json:"time,omitempty"` + Hash string `json:"hash" ts_doc:"Block hash."` + Prev string `json:"previousblockhash" ts_doc:"Hash of the previous block in the chain."` + Next string `json:"nextblockhash" ts_doc:"Hash of the next block, if known."` + Height uint32 `json:"height" ts_doc:"Block height (0-based index in the chain)."` + Confirmations int `json:"confirmations" ts_doc:"Number of confirmations (distance from best chain tip)."` + Size int `json:"size" ts_doc:"Block size in bytes."` + Time int64 `json:"time,omitempty" ts_doc:"Timestamp of when this block was mined."` } // BlockInfo contains extended block header data and a list of block txids type BlockInfo struct { BlockHeader - Version common.JSONNumber `json:"version"` - MerkleRoot string `json:"merkleroot"` - Nonce common.JSONNumber `json:"nonce"` - Bits string `json:"bits"` - Difficulty common.JSONNumber `json:"difficulty"` - Txids []string `json:"tx,omitempty"` + Version common.JSONNumber `json:"version" ts_doc:"Block version (chain-specific meaning)."` + MerkleRoot string `json:"merkleroot" ts_doc:"Merkle root of the block's transactions."` + Nonce common.JSONNumber `json:"nonce" ts_doc:"Nonce used in the mining process."` + Bits string `json:"bits" ts_doc:"Compact representation of the target threshold."` + Difficulty common.JSONNumber `json:"difficulty" ts_doc:"Difficulty target for mining this block."` + Txids []string `json:"tx,omitempty" ts_doc:"List of transaction IDs included in this block."` } // MempoolEntry is used to get data about mempool entry type MempoolEntry struct { - Size uint32 `json:"size"` - FeeSat big.Int - Fee common.JSONNumber `json:"fee"` - ModifiedFeeSat big.Int - ModifiedFee common.JSONNumber `json:"modifiedfee"` - Time uint64 `json:"time"` - Height uint32 `json:"height"` - DescendantCount uint32 `json:"descendantcount"` - DescendantSize uint32 `json:"descendantsize"` - DescendantFees uint32 `json:"descendantfees"` - AncestorCount uint32 `json:"ancestorcount"` - AncestorSize uint32 `json:"ancestorsize"` - AncestorFees uint32 `json:"ancestorfees"` - Depends []string `json:"depends"` + Size uint32 `json:"size" ts_doc:"Size of the transaction in bytes, as stored in mempool."` + FeeSat big.Int `ts_doc:"Transaction fee in satoshi/base units."` + Fee common.JSONNumber `json:"fee" ts_doc:"String-based fee for JSON usage."` + ModifiedFeeSat big.Int `ts_doc:"Modified fee in satoshi/base units after priority adjustments."` + ModifiedFee common.JSONNumber `json:"modifiedfee" ts_doc:"String-based modified fee for JSON usage."` + Time uint64 `json:"time" ts_doc:"Unix timestamp when the tx entered the mempool."` + Height uint32 `json:"height" ts_doc:"Block height when the tx entered the mempool."` + DescendantCount uint32 `json:"descendantcount" ts_doc:"Number of descendant transactions in mempool."` + DescendantSize uint32 `json:"descendantsize" ts_doc:"Total size of all descendant transactions in bytes."` + DescendantFees uint32 `json:"descendantfees" ts_doc:"Combined fees of all descendant transactions."` + AncestorCount uint32 `json:"ancestorcount" ts_doc:"Number of ancestor transactions in mempool."` + AncestorSize uint32 `json:"ancestorsize" ts_doc:"Total size of all ancestor transactions in bytes."` + AncestorFees uint32 `json:"ancestorfees" ts_doc:"Combined fees of all ancestor transactions."` + Depends []string `json:"depends" ts_doc:"List of txids this transaction depends on."` } // ChainInfo is used to get information about blockchain type ChainInfo struct { - Chain string `json:"chain"` - Blocks int `json:"blocks"` - Headers int `json:"headers"` - Bestblockhash string `json:"bestblockhash"` - Difficulty string `json:"difficulty"` - SizeOnDisk int64 `json:"size_on_disk"` - Version string `json:"version"` - Subversion string `json:"subversion"` - ProtocolVersion string `json:"protocolversion"` - Timeoffset float64 `json:"timeoffset"` - Warnings string `json:"warnings"` - ConsensusVersion string `json:"consensus_version,omitempty"` - Consensus interface{} `json:"consensus,omitempty"` + Chain string `json:"chain" ts_doc:"Name of the chain (e.g. 'main')."` + Blocks int `json:"blocks" ts_doc:"Number of fully verified blocks in the chain."` + Headers int `json:"headers" ts_doc:"Number of block headers in the chain (can be ahead of full blocks)."` + Bestblockhash string `json:"bestblockhash" ts_doc:"Hash of the best (latest) block."` + Difficulty string `json:"difficulty" ts_doc:"Current difficulty of the network."` + SizeOnDisk int64 `json:"size_on_disk" ts_doc:"Size of the blockchain data on disk in bytes."` + Version string `json:"version" ts_doc:"Version of the blockchain backend."` + Subversion string `json:"subversion" ts_doc:"Subversion string of the blockchain backend."` + ProtocolVersion string `json:"protocolversion" ts_doc:"Protocol version for this chain node."` + Timeoffset float64 `json:"timeoffset" ts_doc:"Time offset (in seconds) reported by the node."` + Warnings string `json:"warnings" ts_doc:"Any warnings generated by the node regarding the chain state."` + ConsensusVersion string `json:"consensus_version,omitempty" ts_doc:"Version of the chain's consensus protocol, if available."` + Consensus interface{} `json:"consensus,omitempty" ts_doc:"Additional consensus details, structure depends on chain."` +} + +// LongTermFeeRate gets information about the fee rate over longer period of time. +type LongTermFeeRate struct { + FeePerUnit big.Int `json:"feePerUnit" ts_doc:"Long term fee rate (in sat/kByte)."` + Blocks uint64 `json:"blocks" ts_doc:"Amount of blocks used for the long term fee rate estimation."` } // RPCError defines rpc error returned by backend type RPCError struct { - Code int `json:"code"` - Message string `json:"message"` + Code int `json:"code" ts_doc:"Error code returned by the backend RPC."` + Message string `json:"message" ts_doc:"Human-readable error message."` } func (e *RPCError) Error() string { @@ -226,6 +234,13 @@ func (ad AddressDescriptor) String() string { return "ad:" + hex.EncodeToString(ad) } +func (ad AddressDescriptor) IsTaproot() bool { + if len(ad) == 34 && ad[0] == 0x51 && ad[1] == 0x20 { + return true + } + return false +} + // AddressDescriptorFromString converts string created by AddressDescriptor.String to AddressDescriptor func AddressDescriptorFromString(s string) (AddressDescriptor, error) { if len(s) > 3 && s[0:3] == "ad:" { @@ -236,8 +251,8 @@ func AddressDescriptorFromString(s string) (AddressDescriptor, error) { // MempoolTxidEntry contains mempool txid with first seen time type MempoolTxidEntry struct { - Txid string - Time uint32 + Txid string `ts_doc:"Transaction ID (hash) of the mempool entry."` + Time uint32 `ts_doc:"Unix timestamp when the transaction was first seen in the mempool."` } // ScriptType - type of output script parsed from xpub (descriptor) @@ -254,22 +269,33 @@ const ( // XpubDescriptor contains parsed data from xpub descriptor type XpubDescriptor struct { - XpubDescriptor string // The whole descriptor - Xpub string // Xpub part of the descriptor - Type ScriptType - Bip string - ChangeIndexes []uint32 - ExtKey interface{} // extended key parsed from xpub, usually of type *hdkeychain.ExtendedKey + XpubDescriptor string `ts_doc:"Full descriptor string including xpub and script type."` + Xpub string `ts_doc:"The xpub part itself extracted from the descriptor."` + Type ScriptType `ts_doc:"Parsed script type (P2PKH, P2WPKH, etc.)."` + Bip string `ts_doc:"BIP standard (e.g. BIP44) inferred from the descriptor."` + ChangeIndexes []uint32 `ts_doc:"Indexes designated as change addresses."` + ExtKey interface{} `ts_doc:"Extended key object parsed from xpub (implementation-specific)."` } // MempoolTxidEntries is array of MempoolTxidEntry type MempoolTxidEntries []MempoolTxidEntry -// OnNewBlockFunc is used to send notification about a new block -type OnNewBlockFunc func(hash string, height uint32) +// MempoolTxidFilterEntries is a map of txids to mempool golomb filters +// Also contains a flag whether constant zeroed key was used when calculating the filters +type MempoolTxidFilterEntries struct { + Entries map[string]string `json:"entries,omitempty" ts_doc:"Map of txid to filter data (hex-encoded)."` + UsedZeroedKey bool `json:"usedZeroedKey,omitempty" ts_doc:"Indicates if a zeroed key was used in filter calculation."` +} + +// ENSResolution represents the result of resolving an ENS name to an Ethereum address. +type ENSResolution struct { + Name string `json:"name"` + Address string `json:"address"` + Error string `json:"error,omitempty"` +} -// OnNewTxAddrFunc is used to send notification about a new transaction/address -type OnNewTxAddrFunc func(tx *Tx, desc AddressDescriptor) +// OnNewBlockFunc is used to send notification about a new block +type OnNewBlockFunc func(block *Block) // OnNewTxFunc is used to send notification about a new transaction/address type OnNewTxFunc func(tx *MempoolTx) @@ -277,6 +303,11 @@ type OnNewTxFunc func(tx *MempoolTx) // AddrDescForOutpointFunc returns address descriptor and value for given outpoint or nil if outpoint not found type AddrDescForOutpointFunc func(outpoint Outpoint) (AddressDescriptor, *big.Int) +// MempoolBatcher allows batch fetching of mempool transactions when supported. +type MempoolBatcher interface { + GetRawTransactionsForMempoolBatch(txids []string) (map[string]*Tx, error) +} + // BlockChain defines common interface to block chain daemon type BlockChain interface { // life-cycle methods @@ -285,7 +316,7 @@ type BlockChain interface { // create mempool but do not initialize it CreateMempool(BlockChain) (Mempool, error) // initialize mempool, create ZeroMQ (or other) subscription - InitializeMempool(AddrDescForOutpointFunc, OnNewTxAddrFunc, OnNewTxFunc) error + InitializeMempool(AddrDescForOutpointFunc, OnNewTxFunc) error // shutdown mempool, ZeroMQ and block chain connections Shutdown(ctx context.Context) error // chain info @@ -306,9 +337,11 @@ type BlockChain interface { GetTransaction(txid string) (*Tx, error) GetTransactionForMempool(txid string) (*Tx, error) GetTransactionSpecific(tx *Tx) (json.RawMessage, error) + GetAddressChainExtraData(addrDesc AddressDescriptor) (json.RawMessage, error) EstimateSmartFee(blocks int, conservative bool) (big.Int, error) EstimateFee(blocks int) (big.Int, error) - SendRawTransaction(tx string) (string, error) + LongTermFeeRate() (*LongTermFeeRate, error) + SendRawTransaction(tx string, disableAlternativeRPC bool) (string, error) GetMempoolEntry(txid string) (*MempoolEntry, error) GetContractInfo(contractDesc AddressDescriptor) (*ContractInfo, error) // parser @@ -317,7 +350,14 @@ type BlockChain interface { EthereumTypeGetBalance(addrDesc AddressDescriptor) (*big.Int, error) EthereumTypeGetNonce(addrDesc AddressDescriptor) (uint64, error) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) + EthereumTypeGetEip1559Fees() (*Eip1559Fees, error) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc AddressDescriptor) (*big.Int, error) + EthereumTypeGetErc20ContractBalances(addrDesc AddressDescriptor, contractDescs []AddressDescriptor) ([]*big.Int, error) + EthereumTypeGetSupportedStakingPools() []string + EthereumTypeGetStakingPoolsData(addrDesc AddressDescriptor) ([]StakingPoolData, error) + EthereumTypeRpcCall(data, to, from string) (string, error) + EthereumTypeGetRawTransaction(txid string) (string, error) + EthereumTypeGetTransactionReceipt(txid string) (*RpcReceipt, error) GetTokenURI(contractDesc AddressDescriptor, tokenID *big.Int) (string, error) } @@ -367,6 +407,10 @@ type BlockChainParser interface { DeriveAddressDescriptorsFromTo(descriptor *XpubDescriptor, change uint32, fromIndex uint32, toIndex uint32) ([]AddressDescriptor, error) // EthereumType specific EthereumTypeGetTokenTransfersFromTx(tx *Tx) (TokenTransfers, error) + GetEthereumTxData(tx *Tx) *EthereumTxData + GetChainExtraPayloadType() ChainExtraPayloadType + GetChainExtraData(tx *Tx) (json.RawMessage, error) + ParseInputData(signatures *[]FourByteSignature, data string) *EthereumParsedInputData // AddressAlias FormatAddressAlias(address string, name string) string } @@ -378,4 +422,5 @@ type Mempool interface { GetAddrDescTransactions(addrDesc AddressDescriptor) ([]Outpoint, error) GetAllEntries() MempoolTxidEntries GetTransactionTime(txid string) uint32 + GetTxidFilterEntries(filterScripts string, fromTimestamp uint32) (MempoolTxidFilterEntries, error) } diff --git a/bchain/types_chainextradata.go b/bchain/types_chainextradata.go new file mode 100644 index 0000000000..17b0286701 --- /dev/null +++ b/bchain/types_chainextradata.go @@ -0,0 +1,47 @@ +package bchain + +// ChainExtraPayloadType identifies the normalized chainExtraData payload shape. +type ChainExtraPayloadType string + +const ( + ChainExtraPayloadTypeUnknown ChainExtraPayloadType = "" + ChainExtraPayloadTypeTron ChainExtraPayloadType = "tron" +) + +// TronVoteExtra describes a single Tron vote entry. +type TronVoteExtra struct { + Address string `json:"address,omitempty"` + Count string `json:"count,omitempty"` +} + +// TronChainExtraData contains normalized Tron-specific transaction metadata. +type TronChainExtraData struct { + ContractType string `json:"contractType,omitempty"` + Operation string `json:"operation,omitempty"` + Resource string `json:"resource,omitempty"` + StakeAmount string `json:"stakeAmount,omitempty"` + UnstakeAmount string `json:"unstakeAmount,omitempty"` + ClaimedVoteReward string `json:"claimedVoteReward,omitempty"` + DelegateAmount string `json:"delegateAmount,omitempty"` + DelegateTo string `json:"delegateTo,omitempty"` + AssetIssueID string `json:"assetIssueID,omitempty"` + TotalFee string `json:"totalFee,omitempty"` + FeeLimit string `json:"feeLimit,omitempty"` + EnergyUsage string `json:"energyUsage,omitempty"` + EnergyUsageTotal string `json:"energyUsageTotal,omitempty"` + EnergyFee string `json:"energyFee,omitempty"` + BandwidthUsage string `json:"bandwidthUsage,omitempty"` + BandwidthFee string `json:"bandwidthFee,omitempty"` + Result string `json:"result,omitempty"` + Votes []TronVoteExtra `json:"votes,omitempty"` +} + +// TronAccountExtraData contains normalized Tron-specific account resource metadata. +type TronAccountExtraData struct { + AvailableStakedBandwidth int64 `json:"availableStakedBandwidth"` + TotalStakedBandwidth int64 `json:"totalStakedBandwidth"` + AvailableFreeBandwidth int64 `json:"availableFreeBandwidth"` + TotalFreeBandwidth int64 `json:"totalFreeBandwidth"` + AvailableEnergy int64 `json:"availableEnergy"` + TotalEnergy int64 `json:"totalEnergy"` +} diff --git a/bchain/types_ethereum_type.go b/bchain/types_ethereum_type.go index adceec3b2e..ceec2121f8 100644 --- a/bchain/types_ethereum_type.go +++ b/bchain/types_ethereum_type.go @@ -1,40 +1,54 @@ package bchain import ( + "encoding/json" "math/big" "github.com/ethereum/go-ethereum/accounts/abi" ) -// EthereumType specific +// ProcessInternalTransactions specifies if internal transactions are processed +var ProcessInternalTransactions bool + +type EthereumInternalDataProvider interface { + GetInternalDataForBlock( + hash string, + height uint32, + txs []RpcTransaction, + ) ([]EthereumInternalData, []ContractInfo, error) +} // EthereumInternalTransfer contains data about internal transfer type EthereumInternalTransfer struct { - Type EthereumInternalTransactionType `json:"type"` - From string `json:"from"` - To string `json:"to"` - Value big.Int `json:"value"` + Type EthereumInternalTransactionType `json:"type" ts_doc:"The type of internal transaction (CALL, CREATE, SELFDESTRUCT)."` + From string `json:"from" ts_doc:"Sender address of this internal transfer."` + To string `json:"to" ts_doc:"Recipient address of this internal transfer."` + Value big.Int `json:"value" ts_doc:"Amount (in Wei) transferred internally."` } +// FourByteSignature contains data about a contract function signature type FourByteSignature struct { // stored in DB - Name string - Parameters []string + Name string `ts_doc:"Original function name as stored in the database."` + Parameters []string `ts_doc:"Raw parameter type definitions (e.g. ['uint256','address'])."` // processed from DB data and stored only in cache - DecamelName string - Function string - ParsedParameters []abi.Type + DecamelName string `ts_doc:"A decamelized version of the function name for readability."` + Function string `ts_doc:"Reconstructed function definition string (e.g. 'transfer(address,uint256)')."` + ParsedParameters []abi.Type `ts_doc:"ABI-parsed parameter types (cached for efficiency)."` } +// EthereumParsedInputParam contains data about a contract function parameter type EthereumParsedInputParam struct { - Type string `json:"type"` - Values []string `json:"values,omitempty"` + Type string `json:"type" ts_doc:"Parameter type (e.g. 'uint256')."` + Values []string `json:"values,omitempty" ts_doc:"List of stringified parameter values."` } + +// EthereumParsedInputData contains the parsed data for an input data hex payload type EthereumParsedInputData struct { - MethodId string `json:"methodId"` - Name string `json:"name"` - Function string `json:"function,omitempty"` - Params []EthereumParsedInputParam `json:"params,omitempty"` + MethodId string `json:"methodId" ts_doc:"First 4 bytes of the input data (method signature ID)."` + Name string `json:"name" ts_doc:"Parsed function name if recognized."` + Function string `json:"function,omitempty" ts_doc:"Full function signature (including parameter types)."` + Params []EthereumParsedInputParam `json:"params,omitempty" ts_doc:"List of parsed parameters for this function call."` } // EthereumInternalTransactionType - type of ethereum transaction from internal data @@ -47,64 +61,103 @@ const ( SELFDESTRUCT ) -// EthereumInternalTransaction contains internal transfers +// EthereumInternalData contains internal transfers type EthereumInternalData struct { - Type EthereumInternalTransactionType `json:"type"` - Contract string `json:"contract,omitempty"` - Transfers []EthereumInternalTransfer `json:"transfers,omitempty"` - Error string + Type EthereumInternalTransactionType `json:"type" ts_doc:"High-level type of the internal transaction (CALL, CREATE, etc.)."` + Contract string `json:"contract,omitempty" ts_doc:"Address of the contract involved, if any."` + Transfers []EthereumInternalTransfer `json:"transfers,omitempty" ts_doc:"List of internal transfers associated with this data."` + Error string `ts_doc:"Error message if something went wrong while processing."` } // ContractInfo contains info about a contract type ContractInfo struct { - Type TokenTypeName `json:"type"` - Contract string `json:"contract"` - Name string `json:"name"` - Symbol string `json:"symbol"` - Decimals int `json:"decimals"` - CreatedInBlock uint32 `json:"createdInBlock,omitempty"` - DestructedInBlock uint32 `json:"destructedInBlock,omitempty"` + // Deprecated: Use Standard instead. + Type TokenStandardName `json:"type" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'" ts_doc:"@deprecated: Use standard instead."` + Standard TokenStandardName `json:"standard" ts_type:"'' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'"` + Contract string `json:"contract" ts_doc:"Smart contract address."` + Name string `json:"name" ts_doc:"Readable name of the contract."` + Symbol string `json:"symbol" ts_doc:"Symbol for tokens under this contract, if applicable."` + Decimals int `json:"decimals" ts_doc:"Number of decimal places, if applicable."` + CreatedInBlock uint32 `json:"createdInBlock,omitempty" ts_doc:"Block height where contract was first created."` + DestructedInBlock uint32 `json:"destructedInBlock,omitempty" ts_doc:"Block height where contract was destroyed (if any)."` + // IsErc4626 is set on a successful asset()+totalAssets() probe; lazy and one-way. + IsErc4626 bool `json:"-"` + // Erc4626AssetContract is the underlying asset (EIP-55), read on the same probe. + Erc4626AssetContract string `json:"-"` +} + +// EthereumTypeRPCCall defines one eth_call request payload. +type EthereumTypeRPCCall struct { + Data string + To string + From string +} + +// EthereumTypeRPCCallResult carries one eth_call response payload. +type EthereumTypeRPCCallResult struct { + Data string + Error error +} + +// EthereumMulticallCall is one sub-call in a Multicall3 aggregate3 batch. +// CallData is "0x"-prefixed hex. AllowFailure=true lets a revert produce +// Success=false in the result slot instead of failing the batch. +type EthereumMulticallCall struct { + Target string + CallData string + AllowFailure bool } -// Ethereum token type names +// EthereumMulticallResult is one slot of the aggregate3 return; Data is the +// "0x"-prefixed return bytes (or revert payload when Success=false). +type EthereumMulticallResult struct { + Success bool + Data string +} + +// Ethereum token standard names const ( - ERC20TokenType TokenTypeName = "ERC20" - ERC771TokenType TokenTypeName = "ERC721" - ERC1155TokenType TokenTypeName = "ERC1155" + ERC20TokenStandard TokenStandardName = "ERC20" + ERC771TokenStandard TokenStandardName = "ERC721" + ERC1155TokenStandard TokenStandardName = "ERC1155" ) -// EthereumTokenTypeMap maps bchain.TokenType to TokenTypeName -// the map must match all bchain.TokenType to avoid index out of range panic -var EthereumTokenTypeMap []TokenTypeName = []TokenTypeName{ERC20TokenType, ERC771TokenType, ERC1155TokenType} +// EthereumTokenStandardMap maps bchain.TokenStandard to TokenStandardName +// the map must match all bchain.TokenStandard to avoid index out of range panic +var EthereumTokenStandardMap = []TokenStandardName{ERC20TokenStandard, ERC771TokenStandard, ERC1155TokenStandard} +// MultiTokenValue holds one ID-value pair for multi-token standards like ERC1155 type MultiTokenValue struct { - Id big.Int - Value big.Int + Id big.Int `ts_doc:"Token ID for this multi-token entry."` + Value big.Int `ts_doc:"Amount of the token ID transferred or owned."` } // TokenTransfer contains a single token transfer type TokenTransfer struct { - Type TokenType - Contract string - From string - To string - Value big.Int - MultiTokenValues []MultiTokenValue + Standard TokenStandard `ts_doc:"Integer value od the token standard."` + Contract string `ts_doc:"Smart contract address for the token."` + From string `ts_doc:"Sender address of the token transfer."` + To string `ts_doc:"Recipient address of the token transfer."` + Value big.Int `ts_doc:"Amount of tokens transferred (for fungible tokens)."` + MultiTokenValues []MultiTokenValue `ts_doc:"List of ID-value pairs for multi-token transfers (e.g., ERC1155)."` } // RpcTransaction is returned by eth_getTransactionByHash type RpcTransaction struct { - AccountNonce string `json:"nonce"` - GasPrice string `json:"gasPrice"` - GasLimit string `json:"gas"` - To string `json:"to"` // nil means contract creation - Value string `json:"value"` - Payload string `json:"input"` - Hash string `json:"hash"` - BlockNumber string `json:"blockNumber"` - BlockHash string `json:"blockHash,omitempty"` - From string `json:"from"` - TransactionIndex string `json:"transactionIndex"` + AccountNonce string `json:"nonce" ts_doc:"Transaction nonce from the sender's account."` + GasPrice string `json:"gasPrice" ts_doc:"Gas price bid by the sender in Wei."` + MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas,omitempty"` + MaxFeePerGas string `json:"maxFeePerGas,omitempty"` + BaseFeePerGas string `json:"baseFeePerGas,omitempty"` + GasLimit string `json:"gas" ts_doc:"Maximum gas allowed for this transaction."` + To string `json:"to" ts_doc:"Recipient address if not a contract creation. Empty if it's contract creation."` + Value string `json:"value" ts_doc:"Amount of Ether (in Wei) sent in this transaction."` + Payload string `json:"input" ts_doc:"Hex-encoded input data for contract calls."` + Hash string `json:"hash" ts_doc:"Transaction hash."` + BlockNumber string `json:"blockNumber" ts_doc:"Block number where this transaction was included, if mined."` + BlockHash string `json:"blockHash,omitempty" ts_doc:"Hash of the block in which this transaction was included, if mined."` + From string `json:"from" ts_doc:"Sender's address derived by the backend."` + TransactionIndex string `json:"transactionIndex" ts_doc:"Index of the transaction within the block, if mined."` // Signature values - ignored // V string `json:"v"` // R string `json:"r"` @@ -113,34 +166,105 @@ type RpcTransaction struct { // RpcLog is returned by eth_getLogs type RpcLog struct { - Address string `json:"address"` - Topics []string `json:"topics"` - Data string `json:"data"` + Address string `json:"address" ts_doc:"Contract or address from which this log originated."` + Topics []string `json:"topics" ts_doc:"Indexed event signatures and parameters."` + Data string `json:"data" ts_doc:"Unindexed event data in hex form."` } -// RpcLog is returned by eth_getTransactionReceipt +// RpcReceipt is returned by eth_getTransactionReceipt type RpcReceipt struct { - GasUsed string `json:"gasUsed"` - Status string `json:"status"` - Logs []*RpcLog `json:"logs"` + GasUsed string `json:"gasUsed" ts_doc:"Amount of gas actually used by the transaction."` + Status string `json:"status" ts_doc:"Transaction execution status (0x0 = fail, 0x1 = success)."` + Logs []*RpcLog `json:"logs" ts_doc:"Array of log entries generated by this transaction."` + L1Fee string `json:"l1Fee,omitempty" ts_doc:"Additional Layer 1 fee, if on a rollup network."` + L1FeeScalar string `json:"l1FeeScalar,omitempty" ts_doc:"Fee scaling factor for L1 fees on some L2s."` + L1GasPrice string `json:"l1GasPrice,omitempty" ts_doc:"Gas price used on L1 for the rollup network."` + L1GasUsed string `json:"l1GasUsed,omitempty" ts_doc:"Amount of L1 gas used by the transaction, if any."` + ContractAddress string `json:"contractAddress,omitempty"` +} + +// TxStatus is status of transaction. +type TxStatus int + +// statuses of transaction +const ( + TxStatusUnknown = TxStatus(iota - 2) + TxStatusPending + TxStatusFailure + TxStatusOK +) + +// EthereumTxData contains Ethereum-like transaction data needed by API worker logic. +type EthereumTxData struct { + Status TxStatus `json:"status"` // 1 OK, 0 Fail, -1 pending, -2 unknown + Nonce uint64 `json:"nonce"` + GasLimit *big.Int `json:"gaslimit"` + GasUsed *big.Int `json:"gasused"` + GasPrice *big.Int `json:"gasprice"` + MaxPriorityFeePerGas *big.Int `json:"maxPriorityFeePerGas,omitempty"` + MaxFeePerGas *big.Int `json:"maxFeePerGas,omitempty"` + BaseFeePerGas *big.Int `json:"baseFeePerGas,omitempty"` + L1Fee *big.Int `json:"l1Fee,omitempty"` + L1FeeScalar string `json:"l1FeeScalar,omitempty"` + L1GasPrice *big.Int `json:"l1GasPrice,omitempty"` + L1GasUsed *big.Int `json:"L1GasUsed,omitempty"` + Data string `json:"data"` } // EthereumSpecificData contains data specific to Ethereum transactions type EthereumSpecificData struct { - Tx *RpcTransaction `json:"tx"` - InternalData *EthereumInternalData `json:"internalData,omitempty"` - Receipt *RpcReceipt `json:"receipt,omitempty"` + Tx *RpcTransaction `json:"tx" ts_doc:"Raw transaction details from the blockchain node."` + InternalData *EthereumInternalData `json:"internalData,omitempty" ts_doc:"Summary of internal calls/transfers, if any."` + Receipt *RpcReceipt `json:"receipt,omitempty" ts_doc:"Transaction receipt info, including logs and gas usage."` + // ChainExtraData holds optional normalized chain-specific data for Ethereum-like chains. + ChainExtraData json.RawMessage `json:"chainExtraData,omitempty"` } // AddressAliasRecord maps address to ENS name type AddressAliasRecord struct { - Address string - Name string + Address string `ts_doc:"Address whose alias is being stored."` + Name string `ts_doc:"The resolved name/alias (e.g. ENS domain)."` } // EthereumBlockSpecificData contain data specific for Ethereum block type EthereumBlockSpecificData struct { - InternalDataError string - AddressAliasRecords []AddressAliasRecord - Contracts []ContractInfo + InternalDataError string `ts_doc:"Error message for processing block internal data, if any."` + AddressAliasRecords []AddressAliasRecord `ts_doc:"List of address-to-alias mappings discovered in this block."` + Contracts []ContractInfo `ts_doc:"List of contracts created or updated in this block."` +} + +// StakingPoolData holds data about address participation in a staking pool contract +type StakingPoolData struct { + Contract string `json:"contract" ts_doc:"Address of the staking pool contract."` + Name string `json:"name" ts_doc:"Human-readable name of the staking pool."` + PendingBalance big.Int `json:"pendingBalance" ts_doc:"Amount not yet finalized in the pool (pendingBalanceOf)."` + PendingDepositedBalance big.Int `json:"pendingDepositedBalance" ts_doc:"Amount pending deposit (pendingDepositedBalanceOf)."` + DepositedBalance big.Int `json:"depositedBalance" ts_doc:"Total amount currently deposited (depositedBalanceOf)."` + WithdrawTotalAmount big.Int `json:"withdrawTotalAmount" ts_doc:"Total amount requested for withdrawal (withdrawRequest[0])."` + ClaimableAmount big.Int `json:"claimableAmount" ts_doc:"Amount that can be claimed (withdrawRequest[1])."` + RestakedReward big.Int `json:"restakedReward" ts_doc:"Total reward that has been restaked (restakedRewardOf)."` + AutocompoundBalance big.Int `json:"autocompoundBalance" ts_doc:"Auto-compounded balance (autocompoundBalanceOf)."` +} + +// Eip1559Fee +type Eip1559Fee struct { + MaxFeePerGas *big.Int `json:"maxFeePerGas"` + MaxPriorityFeePerGas *big.Int `json:"maxPriorityFeePerGas"` + MinWaitTimeEstimate int `json:"minWaitTimeEstimate,omitempty"` + MaxWaitTimeEstimate int `json:"maxWaitTimeEstimate,omitempty"` +} + +// Eip1559Fees +type Eip1559Fees struct { + BaseFeePerGas *big.Int `json:"baseFeePerGas,omitempty"` + Low *Eip1559Fee `json:"low,omitempty"` + Medium *Eip1559Fee `json:"medium,omitempty"` + High *Eip1559Fee `json:"high,omitempty"` + Instant *Eip1559Fee `json:"instant,omitempty"` + NetworkCongestion float64 `json:"networkCongestion,omitempty"` + LatestPriorityFeeRange []*big.Int `json:"latestPriorityFeeRange,omitempty"` + HistoricalPriorityFeeRange []*big.Int `json:"historicalPriorityFeeRange,omitempty"` + HistoricalBaseFeeRange []*big.Int `json:"historicalBaseFeeRange,omitempty"` + PriorityFeeTrend string `json:"priorityFeeTrend,omitempty"` + BaseFeeTrend string `json:"baseFeeTrend,omitempty"` } diff --git a/blockbook-api.ts b/blockbook-api.ts new file mode 100644 index 0000000000..1c2475436c --- /dev/null +++ b/blockbook-api.ts @@ -0,0 +1,882 @@ +/* Do not change, this code is generated from Golang structs */ + + +export interface APIError { + /** Human-readable error message describing the issue. */ + Text: string; + /** Whether the error message can safely be shown to the end user. */ + Public: boolean; +} +export interface TronVoteExtra { + address?: string; + count?: string; +} +export interface TronChainExtraData { + contractType?: string; + operation?: string; + resource?: string; + stakeAmount?: string; + unstakeAmount?: string; + delegateAmount?: string; + delegateTo?: string; + assetIssueID?: string; + totalFee?: string; + energyUsage?: string; + energyUsageTotal?: string; + energyFee?: string; + bandwidthUsage?: string; + bandwidthFee?: string; + result?: string; + votes?: TronVoteExtra[]; +} +export interface TronAccountExtraData { + availableStakedBandwidth: number; + totalStakedBandwidth: number; + availableFreeBandwidth: number; + totalFreeBandwidth: number; + availableEnergy: number; + totalEnergy: number; +} +export type TxChainExtraData = { payloadType: 'tron'; payload?: TronChainExtraData } | { payloadType: string; payload?: any }; +export type AccountChainExtraData = { payloadType: 'tron'; payload?: TronAccountExtraData } | { payloadType: string; payload?: any }; +export interface AddressAlias { + /** Type of alias, e.g., user-defined name or contract name. */ + Type: string; + /** Alias string for the address. */ + Alias: string; +} +export interface EthereumInternalTransfer { + /** Type of internal transfer (CALL, CREATE, etc.). */ + type: number; + /** Address from which the transfer originated. */ + from: string; + /** Address to which the transfer was sent. */ + to: string; + /** Value transferred internally (in Wei or base units). */ + value?: string; +} +export interface EthereumParsedInputParam { + /** Parameter type (e.g. 'uint256'). */ + type: string; + /** List of stringified parameter values. */ + values?: string[]; +} +export interface EthereumParsedInputData { + /** First 4 bytes of the input data (method signature ID). */ + methodId: string; + /** Parsed function name if recognized. */ + name: string; + /** Full function signature (including parameter types). */ + function?: string; + /** List of parsed parameters for this function call. */ + params?: EthereumParsedInputParam[]; +} +export interface EthereumSpecific { + /** High-level type of the Ethereum tx (e.g., 'call', 'create'). */ + type?: number; + /** Address of contract created by this transaction, if any. */ + createdContract?: string; + /** Execution status of the transaction (1: success, 0: fail, -1: pending). */ + status: number; + /** Error encountered during execution, if any. */ + error?: string; + /** Transaction nonce (sequential number from the sender). */ + nonce: number; + /** Maximum gas allowed by the sender for this transaction. */ + gasLimit?: number; + /** Actual gas consumed by the transaction execution. */ + gasUsed?: number; + /** Price (in Wei or base units) per gas unit. */ + gasPrice?: string; + maxPriorityFeePerGas?: string; + maxFeePerGas?: string; + baseFeePerGas?: string; + /** Fee used for L1 part in rollups (e.g. Optimism). */ + l1Fee?: number; + /** Scaling factor for L1 fees in certain Layer 2 solutions. */ + l1FeeScalar?: string; + /** Gas price for L1 component, if applicable. */ + l1GasPrice?: string; + /** Amount of gas used in L1 for this tx, if applicable. */ + l1GasUsed?: number; + /** Hex-encoded input data for the transaction. */ + data?: string; + /** Decoded transaction data (function name, params, etc.). */ + parsedData?: EthereumParsedInputData; + /** List of internal (sub-call) transfers. */ + internalTransfers?: EthereumInternalTransfer[]; +} +export interface MultiTokenValue { + /** Token ID (for ERC1155). */ + id?: string; + /** Amount of that specific token ID. */ + value?: string; +} +export interface TokenTransfer { + /** @deprecated: Use standard instead. */ + type: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'; + standard: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'; + /** Source address of the token transfer. */ + from: string; + /** Destination address of the token transfer. */ + to: string; + /** Contract address of the token. */ + contract: string; + /** Token name. */ + name?: string; + /** Token symbol. */ + symbol?: string; + /** Number of decimals for this token (if applicable). */ + decimals?: number; + /** Amount (in base units) of tokens transferred. */ + value?: string; + /** List of multiple ID-value pairs for ERC1155 transfers. */ + multiTokenValues?: MultiTokenValue[]; +} +export interface Vout { + /** Amount (in satoshi or base units) of the output. */ + value?: string; + /** Relative index of this output within the transaction. */ + n: number; + /** Indicates whether this output has been spent. */ + spent?: boolean; + /** Transaction ID in which this output was spent. */ + spentTxId?: string; + /** Index of the input that spent this output. */ + spentIndex?: number; + /** Block height at which this output was spent. */ + spentHeight?: number; + /** Raw script hex data for this output - aka ScriptPubKey. */ + hex?: string; + /** Disassembled script for this output. */ + asm?: string; + /** List of addresses associated with this output. */ + addresses: string[]; + /** Indicates whether this output is owned by valid address. */ + isAddress: boolean; + /** Indicates if this output belongs to the wallet in context. */ + isOwn?: boolean; + /** Output script type (e.g., 'P2PKH', 'P2SH'). */ + type?: string; +} +export interface Vin { + /** ID/hash of the originating transaction (where the UTXO comes from). */ + txid?: string; + /** Index of the output in the referenced transaction. */ + vout?: number; + /** Sequence number for this input (e.g. 4294967293). */ + sequence?: number; + /** Relative index of this input within the transaction. */ + n: number; + /** List of addresses associated with this input. */ + addresses?: string[]; + /** Indicates if this input is from a known address. */ + isAddress: boolean; + /** Indicates if this input belongs to the wallet in context. */ + isOwn?: boolean; + /** Amount (in satoshi or base units) of the input. */ + value?: string; + /** Raw script hex data for this input. */ + hex?: string; + /** Disassembled script for this input. */ + asm?: string; + /** Data for coinbase inputs (when mining). */ + coinbase?: string; +} +export interface Tx { + /** Transaction ID (hash). */ + txid: string; + /** Version of the transaction (if applicable). */ + version?: number; + /** Locktime indicating earliest time/height transaction can be mined. */ + lockTime?: number; + /** Array of inputs for this transaction. */ + vin: Vin[]; + /** Array of outputs for this transaction. */ + vout: Vout[]; + /** Hash of the block containing this transaction. */ + blockHash?: string; + /** Block height in which this transaction was included. */ + blockHeight: number; + /** Number of confirmations (blocks mined after this tx's block). */ + confirmations: number; + /** Estimated blocks remaining until confirmation (if unconfirmed). */ + confirmationETABlocks?: number; + /** Estimated seconds remaining until confirmation (if unconfirmed). */ + confirmationETASeconds?: number; + /** Unix timestamp of the block in which this transaction was included. 0 if unconfirmed. */ + blockTime: number; + /** Transaction size in bytes. */ + size?: number; + /** Virtual size in bytes, for SegWit-enabled chains. */ + vsize?: number; + /** Total value of all outputs (in satoshi or base units). */ + value?: string; + /** Total value of all inputs (in satoshi or base units). */ + valueIn?: string; + /** Transaction fee (inputs - outputs). */ + fees?: string; + /** Raw hex-encoded transaction data. */ + hex?: string; + /** Indicates if this transaction is replace-by-fee (RBF) enabled. */ + rbf?: boolean; + /** Blockchain-specific extended data. */ + coinSpecificData?: any; + /** Additional normalized chain-specific transaction data. Use payloadType as discriminator for payload. */ + chainExtraData?: TxChainExtraData; + /** List of token transfers that occurred in this transaction. */ + tokenTransfers?: TokenTransfer[]; + /** Ethereum-like blockchain specific data (if applicable). */ + ethereumSpecific?: EthereumSpecific; + /** Aliases for addresses involved in this transaction. */ + addressAliases?: {[key: string]: AddressAlias}; +} +export interface FeeStats { + /** Number of transactions in the given block. */ + txCount: number; + /** Sum of all fees in satoshi or base units. */ + totalFeesSat?: string; + /** Average fee per kilobyte in satoshi or base units. */ + averageFeePerKb: number; + /** Fee distribution deciles (0%..100%) in satoshi or base units per kB. */ + decilesFeePerKb: number[]; +} +export interface StakingPool { + /** Staking pool contract address on-chain. */ + contract: string; + /** Name of the staking pool contract. */ + name: string; + /** Balance pending deposit or withdrawal, if any. */ + pendingBalance?: string; + /** Any pending deposit that is not yet finalized. */ + pendingDepositedBalance?: string; + /** Currently deposited/staked balance. */ + depositedBalance?: string; + /** Total amount withdrawn from this pool by the address. */ + withdrawTotalAmount?: string; + /** Rewards or principal currently claimable by the address. */ + claimableAmount?: string; + /** Total rewards that have been restaked automatically. */ + restakedReward?: string; + /** Any balance automatically reinvested into the pool. */ + autocompoundBalance?: string; +} +export interface Erc4626TokenMetadata { + /** Token contract address. */ + contract: string; + /** Human-readable token name. */ + name?: string; + /** Token symbol. */ + symbol?: string; + /** Token decimals. */ + decimals: number; +} +export interface Erc4626Token { + /** Metadata of the underlying asset token. */ + asset?: Erc4626TokenMetadata; + /** Metadata of the vault share token. */ + share?: Erc4626TokenMetadata; + /** Total underlying assets managed by the vault. */ + totalAssets?: string; + /** Underlying assets for one whole share unit. */ + convertToAssets1Share?: string; + /** Shares for one whole underlying asset unit. */ + convertToShares1Asset?: string; + /** Previewed shares minted for one whole underlying asset unit. */ + previewDeposit1Asset?: string; + /** Previewed assets redeemed for one whole share unit. */ + previewRedeem1Share?: string; + /** Error message for partial failures while fetching ERC4626 fields. */ + error?: string; +} +export interface Token { + /** @deprecated: Use standard instead. */ + type: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'; + standard: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'; + /** Readable name of the token. */ + name: string; + /** Derivation path if this token is derived from an XPUB-based address. */ + path?: string; + /** Contract address on-chain. */ + contract?: string; + /** Total number of token transfers for this address. */ + transfers: number; + /** Symbol for the token (e.g., 'ETH', 'USDT'). */ + symbol?: string; + /** Number of decimals for this token. */ + decimals?: number; + /** Current token balance (in minimal base units). */ + balance?: string; + /** Value in the base currency (e.g. ETH for ERC20 tokens). */ + baseValue?: number; + /** Value in a secondary currency (e.g. fiat), if available. */ + secondaryValue?: number; + /** List of token IDs (for ERC721, each ID is a unique collectible). */ + ids?: string[]; + /** Multiple ERC1155 token balances (id + value). */ + multiTokenValues?: MultiTokenValue[]; + /** Total amount of tokens received. */ + totalReceived?: string; + /** Total amount of tokens sent. */ + totalSent?: string; + /** Optional protocol-specific enrichments requested by the caller. */ + protocols?: ContractInfoProtocols; +} +export interface Address { + /** Current page index. */ + page?: number; + /** Total number of pages available. */ + totalPages?: number; + /** Number of items returned on this page. */ + itemsOnPage?: number; + /** The address string in standard format. */ + address: string; + /** Current confirmed balance (in satoshi or base units). */ + balance?: string; + /** Total amount ever received by this address. */ + totalReceived?: string; + /** Total amount ever sent by this address. */ + totalSent?: string; + /** Unconfirmed balance for this address. */ + unconfirmedBalance?: string; + /** Number of unconfirmed transactions for this address. */ + unconfirmedTxs: number; + /** Unconfirmed outgoing balance for this address. */ + unconfirmedSending?: string; + /** Unconfirmed incoming balance for this address. */ + unconfirmedReceiving?: string; + /** Number of transactions for this address (including confirmed). */ + txs: number; + /** Historical total count of transactions, if known. */ + addrTxCount?: number; + /** Number of transactions not involving tokens (pure coin transfers). */ + nonTokenTxs?: number; + /** Number of internal transactions (e.g., Ethereum calls). */ + internalTxs?: number; + /** List of transaction details (if requested). */ + transactions?: Tx[]; + /** List of transaction IDs (if detailed data is not requested). */ + txids?: string[]; + /** Current transaction nonce for Ethereum-like addresses. */ + nonce?: string; + /** Number of tokens with any historical usage at this address. */ + usedTokens?: number; + /** List of tokens associated with this address. */ + tokens?: Token[]; + /** Total value of the address in secondary currency (e.g. fiat). */ + secondaryValue?: number; + /** Sum of token values in base currency. */ + tokensBaseValue?: number; + /** Sum of token values in secondary currency (fiat). */ + tokensSecondaryValue?: number; + /** Address's entire value in base currency, including tokens. */ + totalBaseValue?: number; + /** Address's entire value in secondary currency, including tokens. */ + totalSecondaryValue?: number; + /** Extra info if the address is a contract. Shape matches getContractInfo; rates and protocols are populated only when explicitly requested via getContractInfo. */ + contractInfo?: ContractInfoResult; + /** @deprecated: replaced by contractInfo */ + erc20Contract?: ContractInfoResult; + /** Aliases assigned to this address. */ + addressAliases?: {[key: string]: AddressAlias}; + /** List of staking pool data if address interacts with staking. */ + stakingPools?: StakingPool[]; + /** Additional normalized chain-specific account/address data. Use payloadType as discriminator for payload. */ + chainExtraData?: AccountChainExtraData; +} +export interface ContractInfoProtocols { + /** ERC4626 vault details when explicitly requested and detected. */ + erc4626?: Erc4626Token; +} +export interface ContractInfoRates { + /** Current price of one whole token in the chain base currency, when available. */ + baseRate?: number; + /** Requested secondary currency code for the secondaryRate field, lower-cased. */ + currency?: string; + /** Current price of one whole token in the requested secondary currency, when available. */ + secondaryRate?: number; +} +export interface ContractInfoResult { + /** @deprecated: Use standard instead. */ + type: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'; + standard: '' | 'XPUBAddress' | 'ERC20' | 'ERC721' | 'ERC1155' | 'BEP20' | 'BEP721' | 'BEP1155' | 'TRC20' | 'TRC721' | 'TRC1155'; + /** Smart contract address. */ + contract: string; + /** Readable name of the contract. */ + name: string; + /** Symbol for tokens under this contract, if applicable. */ + symbol: string; + /** Number of decimal places, if applicable. */ + decimals: number; + /** Block height where contract was first created. */ + createdInBlock?: number; + /** Block height where contract was destroyed (if any). */ + destructedInBlock?: number; + /** Current rate data for the contract when available. */ + rates?: ContractInfoRates; + /** Optional protocol-specific enrichments requested by the caller. */ + protocols?: ContractInfoProtocols; + /** Indexed best block height used as freshness metadata for this response. */ + blockHeight: number; +} +export interface Utxo { + /** Transaction ID in which this UTXO was created. */ + txid: string; + /** Index of the output in that transaction. */ + vout: number; + /** Value of this UTXO (in satoshi or base units). */ + value?: string; + /** Block height in which the UTXO was confirmed. */ + height?: number; + /** Number of confirmations for this UTXO. */ + confirmations: number; + /** Address to which this UTXO belongs. */ + address?: string; + /** Derivation path for XPUB-based wallets, if applicable. */ + path?: string; + /** If non-zero, locktime required before spending this UTXO. */ + lockTime?: number; + /** Indicates if this UTXO originated from a coinbase transaction. */ + coinbase?: boolean; +} +export interface BalanceHistory { + /** Unix timestamp for this point in the balance history. */ + time: number; + /** Number of transactions in this interval. */ + txs: number; + /** Amount received in this interval (in satoshi or base units). */ + received?: string; + /** Amount sent in this interval (in satoshi or base units). */ + sent?: string; + /** Amount sent to the same address (self-transfer). */ + sentToSelf?: string; + /** Exchange rates at this point in time, if available. */ + rates?: {[key: string]: number}; + /** Transaction ID if the time corresponds to a specific tx. */ + txid?: string; +} +export interface BlockInfo { + Hash: string; + Time: number; + Txs: number; + Size: number; + Height: number; +} +export interface Blocks { + /** Current page index. */ + page?: number; + /** Total number of pages available. */ + totalPages?: number; + /** Number of items returned on this page. */ + itemsOnPage?: number; + /** List of blocks. */ + blocks: BlockInfo[]; +} +export interface Block { + /** Current page index. */ + page?: number; + /** Total number of pages available. */ + totalPages?: number; + /** Number of items returned on this page. */ + itemsOnPage?: number; + /** Block hash. */ + hash: string; + /** Hash of the previous block in the chain. */ + previousBlockHash?: string; + /** Hash of the next block, if known. */ + nextBlockHash?: string; + /** Block height (0-based index in the chain). */ + height: number; + /** Number of confirmations of this block (distance from best chain tip). */ + confirmations: number; + /** Size of the block in bytes. */ + size: number; + /** Timestamp of when this block was mined. */ + time?: number; + /** Block version (chain-specific meaning). */ + version: string; + /** Merkle root of the block's transactions. */ + merkleRoot: string; + /** Nonce used in the mining process. */ + nonce: string; + /** Compact representation of the target threshold. */ + bits: string; + /** Difficulty target for mining this block. */ + difficulty: string; + /** List of transaction IDs included in this block. */ + tx?: string[]; + /** Total count of transactions in this block. */ + txCount: number; + /** List of full transaction details (if requested). */ + txs?: Tx[]; + /** Optional aliases for addresses found in this block. */ + addressAliases?: {[key: string]: AddressAlias}; +} +export interface BlockRaw { + /** Hex-encoded block data. */ + hex: string; +} +export interface BackendInfo { + /** Error message if something went wrong in the backend. */ + error?: string; + /** Name of the chain - e.g. 'main'. */ + chain?: string; + /** Number of fully verified blocks in the chain. */ + blocks?: number; + /** Number of block headers in the chain. */ + headers?: number; + /** Hash of the best block in hex. */ + bestBlockHash?: string; + /** Current difficulty of the network. */ + difficulty?: string; + /** Size of the blockchain data on disk in bytes. */ + sizeOnDisk?: number; + /** Version of the blockchain backend - e.g. '280000'. */ + version?: string; + /** Subversion of the blockchain backend - e.g. '/Satoshi:28.0.0/'. */ + subversion?: string; + /** Protocol version of the blockchain backend - e.g. '70016'. */ + protocolVersion?: string; + /** Time offset (in seconds) reported by the backend. */ + timeOffset?: number; + /** Any warnings given by the backend regarding the chain state. */ + warnings?: string; + /** Version or details of the consensus protocol in use. */ + consensus_version?: string; + /** Additional chain-specific consensus data. */ + consensus?: any; +} +export interface InternalStateColumn { + /** Name of the database column. */ + name: string; + /** Version or schema version of the column. */ + version: number; + /** Number of rows stored in this column. */ + rows: number; + /** Total size (in bytes) of keys stored in this column. */ + keyBytes: number; + /** Total size (in bytes) of values stored in this column. */ + valueBytes: number; + /** Timestamp of the last update to this column. */ + updated: string; +} +export interface BlockbookInfo { + /** Coin name, e.g. 'Bitcoin'. */ + coin: string; + /** Network shortcut, e.g. 'BTC'. */ + network: string; + /** Hostname of the blockbook instance, e.g. 'backend5'. */ + host: string; + /** Running blockbook version, e.g. '0.4.0'. */ + version: string; + /** Git commit hash of the running blockbook, e.g. 'a0960c8e'. */ + gitCommit: string; + /** Build time of running blockbook, e.g. '2024-08-08T12:32:50+00:00'. */ + buildTime: string; + /** If true, blockbook is syncing from scratch or in a special sync mode. */ + syncMode: boolean; + /** Indicates if blockbook is in its initial sync phase. */ + initialSync: boolean; + /** Indicates if the backend is fully synced with the blockchain. */ + inSync: boolean; + /** Best (latest) block height according to this instance. */ + bestHeight: number; + /** Timestamp of the latest block in the chain. */ + lastBlockTime: string; + /** Indicates if mempool info is synced as well. */ + inSyncMempool: boolean; + /** Timestamp of the last mempool update. */ + lastMempoolTime: string; + /** Number of unconfirmed transactions in the mempool. */ + mempoolSize: number; + /** Number of decimals for this coin's base unit. */ + decimals: number; + /** Size of the underlying database in bytes. */ + dbSize: number; + /** Whether this instance provides fiat exchange rates. */ + hasFiatRates?: boolean; + /** Whether this instance provides fiat exchange rates for tokens. */ + hasTokenFiatRates?: boolean; + /** Timestamp of the latest fiat rates update. */ + currentFiatRatesTime?: string; + /** Timestamp of the latest historical fiat rates update. */ + historicalFiatRatesTime?: string; + /** Timestamp of the latest historical token fiat rates update. */ + historicalTokenFiatRatesTime?: string; + /** List of contract addresses supported for staking. */ + supportedStakingPools?: string[]; + /** Optional calculated DB size from columns. */ + dbSizeFromColumns?: number; + /** List of columns/tables in the DB for internal state. */ + dbColumns?: InternalStateColumn[]; + /** Additional human-readable info about this blockbook instance. */ + about: string; +} +export interface SystemInfo { + /** Blockbook instance information. */ + blockbook?: BlockbookInfo; + /** Information about the connected backend node. */ + backend?: BackendInfo; +} +export interface FiatTicker { + /** Unix timestamp for these fiat rates. */ + ts?: number; + /** Map of currency codes to their exchange rate. */ + rates: {[key: string]: number}; + /** Any error message encountered while fetching rates. */ + error?: string; +} +export interface FiatTickers { + /** List of fiat tickers with timestamps and rates. */ + tickers: FiatTicker[]; +} +export interface AvailableVsCurrencies { + /** Timestamp for the available currency list. */ + ts?: number; + /** List of currency codes (e.g., USD, EUR) supported by the rates. */ + available_currencies: string[]; + /** Error message, if any, when fetching the available currencies. */ + error?: string; +} +export interface WsReq { + /** Unique request identifier. */ + id: string; + /** Requested method name. */ + method: 'getAccountInfo' | 'getContractInfo' | 'getInfo' | 'getBlockHash'| 'getBlock' | 'getAccountUtxo' | 'getBalanceHistory' | 'getTransaction' | 'getTransactionSpecific' | 'estimateFee' | 'sendTransaction' | 'subscribeNewBlock' | 'unsubscribeNewBlock' | 'subscribeNewTransaction' | 'unsubscribeNewTransaction' | 'subscribeAddresses' | 'unsubscribeAddresses' | 'subscribeFiatRates' | 'unsubscribeFiatRates' | 'ping' | 'getCurrentFiatRates' | 'getFiatRatesForTimestamps' | 'getFiatRatesTickersList' | 'getMempoolFilters'; + /** Parameters for the requested method in raw JSON format. */ + params: any; +} +export interface WsRes { + /** Corresponding request identifier. */ + id: string; + /** Payload of the response, structure depends on the request. */ + data: any; +} +export interface WsAccountInfoReq { + /** Address or XPUB descriptor to query. */ + descriptor: string; + /** Level of detail to retrieve about the account. */ + details?: 'basic' | 'tokens' | 'tokenBalances' | 'txids' | 'txslight' | 'txs'; + /** Which tokens to include in the account info. */ + tokens?: 'derived' | 'used' | 'nonzero'; + /** Optional protocol enrichments to include. Supported values currently include 'erc4626'. */ + protocols?: string[]; + /** Number of items per page, if paging is used. */ + pageSize?: number; + /** Requested page index, if paging is used. */ + page?: number; + /** Starting block height for transaction filtering. */ + from?: number; + /** Ending block height for transaction filtering. */ + to?: number; + /** Filter by specific contract address (for token data). */ + contractFilter?: string; + /** Currency code to convert values into (e.g. 'USD'). */ + secondaryCurrency?: string; + /** Gap limit for XPUB scanning, if relevant. */ + gap?: number; +} +export interface WsContractInfoReq { + /** Contract address to query. */ + contract: string; + /** Optional secondary currency code used to include fiat pricing information. */ + currency?: string; + /** Optional protocol enrichments to include. Supported values currently include 'erc4626'. */ + protocols?: string[]; +} +export interface WsBackendInfo { + /** Backend version string. */ + version?: string; + /** Backend sub-version string. */ + subversion?: string; + /** Consensus protocol version in use. */ + consensus_version?: string; + /** Additional consensus details, structure depends on blockchain. */ + consensus?: any; +} +export interface WsInfoRes { + /** Human-readable blockchain name. */ + name: string; + /** Short code for the blockchain (e.g. BTC, ETH). */ + shortcut: string; + /** Network identifier (e.g. mainnet, testnet). */ + network: string; + /** Number of decimals in the base unit of the coin. */ + decimals: number; + /** Version of the blockbook or backend service. */ + version: string; + /** Current best chain height according to the backend. */ + bestHeight: number; + /** Block hash of the best (latest) block. */ + bestHash: string; + /** Genesis block hash or identifier. */ + block0Hash: string; + /** Indicates if this is a test network. */ + testnet: boolean; + /** Additional backend-related information. */ + backend: WsBackendInfo; +} +export interface WsBlockHashReq { + /** Block height for which the hash is requested. */ + height: number; +} +export interface WsBlockHashRes { + /** Block hash at the requested height. */ + hash: string; +} +export interface WsBlockReq { + /** Block identifier (hash). */ + id: string; + /** Number of transactions per page in the block. Defaults to 1000 and is capped at 10000. */ + pageSize?: number; + /** 1-based page index to retrieve if multiple pages of transactions are available. Values above the safe internal limit are clamped. */ + page?: number; +} +export interface WsBlockFilterReq { + /** Type of script filter (e.g., P2PKH, P2SH). */ + scriptType: string; + /** Block hash for which we want the filter. */ + blockHash: string; + /** Optional parameter for certain filter logic. */ + M?: number; +} +export interface WsBlockFiltersBatchReq { + /** Type of script filter (e.g., P2PKH, P2SH). */ + scriptType: string; + /** Hash of the latest known block. Filters will be retrieved backward from here. */ + bestKnownBlockHash: string; + /** Number of block filters per request. */ + pageSize?: number; + /** Optional parameter for certain filter logic. */ + M?: number; +} +export interface WsAccountUtxoReq { + /** Address or XPUB descriptor to retrieve UTXOs for. */ + descriptor: string; +} +export interface WsBalanceHistoryReq { + /** Address or XPUB descriptor to query history for. */ + descriptor: string; + /** Unix timestamp from which to start the history. */ + from?: number; + /** Unix timestamp at which to end the history. */ + to?: number; + /** List of currency codes for which to fetch exchange rates at each interval. */ + currencies?: string[]; + /** Gap limit for XPUB scanning, if relevant. */ + gap?: number; + /** Size of each aggregated time window in seconds. */ + groupBy?: number; +} +export interface WsTransactionReq { + /** Transaction ID to retrieve details for. */ + txid: string; +} +export interface WsTransactionSpecificReq { + /** Transaction ID for the detailed blockchain-specific data. */ + txid: string; +} +export interface WsEstimateFeeReq { + /** Block confirmations targets for which fees should be estimated. */ + blocks?: number[]; + /** Additional chain-specific parameters (e.g. for Ethereum). */ + specific?: {conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string;}; +} +export interface Eip1559Fee { + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + minWaitTimeEstimate?: number; + maxWaitTimeEstimate?: number; +} +export interface Eip1559Fees { + baseFeePerGas?: string; + low?: Eip1559Fee; + medium?: Eip1559Fee; + high?: Eip1559Fee; + instant?: Eip1559Fee; + networkCongestion?: number; + latestPriorityFeeRange?: string[]; + historicalPriorityFeeRange?: string[]; + historicalBaseFeeRange?: string[]; + priorityFeeTrend?: 'up' | 'down'; + baseFeeTrend?: 'up' | 'down'; +} +export interface WsEstimateFeeRes { + /** Estimated total fee per transaction, if relevant. */ + feePerTx?: string; + /** Estimated fee per unit (sat/byte, Wei/gas, etc.). */ + feePerUnit?: string; + /** Max fee limit for blockchains like Ethereum. */ + feeLimit?: string; + eip1559?: Eip1559Fees; +} +export interface WsLongTermFeeRateRes { + /** Long term fee rate (in sat/kByte). */ + feePerUnit: string; + /** Amount of blocks used for the long term fee rate estimation. */ + blocks: number; +} +export interface WsSendTransactionReq { + /** Hex-encoded transaction data to broadcast (string format). */ + hex?: string; + /** Use alternative RPC method to broadcast transaction. */ + disableAlternativeRpc: boolean; +} +export interface WsSubscribeAddressesReq { + /** List of addresses to subscribe for updates (e.g., new transactions). */ + addresses: string[]; + /** If true, also publish confirmed transactions for subscribed addresses when new blocks are connected. */ + newBlockTxs?: boolean; +} +export interface WsSubscribeFiatRatesReq { + /** Fiat currency code (e.g. 'USD'). */ + currency?: string; + /** List of token symbols or IDs to get fiat rates for. */ + tokens?: string[]; +} +export interface WsCurrentFiatRatesReq { + /** List of fiat currencies, e.g. ['USD','EUR']. */ + currencies?: string[]; + /** Token symbol or ID if asking for token fiat rates (e.g. 'ETH'). */ + token?: string; +} +export interface WsFiatRatesForTimestampsReq { + /** List of Unix timestamps for which to retrieve fiat rates. */ + timestamps: number[]; + /** List of fiat currencies, e.g. ['USD','EUR']. */ + currencies?: string[]; + /** Token symbol or ID if asking for token fiat rates. */ + token?: string; +} +export interface WsFiatRatesTickersListReq { + /** Timestamp for which the list of available tickers is needed. */ + timestamp?: number; + /** Token symbol or ID if asking for token-specific fiat rates. */ + token?: string; +} +export interface WsMempoolFiltersReq { + /** Type of script we are filtering for (e.g., P2PKH, P2SH). */ + scriptType: string; + /** Only retrieve filters for mempool txs after this timestamp. */ + fromTimestamp: number; + /** Optional parameter for certain filter logic (e.g., n-bloom). */ + M?: number; +} +export interface WsRpcCallReq { + /** Address from which the RPC call is originated (if relevant). */ + from?: string; + /** Contract or address to which the RPC call is made. */ + to: string; + /** Hex-encoded call data (function signature + parameters). */ + data: string; +} +export interface WsRpcCallRes { + /** Hex-encoded return data from the call. */ + data: string; +} +export interface MempoolTxidFilterEntries { + /** Map of txid to filter data (hex-encoded). */ + entries?: {[key: string]: string}; + /** Indicates if a zeroed key was used in filter calculation. */ + usedZeroedKey?: boolean; +} diff --git a/blockbook.go b/blockbook.go index aac9696498..4029e4e345 100644 --- a/blockbook.go +++ b/blockbook.go @@ -2,9 +2,7 @@ package main import ( "context" - "encoding/json" "flag" - "io/ioutil" "log" "math/rand" "net/http" @@ -12,7 +10,9 @@ import ( "os" "os/signal" "runtime/debug" + "strconv" "strings" + "sync" "syscall" "time" @@ -28,8 +28,8 @@ import ( "github.com/trezor/blockbook/server" ) -// debounce too close requests for resync -const debounceResyncIndexMs = 1009 +// default debounce for too-close requests for resync +const defaultResyncIndexDebounceMs = 1009 // debounce too close requests for resync mempool (ZeroMQ sends message for each tx, when new block there are many transactions) const debounceResyncMempoolMs = 1009 @@ -82,8 +82,13 @@ var ( // resync index at least each resyncIndexPeriodMs (could be more often if invoked by message from ZeroMQ) resyncIndexPeriodMs = flag.Int("resyncindexperiod", 935093, "resync index period in milliseconds") + // debounce for push-triggered index resync requests + resyncIndexDebounceMs = flag.Int("resyncindexdebounce", defaultResyncIndexDebounceMs, "debounce for push-triggered index resync requests in milliseconds") + // resync mempool at least each resyncMempoolPeriodMs (could be more often if invoked by message from ZeroMQ) resyncMempoolPeriodMs = flag.Int("resyncmempoolperiod", 60017, "resync mempool period in milliseconds") + + extendedIndex = flag.Bool("extendedindex", false, "if true, create index of input txids and spending transactions") ) var ( @@ -100,8 +105,8 @@ var ( metrics *common.Metrics syncWorker *db.SyncWorker internalState *common.InternalState + fiatRates *fiat.FiatRates callbacksOnNewBlock []bchain.OnNewBlockFunc - callbacksOnNewTxAddr []bchain.OnNewTxAddrFunc callbacksOnNewTx []bchain.OnNewTxFunc callbacksOnNewFiatRatesTicker []fiat.OnNewFiatRatesTicker chanOsSignal chan os.Signal @@ -132,7 +137,24 @@ func mainWithExitCode() int { rand.Seed(time.Now().UTC().UnixNano()) chanOsSignal = make(chan os.Signal, 1) - signal.Notify(chanOsSignal, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) + shutdownSigCh := make(chan os.Signal, 1) + signalCh := make(chan os.Signal, 1) + // Use a single signal listener and fan out shutdown signals to avoid races + // where long-running workers consume the OS signal before main shutdown runs. + signal.Notify(signalCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) + var shutdownOnce sync.Once + go func() { + sig := <-signalCh + shutdownOnce.Do(func() { + // Flip global shutdown state and close chanOsSignal to broadcast shutdown. + // Closing the channel unblocks select loops that only receive from it. + common.SetInShutdown() + close(chanOsSignal) + // Ensure waitForSignalAndShutdown can proceed even if the OS signal + // was already consumed by another goroutine in previous versions. + shutdownSigCh <- sig + }) + }() glog.Infof("Blockbook: %+v, debug mode %v", common.GetVersionInfo(), *debugMode) @@ -150,36 +172,37 @@ func mainWithExitCode() int { return exitCodeOK } - if *configFile == "" { - glog.Error("Missing blockchaincfg configuration parameter") - return exitCodeFatal - } - - coin, coinShortcut, coinLabel, err := coins.GetCoinNameFromConfig(*configFile) + config, err := common.GetConfig(*configFile) if err != nil { glog.Error("config: ", err) return exitCodeFatal } - metrics, err = common.GetMetrics(coin) + metrics, err = common.GetMetrics(config.CoinName) if err != nil { glog.Error("metrics: ", err) return exitCodeFatal } - if chain, mempool, err = getBlockChainWithRetry(coin, *configFile, pushSynchronizationHandler, metrics, 120); err != nil { + if chain, mempool, err = getBlockChainWithRetry(config.CoinName, *configFile, pushSynchronizationHandler, metrics, 120); err != nil { glog.Error("rpc: ", err) return exitCodeFatal } - index, err = db.NewRocksDB(*dbPath, *dbCache, *dbMaxOpenFiles, chain.GetChainParser(), metrics) + index, err = db.NewRocksDB(*dbPath, *dbCache, *dbMaxOpenFiles, chain.GetChainParser(), metrics, *extendedIndex) if err != nil { glog.Error("rocksDB: ", err) return exitCodeFatal } - defer index.Close() + defer func() { + glog.Info("shutdown: rocksdb close start") + if err := index.Close(); err != nil { + glog.Error("shutdown: rocksdb close error: ", err) + } + glog.Info("shutdown: rocksdb close finished") + }() - internalState, err = newInternalState(coin, coinShortcut, coinLabel, index) + internalState, err = newInternalState(config, index, *enableSubNewTx) if err != nil { glog.Error("internalState: ", err) return exitCodeFatal @@ -194,6 +217,17 @@ func mainWithExitCode() int { } internalState.UtxoChecked = true } + + // sort addressContracts if necessary + if !internalState.SortedAddressContracts { + err = index.SortAddressContracts(chanOsSignal) + if err != nil { + glog.Error("sortAddressContracts: ", err) + return exitCodeFatal + } + internalState.SortedAddressContracts = true + } + index.SetInternalState(internalState) if *fixUtxo { err = index.StoreInternalState(internalState) @@ -260,6 +294,11 @@ func mainWithExitCode() int { return exitCodeFatal } + if fiatRates, err = fiat.NewFiatRates(index, config, metrics, onNewFiatRatesTicker); err != nil { + glog.Error("fiatRates ", err) + return exitCodeFatal + } + // report BlockbookAppInfo metric, only log possible error if err = blockbookAppInfoMetric(index, chain, txCache, internalState, metrics); err != nil { glog.Error("blockbookAppInfoMetric ", err) @@ -298,7 +337,7 @@ func mainWithExitCode() int { if chain.GetChainParser().GetChainType() == bchain.ChainBitcoinType { addrDescForOutpoint = index.AddrDescForOutpoint } - err = chain.InitializeMempool(addrDescForOutpoint, onNewTxAddr, onNewTx) + err = chain.InitializeMempool(addrDescForOutpoint, onNewTx) if err != nil { glog.Error("initializeMempool ", err) return exitCodeFatal @@ -318,7 +357,6 @@ func mainWithExitCode() int { if publicServer != nil { // start full public interface callbacksOnNewBlock = append(callbacksOnNewBlock, publicServer.OnNewBlock) - callbacksOnNewTxAddr = append(callbacksOnNewTxAddr, publicServer.OnNewTxAddr) callbacksOnNewTx = append(callbacksOnNewTx, publicServer.OnNewTx) callbacksOnNewFiatRatesTicker = append(callbacksOnNewFiatRatesTicker, publicServer.OnNewFiatRatesTicker) publicServer.ConnectFullPublicInterface() @@ -332,7 +370,7 @@ func mainWithExitCode() int { until := uint32(*blockUntil) if !*synchronize { - if err = syncWorker.ConnectBlocksParallel(height, until); err != nil { + if err = syncWorker.BulkConnectBlocks(height, until); err != nil { if err != db.ErrOperationInterrupted { glog.Error("connectBlocksParallel ", err) return exitCodeFatal @@ -344,18 +382,19 @@ func mainWithExitCode() int { if internalServer != nil || publicServer != nil || chain != nil { // start fiat rates downloader only if not shutting down immediately - initDownloaders(index, chain, *configFile) - waitForSignalAndShutdown(internalServer, publicServer, chain, 10*time.Second) + initDownloaders(index, chain, config) + waitForSignalAndShutdown(internalServer, publicServer, chain, shutdownSigCh, 10*time.Second) } + // Always stop periodic state storage to prevent writes during shutdown. + close(chanStoreInternalState) if *synchronize { close(chanSyncIndex) close(chanSyncMempool) - close(chanStoreInternalState) <-chanSyncIndexDone <-chanSyncMempoolDone - <-chanStoreInternalStateDone } + <-chanStoreInternalStateDone return exitCodeOK } @@ -384,7 +423,7 @@ func getBlockChainWithRetry(coin string, configFile string, pushHandler func(bch } func startInternalServer() (*server.InternalServer, error) { - internalServer, err := server.NewInternalServer(*internalBinding, *certFiles, index, chain, mempool, txCache, metrics, internalState) + internalServer, err := server.NewInternalServer(*internalBinding, *certFiles, index, chain, mempool, txCache, metrics, internalState, fiatRates) if err != nil { return nil, err } @@ -404,7 +443,7 @@ func startInternalServer() (*server.InternalServer, error) { func startPublicServer() (*server.PublicServer, error) { // start public server in limited functionality, extend it after sync is finished by calling ConnectFullPublicInterface - publicServer, err := server.NewPublicServer(*publicBinding, *certFiles, index, chain, mempool, txCache, *explorerURL, metrics, internalState, *debugMode, *enableSubNewTx) + publicServer, err := server.NewPublicServer(*publicBinding, *certFiles, index, chain, mempool, txCache, *explorerURL, metrics, internalState, fiatRates, *debugMode) if err != nil { return nil, err } @@ -450,7 +489,7 @@ func performRollback() error { } func blockbookAppInfoMetric(db *db.RocksDB, chain bchain.BlockChain, txCache *db.TxCache, is *common.InternalState, metrics *common.Metrics) error { - api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is) + api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is, fiatRates) if err != nil { return err } @@ -477,16 +516,13 @@ func blockbookAppInfoMetric(db *db.RocksDB, chain bchain.BlockChain, txCache *db return nil } -func newInternalState(coin, coinShortcut, coinLabel string, d *db.RocksDB) (*common.InternalState, error) { - is, err := d.LoadInternalState(coin) +func newInternalState(config *common.Config, d *db.RocksDB, enableSubNewTx bool) (*common.InternalState, error) { + is, err := d.LoadInternalState(config) if err != nil { return nil, err } - is.CoinShortcut = coinShortcut - if coinLabel == "" { - coinLabel = coin - } - is.CoinLabel = coinLabel + + is.EnableSubNewTx = enableSubNewTx name, err := os.Hostname() if err != nil { glog.Error("get hostname ", err) @@ -496,6 +532,12 @@ func newInternalState(coin, coinShortcut, coinLabel string, d *db.RocksDB) (*com } is.Host = name } + + is.WsGetAccountInfoLimit, _ = strconv.Atoi(os.Getenv(strings.ToUpper(is.GetNetwork()) + "_WS_GETACCOUNTINFO_LIMIT")) + if is.WsGetAccountInfoLimit > 0 { + glog.Info("WsGetAccountInfoLimit enabled with limit ", is.WsGetAccountInfoLimit) + is.WsLimitExceedingIPs = make(map[string]int) + } return is, nil } @@ -503,12 +545,18 @@ func syncIndexLoop() { defer close(chanSyncIndexDone) glog.Info("syncIndexLoop starting") // resync index about every 15 minutes if there are no chanSyncIndex requests, with debounce 1 second - common.TickAndDebounce(time.Duration(*resyncIndexPeriodMs)*time.Millisecond, debounceResyncIndexMs*time.Millisecond, chanSyncIndex, func() { - if err := syncWorker.ResyncIndex(onNewBlockHash, false); err != nil { + common.TickAndDebounce(time.Duration(*resyncIndexPeriodMs)*time.Millisecond, time.Duration(*resyncIndexDebounceMs)*time.Millisecond, chanSyncIndex, func() { + if err := syncWorker.ResyncIndex(onNewBlock, false); err != nil { + if err == db.ErrOperationInterrupted || common.IsInShutdown() { + return + } glog.Error("syncIndexLoop ", errors.ErrorStack(err), ", will retry...") // retry once in case of random network error, after a slight delay time.Sleep(time.Millisecond * 2500) - if err := syncWorker.ResyncIndex(onNewBlockHash, false); err != nil { + if err := syncWorker.ResyncIndex(onNewBlock, false); err != nil { + if err == db.ErrOperationInterrupted || common.IsInShutdown() { + return + } glog.Error("syncIndexLoop ", errors.ErrorStack(err)) } } @@ -516,14 +564,14 @@ func syncIndexLoop() { glog.Info("syncIndexLoop stopped") } -func onNewBlockHash(hash string, height uint32) { +func onNewBlock(block *bchain.Block) { defer func() { if r := recover(); r != nil { glog.Error("onNewBlockHash recovered from panic: ", r) } }() for _, c := range callbacksOnNewBlock { - c(hash, height) + c(block) } } @@ -556,12 +604,11 @@ func syncMempoolLoop() { } func storeInternalStateLoop() { - stopCompute := make(chan os.Signal) defer func() { - close(stopCompute) close(chanStoreInternalStateDone) }() - signal.Notify(stopCompute, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) + // Reuse the global shutdown channel so compute work stops when shutdown begins. + stopCompute := chanOsSignal var computeRunning bool lastCompute := time.Now() lastAppInfo := time.Now() @@ -601,17 +648,6 @@ func storeInternalStateLoop() { glog.Info("storeInternalStateLoop stopped") } -func onNewTxAddr(tx *bchain.Tx, desc bchain.AddressDescriptor) { - defer func() { - if r := recover(); r != nil { - glog.Error("onNewTxAddr recovered from panic: ", r) - } - }() - for _, c := range callbacksOnNewTxAddr { - c(tx, desc) - } -} - func onNewTx(tx *bchain.MempoolTx) { defer func() { if r := recover(); r != nil { @@ -637,11 +673,17 @@ func pushSynchronizationHandler(nt bchain.NotificationType) { } } -func waitForSignalAndShutdown(internal *server.InternalServer, public *server.PublicServer, chain bchain.BlockChain, timeout time.Duration) { - sig := <-chanOsSignal +func waitForSignalAndShutdown(internal *server.InternalServer, public *server.PublicServer, chain bchain.BlockChain, shutdownSig <-chan os.Signal, timeout time.Duration) { + // Read the first OS signal from the dedicated channel to avoid races with worker shutdown paths. + sig := <-shutdownSig common.SetInShutdown() - glog.Infof("shutdown: %v", sig) + if sig != nil { + glog.Infof("shutdown: %v", sig) + } else { + glog.Info("shutdown: signal received") + } + // Bound server/RPC shutdown; RocksDB close happens after main returns via defer. ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -668,7 +710,7 @@ func waitForSignalAndShutdown(internal *server.InternalServer, public *server.Pu func computeFeeStats(stopCompute chan os.Signal, blockFrom, blockTo int, db *db.RocksDB, chain bchain.BlockChain, txCache *db.TxCache, is *common.InternalState, metrics *common.Metrics) error { start := time.Now() glog.Info("computeFeeStats start") - api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is) + api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is, fiatRates) if err != nil { return err } @@ -677,36 +719,9 @@ func computeFeeStats(stopCompute chan os.Signal, blockFrom, blockTo int, db *db. return err } -func initDownloaders(db *db.RocksDB, chain bchain.BlockChain, configFile string) { - data, err := ioutil.ReadFile(configFile) - if err != nil { - glog.Errorf("Error reading file %v, %v", configFile, err) - return - } - - var config struct { - FiatRates string `json:"fiat_rates"` - FiatRatesParams string `json:"fiat_rates_params"` - FiatRatesVsCurrencies string `json:"fiat_rates_vs_currencies"` - FourByteSignatures string `json:"fourByteSignatures"` - } - - err = json.Unmarshal(data, &config) - if err != nil { - glog.Errorf("Error parsing config file %v, %v", configFile, err) - return - } - - if config.FiatRates == "" || config.FiatRatesParams == "" { - glog.Infof("FiatRates config (%v) is empty, not downloading fiat rates", configFile) - } else { - fiatRates, err := fiat.NewFiatRatesDownloader(db, config.FiatRates, config.FiatRatesParams, config.FiatRatesVsCurrencies, onNewFiatRatesTicker) - if err != nil { - glog.Errorf("NewFiatRatesDownloader Init error: %v", err) - } else { - glog.Infof("Starting %v FiatRates downloader...", config.FiatRates) - go fiatRates.Run() - } +func initDownloaders(db *db.RocksDB, chain bchain.BlockChain, config *common.Config) { + if fiatRates.Enabled { + go fiatRates.RunDownloader() } if config.FourByteSignatures != "" && chain.GetChainParser().GetChainType() == bchain.ChainEthereumType { diff --git a/build/docker/bin/Dockerfile b/build/docker/bin/Dockerfile index a8b0a6380e..07e4254dae 100644 --- a/build/docker/bin/Dockerfile +++ b/build/docker/bin/Dockerfile @@ -11,8 +11,8 @@ RUN apt-get update && \ libzstd-dev liblz4-dev graphviz && \ apt-get clean ARG GOLANG_VERSION -ENV GOLANG_VERSION=go1.19.2 -ENV ROCKSDB_VERSION=v7.7.2 +ENV GOLANG_VERSION=go1.25.4 +ENV ROCKSDB_VERSION=v9.10.0 ENV GOPATH=/go ENV PATH=$PATH:$GOPATH/bin ENV CGO_CFLAGS="-I/opt/rocksdb/include" @@ -39,7 +39,7 @@ RUN echo -n "GOPATH: " && echo $GOPATH # install rocksdb RUN cd /opt && git clone -b $ROCKSDB_VERSION --depth 1 https://github.com/facebook/rocksdb.git -RUN cd /opt/rocksdb && CFLAGS=-fPIC CXXFLAGS=-fPIC PORTABLE=$PORTABLE_ROCKSDB make -j 4 release +RUN cd /opt/rocksdb && CFLAGS=-fPIC CXXFLAGS=-fPIC PORTABLE=$PORTABLE_ROCKSDB DISABLE_WARNING_AS_ERROR=1 make -j 4 release RUN strip /opt/rocksdb/ldb /opt/rocksdb/sst_dump && \ cp /opt/rocksdb/ldb /opt/rocksdb/sst_dump /build diff --git a/build/docker/bin/Makefile b/build/docker/bin/Makefile index 9111e24e0b..d081d8f146 100644 --- a/build/docker/bin/Makefile +++ b/build/docker/bin/Makefile @@ -1,6 +1,6 @@ SHELL = /bin/bash VERSION ?= devel -GITCOMMIT = $(shell cd /src && git config --global --add safe.directory /src && git describe --always --dirty) +GITCOMMIT ?= $(shell cd /src && git config --global --add safe.directory /src && git describe --always --dirty) BUILDTIME = $(shell date --iso-8601=seconds) LDFLAGS := -X github.com/trezor/blockbook/common.version=$(VERSION) -X github.com/trezor/blockbook/common.gitcommit=$(GITCOMMIT) -X github.com/trezor/blockbook/common.buildtime=$(BUILDTIME) BLOCKBOOK_BASE := $(GOPATH)/src/github.com/trezor @@ -27,10 +27,16 @@ test: prepare-sources cd $(BLOCKBOOK_SRC) && go test -tags 'unittest' `go list ./... | grep -vP '^github.com/trezor/blockbook/(contrib|tests)'` $(ARGS) test-integration: prepare-sources - cd $(BLOCKBOOK_SRC) && go test -tags 'integration' `go list github.com/trezor/blockbook/tests/...` $(ARGS) + cd $(BLOCKBOOK_SRC) && go test -tags 'integration' `go list github.com/trezor/blockbook/tests/...` -run 'TestIntegration/.*/(rpc|sync)' -timeout 30m $(ARGS) + +test-e2e: prepare-sources + cd $(BLOCKBOOK_SRC) && go test -tags 'integration' `go list github.com/trezor/blockbook/tests/...` -run "$${E2E_REGEX:-TestIntegration/.*/api}" -timeout 30m $(ARGS) + +test-connectivity: prepare-sources + cd $(BLOCKBOOK_SRC) && go test -tags 'integration' github.com/trezor/blockbook/tests -run 'TestIntegration/.*/connectivity' -timeout 3m $(ARGS) test-all: prepare-sources - cd $(BLOCKBOOK_SRC) && go test -tags 'unittest integration' `go list ./... | grep -v '^github.com/trezor/blockbook/contrib'` $(ARGS) + cd $(BLOCKBOOK_SRC) && go test -tags 'unittest integration' `go list ./... | grep -v '^github.com/trezor/blockbook/contrib'` -timeout 30m $(ARGS) prepare-sources: @ [ -n "`ls /src 2> /dev/null`" ] || (echo "/src doesn't exist or is empty" 1>&2 && exit 1) @@ -38,4 +44,3 @@ prepare-sources: mkdir -p $(BLOCKBOOK_BASE) cp -r /src $(BLOCKBOOK_SRC) cd $(BLOCKBOOK_SRC) && go mod download - sed -i 's/wsMessageSizeLimit\ =\ 15\ \*\ 1024\ \*\ 1024/wsMessageSizeLimit = 50 * 1024 * 1024/g' $(GOPATH)/pkg/mod/github.com/ethereum/go-ethereum*/rpc/websocket.go diff --git a/build/docker/deb/Dockerfile b/build/docker/deb/Dockerfile index 55989099ab..fd8fa114ef 100644 --- a/build/docker/deb/Dockerfile +++ b/build/docker/deb/Dockerfile @@ -6,9 +6,19 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get upgrade -y && \ - apt-get install -y devscripts debhelper make dh-exec && \ + apt-get install -y devscripts debhelper make dh-exec zstd && \ apt-get clean +# install docker cli +ARG DOCKER_VERSION + +RUN if [ -z "$DOCKER_VERSION" ]; then echo "DOCKER_VERSION is a required build arg" && exit 1; fi + +RUN wget -O docker.tgz "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz" && \ + tar -xzf docker.tgz --strip 1 -C /usr/local/bin/ && \ + rm docker.tgz && \ + docker --version + ADD gpg-keys /tmp/gpg-keys RUN gpg --batch --import /tmp/gpg-keys/* diff --git a/build/docker/deb/gpg-keys/dash-releases.asc b/build/docker/deb/gpg-keys/dash-releases.asc index 66f98fee0a..061617a4a6 100644 --- a/build/docker/deb/gpg-keys/dash-releases.asc +++ b/build/docker/deb/gpg-keys/dash-releases.asc @@ -1,5 +1,103 @@ -----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBGWp8IkBCADEaVzTSOymYATI+x7Wp72QZnMZy5dbiOKvRd1E+zMAxamk3RgP +xu1g9zwecxRR5EU6HQoDawFckDp2kM014N055bXkIoQS04RTspfTWKa5TkcII2vR +sPRI7Hz3UXFvs3FngzLe3Kqp7HZ5dHzBiynm2hT1a0Bmzc19B/9A1zN51Hsvfdgo +tIfb9sHBUiq6+Sx8b/oKiouW/HQA6uFrYZFPwIVntagFcJjkNGwhziFHgo3yrMWm +qR4Nsuag/P0aa1byIvE6vkTOD05W7IfxasWy3bMxvTEWFsQCHJ5he5RBIzh9tq57 +YEhGqYfdTeAZ1GlJC/ByoCzrEQnXylQiRbylABEBAAG0I1Bhc3RhIFl1YmlrZXkg +PHBhc3RhQGRhc2hib29zdC5vcmc+iQFoBBMBCABSAhsDAhkBBQsJCAcCBhUKCQgL +AgUWAgMBAAIeBBYhBGCs9wv3EmRQSe5vFe/q8WaGIl9kBQJlqfxxGBhoa3BzOi8v +a2V5cy5vcGVucGdwLm9yZwAKCRDv6vFmhiJfZFErCAC6Fn5eiLMF0Ge0FFUWFQvw +NDpIEIqECRgp1Y44H6Rn4KPJArmVRB9UYmm9ntPo2v/fX6wFCRm+1sud8pZq4leF +I8efyKcCRqFDQm3GlXqpfqXD/Utbn2MVhUYhFu0FyLBbx9P4ZN5y1+dKJcBISDqD +XZ4GXSVBUPuBaygE5lbcTk+wFQWfiqjg8mk9dq/qlFEuL2rSQIYWW8z8pNYllg8M +T/qQ3ydY/O5BQuliUjFnyLCorghifUtO4cgMSXKdtop+Sle5GEUaQqM13wPOBo3V +SMWCxcPjwMj8x3q4b83fq9q2O1UVHhzmL7wFFUOKWBOZvokJPJqsUYRVGgT9J6WX +iQIzBBABCAAdFiEEKVkDYuyHioH9PCArUlJ77avoeYQFAmWqA6MACgkQUlJ77avo +eYTdGBAAlGZQ0GTf9fp6cwGW057fLZP0ysA+ThJlEqxOLXeGfuHlo+xxlDy6k8SN +DlmcFEgXsAWoD0X/HWZ+7G1kVVPJSixpVuuP513z38a7vNDlgF42livLcKticDpu +6gPuAS7YEEa5uugGJwmylHUeIVE69gp1QgJVPy0Egynv4IpsCiuuWLc/HL0uOS59 +KljH150cxsWX1sUIbgFapEqU5T2f5JFNO/ikBCqh9kFBw9ccMoQWBLw/AwpUqNH/ +8U7czzgnTvJqnXA97s1zUlbvOBpt7om2FRAcSGKcZNEGDp/jIOZUBAT3X+T4mvta +w+3g9U/7yg8mlka+DVxOE43eypQyyNoWP5ZetTb2R1Qq+WBaZHRJh9JoS03EYenL +XxDELYzkt2S6keh7sExc0j4nV9XmoRr5LD848HSQKB9fymcxkxPgn3avK28NMGpm +Xudqh/pz4PrOn+WOJJQg4494UvFtZ2zkAUnc6O0EUbr3ti6AUZCuyIZWc1GJmDrA +F3NtT4FgX40LjV6jcWAurN9HBX5mrV79X/5tqQBpho4DpNPs5rm8tDEYTWF+irFD +O96VJSVr5A9otM5kzHC7aUFCeXPgcCH5lpgZXj/7nE46Xf9MX4lmJ63oQ1hzELOe +Xtl1kSVmmtHDbj55LG496sxn0C5wc7WSZYge9llkLFnlgJQG8h60HlBhc3RhIFl1 +YmlrZXkgPHBhc3RhQGRhc2gub3JnPokBZQQTAQgATwIbAwULCQgHAgYVCgkICwIF +FgIDAQACHgQWIQRgrPcL9xJkUEnubxXv6vFmhiJfZAUCZan8cRgYaGtwczovL2tl +eXMub3BlbnBncC5vcmcACgkQ7+rxZoYiX2SjXAf/fXPwm0j84B9gVxjB4la1YahZ +/jomHhMzZm/HYqEs/3KrBPVUSM0+tkqI6pgVQVI9hTlijkcNhhZKAIF5Ye87Ule1 +x7wlnTJ+msWXMtybhaTv55BQVsnGRN/h88yoZH5UOylbMnFmeYh9IP9WKvrTTfZS +cSDN1Ib2LjeiPvxTyL9HiOTtCz1w6iijdS3rDWIEJhugBnFZ52nG+mQU5sy5+5S2 +W/PKr8hKqDVifCeZAju3sYTRsBBbCnGeTlqOtj/IJ65A2bw5tzM4gK6hrQwolzrC +c7teu9bZdP2dYuspkaGNX6afxR62VZYnpH/VCPp54c0/0Hl+TWEbERfGicLbC4kC +MgQQAQgAHRYhBClZA2Lsh4qB/TwgK1JSe+2r6HmEBQJlqfWlAAoJEFJSe+2r6HmE +C1QP9Ryh2XiUhQmvtiiDFPxzK0sa9YNAk84nUAOSrRLIQ1Xs3g33cg15kxMvtKf9 +OIJD14Mu1ypnfa1jsDr6zdy3CQCKAKEBTH41jw3XLa9R9XWaT6+0YV+meIHZ6uVJ +3+5M1xZGsnErsTM+iGGmneRIt2L0cZTt7HRJaL0EJrd7PXQb8B9BxgPnRa4UVpqd +FlhMhNHad7rz5hFAz8YkYEGX/bctF2y/gmHnu/xKkQsOlV+fQfROOlo/wQ/2vXRY +YBqWrVw0gAFDaI4P43CoKlYFzZOxrX+RLSc6eOSgmRkwMx5NzpOvfbypuiXLCmed +8pTF9SeXH3LzdO1gJQsKkia04OBohCosmnIjOCjeN3bxf606HZpBgXhj72kXZOX0 +NeA+yxEh1QIhvjxvD0WyIUChaXYsGy61F16vIUytE319diU/e/KQKnTC+oepiju6 +N23Iy8c2gRux48ghkmcN58bLOCUUvO+UYb7U9YYsi6HEiL8yd8KVPHVJ293NcMt0 +FsmxFd4Fddr2HYK0NLtf5MDo4yYMw2PmbQ/1/cy/Sr6BvlHmZ6R9+I9beO5LjPBQ +EN62PWWBfl6b2EpYyA9RTFUKFiRhEoqLpmORlzMcUcmIsIYX5ZWanitBnSnIznGe +TapoOXPE93OrpDJU9vIcYx7Y4E8drNAdW1zZcFBo9ilNexq0i1Bhc3RhIFl1Ymlr +ZXkgKFRoaXMgaXMgYW4gb2ZmbGluZSBvbmx5IGtleSB1c2VkIGZvciB0aGUgaGln +aGVzdCBsZXZlbCBvZiB2YWxpZGF0aW9uLiBNeSBtYWluIGtleSBpcyA1MjUyN0JF +REFCRTg3OTg0LiBTZWUga2V5YmFzZS5pby9wYXN0YSmJAWUEEwEIAE8CGwMFCwkI +BwIGFQoJCAsCBRYCAwEAAh4EFiEEYKz3C/cSZFBJ7m8V7+rxZoYiX2QFAmWp/HEY +GGhrcHM6Ly9rZXlzLm9wZW5wZ3Aub3JnAAoJEO/q8WaGIl9kVUYH/2HrXiEHYIZU +NojBSKzBqWUSoXjvN1lITo7WSzdg/saQLtIBuEWwVtZKGH9HcRpi93glAZk+0xeO +Twke4fEAeEiYS3U3t+GqqH5bo4aJD1+EedvpjM5PVhtDyM4VVw8wu/29Tl7lIZQ9 +57Un1dwuYrsO6BEmKWmnV31XpN7JMd4qIAIeQoN9NMOFBT2PS7LXiIUZ36TH3ZAP +hgbec/MhgCQW//KmMd6lqVCNhjJ4ggYeifsAhFo/xMMYxbpFZXkYkpMxziZoG7MT +gQLR2YQEVQm9rQOjdn4IOWN6qoEtxx/82mMq/JynGeMXMyt4rgdSpcjTgnBlKMBv +DU2FF+hvMWiJAjMEEAEIAB0WIQQpWQNi7IeKgf08ICtSUnvtq+h5hAUCZaoDowAK +CRBSUnvtq+h5hKMFD/9zrGMZh6da8RBO1+cU4LZi0KDcFPd0dMHIpnvJ0w1oI3aY +WBmtKbLm5lQZ9OqgRp3MTFZPXbnMrfjqNwmRkEW5V1RjA24MMXjCb5wdD7ZMQ3VN +sXMi4WEJ61o1uVobrBSowmtBJMXyx3tGcHOXOpIXzG+HVx2gnlqFytK621PmSjlA +If498EpqQriIqoEuVkeoyQ0fhSl1d5/gnfP629i1ERnyRN8htJ+J6CJUuHNRPfST +pqvfyrLQTvPSDC7tTNuTY47EKEy3QP1s+R6hLFVbBTxBK1lJVrxBpBqLFCdRQswX +7Xv2p6syn9ia3DmBpw2Bfh8ySPmgVwgonZODXTRAo0uYV3hdeJgblVt9XhSa9C9z +DYgrjXR3EGT+N3GYkjdXqdoOnZzsaUD7CQLnobW4ZIjM+EtwP7QBXv89liqW0ppK +RuZOJ8Zycbiqa+ThK0r2gFm8j7HZWBNE/osVuschQ89d1FmwUKmcMCba/IbNDDHG +JdTr6fJvbXdyF183GZhvSlXdOMPNhcX4dRUcxkooMcUjbnERHKb6q1AKvoIYceb+ +/WaO/RUzCWCRbIEdYKxqYFuKRvuMHcR/F0fGeUUNsujLBuL5xSdZmNDpOrefTH0R +ZDLdTtKATr4GbkVZGBtXvWmd6c5NdJLCMO/n1V6j2ZdpbRBsvB/tl0emdXUvr7kB +DQRlqfCJAQgAqVzAtdH5r5+WezUAbKxwxYopkMJauEhjSE08CLFr8MHiImcIKY2S +rtUTKA+bJYdaaTE1HqIhPTg18wo166/HKdvRR2vi7ACvb8sunAg0/H1Vq6d+y262 +4mLYqoRMQqBBJds0TIC4IDawJFjrkNT/S36jLtaEifENgskTQgashamRFYnwSgKv +BKyobdiRMh26GGoxZLRiZVehCR0FQqchd8GpFOJsSANyX2Hlyi9i8ZhU+Ld2PcPK +nmfkFsS35Dqjm7IkDLpMx7kwjr5YlTcIpQhENbJ68dAzzG9A3mV7Wojfv3Dzpz3j +9wXvoj2EYDYPvNAyftQlfrWKe3r8wcjBKQARAQABiQE2BBgBCAAgFiEEYKz3C/cS +ZFBJ7m8V7+rxZoYiX2QFAmWp8IkCGyAACgkQ7+rxZoYiX2ThTAf/cNb4kEhk+Wjj +FzRHNUinzwA/7+YT5gbEnVh/1x+IpeYpnnuVEdOhNFxz76SL3dtDF8ciIhWxsE4b +v6hpdqcps1Hnq2dkbZ+z9T1r8+IZ03eyYXOo7kZtCwX4UODFwFHi2WaZpCCgOvLX +pA8tKJ04VfIBjp3shlUo+vCROgMouOpJgaLs80LQpoHEB8enHIuNByqWhHl+D4DV +z2l4TPL3HQaCMcW2KCexVz1+9pnPT2hf8DQXrxmchC1CnJVgV64yDzmjhND9C2Hw +OPS0JcBhAzB1FqtVZGYfQSkE5FAA7FLN/IYcCDhxYKVzdKay6m/JL8cbcSpQqLWO +/MR86YndjbkBDQRlqfCJAQgArkCO/giMQ8ReApeP/B4GoNiWlax5bFqMQVPevVix +QfAJ7IQ+8W/JxFmV2F0U2CQU38u9c0kAhYtFk/H/0cC/aEnqKPT6SGpZ4+W7Ehmp +ngSx+1r0sVV1cuZcUncetQeK2IZsBYCCf9XjZIqgFMDygnfM5TvPUyj5qiATxIxV +9bRjI/oNYVPngfnot7VZafVq/yW5+JlYx8u0rKsn5ikpzSDV8IrHmehydrHUUhYj +6/y6ChDzs2ZAq+qoCgFov5z7VzczzEybfPTbAwXpDahCHxF2V6k81c5ZeKEr9l3K +l8Kcc2ybwRe2MbePYCSDHle4GRaYExTXjYnkgyOKtr5YgwARAQABiQE2BBgBCAAg +FiEEYKz3C/cSZFBJ7m8V7+rxZoYiX2QFAmWp8IkCGwwACgkQ7+rxZoYiX2Rx4gf+ +MmibxLDOnVrMv2joky9DJajtZow8ayipXjU1AgIjuvcoMV/GBn8OMx3IAXHVGpyV +16jJ00X8Q+MAwVxd8+7OUoOSFECBqECv5iD4q0OqcZqFx7EyC7iDVUfY9IG0EKjV +4AOzP/azJgT916t3OqcXXDJ2wIUbDIvUQUwTMjX0Fw7OQNGYlHS709UF3y0DwBdq +pCxj1y74D9XzjvWHYxlKI5X8Lt2QW+xsGKkaRp5aIXn6MUnpmdIFZEcTj8s553+m +iqlYokmTvkTa4cQsgwC6RqkVsYopJrYsKnDs/l4/m+4TrPdforaD6mKNKzlsLJSj +gZfWLfoIul+B10SwJHXuoQ== +=/A3N +-----END PGP PUBLIC KEY BLOCK----- + +-----BEGIN PGP PUBLIC KEY BLOCK----- + mQINBF1ULyUBEADFFliU0Hr+PRCQNT9/9ZEhZtLmMMu7tai3VCxhmrHrOpNJJHqX f1/CeUyBhmCvXpKIpAAbH66l/Uc9GH5UgMZ19gMyGa3q3QJn9A6RR9ud4ALRg60P fmYTAci+6Luko7bqTzkS+fYOUSy/LY57s5ANTpveE+iTsBd5grXczCxaYYnthKKA @@ -11,55 +109,317 @@ dH9rZNbO0vuv6rCP7e0nt2ACVT/fExdvrwuHHYZ/7IlwOBlFhab3QYpl/WWep2+X ae33WKl/AOmHVirgtipnq70PW9hHViaSg3rz0NyYHHczNVaCROHE8YdIM/bAmKY/ IYVBXJtT+6Mn8N87isK2TR7zMM3FvDJ4Dsqm1UTGwtDvMtB0sNa5IROaUCHdlMFu rG8n+Bq/oGBFjk9Ay/twH4uOpxyr91aGoGtytw/jhd1+LOb0TGhFGpdc8QARAQAB -tBZQYXN0YSA8cGFzdGFAZGFzaC5vcmc+iQJUBBMBCgA+FiEEKVkDYuyHioH9PCAr -UlJ77avoeYQFAl8FFxMCGwMFCQPDx2sFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AA -CgkQUlJ77avoeYS4zhAAlFQAdXZnKprIFGf5ptm7eXeat3gtTMXkfsjXNM7/Q6vo -/HZQwoegfrh1CG1A6ND4NzHg4b6aCuHxWZOmdWKegxjnA2CRD+3cA/xLGlUtAoYC -1SYv6YdFy9A/s97ug4tUyHrVKTfEu0MxVkUrljzXNxSUawcHrNngRN7Sxn6diNH8 -kJWr8asJg+gfEYqXPKferbKap/3RYxX16EDHrX0iJJ4s7gvgzSDvWQMqW7WcOIOL -FVPji2Zqj06RoLvqH8Se/UsdWdcAHEcwRIxxIz2I6QN9gFFZGoL3lySrBhKifN3a -jDc2Y+NqWwTCbgisC6RseM1hkAhXiNX7zTN4uz8QCULSC+wqoNq9dQrHZTfwQ0qN -A4NGKgRCjFt4z0Bl9tYVwgS6dE8kuJCwn385C4y1jXWsS49BIXQIJFBT4kBm1h2l -ruwPvgdiY1iiPmj4UWyJZxBiU/EkHX3vyoQjU0Mfbehokt1Vu7rTZy2Xz6Hv1ZBv -nM9OGAjFJiVrK0lj9yUzXxd/7udqM/G3Y6nad17zKMMpSlUdGjLKU7uoYFfQz/sX -pMmU9gLgapOtE6MMMnxTWlK/Y4vnX0vd4y2oE8jo8luKgTrH+x5MhxTcU3F4DLIz -AyZF/7aupYUR0QURfLlYyHBu/HRZMayBsC25kGC4pz1FT8my+njJAJ+i/HE0cMy0 -G1Bhc3RhIDxwYXN0YUBkYXNoYm9vc3Qub3JnPokCVAQTAQgAPhYhBClZA2Lsh4qB -/TwgK1JSe+2r6HmEBQJdVC8lAhsDBQkDw8drBQsJCAcCBhUKCQgLAgQWAgMBAh4B -AheAAAoJEFJSe+2r6HmEyp4QAJC15jnvVcrnR1bWhDOOA+rm1W5yGhFAjvbumvvn -Xjmjas57R7TGtbNU2eF31kPMLiPx2HrBZVBYSsev7ceGfywJRbY81T6jca+EZHpq -o+XQ6HmC3jAdlqWtxSdnm79G0VsOYaKWht0BIv+almB7zKYsGPaUqJFHZf8lB78o -DOv/tBbXMuHagRQ44ZVqzoS/7OKiwATRve6kZMckU9A8wW/jNrbYxt5Mph6rInpb -ot1AMOywL9EFAplePelHB4DpFAUY6rDjgJu0ge5C789XxkNOkT6/1xYDOg0IxxDZ -+bm0IzzNjK23el6tsDdU/Bk1dywhNxGkhLkWCh46e2AjDPMpWZj7gYPy5Yz8Me0k -/HKvLsulJrwI3LH6g35naoIKGfTfJwnM7dQWxoIwb8IwASQvFuDQBzE3JDyS8gaV -wQMsg1rPXG4cC0DGpNAoxgI/XG13muEY57UWQZ9VgQlf3v4mAwZrz7acPn4DrAbT -4lomWWrN9djVWE2hWZ9L+EU9D63/ziM1IZHkqf3noLky9MrrlW6Yf41ETn2Sm3We -whA0q7+/p9lSdtG0IULTkFLAiOhPMW8pfJwmQJWN1JgBFaRqCSLhtsULVZlC4D0E -4XlM5QBi3rNoQF8AmCN5FPvUyvTd40TFdoub2T+Ga9qkama0lCEtjo0o+b9y3J8h -oTP9uQINBF1ULyUBEAC7rghotYC8xK3FWwL/42fAEHFg95/girmAHk/U2CSaQP63 -KiFZWfN03+HBUNfcEBd68Xwz7Loyi5QD0jElG3Zb08rToCtN3CEWmJqbY0A7k45S -G4nUXx4CFFDlW8jwxtW21kpKTcuIKZcZKPlRRcQUpLUHtbO1lXCobpizCgA/Bs16 -tm7BhsfaB9r0sr5q/Vx1ny2cNpWZlYvzPXFILJ9Fr9QC1mG38IShO8DBcnoLFVQG -eAiWpWcrQq86s3OiXabnHg2A9x210OWtNAT5KmpMqPKuhF7bsP5q2I7qkUb9M5OT -HhNZdHTthN5lAlP9+e1XjT11ojESBKEPSZ3ucnutVjLy771ngkuW3aa2exQod7Oj -UDGuWuLTlx7A9VhAu4k0P/l7Zf1TNJOljc25tAC2QPU+kzkl4JuyVP09wydG5TJ1 -luGfuJ5bRvnu5ak6kTXWzZ4gnmLFJyLiZIkT2Rb4hwKJz88+gPVGHYK8VME+X9uz -DoHPDrgsx+U+OBaRHs1VBvUMRN9ejkLYD9BTpn+js7gloB4CgaSL+wKZ4CLlb4XW -RyM+T8v9NczplxwzK1VA4QJgE5hVTFnZVuGSco5xIVBymTxuPbGwPXFfYRiGRdwJ -CS+60iAcbP923p229xpovzmStYP/LyHrxNMWNBcrT6DyByl7F+pMxwucXumoQQAR -AQABiQI8BBgBCAAmFiEEKVkDYuyHioH9PCArUlJ77avoeYQFAl1ULyUCGwwFCQPD -x2sACgkQUlJ77avoeYQPMQ/8DwfcmR5Jr/TeRa+50WWhVsZt+8/5eQq8acBk8YfP -ed79JXa1xeWM2BTXnEe8uS0jgaW4R8nFE9Sq9RqXXM5H2GqlqzS9fyCx/SvR3eib -YMcLIxjwaxx8MXTljx+p/SdTn+gsOXDCnXUjJbwEMtLDAA2xMtnXKy6R9hziGiil -TvX/B0CXzl9p7sjZBF24iZaUwAN9S1z06t9vW0CE+1oIlVmPm+B9Q1Jk5NQnvdEZ -t0vdnZ1zjaU7eZEzIOQ93KSSrQSA6jrNku4dlAWHFPNYhZ5RPy9Y2OmR1N5Ecu+/ -dzA9HHWTVq2sz6kT1iSEKDQQ4xNyY34Ux6SCdT557RyJufnBY68TTnPBEphE7Hfi -9rZTpNRToqRXd8W6reqqRdqIwVq6EjWVIUaBxyDsEI0yFsGk4GR8YjdyugUZKbal -PJ0nzv/4/0L15w5lKoITtm3kh8Oz/FXsOPEEr31nn5EbG2wik2XGmxS+UxKzFQ2E -5bKIIqvo0g587N0tgOSEdwoypYaZzXMLccce5m9fm7qitPJhdapzxfmncqHtCN/8 -KG03Y/pII5RCq4S+mJjknVN2ZBK6iofODdms37sQ4p2dQfvLUoHuJO+BDTuVwecA -xuQUNylAD60Ax330tU1JeHy6teEn8C3Fols1sJK+mQ4YHhYcvL9X4l2iYUL09veg -96I= -=85Kq ------END PGP PUBLIC KEY BLOCK----- \ No newline at end of file +tHxQYXN0YSAoU2VlIGtleWJhc2UuaW8vcGFzdGEgZm9yIHByb29mcyBvbiBteSBp +ZGVudGlmeS4gNjBBQ0Y3MEJGNzEyNjQ1MDQ5RUU2RjE1RUZFQUYxNjY4NjIyNUY2 +NCBpcyBteSBvZmZsaW5lIG9ubHkgR1BHIGtleS4piQJUBBMBCAA+AhsDBQkNLaMv +AheAFiEEKVkDYuyHioH9PCArUlJ77avoeYQFAmWp/WUFCwkIBwIGFQoJCAsCBBYC +AwECHgUACgkQUlJ77avoeYSFAw/+OIgYP39nPBoZ4G2sIPjpY1PsbGz2D8uj46we +orOJ438fwRbrW5LSSaQ/uQol0keekvt7xDbzQ4L5jFXlgwbhvIea05K8BsM0JMbw +SDcLtBbv0QIhlomV2nkG/rKtvCqwnJ4M19HrVmrqXIbYC2+C3p8qN4enGcNR+vRr +0Op+Q3wMsAPPLWyvBaXCKVIDOEYFGxLs5XqCxuJmtD/iyH9k21//iWjdf+/KEpK1 +OOH1QQQnKTCQPJX4iHeG2tQCMeQqXrTAdQqhvEEmGxqvJ74Oas34Uisd+/LCm4a/ +5enoRfEaVvOVNS1NoMUX1vvUC4YMU6OmtsNo0kCt5wOPxbDFb2vDKtEfnZMEAC0s +k2STti3uuu5WhwODAmjSH1Y/w4jN6tkOfSxQ2k04a12dtZGQBWBIKCgVWB5FZfhS +lPXbS8NMS7CSGnuvwyE2oT3osakEFFSGTW1KsqX57AqA/V/+nH6E77R6v1/61MU/ +m8f1FDe/5WmPPBUrZ7aZ7P+dHCR2PQ5W5tQPStRxeIi3usY1JKMYO88qtEWwClgg +Yh94OD3L9zQvQ8IGqJnpcSLjo0QNgka62D8KFsz3AjcPVYsLego/hn7bP3oXKI6S ++PuxgzbeMBWKLthPXx2klLDoHuNXgUGkTuauUVSoGWxIlyTqBvSpeSZ81O2BE/T/ +wN77yn2JATMEEAEIAB0WIQRgrPcL9xJkUEnubxXv6vFmhiJfZAUCZan2hwAKCRDv +6vFmhiJfZIsRB/4xeq0PhYYyIaAqD15pUIYwmfw35jSerHCkJWrpEAkZ2NhxPgEJ +81PCN1gqoEQ9F8rkk/5VnpFnqcF9nFRN/OiZZYUvoz4DoDX7hjz75Im+dKf4KqW8 +g6MUBTHfuV/srBdENYor2mZCfX6JnQjCjBe9HOUMh/CVzmmFOrthQ1kuCbK0/WPT +KGZ0UfNpNRyrnBpkjAgoO1pU5FTI4KlRhzSx6/NnePW4vHxpZBdd9VhNBU2/WGah +qtNmu7TDSrkpO4ljIJfiq4GMi60ign43zQ4ndJR0CQIcWjhgRAAq5sL8bsEdLhDV +u1+qOQYXaQNf17hqYhCesXfByKYRKqLnGmfrtBtQYXN0YSA8cGFzdGFAZGFzaGJv +b3N0Lm9yZz6JAlQEEwEIAD4WIQQpWQNi7IeKgf08ICtSUnvtq+h5hAUCXVQvJQIb +AwUJA8PHawULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBSUnvtq+h5hMqeEACQ +teY571XK50dW1oQzjgPq5tVuchoRQI727pr75145o2rOe0e0xrWzVNnhd9ZDzC4j +8dh6wWVQWErHr+3Hhn8sCUW2PNU+o3GvhGR6aqPl0Oh5gt4wHZalrcUnZ5u/RtFb +DmGilobdASL/mpZge8ymLBj2lKiRR2X/JQe/KAzr/7QW1zLh2oEUOOGVas6Ev+zi +osAE0b3upGTHJFPQPMFv4za22MbeTKYeqyJ6W6LdQDDssC/RBQKZXj3pRweA6RQF +GOqw44CbtIHuQu/PV8ZDTpE+v9cWAzoNCMcQ2fm5tCM8zYytt3perbA3VPwZNXcs +ITcRpIS5FgoeOntgIwzzKVmY+4GD8uWM/DHtJPxyry7LpSa8CNyx+oN+Z2qCChn0 +3ycJzO3UFsaCMG/CMAEkLxbg0AcxNyQ8kvIGlcEDLINaz1xuHAtAxqTQKMYCP1xt +d5rhGOe1FkGfVYEJX97+JgMGa8+2nD5+A6wG0+JaJllqzfXY1VhNoVmfS/hFPQ+t +/84jNSGR5Kn956C5MvTK65VumH+NRE59kpt1nsIQNKu/v6fZUnbRtCFC05BSwIjo +TzFvKXycJkCVjdSYARWkagki4bbFC1WZQuA9BOF5TOUAYt6zaEBfAJgjeRT71Mr0 +3eNExXaLm9k/hmvapGpmtJQhLY6NKPm/ctyfIaEz/YkCVwQTAQgAQQIbAwIXgAUJ +DS2jLwULCQgHAgYVCgkICwIEFgIDAQIeBRYhBClZA2Lsh4qB/TwgK1JSe+2r6HmE +BQJlrVMsAhkBAAoJEFJSe+2r6HmE0KcP/2EGb4CWvsmn3q6NoBmZ+u+rCitaX33+ +kXc4US6vRvAfhe0YiOWr5tNd4lg2JID+6jsN2NkAZYgzm4TXXJLkjXkrB+s0sFkC +jyG1/wBfZlPUSfxoDFusJry87N/7E9yMX7A+YV2Hh/yOXbR+/jSINfmjC+3ttjWD +UsUWT9m1yN8SBNg6h66TLffFyXgGFkRKYE27eprP0cuVkI6Fks68ocSQ5FQ7gmdM +CC4JFtOI4e1ax6mfvTFz2e2f5DlohPjW9w4eKTn+k98Nuev+s3WGiDXjxSABoehA +dwz2mbEjPsuz0jLeYKn6ialHh+hruYZozx8dxpUIWEVlMwLDBteWCuwTp+XPmOva +KkgYLxkfjjeIqUy17f6py17GrDZFHLeiopcJqyQJ0XLQI/qAKXkySBpvGD86nrM1 +i+5X7nLxZ0YfjKQ7cI+fp5A6SsQPUk9SI95PXRssx481zNse5wxFMP8J9oIB6nge +r39lpRRmvaSUJDNWjfsRZ/XK4mfib2OlLXooWuU5lCwqtQ+Jw9Zr/Gby2kTNIjrf +IpdNyThTnth+uTwcA8KCJRJY2BrPBtWNWqPLxLv9RLR3/N1siyJcichExIBKEzOh +zzi/i/PTU8dK2OBXrSaJ8DXhPwyNTB2l7jnXBO0hxeO4gmzAFQpM7QXXVDguL0b5 +94y05UNOM/ljiQIcBBMBAgAGBQJeut/oAAoJECqAP87D6bin7ZMP/3be6BDv/zf0 +gCTmgjD6StvPHu+F17op4VPj2cHYCgFP1ZHFH2RjqRVhSN6Wk+hbmR5PDHoVA2nc +xITv/DddKRjYc7fPRlrje7H19+urJgqqkWzmuUbNlxKiXiVW/OPmCjjI89Okt3dZ +GCTicEAPzJ6LTpoVgo4n/Eu81nMm6caf++Pzz1vEI3bJdPHPYyI+gN64mEhfP4OJ +u8v2XTbj+0ua3JxYWilxF7haytApmaPqeT7uOEBrX7EV1M+DlQCSM61u2EC5eIwA +oDba/ENXNyg5Z1JbFe3DxqE6ZVcAcZWXGdtPotayuEy6WL3LB2UUsM4UB4FPSUwc +FvnkV8YzBSV8Rqx+mkOFM6BhxzwK0zPvY+vv+rXSwz7uE/yrToqO9KvGhFxMwMwz +TRAJXI870fJQ9c5z2LzxoNg5gOUQH4vPG6YQT1ev04fj7IGYch9EhrSjuLCm94BA +pOEA+h/TTN6+xVLemUSB/l+Obm5701PP/naVprCJcCqIU3tH5HU3BXpZH++AzWo0 +pmgbtd7ECsR/y0NR4Mxoef677q9YGJEG/psYC0GZlzWsY5zjala+bEVn5gvbw6Lh +4Q2gwpvVXdygb6PSPwRSkpgHtUxdvIQsDEaBBGg/ae0x3O55z2/z95acnhIMRqQp +UpnPmDZUBKlsDJ8tivw/2r8o16YtAlJ0iQEzBBABCAAdFiEEYKz3C/cSZFBJ7m8V +7+rxZoYiX2QFAmWp9dIACgkQ7+rxZoYiX2StMwf8CdL0fhz2TM1R79n+FW7QCSaI +NBzIE1lN2TbdVEZeyiwQLn9cbqOvVPFavj4vxWFIXfAYzitLDHkikmg5Qzj7OXB2 +plFnqJxZ1tZSC1EdMHuNX1j55FDAggV/U/yv2PDY2XuwJbj/hLj80oNzIL5qLnNc +o0CLggB8QLLleFw4BTKycGDrzQCk4AGQ8tDRNoyI6Q/oFQtWQgQdm9Cs02Myr51Q +ZBe09XXA4wpyqv9BM+E0o8SLp/x/wZXM99vDNa7Df0nsRIQukFy5HqJJTufP1b6Q +FVMY1ouweyLxABXO4cvtYpOAUwQroY4U/q9ZnRzxj8Sq+reAt8O/wwJ8ujy9ILQW +UGFzdGEgPHBhc3RhQGRhc2gub3JnPokCVAQTAQgAPgIbAwUJA8PHawIXgBYhBClZ +A2Lsh4qB/TwgK1JSe+2r6HmEBQJlqf1lBQsJCAcCBhUKCQgLAgQWAgMBAh4FAAoJ +EFJSe+2r6HmECFwQAIDwX6fe0y6bc42zNU3Sqtd+Q3OgZfW0Rg23viI1ujyJE1uk +mmGR0i0b2luM+lSw1xOpr+pEsRX0dfaqAbbyUVIgyIZ5viXDZyWyJXr7NuBQZalX +k4njNfAELnQN2MPy/dqpelb6/J+kn6q4TC4DN95bJtSzPLK16rI94sSO+XUAJaiU +pr++cUelALoa5yHBL0mGuhlkNgCNdTE0eVwBLRQDrAywcUOEb6f2eNHyK6UY7WLy +0/LZZv2SzG/ZNQEQNY15/vrDwsQvD1ZueY5haCRK0Ga5o3GWZACU/+/c4VL2Ew7K +odxAjhVHBz50wIe35DUKVkYOQDIx9y+e50CPJicKOsnwjpC+NzQCk462ixCO9DFI ++9AFTJ6TD2BxVRHxLyUY7J21Mes4EILKFAV2dAOSZnd6LgqiYzqovJl6FmaLJyRM +JEfqvTi6Vy38Ns/6PCVGJTWKVsKz2lDas6U3/71jS0FSEwEJ9Rv9Yo75uErypNlJ +MiEahwy7kxqs8BKLtuPrF6QKRB7RgWgVxxU7z92VKCBzKDD0Oe3CDu4Lfva0487d ++TwNIGJdDeJ+ywhhFXIoGmeRm1YZferx1u5PCphiDLVkDDlLEolbp3bxKnN+l4wC +OUvhabciX46H3sM6KGMSoDRjh5n0UPr2+67qBq/rNJRCkALEFrG46i/+mNrYiQEz +BBABCAAdFiEEYKz3C/cSZFBJ7m8V7+rxZoYiX2QFAmWp9dIACgkQ7+rxZoYiX2Se +cQf+IKiMpD8+D93HtmmwG0twBbPMOVta0NU90Gvjxkw/v/JIDEWlZECClUW6Se8Z +Icq+WRZeDP6UZharGAg2GfRpfrKIwVt/aP16LsCqq+SiP4xaohmpcXQxacS5u813 +G9FFuxmHud3x7/sXtxKSVQRkhgQlq+RRG/s5CodNvjliM5OQiiXGr+q1tWy5QhRs +xCXj4CTc2CiV0ycWB36Cx9tkx+/s0pf7X4778wCrhzT6Ds5fT0W9uZifcglfI/p5 +jYYQkGpOrnOiHkBU3F80iFowIGsiv8pfaSqBP8yBAOtNBSVo5ksqSaH+TpVeIb0/ +pfGrM1BOzpTVfTmEj77qSE2tvrkCDQRdVC8lARAAu64IaLWAvMStxVsC/+NnwBBx +YPef4Iq5gB5P1NgkmkD+tyohWVnzdN/hwVDX3BAXevF8M+y6MouUA9IxJRt2W9PK +06ArTdwhFpiam2NAO5OOUhuJ1F8eAhRQ5VvI8MbVttZKSk3LiCmXGSj5UUXEFKS1 +B7WztZVwqG6YswoAPwbNerZuwYbH2gfa9LK+av1cdZ8tnDaVmZWL8z1xSCyfRa/U +AtZht/CEoTvAwXJ6CxVUBngIlqVnK0KvOrNzol2m5x4NgPcdtdDlrTQE+SpqTKjy +roRe27D+atiO6pFG/TOTkx4TWXR07YTeZQJT/fntV409daIxEgShD0md7nJ7rVYy +8u+9Z4JLlt2mtnsUKHezo1Axrlri05cewPVYQLuJND/5e2X9UzSTpY3NubQAtkD1 +PpM5JeCbslT9PcMnRuUydZbhn7ieW0b57uWpOpE11s2eIJ5ixSci4mSJE9kW+IcC +ic/PPoD1Rh2CvFTBPl/bsw6Bzw64LMflPjgWkR7NVQb1DETfXo5C2A/QU6Z/o7O4 +JaAeAoGki/sCmeAi5W+F1kcjPk/L/TXM6ZccMytVQOECYBOYVUxZ2VbhknKOcSFQ +cpk8bj2xsD1xX2EYhkXcCQkvutIgHGz/dt6dtvcaaL85krWD/y8h68TTFjQXK0+g +8gcpexfqTMcLnF7pqEEAEQEAAYkCPAQYAQgAJhYhBClZA2Lsh4qB/TwgK1JSe+2r +6HmEBQJdVC8lAhsMBQkDw8drAAoJEFJSe+2r6HmEDzEP/A8H3JkeSa/03kWvudFl +oVbGbfvP+XkKvGnAZPGHz3ne/SV2tcXljNgU15xHvLktI4GluEfJxRPUqvUal1zO +R9hqpas0vX8gsf0r0d3om2DHCyMY8GscfDF05Y8fqf0nU5/oLDlwwp11IyW8BDLS +wwANsTLZ1ysukfYc4hoopU71/wdAl85fae7I2QRduImWlMADfUtc9Orfb1tAhPta +CJVZj5vgfUNSZOTUJ73RGbdL3Z2dc42lO3mRMyDkPdykkq0EgOo6zZLuHZQFhxTz +WIWeUT8vWNjpkdTeRHLvv3cwPRx1k1atrM+pE9YkhCg0EOMTcmN+FMekgnU+ee0c +ibn5wWOvE05zwRKYROx34va2U6TUU6KkV3fFuq3qqkXaiMFauhI1lSFGgccg7BCN +MhbBpOBkfGI3croFGSm2pTydJ87/+P9C9ecOZSqCE7Zt5IfDs/xV7DjxBK99Z5+R +GxtsIpNlxpsUvlMSsxUNhOWyiCKr6NIOfOzdLYDkhHcKMqWGmc1zC3HHHuZvX5u6 +orTyYXWqc8X5p3Kh7Qjf/ChtN2P6SCOUQquEvpiY5J1TdmQSuoqHzg3ZrN+7EOKd +nUH7y1KB7iTvgQ07lcHnAMbkFDcpQA+tAMd99LVNSXh8urXhJ/AtxaJbNbCSvpkO +GB4WHLy/V+JdomFC9Pb3oPeiiQI8BBgBCAAmAhsMFiEEKVkDYuyHioH9PCArUlJ7 +7avoeYQFAmEb0RAFCQ0to2sACgkQUlJ77avoeYRHuxAAigKlhF2q7RYOxcCIsA+z +Af4jJCCkpdOWwWhjqgjtbFrS/39/FoRSC9TClO2CU4j5FIAkPKdv7EFiAXaMIDur +tpN4Ps+l6wUX/tS+xaGDVseRoAdhVjp7ilG9WIvmV3UMqxge6hbam3H5JhiVlmS+ +DAxG07dbHiFrdqeHrVZU/3649K8JOO9/xSs7Qzf6XJqepfzCjQ4ZRnGy4A/0hhYT +yzGeJOcTNigSjsPHl5PNipG0xbnAn7mxFm2i5XdVmTMCqsThkH6Ac3OBbLgRBvBh +VRWUR1Fbod7ypLTjOrXFW3Yvm7mtbZU8oqLKgcaACyXaIvwAoBY9dIXgrws6Z1dg +wvFH+1N7V2A+mVkbjPzS7Iko9lC1e5WBAJ7VkW20/5Ki08JXpLmd7UyglCcioQTM +d7YyE/Aho3zQbo/9A10REC4kOsl/Ou6IeEURa+mfb9MYPgoVGTcKZnaX0d40auRJ +ptosuoYLenXciRdUmfsADAb2pVdm5b2H3+NLXf+TnbyY/zm24ZFGPXBRSj7tQgaV +6kn9NPSg32Z1WcR+pAn3Jwqts3f1PNuYCrZvWv66NohJRrdCZc1wV4dkYvl2M1s+ +zf8iTVti4IifNjn57slXtEsH36miQy2vN6Cp9I3A7m5WeL07i27P8bvhxOg9q6r3 +NAgNcAK3mOfpQ/ej25jgI5w= +=LIEu +-----END PGP PUBLIC KEY BLOCK----- + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGKiMDcBEAC5eXHp6VV0fEBsHvqy2AGTuNAf9Zv7ux5GDT65XM1UuoXqhS0q +EGeijp1a70ndQ8TugzGSzoYT9W5xHPvgzDFpKAsiL1fELljnxd8KSl+3KKEX+QLK +1GHVDyLZTpL+vx+Nmb8920kqUMDLIeb/+TrbxGyWyOt8DFcCuigwNqsIVb5EMG/m +cbpO6pPiMahXZxmW1Hb8Pa047BC5kX2Qy07c/HhDAMyPp8C1xjyusgB+w7mSILzc +/n94CETPUztZbLEL+H9cUPFpSXEm53ZJ9MJIt2/eFYBVZ1XEU2hi341/mv2VPAiN +lUqoESJuim+OECPTUPdS8WLV5bmIAkyLj8uhArA1JpX6QwnhPuxCgptg00oHvmy0 ++DAR4DoIU1jndOIU/go78CHGIg3MrtOrXOvarKIairsX0sczRrdedZx9o1JoOiCt +K6k/lK5cYoH/WMiq3DvIUizOboH9jTj5DRXPoX/0eilGRNgRkpX3E1CbUJimiggx +6sgaC13RIaP/8tb9XQVUDsqaXHVAASZHwq4lAu1VjIh//IwQe++Qgr/k3gtkX3Hd +TvC9/Npx4pyODBGxk8KhJDHBeTP7vwl/VtYGD2XUtV/UfRGIvx93VYicsS04QlOu +3oSEzX6ayFOhwZxlbi/KY65xBMfuWw+zTs2qXbPWPkLuMZUOu0KN3oEQKwARAQAB +tCRLb25zdGFudGluIEFraW1vdiA8a25zdHFxQGdtYWlsLmNvbT6JAjgEEwEIACwF +AmKiMDcJECF2xKXQHqUkAhsDBQkeEzgAAhkBBAsHCQMFFQgKAgMEFgABAgAAYAIP +/i/mjLqeJI4l5WUckyocqALaQhe9pAX6JEk0gOlEuIgH9N/cl8fuEEv8j51TNIh2 +EQQZoNM//9Kj1dMxoy9Wtkh1yFe5OT9tKXkaXNwVeox45OqXYs/ARJ/rDUt1BNXu +Nbhdh5+OAYbFltF33JdfLXMRK22LoSOXPn1opEH1Zu6HS40lXl06CVqa7m3gvLY3 +BC/9pi8bSow/INnpJPjavtSA2uLLtRQRaqXs0iwF2FkyAKmAT7zANCA1pkBVMa7E +W+ulP0cr5/nqIPKIBfZxYmqE4YvN3px3JBNtzj7cdC3hAn1km1thOWSaBzb9lXLT +eXSHSRgG6AY2GdfC3F5UC6g6rEIncEJ8drfnTPpMLvXF3+KZ0ssdbLG9ctfev6X+ +lKS+TFEZs7TCANa1lEPr/ISCQYBbL63+xAbIz9SXG07jH6aFF07j6I3h+bWvZTJn +GIj2pq3QxBwh/pYf6hICxYU+fDP67mhlYor7yNIT83W+Ik4IhbLj9AtiW05NIavx +HPrEeYbjovsGWUhvN1LCAO7GFgmcTyQIqDDtLYLxLjnvjptc8HlKh4WW7KqCVawt +GayAcYYQXePDxerkiR0y6jCUSzr3MR8c9yfYarieQVKQLJTDP0UDYnXd20dlvzR9 +Q7wCbwu6jb0EcRDcnbZg8K8gOu1N2gfyFnesz3rq+PCAtC5Lb25zdGFudGluIEFr +aW1vdiA8a29uc3RhbnRpbi5ha2ltb3ZAZGFzaC5vcmc+iQJUBBMBCgA+FiEEFRkd +BbXPlW/jfJWWIXbEpdAepSQFAmKiSfACGwMFCR4TOAAFCwkIBwIGFQoJCAsCBBYC +AwECHgECF4AACgkQIXbEpdAepSTsahAAlq+6OBs1BL7k0drcK3hzN22y3E1LzBEK +mpxeIJ+eHDMerhVoSuDM75fwWk6SXoKxaRRErQ2EP3a5jDfu8MGD2xDypEcMLvE+ +EcFT3M2X79w/+MduR8cp9lUd0NCwpI7zAANq7Mj6gLDFdKEnA8pe730sHZB9I4G9 +vZl961FqzFUMwMttl8KxTzMKnbH/u5Tsvybh/dsv0lcV10irDuCoGGIM/MP42Hul +9CO3bAs69KXA30r/711ooAL3cpw5J5CeMvV2N5GnE656Cl9wRl6rCOSNoaRNJG4t +KtfNZeDd6na6+fABFnOYzzG/kd1+OcmfCFK79ljtL92b7cJzSkoOXfLYvM+V7UN4 +AohH8Lmon4MzGjieBFitHOOUMQy80hBEhuliajtFTv6JB4wS1K5U0NzNKjvLbUhQ +e+iabtChSAtYr3/liDALdROXyrEzAHYxK8Q5ZWdE9wUIz2HcQpHiFt0L33Y4lA8V +Dm26fi02svgHg5SBGGwQ65hSlzIQgmASaogoW3cYPOqVveibcGlM0bxM+0MN3QR6 +0T98PmqcdUV6S+xUkR1LI+5bj7ObzOusc0UGM8m4GQ+DdY46UqInc4yFrgLPzoj4 +QZPwn7aMRFbBF8YSTh7Cr4XvAx8CP2Abau8Sm6YHxXaausKRKaT4eKQlxryGkKdD +sQO3K/PaBWu5Ag0EYqIwNwEQAMaVJMN/2qrJUQnZgoOTcAmjKKUxphnGR27jqVKh +wTT3JW0qEap4ZUF0o6dJTHA0Ni2FltsGMddfyE++ipDgpW/+q9pFE6rs/eUufBX2 +yeYpf/4CSh1rZ6zqXqBQeifEflhEC1PXI+LGFOUyjuR5DV7cHw/i74UWXpUy8zT6 +RGyExSecmqNu9/6zCMnlNsfCAIfurwtrS6RdsYbvxSGWkNOnqkJ6zxOKgmtlOkeL +eNTxk4Oq+o7vPVh0zK/o5owMGpJzef1myMbB5H1aWeM5ReHf4y0VYCpR/IKhVCMm +qrgg70iGDLeeGaB3KFrCyhkFz/hBEcaL4juglQUq9CsfT+bbHWEoQS8jVBekRJi6 +iObupFIibC+W26p4/d5IYmYfU5gKMxPkfFoSokFGeICb8i35Rshv17vvt/Z/MXvk +RcGLAM2ydDtl5VG/h2dH1Dk9CLE3xa6AgtpIyUot+Y5VU1PC5p6gyD7WEo/dSVw7 +AJlgFKIx/UM3wx9MlVm5rB7sHvwnjaUcCTuBRtVitsmWsY/5N0K+qxGj/S1hKWQX +rmT+0K4/sRHfwv3lnFdeocq4hKfcmfhJJXoGXDL/jn/2ml2Oi+0hl0Mtds85MeJR +FwHETjhi/F5xAu9IgKJv/ewomKo8hwk0yHiNm46CCjHb7XmoIzz3e08z73pODzin +2WX7ABEBAAGJAjUEGAEIACkFAmKiMDcJECF2xKXQHqUkAhsMBQkeEzgABAsHCQMF +FQgKAgMEFgABAgAAT1cP/RStJ3oBrHGWB0fjPCfyossmgSeUKo4it+dHqNPTumIj +Zyy5p4FAhFsYeSQwoqlrNgZgt0MZxWQjvV6vNKqx0DXVR5S+xilPI8vpRSfnJhkI +vVdVY8qMj4I0/cyYqrasiR7YVIKepmEZe4aQTzhs/ifMooeY1+ZIwwLYollN91se +Nf3JqWmhY5Q7lPhUZXiyFNyE87geM1P4aOgwZm4EikEadzBFcoHzAXczSCpBwRxM +u53EQbz7Oq2xnFLORPAAwz9yJjCO/0N9HzH2o2Du6GeRccMeHZ65U8tQLvDO79Od +iWDZsU1h56BkDMhqTOsymHnv/QX4vO/X0tShhZXzLwe97++U+HUDobjcHmAySVNy +OugeGdFyYExMNM6Jd/GoS7Xo+RecBSP1yeDnweZgupCmzHVbrfRf7Vdesf6rY7hl +81amRIjdMlhWjOX8OxE4/u+npiQH+wT0VLOwTbxDNvGAAqzYzuETdNROiqqHGNXR +nc3pdm9EUvG/ur4AABDKllnsa0OP0oTOh+FqMQSlTEHwxPhlE11lyIIh2kkuNMmq +Vr7qNeOq3i6dA6EvGn2bikTsvHDw/kF0h08xZRTuy1I0Fcb6GYStM6Qskt4Hhrsa +xwuUTBELdLnf2nLk7sAoUl269juuWXTELTGC40olQh0m8bEXDinknhu6Jug3d0uW +=d05p +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFUkMgoBEAD5lFzlr4fIR3CKlsgx1KXLNR+1+IIe3AT8YloMq3rlvylOTgGl +j1PTeQL0eHH+fD3ukSHHiZC7FcY2aC3vTPCd16+OO+ii/Nfx6vAyve2RiTA4brKi +BOGuI/Neh/ow9Sg1AOZY0xsjXVqkabExg+zlUy/6DoabuVEnv/kpl1Bjr5pTfXNG +yeXDKF7MkItib6E9qDE5AsU31XQEAVKBv6u9r+W297+Db3AH6rK3WXiSLfT4KfmV +oufRIubPQvPnYt9l22mPS0gtO4NLB1Qruu/IEYbSUYcWa1GOe9EYoxbPOhWUOj1G +Dt6E4fb4JtmJ/7vkEeHFDRcrW/3EHQLkdLWE4sWrtxWBS4mfjwW9IiT3uDIHiG4F +OjftU5eCefxa7eLJBwjL6YSvD3IdxCLE2fIhNWFgvvCX4gYOayNk8kseV4qdAh7V +PmNhelB3vOnB6S4ufv3ByCwjkviUMZv+L9miAM3Nr1wnX89//ie99s+0FgHtO12c +LPbNCtfHfocnXYdMKoH8cbziOnoKOSUJYtGrtXXRJlKL9KmYCJnbx+sJXdRucCm1 ++xEPRD8m9KHuuOk3powaAWztmL0fpkfrZ4MgHL64VOHlRVq8BpcUhMhrVUiBPL2U +Qh9Bik5QTF0+Cb0WnYV1ktD5QSuI/7LVngd2VVhynMxJ/0TgFwhGwMkA4wARAQAB +tBpVZGppbk02IDxVZGppbk02QGRhc2gub3JnPokCVwQTAQoAQQIbAwULCQgHAwUV +CgkICwUWAgMBAAIeAQIXgAIZARYhBD9dSMnwApPNNlo6mINZK9FADVjZBQJfX098 +BQkdmE+iAAoJEINZK9FADVjZQKcP/3m+uvemzL2Nfo6Ewm0qUjG8dFvD6scVrX0Y +Wc2C+l8mX8niLJz7p4ulg+f8qqZ9ai7zwPHzXlq+qnFMljqqD0zBkemnfzWboUqP +fQ1OF9p6CYwDWG60+YQqz+2wH8/ScLeBiJEpjGIQR2/TgvX0NH+aU7zkfdT26aVT +S7XgF9BVISlUgnPjmq/5uq3944zkv8afFuHWbo4KHokKIBW9ZQ8auoK/xwCotszX +/q//sqHsYLHu8iQN6qWNMD2uXlp/v10qZsiCgrbCOuxmBZ5si49rgnc0jnJRq4/1 +eBbRVqGlLM79mzUQ6X4lerCpZBXLdC6qGF2N7+7RbRYQ8QZomQhGJPMSJ+pQlgT7 +tb+GhpMy01fGmatL+GEEXzhZPjYSqR/HIzx4ZZUV2R691wzGXk/oLhLyAy4NUabc +G6ykylcEZG27G1PldbZlRCGrr5eCnOFULNYDIKWyoyuabzsgDLIBzNDNo97SmTaB +46iUVYVxxHpVsi/p1TL2jCTo0P15oQoyfVX/a1keRRkymQazTjgMSiSrFG0GxGHV +LZ4x4dcdTVj9PBeJRAS8JJCwR3ZmO1+nEdPAPTiQTjQYZKPTCi3kB1LD69jKY6wp +7pX8gN+U8wWl1sV+CBqU9Ts/lKbH/eKFUcKC2nxYOYdsDjOOjvUGrRYJ3hmhGfoJ +kqlmgoyaiQJXBBMBCgBBAhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAhkBFiEE +P11IyfACk802WjqYg1kr0UANWNkFAlyKGfUFCQsoeRsACgkQg1kr0UANWNlpcw/+ +OX/tl7kbtY4ndb2ugscIM2W5mgAlJH/dzXO3W7c1fYb/u4RQlGZlekHjzT15mApd +jy2AKfxGFemFRHT9aQaETHDJwNrkn6PYjXrHDqWmgdygJSUCCBrq3Vz4BbIa0Hse +6eUjOT/bzrmrLbOc3kyITVt+MfvuNiCs0po9FcDt0yU1sIy51Xt3xricA5sXZnwK +iIxWVGtWw0TqIRtWW9piSGDJvGri1MIbLvxjIKEkKZsfcxMB5Lun7lQ7J0qrrOFW +XBbhAyuyOXzcuZBVvDyUrk6f5HDRvO78KYwUudWwW0T0rMDT4hh+Iq4TO3GkU6y2 +FUWfggw3sf5JKC8hrcSLVBZ8Qu+ZcwbWDX1ZBGtl+x6eNhphOapUuwCuwnPQ6vmH +SePhssXLRPMCcketgDtacNuN14OKAJws+40TuEuAW9hsMqXzlJgrMfeGG7m/NMeP +cp4LnYnaOZCzRZjUHlP5ljKvYF1MAYrG1vVYJOi4z3HoRJAg1qA1RsW3CRc/YkRR +cHCXG28srtgALP5jY/264Pd7xKWtpvTiuB0cjQQbwY/xnQK7DDPEhfs9xo0yjhZf +iaycN/BWn6YvZdkXjgDp0BtxqkFaDwqtDLCLnPdAab9czpajQRoneAWPQkh263qf +6Nj8xprw5sdnTrPsNY0QBNh5PgPxzjY2+HMHVPNgDPeJAlcEEwEKACoCGwMFCQeG +H4AFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlcReQUCGQEAIQkQg1kr0UANWNkW +IQQ/XUjJ8AKTzTZaOpiDWSvRQA1Y2ak/EACu/O/MdMW7g4QJluc4u/TxknVvMyiU +wZpTRztvSc4ktnQIpMa/neRA3dLyA0QhRkPocOPAvcCf1zrgOf+L6TzYcBoDNTST +Rxuy9zCegbjfTMeIhfG8dg3sdB6FAs3+TeeyOTOz5enPVKxHAyyG+UCc3B16T0dY +k+twopQ6Wfuqtr6cK9OSYUDg/7mqHTfHJpt3go9ppuNFiiYHyR3uEztFYNYQj70n +mCgqIajIPoLsaFmtxVKm2jXJkbXlPQG/58XfRQYEskXtJNKItQQxEG/wMryXOknZ +yuituJwTW9eOe7CaUWcsVIbxLjt5nuuatKnbuagjDKtmb44kymPBsgdkgfRM1fCl +lkylxghtTSXdHG3Y+hcixgFuzQsxibtmANsSNd3chuETz5isz2ZWbcW4ItV3Izy7 +Gf9dcCHtIQEVD2ja9Vz2PBN4Y9RmSwPgnAFpS0gx0FKzq7oQbccatrcI6y+PV5D0 +CbA/Tjnt1Ik8W8+qIGzEpv6Pe09sWHKXbLEhoujBa+xHpWU+5tPiRElKDxze4sTh +x7rhN2wIyyqPjKjMAs2b/NFQjYdvA1/D4wOtqpFCwRxcyRO47zlpsD+Zjd8EhIAE +VbUzyFIousHbXl8fM3rtYehcJFufd49F8oUD0fm/HOQvnHQB5VMQ7wNPQQ7VgbjN +PfbzrNzagNvKnYkCVAQTAQoAJwUCVxF46AIbAwUJB4YfgAULCQgHAwUVCgkICwUW +AgMBAAIeAQIXgAAhCRCDWSvRQA1Y2RYhBD9dSMnwApPNNlo6mINZK9FADVjZcAYP +/j5fgs6jYafTrlHpH96yji5t2bJzNLWqQx6KtVVB7hyL2wPdm0lFXn/0m3HjjuY0 +KurIz2BQ7wW/k9mnYxhhCCh3YYf8fax9ECDJrSAMej+ugYBmYBaAmlROSKEzRKNt +rycBYbYwRuh4yAymgi97vFe8B+HPBe/YiqpzZ7h1TPG6+OLCZRQ9tDvPc1cjnzbu +Z+LU52B9jIkxpM8zJsaCaSg3F/S2e2Y3OUaWhNPsNIaAqYVMUlRTy+yzo5F75f7w +e1ze6AK9Z76I/F13tLNJG03BVJ8OnNkwSMuaJZCbzuQ1MSfFlgTOOdrQjnMjB348 +Ry5c2Sdwmn/ygCjzwBxxRrn1GUAzRoO1goe7SYKUXfPj4yN8gWbeeJGnUyHx57BQ +fdnotXbg9k8TIWCTcKKVxdlABgyhUy8AD4maETMASUZLVT04xNptMj4WQ81fk/Np +g6RAOzK35NfBOAjQ9rRIrIyDD1jVqH3bZPjkO0HS2mgldkIDMi+KNL9MdA83P6Cb +DakBWxPeD+xVtMfDa0vGodcOE228Ex6JcjGljqQT8xW+D31cz4Uw4pnzrB8WxybV +sBMsWLyjhRfhv8qnUW0h3icW26gFFSutPnyA51NS8p5HScHdN27ilyz/r0lye2/D +6Z6oyo3gEyvxEEjJaOK6GO1I8C5TCGfdMvPKaRq2uJqMtBtVZGppbk02IDxVZGpp +bk02QGdtYWlsLmNvbT6JAlQEEwEKAD4CGwMFCwkIBwMFFQoJCAsFFgIDAQACHgEC +F4AWIQQ/XUjJ8AKTzTZaOpiDWSvRQA1Y2QUCX19PfAUJHZhPogAKCRCDWSvRQA1Y +2XreEADJKYpzMt6wUm0bqR3oAdSD5WvCl7PNV+uqsREIfA2enkI7HbNXWqr9f/53 +BQwBFhJsLz7xWfY7gMj28YoJ2FVWGHj1ZPLh7XtEmPZwFXSq7v3SoqygrgYZ3yaS +JW3TdDCfMlhKG+oJKWbOIyDR78tM1WtIkmB3UZCKL2ymiEHxRftJcEdlmxUBS2h+ +unHpx7HKWTPJvza/PoVd7YYkXsmZSoCDJ0fCxpDMIzXuP4AA3Mr5uZj+DTfKhaKi +yyBOi+xkZAwpVsnSqAj2s8BWlqjETDCtNOzSmLVXsUv74p5JtQunb8v1waODo68m +aB/VuV1gMJvfOWj88VnkgWglUO859eRWQ5LwEjzZ8KGEV0MFqDFHEI14a5SsZrtn +hVTXT7yUD9IyZod/fWNGZJT3uUkzykpQ2IKszkbuG3zriDv8rk7Ppx8gQ+kBrXwJ +IXCxG8sXj96ugfp23oh6b6iNBJqXFfJ8567LzIr5pFQChRAG+L8qruBNd0LXES/9 +EZBZPB2DOCQnYf/igtdb3XVKHhpHzrwsYhFExNia7eYz1lf7GklL50mzcP2xcQ5D +uZ5acS0JO3y6cUPJQxsEC26naw32sctxaKFz3DeAYlMmIR1Z8PgeO2cdDZucxZHA +FZL4poIRnBcHGPkytlr/zk/F946gtp3HU29w3cwhB6vDikVjfokCVAQTAQoAPgIb +AwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgBYhBD9dSMnwApPNNlo6mINZK9FADVjZ +BQJcihn6BQkLKHkbAAoJEINZK9FADVjZuoQQAIuc55ExIDZYkzHy3Q0amIRH7Eif +XJuTGu6NkyzYBmqgfXGLLfqZAXjCSyKa0N/ktW9y6cQtU+bUItzPIaVtn+56WjQw +U7ojQfJeyNu8wraRKiaNlSkLfC447ZB5Eq5w7TML67zCvYGB1DxAsNLiOas/evAY +Fwm7QfpwvmnXnOU7u/EuWRoCCfkP+6pZc26u034zv4CD7Jwp37Tk+L38LlZ8zKn1 +ksMd+nqV6lvdwY2iPCV75rqJ1gDh3I91+een1dHHMllsbWRShaC7Z622SXUsDibA +CfE9aqFyvf7H0AL5cc/7CJbUbmoREnj0N+dBzhsH8Qi6ofgfWLP0lxHyUlLpFAua +wLBBzg21d7goA4yShaE2lVIpRp3pjbbHqE0NMB/FvcL3HDe0SUERkxdA5WSEmEYz +5NSBZkPLSQSs6pMKYRrUXdiwysjEOP6hmydUkwmfSZAGogFgDC/cUxVMv391WQMP +m+VpECQKVTX5IBERiUk4suKMCxBdxUw7wXsnE3OlOwdK6KEclLzy3fhEKA7HsNSs +eJr2NiF4Ue494oJP/TzZO7fmi5Q+H9CASRQySOOhYJFH9bRvJMa/HSvoYbwE05RQ +3zhB3i3dFmWfeRCmhCiRkCWlZJyRuRemyAW0mhDLkatWX/2Wew15/eKn4CeoPubb +nDNXb1NCgs8IK8e6iQJUBBMBCgAnBQJYxm9cAhsDBQkHhh+ABQsJCAcDBRUKCQgL +BRYCAwEAAh4BAheAACEJEINZK9FADVjZFiEEP11IyfACk802WjqYg1kr0UANWNlW +PRAAqZPmW/7lsLFaL0hQ+Votj+32FnamiABJKpS+t6Fkm1ckIK+e+nuFXz3pr/WQ +J0eCmLoUwsngz+eOChPJDRAUdMb4eCKcW0yRd06UWZfwg7ugW/j7nXvDu4kJMnwW +thpysyVDpFpnRWC2bwplJzU+LexIF2ijjQTNFzQg0CGCxP0wZu+Be8NSVq0jgjYk +Hs6ekWBEWGlgCspJD/OeVvicRglump4/G5vqXt3jZyrAxt11N/Kl+uCnt1nnFQrn +6KQQbV42+P4ONGGK0DTlfGDYYICDP7XzNLHf0h7GElSjYEWeXLRh4jerkLIm3/1p +aa2XJuk4YSTAs1AuovAQGsbAMBgoecMFPE4qN+MNG6oXgl3PGrz2wvIZjpLjT9DS +u8FM4UqZX8ne+Hj0nn1wVKebQKfbSRiXaCxd0DM1EjmAZAsX85iikIhgd7/bP2Bw +ybrhQTp6dq+oS6/+z3qWeI1UWeYj49bKd+zTSjRVJEpRCkzXcIclTCcQw4ktRHv6 +ZdnFlx0TPzmvF8l5zOG0XvUQSOjCdGp1YulHAe681XXtYf7xG0lBxx2BsbTTKotm +/p75OytX9Y3/TMVoqkbog6fEt7yMWnWWzA7PLigoJwBfRW0FNvAmlSu3gbyUMw3P +wxLbBzaJsXbgdu5dyOOqyANVmugt2hLAkPds7H4tXsugODS5Ag0EVSQyCgEQAKKA +lbyFjfBNciP4c5JoYiDs/GNwmAh19TvZK9PDcmIQ8in76Yvpyiw9O+V7fCdyE/9N ++Pp8nVMv+HYREE14KsZVZMhi2oLkrta1N7nqwKHNcgh0OE/PN7yGUndq93hrCgDN +hTpfBAMb1tAsVljXTuKlxKgg+2ebznCSR9WfU72028kNBoMas1Z+orkXpknO2BOc +WUP8NShroxBdXg2I2k+w9zGNmLrWOsK+pqCFWY3xEObyy3e47McYiAYYXY3Ifb2Q +Saa4RzDQO97yKQcPWUYbpmbECAIqxsZzo/zCCZTx5c0zsPjuKpCxZY/oYx8K5opm +0cdcN51VsOl2YKGmpHd+lywc6huaWL+uSFspdshaufhvIJZ/neCsf7P5dZaoiUd8 +1RvEMaos4ZIMb5FBZSKqAFwTbAPu3w0UhW2JPCmNOphFenSNbCLjz3xqtZ/lpMy6 +7i+xJY7kv1RNbSXWdZIr2mwLMDJ8dqtacwA/A079ly/ze6iO7yNASQe78gd7/RCd +1tO97PK3xyaLs2lR1fHh8PKzPBxHKeoLjyCM3NH1JFGOtanFpubwBzyV2NShG8Wz +wkImT/noLqhOM/CEY8W6CdMabhoTUjDPRF18EVnSlKkVj7k+J2h7t7/P/CylcMhr +F1r5tUs5Ue48202dYFoNfNsN4b8djSk11HjMry3RABEBAAGJAjwEGAEKACYCGwwW +IQQ/XUjJ8AKTzTZaOpiDWSvRQA1Y2QUCY8Wf3AUJEmPU0gAKCRCDWSvRQA1Y2cKx +EADN/UUwxKSkhp/DWtw8Vp0PCYkuj3edFS+BXw/S8X6QCh6kBcFzh/YFRSVnuxrg +U5KxQ3BXEAEgTtapfPWckE2UAdLgOREjGj+ZPs9YnDbihKeizzBW4aC8e6zNRS7y +f92G00N1cr+LNjOpF9WUkuoU8FdfKo1tXmUi1KW/zhUVOMsZCvWlrDXA/ldSJ8FI +BtrNpc+OvWtOTkfKwPKvE0YUk93ukyxNPmoY8TYrxxzMe7C77tEb5mlW3nRCb8vb +ETOGz2HZCYpSQs7n4UNbUMLojHYbJMtW/UAoNrCYOiTfyTmbsvPvkgP4USlBNr7K +txcJTU+ZhqbQsWz/iHCvTKnP+Vw1CLpjQ4L7hvJwN4v3YI5Arc60YGwycvj23jE/ +5ZH7TuqymJ/1G0pRNk6oTWDDv10zFSIT15w1wYkmpbr9gHgeYOg6uwTPuevbpyLa +U2jKX6faTvhxg/8h2eUNUM6agjWAHxaemEiDX5NWiwA1Tkh/7086/jdu/ZQcGSJ8 +d46lqMDc1BhhR+5WePouf2UElAGdxqWhHKzM2Bt7D+jCrSbvtOlgrotg5Xx35vA5 +LAMYhJG4/etvORZiXuWWHs0gtZ85Itxjet8n58oehUI4mhpXQt2Ya+2oTpc7D5RD +2x++a0fd30gBgGGz81kMJpWewGAKlWEIrGmV/CfzR7eqxQ== +=lTCd +-----END PGP PUBLIC KEY BLOCK----- diff --git a/build/templates/backend/Makefile b/build/templates/backend/Makefile index 5b9e0bd4fa..570444583f 100644 --- a/build/templates/backend/Makefile +++ b/build/templates/backend/Makefile @@ -1,7 +1,21 @@ {{define "main" -}} +{{- if ne .Backend.BinaryURL "" }} ARCHIVE := $(shell basename {{.Backend.BinaryURL}}) +{{- else }} +ARCHIVE := +{{- end }} all: + mkdir backend +{{- if ne .Backend.DockerImage "" }} + docker container inspect extract > /dev/null 2>&1 && docker rm extract || true + docker create --name extract {{.Backend.DockerImage}} +{{- if eq .Backend.VerificationType "docker"}} + [ "$$(docker inspect --format='{{`{{index .RepoDigests 0}}`}}' {{.Backend.DockerImage}} | sed 's/.*@sha256://')" = "{{.Backend.VerificationSource}}" ] +{{- end}} + {{.Backend.ExtractCommand}} + docker rm extract +{{- else }} wget {{.Backend.BinaryURL}} {{- if eq .Backend.VerificationType "gpg"}} wget {{.Backend.VerificationSource}} -O checksum @@ -13,8 +27,8 @@ all: {{- else if eq .Backend.VerificationType "sha256"}} [ "$$(sha256sum ${ARCHIVE} | cut -d ' ' -f 1)" = "{{.Backend.VerificationSource}}" ] {{- end}} - mkdir backend {{.Backend.ExtractCommand}} ${ARCHIVE} +{{- end}} {{- if .Backend.ExcludeFiles}} # generated from exclude_files {{- range $index, $name := .Backend.ExcludeFiles}} @@ -24,6 +38,8 @@ all: clean: rm -rf backend +{{- if ne .Backend.BinaryURL "" }} rm -f ${ARCHIVE} +{{- end }} rm -f checksum {{end}} diff --git a/build/templates/backend/config/bcash.conf b/build/templates/backend/config/bcash.conf index 8fb7269c7c..124580bfc2 100644 --- a/build/templates/backend/config/bcash.conf +++ b/build/templates/backend/config/bcash.conf @@ -6,6 +6,8 @@ nolisten=1 rpcuser={{.IPC.RPCUser}} rpcpassword={{.IPC.RPCPass}} rpcport={{.Ports.BackendRPC}} +rpcbind={{.Env.RPCBindHost}} +rpcallowip={{.Env.RPCAllowIP}} txindex=1 zmqpubhashtx={{template "IPC.MessageQueueBindingTemplate" .}} diff --git a/build/templates/backend/config/bitcoin.conf b/build/templates/backend/config/bitcoin.conf index d10eed8880..068284d9f1 100644 --- a/build/templates/backend/config/bitcoin.conf +++ b/build/templates/backend/config/bitcoin.conf @@ -10,9 +10,14 @@ zmqpubhashtx={{template "IPC.MessageQueueBindingTemplate" .}} zmqpubhashblock={{template "IPC.MessageQueueBindingTemplate" .}} rpcworkqueue=1100 -maxmempool=2000 +maxmempool=4096 +mempoolexpiry=8760 +mempoolfullrbf=1 + dbcache=1000 +deprecatedrpc=warnings + {{- if .Backend.AdditionalParams}} # generated from additional_params {{- range $name, $value := .Backend.AdditionalParams}} @@ -29,5 +34,6 @@ addnode={{$node}} {{if .Backend.Mainnet}}[main]{{else}}[test]{{end}} {{generateRPCAuth .IPC.RPCUser .IPC.RPCPass -}} rpcport={{.Ports.BackendRPC}} - +rpcbind={{.Env.RPCBindHost}} +rpcallowip={{.Env.RPCAllowIP}} {{end}} diff --git a/build/templates/backend/config/bitcoin_like.conf b/build/templates/backend/config/bitcoin_like.conf index 170b432508..983c5c002d 100644 --- a/build/templates/backend/config/bitcoin_like.conf +++ b/build/templates/backend/config/bitcoin_like.conf @@ -5,6 +5,8 @@ server=1 nolisten=1 rpcuser={{.IPC.RPCUser}} rpcpassword={{.IPC.RPCPass}} +rpcbind={{.Env.RPCBindHost}} +rpcallowip={{.Env.RPCAllowIP}} {{if .Backend.Mainnet}}rpcport={{.Ports.BackendRPC}}{{end}} txindex=1 diff --git a/build/templates/backend/config/bitcoin_regtest.conf b/build/templates/backend/config/bitcoin_regtest.conf index 0fb7aef215..15eb979b82 100644 --- a/build/templates/backend/config/bitcoin_regtest.conf +++ b/build/templates/backend/config/bitcoin_regtest.conf @@ -12,6 +12,8 @@ rpcworkqueue=1100 maxmempool=2000 dbcache=1000 +deprecatedrpc=warnings + {{- if .Backend.AdditionalParams}} # generated from additional_params {{- range $name, $value := .Backend.AdditionalParams}} @@ -28,9 +30,8 @@ addnode={{$node}} regtest=1 {{if .Backend.Mainnet}}[main]{{else}}[regtest]{{end}} -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0 +rpcallowip={{.Env.RPCAllowIP}} +rpcbind={{.Env.RPCBindHost}} {{generateRPCAuth .IPC.RPCUser .IPC.RPCPass -}} rpcport={{.Ports.BackendRPC}} - {{end}} diff --git a/build/templates/backend/config/bitcoin-signet.conf b/build/templates/backend/config/bitcoin_signet.conf similarity index 89% rename from build/templates/backend/config/bitcoin-signet.conf rename to build/templates/backend/config/bitcoin_signet.conf index c26fa574e1..11b639664a 100644 --- a/build/templates/backend/config/bitcoin-signet.conf +++ b/build/templates/backend/config/bitcoin_signet.conf @@ -13,6 +13,8 @@ rpcworkqueue=1100 maxmempool=2000 dbcache=1000 +deprecatedrpc=warnings + {{- if .Backend.AdditionalParams}} # generated from additional_params {{- range $name, $value := .Backend.AdditionalParams}} @@ -29,5 +31,6 @@ addnode={{$node}} {{if .Backend.Mainnet}}[main]{{else}}[signet]{{end}} {{generateRPCAuth .IPC.RPCUser .IPC.RPCPass -}} rpcport={{.Ports.BackendRPC}} - +rpcbind={{.Env.RPCBindHost}} +rpcallowip={{.Env.RPCAllowIP}} {{end}} diff --git a/build/templates/backend/config/bitcoin_testnet4.conf b/build/templates/backend/config/bitcoin_testnet4.conf new file mode 100644 index 0000000000..10eae98953 --- /dev/null +++ b/build/templates/backend/config/bitcoin_testnet4.conf @@ -0,0 +1,39 @@ +{{define "main" -}} +daemon=1 +server=1 +{{if .Backend.Mainnet}}mainnet=1{{else}}testnet4=1{{end}} +nolisten=1 +txindex=1 +disablewallet=1 + +zmqpubhashtx={{template "IPC.MessageQueueBindingTemplate" .}} +zmqpubhashblock={{template "IPC.MessageQueueBindingTemplate" .}} + +rpcworkqueue=1100 +maxmempool=4096 +mempoolexpiry=8760 +mempoolfullrbf=1 + +dbcache=1000 + +deprecatedrpc=warnings + +{{- if .Backend.AdditionalParams}} +# generated from additional_params +{{- range $name, $value := .Backend.AdditionalParams}} +{{- if eq $name "addnode"}} +{{- range $index, $node := $value}} +addnode={{$node}} +{{- end}} +{{- else}} +{{$name}}={{$value}} +{{- end}} +{{- end}} +{{- end}} + +{{if .Backend.Mainnet}}[main]{{else}}[testnet4]{{end}} +{{generateRPCAuth .IPC.RPCUser .IPC.RPCPass -}} +rpcport={{.Ports.BackendRPC}} +rpcbind={{.Env.RPCBindHost}} +rpcallowip={{.Env.RPCAllowIP}} +{{end}} diff --git a/build/templates/backend/config/decred.conf b/build/templates/backend/config/decred.conf index aa8584b410..2925e26e22 100644 --- a/build/templates/backend/config/decred.conf +++ b/build/templates/backend/config/decred.conf @@ -6,5 +6,5 @@ txindex=1 addrindex=1 rpcuser={{.IPC.RPCUser}} rpcpass={{.IPC.RPCPass}} -rpclisten=[127.0.0.1]:{{.Ports.BackendRPC}} +rpclisten=[{{.Env.RPCBindHost}}]:{{.Ports.BackendRPC}} {{ end }} diff --git a/build/templates/backend/config/deeponion.conf b/build/templates/backend/config/deeponion.conf index ca92d14fd2..2145862c95 100644 --- a/build/templates/backend/config/deeponion.conf +++ b/build/templates/backend/config/deeponion.conf @@ -5,6 +5,8 @@ server=1 rpcuser={{.IPC.RPCUser}} rpcpassword={{.IPC.RPCPass}} rpcport={{.Ports.BackendRPC}} +rpcbind={{.Env.RPCBindHost}} +rpcallowip={{.Env.RPCAllowIP}} txindex=1 zmqpubhashtx={{template "IPC.MessageQueueBindingTemplate" .}} diff --git a/build/templates/backend/config/zcash.conf b/build/templates/backend/config/zcash.conf new file mode 100644 index 0000000000..edd7e6c1da --- /dev/null +++ b/build/templates/backend/config/zcash.conf @@ -0,0 +1,56 @@ +{{define "main" -}}[consensus] +checkpoint_sync = true + +[mempool] +eviction_memory_time = "1h" +tx_cost_limit = 80000000 + +[metrics] + +[mining] +internal_miner = false + +[network] +cache_dir = true +crawl_new_peer_interval = "1m 1s" +initial_mainnet_peers = [ + "dnsseed.z.cash:8233", + "dnsseed.str4d.xyz:8233", + "mainnet.seeder.zfnd.org:8233", + "mainnet.is.yolo.money:8233", +] +initial_testnet_peers = [ + "dnsseed.testnet.z.cash:18233", + "testnet.seeder.zfnd.org:18233", + "testnet.is.yolo.money:18233", +] +listen_addr = "0.0.0.0:8233" +max_connections_per_ip = 1 +network = "Mainnet" +peerset_initial_target_size = 25 + +[rpc] +cookie_dir = "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend" +debug_force_finished_sync = false +enable_cookie_auth = false +parallel_cpu_threads = 0 +listen_addr = '127.0.0.1:{{.Ports.BackendRPC}}' + +[state] +cache_dir = "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/zebra" +delete_old_database = true +ephemeral = false + +[sync] +checkpoint_verify_concurrency_limit = 1000 +download_concurrency_limit = 50 +full_verify_concurrency_limit = 20 +parallel_cpu_threads = 0 + +[tracing] +buffer_limit = 128000 +force_use_color = false +use_color = true +use_journald = false +log_file = "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/zebra.log" +{{end}} \ No newline at end of file diff --git a/build/templates/backend/debian/install b/build/templates/backend/debian/install index 27e686617c..950633bb4e 100755 --- a/build/templates/backend/debian/install +++ b/build/templates/backend/debian/install @@ -3,4 +3,5 @@ backend/* {{.Env.BackendInstallPath}}/{{.Coin.Alias}} server.conf => {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf client.conf => {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}_client.conf +{{if .Backend.ExecScript }}exec.sh => {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}_exec.sh{{end}} {{end}} diff --git a/build/templates/backend/debian/service b/build/templates/backend/debian/service index 54473b3b63..d25d1d64bb 100644 --- a/build/templates/backend/debian/service +++ b/build/templates/backend/debian/service @@ -7,7 +7,10 @@ After=network.target ExecStart={{template "Backend.ExecCommandTemplate" .}} User={{.Backend.SystemUser}} Restart=on-failure +# Allow enough time for graceful shutdown/flush work before SIGKILL. TimeoutStopSec=300 +# Be explicit about the signal used for graceful shutdown. +KillSignal=SIGTERM WorkingDirectory={{.Env.BackendInstallPath}}/{{.Coin.Alias}} {{if eq .Backend.ServiceType "forking" -}} Type=forking @@ -19,7 +22,7 @@ Type=simple {{template "Backend.ServiceAdditionalParamsTemplate" .}} # Resource limits -LimitNOFILE=500000 +LimitNOFILE=2000000 # Hardening measures #################### diff --git a/build/templates/backend/scripts/arbitrum.sh b/build/templates/backend/scripts/arbitrum.sh new file mode 100755 index 0000000000..17d16b0f87 --- /dev/null +++ b/build/templates/backend/scripts/arbitrum.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +NITRO_BIN=$INSTALL_DIR/nitro + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$NITRO_BIN \ + --chain.name arb1 \ + --init.latest pruned \ + --init.download-path $DATA_DIR/tmp \ + --auth.jwtsecret $DATA_DIR/jwtsecret \ + --persistent.chain $DATA_DIR \ + --parent-chain.connection.url http://127.0.0.1:8136 \ + --parent-chain.blob-client.beacon-url http://127.0.0.1:7536 \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,debug,txpool,arb \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.api eth,net,web3,debug,txpool,arb \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.origins '*' \ + --file-logging.enable='false' \ + --node.staker.enable='false' \ + --execution.tx-lookup-limit 0 \ + --validation.wasm.allowed-wasm-module-roots "$INSTALL_DIR/nitro-legacy/machines,$INSTALL_DIR/target/machines" + +{{end}} diff --git a/build/templates/backend/scripts/arbitrum_archive.sh b/build/templates/backend/scripts/arbitrum_archive.sh new file mode 100755 index 0000000000..77149183dc --- /dev/null +++ b/build/templates/backend/scripts/arbitrum_archive.sh @@ -0,0 +1,36 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +NITRO_BIN=$INSTALL_DIR/nitro + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$NITRO_BIN \ + --chain.name arb1 \ + --init.latest archive \ + --init.download-path $DATA_DIR/tmp \ + --auth.jwtsecret $DATA_DIR/jwtsecret \ + --persistent.chain $DATA_DIR \ + --parent-chain.connection.url http://127.0.0.1:8116 \ + --parent-chain.blob-client.beacon-url http://127.0.0.1:7516 \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,debug,txpool,arb \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.api eth,net,web3,debug,txpool,arb \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.origins '*' \ + --file-logging.enable='false' \ + --node.staker.enable='false' \ + --execution.caching.archive \ + --execution.tx-lookup-limit 0 \ + --validation.wasm.allowed-wasm-module-roots "$INSTALL_DIR/nitro-legacy/machines,$INSTALL_DIR/target/machines" + +{{end}} diff --git a/build/templates/backend/scripts/arbitrum_nova.sh b/build/templates/backend/scripts/arbitrum_nova.sh new file mode 100755 index 0000000000..c34cc19065 --- /dev/null +++ b/build/templates/backend/scripts/arbitrum_nova.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +NITRO_BIN=$INSTALL_DIR/nitro + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$NITRO_BIN \ + --chain.name nova \ + --init.latest pruned \ + --init.download-path $DATA_DIR/tmp \ + --auth.jwtsecret $DATA_DIR/jwtsecret \ + --persistent.chain $DATA_DIR \ + --parent-chain.connection.url http://127.0.0.1:8136 \ + --parent-chain.blob-client.beacon-url http://127.0.0.1:7536 \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,debug,txpool,arb \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.api eth,net,web3,debug,txpool,arb \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.origins '*' \ + --file-logging.enable='false' \ + --node.staker.enable='false' \ + --execution.tx-lookup-limit 0 \ + --validation.wasm.allowed-wasm-module-roots "$INSTALL_DIR/nitro-legacy/machines,$INSTALL_DIR/target/machines" + +{{end}} diff --git a/build/templates/backend/scripts/arbitrum_nova_archive.sh b/build/templates/backend/scripts/arbitrum_nova_archive.sh new file mode 100755 index 0000000000..e6ccf38f80 --- /dev/null +++ b/build/templates/backend/scripts/arbitrum_nova_archive.sh @@ -0,0 +1,36 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +NITRO_BIN=$INSTALL_DIR/nitro + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$NITRO_BIN \ + --chain.name nova \ + --init.latest archive \ + --init.download-path $DATA_DIR/tmp \ + --auth.jwtsecret $DATA_DIR/jwtsecret \ + --persistent.chain $DATA_DIR \ + --parent-chain.connection.url http://127.0.0.1:8116 \ + --parent-chain.blob-client.beacon-url http://127.0.0.1:7516 \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,debug,txpool,arb \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.api eth,net,web3,debug,txpool,arb \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.origins '*' \ + --file-logging.enable='false' \ + --node.staker.enable='false' \ + --execution.caching.archive \ + --execution.tx-lookup-limit 0 \ + --validation.wasm.allowed-wasm-module-roots "$INSTALL_DIR/nitro-legacy/machines,$INSTALL_DIR/target/machines" + +{{end}} diff --git a/build/templates/backend/scripts/base.sh b/build/templates/backend/scripts/base.sh new file mode 100644 index 0000000000..3d982378c1 --- /dev/null +++ b/build/templates/backend/scripts/base.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +GETH_BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +CHAINDATA_DIR=$DATA_DIR/geth/chaindata +SNAPSHOT=https://mainnet-full-snapshots.base.org/$(curl https://mainnet-full-snapshots.base.org/latest) + +if [ ! -d "$CHAINDATA_DIR" ]; then + wget -c $SNAPSHOT -O - | zstd -cd | tar xf - --strip-components=1 -C $DATA_DIR +fi + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$GETH_BIN \ + --op-network base-mainnet \ + --datadir $DATA_DIR \ + --authrpc.jwtsecret $DATA_DIR/jwtsecret \ + --authrpc.addr 127.0.0.1 \ + --authrpc.port {{.Ports.BackendAuthRpc}} \ + --authrpc.vhosts "*" \ + --port {{.Ports.BackendP2P}} \ + --http \ + --http.port {{.Ports.BackendHttp}} \ + --http.addr {{.Env.RPCBindHost}} \ + --http.api eth,net,web3,debug,txpool,engine \ + --http.vhosts "*" \ + --http.corsdomain "*" \ + --ws \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.api eth,net,web3,debug,txpool,engine \ + --ws.origins "*" \ + --rollup.disabletxpoolgossip=true \ + --rollup.sequencerhttp https://mainnet-sequencer.base.io \ + --state.scheme hash \ + --history.transactions 0 \ + --cache 4096 \ + --syncmode full \ + --maxpeers 0 \ + --nodiscover + +{{end}} diff --git a/build/templates/backend/scripts/base_archive.sh b/build/templates/backend/scripts/base_archive.sh new file mode 100644 index 0000000000..111add774e --- /dev/null +++ b/build/templates/backend/scripts/base_archive.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +GETH_BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +CHAINDATA_DIR=$DATA_DIR/geth/chaindata +SNAPSHOT=https://mainnet-full-snapshots.base.org/$(curl https://mainnet-full-snapshots.base.org/latest) + +if [ ! -d "$CHAINDATA_DIR" ]; then + wget -c $SNAPSHOT -O - | zstd -cd | tar xf - --strip-components=1 -C $DATA_DIR +fi + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$GETH_BIN \ + --op-network base-mainnet \ + --datadir $DATA_DIR \ + --authrpc.jwtsecret $DATA_DIR/jwtsecret \ + --authrpc.addr 127.0.0.1 \ + --authrpc.port {{.Ports.BackendAuthRpc}} \ + --authrpc.vhosts "*" \ + --port {{.Ports.BackendP2P}} \ + --http \ + --http.port {{.Ports.BackendHttp}} \ + --http.addr {{.Env.RPCBindHost}} \ + --http.api eth,net,web3,debug,txpool,engine \ + --http.vhosts "*" \ + --http.corsdomain "*" \ + --ws \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.api eth,net,web3,debug,txpool,engine \ + --ws.origins "*" \ + --rollup.disabletxpoolgossip=true \ + --rollup.sequencerhttp https://mainnet.sequencer.optimism.io \ + --cache 4096 \ + --cache.gc 0 \ + --cache.trie 30 \ + --cache.snapshot 20 \ + --syncmode full \ + --gcmode archive \ + --maxpeers 0 \ + --nodiscover + +{{end}} diff --git a/build/templates/backend/scripts/base_archive_op_node.sh b/build/templates/backend/scripts/base_archive_op_node.sh new file mode 100644 index 0000000000..1e3c374fff --- /dev/null +++ b/build/templates/backend/scripts/base_archive_op_node.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/op-node + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$BIN \ + --network base-mainnet \ + --l1 http://127.0.0.1:8116 \ + --l1.beacon http://127.0.0.1:7516 \ + --l1.trustrpc \ + --l1.rpckind=debug_geth \ + --l2 http://127.0.0.1:8411 \ + --rpc.addr {{.Env.RPCBindHost}} \ + --rpc.port {{.Ports.BackendRPC}} \ + --l2.jwt-secret {{.Env.BackendDataPath}}/base_archive/backend/jwtsecret \ + --p2p.bootnodes enr:-J24QNz9lbrKbN4iSmmjtnr7SjUMk4zB7f1krHZcTZx-JRKZd0kA2gjufUROD6T3sOWDVDnFJRvqBBo62zuF-hYCohOGAYiOoEyEgmlkgnY0gmlwhAPniryHb3BzdGFja4OFQgCJc2VjcDI1NmsxoQKNVFlCxh_B-716tTs-h1vMzZkSs1FTu_OYTNjgufplG4N0Y3CCJAaDdWRwgiQG,enr:-J24QH-f1wt99sfpHy4c0QJM-NfmsIfmlLAMMcgZCUEgKG_BBYFc6FwYgaMJMQN5dsRBJApIok0jFn-9CS842lGpLmqGAYiOoDRAgmlkgnY0gmlwhLhIgb2Hb3BzdGFja4OFQgCJc2VjcDI1NmsxoQJ9FTIv8B9myn1MWaC_2lJ-sMoeCDkusCsk4BYHjjCq04N0Y3CCJAaDdWRwgiQG,enr:-J24QDXyyxvQYsd0yfsN0cRr1lZ1N11zGTplMNlW4xNEc7LkPXh0NAJ9iSOVdRO95GPYAIc6xmyoCCG6_0JxdL3a0zaGAYiOoAjFgmlkgnY0gmlwhAPckbGHb3BzdGFja4OFQgCJc2VjcDI1NmsxoQJwoS7tzwxqXSyFL7g0JM-KWVbgvjfB8JA__T7yY_cYboN0Y3CCJAaDdWRwgiQG,enr:-J24QHmGyBwUZXIcsGYMaUqGGSl4CFdx9Tozu-vQCn5bHIQbR7On7dZbU61vYvfrJr30t0iahSqhc64J46MnUO2JvQaGAYiOoCKKgmlkgnY0gmlwhAPnCzSHb3BzdGFja4OFQgCJc2VjcDI1NmsxoQINc4fSijfbNIiGhcgvwjsjxVFJHUstK9L1T8OTKUjgloN0Y3CCJAaDdWRwgiQG,enr:-J24QG3ypT4xSu0gjb5PABCmVxZqBjVw9ca7pvsI8jl4KATYAnxBmfkaIuEqy9sKvDHKuNCsy57WwK9wTt2aQgcaDDyGAYiOoGAXgmlkgnY0gmlwhDbGmZaHb3BzdGFja4OFQgCJc2VjcDI1NmsxoQIeAK_--tcLEiu7HvoUlbV52MspE0uCocsx1f_rYvRenIN0Y3CCJAaDdWRwgiQG \ + --p2p.useragent base \ + --rollup.load-protocol-versions=true \ + --verifier.l1-confs 4 + +{{end}} diff --git a/build/templates/backend/scripts/base_op_node.sh b/build/templates/backend/scripts/base_op_node.sh new file mode 100644 index 0000000000..ad8cb2f015 --- /dev/null +++ b/build/templates/backend/scripts/base_op_node.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/op-node + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$BIN \ + --network base-mainnet \ + --l1 http://127.0.0.1:8136 \ + --l1.beacon http://127.0.0.1:7536 \ + --l1.trustrpc \ + --l1.rpckind debug_geth \ + --l2 http://127.0.0.1:8409 \ + --rpc.addr {{.Env.RPCBindHost}} \ + --rpc.port {{.Ports.BackendRPC}} \ + --l2.jwt-secret {{.Env.BackendDataPath}}/base/backend/jwtsecret \ + --p2p.bootnodes enr:-J24QNz9lbrKbN4iSmmjtnr7SjUMk4zB7f1krHZcTZx-JRKZd0kA2gjufUROD6T3sOWDVDnFJRvqBBo62zuF-hYCohOGAYiOoEyEgmlkgnY0gmlwhAPniryHb3BzdGFja4OFQgCJc2VjcDI1NmsxoQKNVFlCxh_B-716tTs-h1vMzZkSs1FTu_OYTNjgufplG4N0Y3CCJAaDdWRwgiQG,enr:-J24QH-f1wt99sfpHy4c0QJM-NfmsIfmlLAMMcgZCUEgKG_BBYFc6FwYgaMJMQN5dsRBJApIok0jFn-9CS842lGpLmqGAYiOoDRAgmlkgnY0gmlwhLhIgb2Hb3BzdGFja4OFQgCJc2VjcDI1NmsxoQJ9FTIv8B9myn1MWaC_2lJ-sMoeCDkusCsk4BYHjjCq04N0Y3CCJAaDdWRwgiQG,enr:-J24QDXyyxvQYsd0yfsN0cRr1lZ1N11zGTplMNlW4xNEc7LkPXh0NAJ9iSOVdRO95GPYAIc6xmyoCCG6_0JxdL3a0zaGAYiOoAjFgmlkgnY0gmlwhAPckbGHb3BzdGFja4OFQgCJc2VjcDI1NmsxoQJwoS7tzwxqXSyFL7g0JM-KWVbgvjfB8JA__T7yY_cYboN0Y3CCJAaDdWRwgiQG,enr:-J24QHmGyBwUZXIcsGYMaUqGGSl4CFdx9Tozu-vQCn5bHIQbR7On7dZbU61vYvfrJr30t0iahSqhc64J46MnUO2JvQaGAYiOoCKKgmlkgnY0gmlwhAPnCzSHb3BzdGFja4OFQgCJc2VjcDI1NmsxoQINc4fSijfbNIiGhcgvwjsjxVFJHUstK9L1T8OTKUjgloN0Y3CCJAaDdWRwgiQG,enr:-J24QG3ypT4xSu0gjb5PABCmVxZqBjVw9ca7pvsI8jl4KATYAnxBmfkaIuEqy9sKvDHKuNCsy57WwK9wTt2aQgcaDDyGAYiOoGAXgmlkgnY0gmlwhDbGmZaHb3BzdGFja4OFQgCJc2VjcDI1NmsxoQIeAK_--tcLEiu7HvoUlbV52MspE0uCocsx1f_rYvRenIN0Y3CCJAaDdWRwgiQG \ + --p2p.useragent base \ + --rollup.load-protocol-versions=true \ + --verifier.l1-confs 4 + +{{end}} diff --git a/build/templates/backend/scripts/bsc.sh b/build/templates/backend/scripts/bsc.sh new file mode 100644 index 0000000000..020be1c975 --- /dev/null +++ b/build/templates/backend/scripts/bsc.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +GETH_BIN=$INSTALL_DIR/geth_linux +CHAINDATA_DIR=$DATA_DIR/geth/chaindata + +if [ ! -d "$CHAINDATA_DIR" ]; then + $GETH_BIN init --datadir $DATA_DIR $INSTALL_DIR/genesis.json +fi + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$GETH_BIN \ + --config $INSTALL_DIR/config.toml \ + --datadir $DATA_DIR \ + --port {{.Ports.BackendP2P}} \ + --http \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,debug,txpool \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.api eth,net,web3,debug,txpool \ + --ws.origins '*' \ + --syncmode full \ + --maxpeers 200 \ + --rpc.allow-unprotected-txs \ + --txlookuplimit 0 \ + --cache 8000 \ + --ipcdisable \ + --nat none + +{{end}} diff --git a/build/templates/backend/scripts/bsc_archive.sh b/build/templates/backend/scripts/bsc_archive.sh new file mode 100644 index 0000000000..8e1b8f94e1 --- /dev/null +++ b/build/templates/backend/scripts/bsc_archive.sh @@ -0,0 +1,44 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +GETH_BIN=$INSTALL_DIR/geth_linux +CHAINDATA_DIR=$DATA_DIR/geth/chaindata + +if [ ! -d "$CHAINDATA_DIR" ]; then + $GETH_BIN init --datadir $DATA_DIR $INSTALL_DIR/genesis.json +fi + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$GETH_BIN \ + --config $INSTALL_DIR/config.toml \ + --datadir $DATA_DIR \ + --port {{.Ports.BackendP2P}} \ + --http \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,debug,txpool \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.api eth,net,web3,debug,txpool \ + --ws.origins '*' \ + --gcmode archive \ + --cache.gc 0 \ + --cache.trie 30 \ + --syncmode full \ + --maxpeers 200 \ + --rpc.allow-unprotected-txs \ + --txlookuplimit 0 \ + --cache 8000 \ + --ipcdisable \ + --nat none + +{{end}} diff --git a/build/templates/backend/scripts/optimism.sh b/build/templates/backend/scripts/optimism.sh new file mode 100644 index 0000000000..481e7d0235 --- /dev/null +++ b/build/templates/backend/scripts/optimism.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +GETH_BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +CHAINDATA_DIR=$DATA_DIR/geth/chaindata +SNAPSHOT=https://r2-snapshots.fastnode.io/op/$(curl -s https://r2-snapshots.fastnode.io/op/latest-mainnet) + +if [ ! -d "$CHAINDATA_DIR" ]; then + wget -c $SNAPSHOT -O - | lz4 -cd | tar xf - -C $DATA_DIR +fi + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$GETH_BIN \ + --op-network op-mainnet \ + --datadir $DATA_DIR \ + --authrpc.jwtsecret $DATA_DIR/jwtsecret \ + --authrpc.addr 127.0.0.1 \ + --authrpc.port {{.Ports.BackendAuthRpc}} \ + --authrpc.vhosts "*" \ + --port {{.Ports.BackendP2P}} \ + --http \ + --http.port {{.Ports.BackendHttp}} \ + --http.addr {{.Env.RPCBindHost}} \ + --http.api eth,net,web3,debug,txpool,engine \ + --http.vhosts "*" \ + --http.corsdomain "*" \ + --ws \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.api eth,net,web3,debug,txpool,engine \ + --ws.origins "*" \ + --rollup.disabletxpoolgossip=true \ + --rollup.sequencerhttp https://mainnet-sequencer.optimism.io \ + --txlookuplimit 0 \ + --cache 4096 \ + --syncmode full \ + --maxpeers 0 \ + --nodiscover + +{{end}} diff --git a/build/templates/backend/scripts/optimism_archive.sh b/build/templates/backend/scripts/optimism_archive.sh new file mode 100644 index 0000000000..beb9d86c88 --- /dev/null +++ b/build/templates/backend/scripts/optimism_archive.sh @@ -0,0 +1,49 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +GETH_BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +CHAINDATA_DIR=$DATA_DIR/geth/chaindata +SNAPSHOT=https://datadirs.optimism.io/latest + +if [ ! -d "$CHAINDATA_DIR" ]; then + wget -c $(curl -sL $SNAPSHOT | grep -oP '(?<=url=)[^"]*') -O - | zstd -cd | tar xf - -C $DATA_DIR +fi + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$GETH_BIN \ + --op-network op-mainnet \ + --datadir $DATA_DIR \ + --authrpc.jwtsecret $DATA_DIR/jwtsecret \ + --authrpc.addr 127.0.0.1 \ + --authrpc.port {{.Ports.BackendAuthRpc}} \ + --authrpc.vhosts "*" \ + --port {{.Ports.BackendP2P}} \ + --http \ + --http.port {{.Ports.BackendHttp}} \ + --http.addr {{.Env.RPCBindHost}} \ + --http.api eth,net,web3,debug,txpool,engine \ + --http.vhosts "*" \ + --http.corsdomain "*" \ + --ws \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.api eth,net,web3,debug,txpool,engine \ + --ws.origins "*" \ + --rollup.disabletxpoolgossip=true \ + --rollup.historicalrpc http://127.0.0.1:8304 \ + --rollup.sequencerhttp https://mainnet.sequencer.optimism.io \ + --cache 4096 \ + --cache.gc 0 \ + --cache.trie 30 \ + --cache.snapshot 20 \ + --syncmode full \ + --gcmode archive \ + --maxpeers 0 \ + --nodiscover + +{{end}} diff --git a/build/templates/backend/scripts/optimism_archive_legacy_geth.sh b/build/templates/backend/scripts/optimism_archive_legacy_geth.sh new file mode 100644 index 0000000000..e8601b14ff --- /dev/null +++ b/build/templates/backend/scripts/optimism_archive_legacy_geth.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +export USING_OVM=true +export ETH1_SYNC_SERVICE_ENABLE=false + +GETH_BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +CHAINDATA_DIR=$DATA_DIR/geth/chaindata +SNAPSHOT=https://datadirs.optimism.io/mainnet-legacy-archival.tar.zst + +if [ ! -d "$CHAINDATA_DIR" ]; then + wget -c $SNAPSHOT -O - | zstd -cd | tar xf - -C $DATA_DIR +fi + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$GETH_BIN \ + --networkid 10 \ + --datadir $DATA_DIR \ + --port {{.Ports.BackendP2P}} \ + --rpc \ + --rpcport {{.Ports.BackendHttp}} \ + --rpcaddr {{.Env.RPCBindHost}} \ + --rpcapi eth,rollup,net,web3,debug \ + --rpcvhosts "*" \ + --rpccorsdomain "*" \ + --ws \ + --wsport {{.Ports.BackendRPC}} \ + --wsaddr {{.Env.RPCBindHost}} \ + --wsapi eth,rollup,net,web3,debug \ + --wsorigins "*" \ + --nousb \ + --ipcdisable \ + --nat=none \ + --nodiscover + +{{end}} diff --git a/build/templates/backend/scripts/optimism_archive_op_node.sh b/build/templates/backend/scripts/optimism_archive_op_node.sh new file mode 100644 index 0000000000..f7169c9662 --- /dev/null +++ b/build/templates/backend/scripts/optimism_archive_op_node.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/op-node +PATH={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$BIN \ + --network op-mainnet \ + --l1 http://127.0.0.1:8116 \ + --l1.beacon http://127.0.0.1:7516 \ + --l1.trustrpc \ + --l1.rpckind=debug_geth \ + --l2 http://127.0.0.1:8402 \ + --rpc.addr {{.Env.RPCBindHost}} \ + --rpc.port {{.Ports.BackendRPC}} \ + --l2.jwt-secret {{.Env.BackendDataPath}}/optimism_archive/backend/jwtsecret \ + --p2p.priv.path $PATH/opnode_p2p_priv.txt \ + --p2p.peerstore.path $PATH/opnode_peerstore_db \ + --p2p.discovery.path $PATH/opnode_discovery_db + +{{end}} diff --git a/build/templates/backend/scripts/optimism_op_node.sh b/build/templates/backend/scripts/optimism_op_node.sh new file mode 100644 index 0000000000..d2982bcd5a --- /dev/null +++ b/build/templates/backend/scripts/optimism_op_node.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +BIN={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/op-node +PATH={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$BIN \ + --network op-mainnet \ + --l1 http://127.0.0.1:8136 \ + --l1.beacon http://127.0.0.1:7536 \ + --l1.trustrpc \ + --l1.rpckind=debug_geth \ + --l2 http://127.0.0.1:8400 \ + --rpc.addr {{.Env.RPCBindHost}} \ + --rpc.port {{.Ports.BackendRPC}} \ + --l2.jwt-secret {{.Env.BackendDataPath}}/optimism/backend/jwtsecret \ + --p2p.priv.path $PATH/opnode_p2p_priv.txt \ + --p2p.peerstore.path $PATH/opnode_peerstore_db \ + --p2p.discovery.path $PATH/opnode_discovery_db + +{{end}} diff --git a/build/templates/backend/scripts/polygon_archive_bor.sh b/build/templates/backend/scripts/polygon_archive_bor.sh new file mode 100644 index 0000000000..fd6b3b1060 --- /dev/null +++ b/build/templates/backend/scripts/polygon_archive_bor.sh @@ -0,0 +1,42 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +BOR_BIN=$INSTALL_DIR/bor + +if [ -z "${BOR_PEBBLE_DB}" ]; then + ARCHIVE_FLAGS="--gcmode archive --db.engine leveldb --state.scheme hash" +else + ARCHIVE_FLAGS="--db.engine pebble" +fi + +# --bor.heimdall = backend-polygon-heimdall-archive ports.backend_http +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$BOR_BIN server \ + --chain $INSTALL_DIR/genesis.json \ + --syncmode full \ + --datadir $DATA_DIR \ + $ARCHIVE_FLAGS \ + --bor.heimdall http://127.0.0.1:8173 \ + --maxpeers 200 \ + --bootnodes enode://76316d1cb93c8ed407d3332d595233401250d48f8fbb1d9c65bd18c0495eca1b43ec38ee0ea1c257c0abb7d1f25d649d359cdfe5a805842159cfe36c5f66b7e8@52.78.36.216:30303,enode://b8f1cc9c5d4403703fbf377116469667d2b1823c0daf16b7250aa576bacf399e42c3930ccfcb02c5df6879565a2b8931335565f0e8d3f8e72385ecf4a4bf160a@3.36.224.80:30303,enode://8729e0c825f3d9cad382555f3e46dcff21af323e89025a0e6312df541f4a9e73abfa562d64906f5e59c51fe6f0501b3e61b07979606c56329c020ed739910759@54.194.245.5:30303,enode://681ebac58d8dd2d8a6eef15329dfbad0ab960561524cf2dfde40ad646736fe5c244020f20b87e7c1520820bc625cfb487dd71d63a3a3bf0baea2dbb8ec7c79f1@34.240.245.39:30303,enode://93faa5d49ba61fa03f43f7e3c76907a9c72953e8628650eef09f5bddc646d9012916824cdd60da989fd954a852205df9a1fd9661379504c92e103a1ada4c2ceb@148.251.142.52:30314,enode://91f6d9873ee2ceee27b4054ec70844e21fa7c525e8d820d6a09989473f4f883951da75a09ef098d544c0c8a71e9ddd2e649e5b455b137260ba8657b2f96cad2c@178.63.148.12:30308,enode://2776f6f0d1c1e4dfddeb9a4b1c3b1a8777fbb3054b92fc55b405d35603667e974e9cad4408f1036cfc17af03dd1a6270c5cb40f854b94760474516b2d8c0f185@88.198.101.172:30308,enode://157321664e79855ee0f914fd05b21cc29ae3a7e805114d1c26efa1d4d2781f5d5bc4e76ed9d00f26d6138f80cc84ea183894c390fcb0e07100a845aed02f6f40@136.243.210.177:30303,enode://6a5e65c6ef3356bc79a780cf0c7534c299fb8cd7b37db80155830478c1e29d35336fe52a888efdf53c0e9bb9b94e20b5349d68798860f1cf36ae96da2b3826cc@178.63.247.234:30304,enode://d6da5ad18e51d492481b29443bd0f588b59d3f72f0da43a722b07fe2a9223a717c976a1cfe00ad86c557756b2bf297ea56c64a1f3d09bebcb9b81290689d8e33@178.63.197.250:30320,enode://51cbc8b750e28d5a4f250d141c032cf282ea873eb1c533c5156cfc51e6a5117d465b7b39b4e0088ee597ee87b89e06cc6c1ed5e6e050b1c3f638765ee584c4f4@178.63.163.68:30310,enode://6484d4394215c222257c97ac74fdcd6f77ecf00e896c38ef35cc41a44add96da64649139b37cc094e88bb985eb84b04d4c6c78f86bf205c9e112c31254cdc443@54.38.217.112:30303?discport=30346,enode://eb3b67d68daef47badfa683c8b04a1cba6a7c431613b8d7619a013aad38bd8d405eb1d0e41279b4f6fe15b264bd388e88282a77a908247b2d1e0198bd4def57b@148.251.224.230:30315,enode://aa228d96217dd91564e13536f3c2808d2040115c7c50509f26f836275e8e65d1bf9400bce3294760be18c9e00a4bf47026e661ba8d8ce1cf2ced30f0a70e5da8@89.187.163.132:30303?discport=30356,enode://c10ab147ba266a80f34dbc423cd12689434cb2cc1f18ced8f4e5828e23d6943a666c2db0f8464983ccc95666b36099b513d1e45d5df94139e42fbecde25832fa@87.249.137.89:30303?discport=30436,enode://e68049c37b182a36c8913fc0780aea5196c1841c917cbd76f83f1a3a8ae99fcfbd2dfa44e36081668120354439008fe4325ffc0d0176771ec2c1863033d4769e@65.108.199.236:30303,enode://a4c74da28447bacd2b3e8443d0917cca7798bca39dbb48b0e210f0fb6685538ba9d1608a2493424086363f04be5e6a99e6eabb70946ed503448d6b282056f87a@198.244.213.85:30303?discport=30315,enode://e28fce95f52cf3368b7b624c6f83379dec858fcebf6a7ff07e97aa9b9445736a165bf1c51cad7bdf6e3167e2b00b11c7911fc330dabb484998d899a1b01d75cf@148.251.194.252:30303?discport=30892,enode://412fdb01125f6868a188f472cf15f07c8f93d606395b909dd5010f2a4a2702739102cea18abb6437fbacd12e695982a77f28edd9bbdd36635b04e9b3c2948f8d@34.203.27.246:30303?discport=30388,enode://9703d9591cb1013b4fa6ea889e8effe7579aa59c324a6e019d690a13e108ef9b4419698347e4305f05291e644a713518a91b0fc32a3442c1394619e2a9b8251e@79.127.216.33:30303?discport=30349 \ + --port {{.Ports.BackendP2P}} \ + --http \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,debug,txpool,bor \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.api eth,net,web3,debug,txpool,bor \ + --ws.origins '*' \ + --txlookuplimit 0 \ + --cache 4096 +{{end}} diff --git a/build/templates/backend/scripts/polygon_archive_heimdall.sh b/build/templates/backend/scripts/polygon_archive_heimdall.sh new file mode 100644 index 0000000000..988956ab6e --- /dev/null +++ b/build/templates/backend/scripts/polygon_archive_heimdall.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +HEIMDALL_BIN=$INSTALL_DIR/heimdalld +HOME_DIR=$DATA_DIR +CONFIG_DIR=$HOME_DIR/config + +if [ ! -d "$CONFIG_DIR" ]; then + # init chain + $HEIMDALL_BIN init $(hostname -s) --home $HOME_DIR --chain-id heimdallv2-137 +fi + +# --bor_rpc_url: backend-polygon-bor-archive ports.backend_http +# --eth_rpc_url: backend-ethereum-archive ports.backend_http +$HEIMDALL_BIN start \ + --home $HOME_DIR \ + --rpc.laddr tcp://127.0.0.1:{{.Ports.BackendRPC}} \ + --p2p.laddr tcp://0.0.0.0:{{.Ports.BackendP2P}} \ + --grpc_server tcp://127.0.0.1:{{.Ports.BackendHttp}} \ + --p2p.seeds "e019e16d4e376723f3adc58eb1761809fea9bee0@35.234.150.253:26656,7f3049e88ac7f820fd86d9120506aaec0dc54b27@34.89.75.187:26656,1f5aff3b4f3193404423c3dd1797ce60cd9fea43@34.142.43.240:26656,2d5484feef4257e56ece025633a6ea132d8cadca@35.246.99.203:26656,17e9efcbd173e81a31579310c502e8cdd8b8ff2e@35.197.233.249:26656,72a83490309f9f63fdca3a0bef16c290e5cbb09c@35.246.95.65:26656,00677b1b2c6282fb060b7bb6e9cc7d2d05cdd599@34.105.180.11:26656,721dd4cebfc4b78760c7ee5d7b1b44d29a0aa854@34.147.169.102:26656,4760b3fc04648522a0bcb2d96a10aadee141ee89@34.89.55.74:26656" \ + --bor_rpc_url http://127.0.0.1:8172 \ + --eth_rpc_url http://127.0.0.1:8116 +{{end}} \ No newline at end of file diff --git a/build/templates/backend/scripts/polygon_bor.sh b/build/templates/backend/scripts/polygon_bor.sh new file mode 100644 index 0000000000..734f0dd93c --- /dev/null +++ b/build/templates/backend/scripts/polygon_bor.sh @@ -0,0 +1,36 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +BOR_BIN=$INSTALL_DIR/bor + +# --bor.heimdall = backend-polygon-heimdall ports.backend_http +# Bind RPC endpoints based on BB_RPC_BIND_HOST_* so defaults remain local unless explicitly overridden. +$BOR_BIN server \ + --chain $INSTALL_DIR/genesis.json \ + --syncmode full \ + --datadir $DATA_DIR \ + --bor.heimdall http://127.0.0.1:8171 \ + --maxpeers 200 \ + --bootnodes enode://76316d1cb93c8ed407d3332d595233401250d48f8fbb1d9c65bd18c0495eca1b43ec38ee0ea1c257c0abb7d1f25d649d359cdfe5a805842159cfe36c5f66b7e8@52.78.36.216:30303,enode://b8f1cc9c5d4403703fbf377116469667d2b1823c0daf16b7250aa576bacf399e42c3930ccfcb02c5df6879565a2b8931335565f0e8d3f8e72385ecf4a4bf160a@3.36.224.80:30303,enode://8729e0c825f3d9cad382555f3e46dcff21af323e89025a0e6312df541f4a9e73abfa562d64906f5e59c51fe6f0501b3e61b07979606c56329c020ed739910759@54.194.245.5:30303,enode://681ebac58d8dd2d8a6eef15329dfbad0ab960561524cf2dfde40ad646736fe5c244020f20b87e7c1520820bc625cfb487dd71d63a3a3bf0baea2dbb8ec7c79f1@34.240.245.39:30303,enode://93faa5d49ba61fa03f43f7e3c76907a9c72953e8628650eef09f5bddc646d9012916824cdd60da989fd954a852205df9a1fd9661379504c92e103a1ada4c2ceb@148.251.142.52:30314,enode://91f6d9873ee2ceee27b4054ec70844e21fa7c525e8d820d6a09989473f4f883951da75a09ef098d544c0c8a71e9ddd2e649e5b455b137260ba8657b2f96cad2c@178.63.148.12:30308,enode://2776f6f0d1c1e4dfddeb9a4b1c3b1a8777fbb3054b92fc55b405d35603667e974e9cad4408f1036cfc17af03dd1a6270c5cb40f854b94760474516b2d8c0f185@88.198.101.172:30308,enode://157321664e79855ee0f914fd05b21cc29ae3a7e805114d1c26efa1d4d2781f5d5bc4e76ed9d00f26d6138f80cc84ea183894c390fcb0e07100a845aed02f6f40@136.243.210.177:30303,enode://6a5e65c6ef3356bc79a780cf0c7534c299fb8cd7b37db80155830478c1e29d35336fe52a888efdf53c0e9bb9b94e20b5349d68798860f1cf36ae96da2b3826cc@178.63.247.234:30304,enode://d6da5ad18e51d492481b29443bd0f588b59d3f72f0da43a722b07fe2a9223a717c976a1cfe00ad86c557756b2bf297ea56c64a1f3d09bebcb9b81290689d8e33@178.63.197.250:30320,enode://51cbc8b750e28d5a4f250d141c032cf282ea873eb1c533c5156cfc51e6a5117d465b7b39b4e0088ee597ee87b89e06cc6c1ed5e6e050b1c3f638765ee584c4f4@178.63.163.68:30310,enode://6484d4394215c222257c97ac74fdcd6f77ecf00e896c38ef35cc41a44add96da64649139b37cc094e88bb985eb84b04d4c6c78f86bf205c9e112c31254cdc443@54.38.217.112:30303?discport=30346,enode://eb3b67d68daef47badfa683c8b04a1cba6a7c431613b8d7619a013aad38bd8d405eb1d0e41279b4f6fe15b264bd388e88282a77a908247b2d1e0198bd4def57b@148.251.224.230:30315,enode://aa228d96217dd91564e13536f3c2808d2040115c7c50509f26f836275e8e65d1bf9400bce3294760be18c9e00a4bf47026e661ba8d8ce1cf2ced30f0a70e5da8@89.187.163.132:30303?discport=30356,enode://c10ab147ba266a80f34dbc423cd12689434cb2cc1f18ced8f4e5828e23d6943a666c2db0f8464983ccc95666b36099b513d1e45d5df94139e42fbecde25832fa@87.249.137.89:30303?discport=30436,enode://e68049c37b182a36c8913fc0780aea5196c1841c917cbd76f83f1a3a8ae99fcfbd2dfa44e36081668120354439008fe4325ffc0d0176771ec2c1863033d4769e@65.108.199.236:30303,enode://a4c74da28447bacd2b3e8443d0917cca7798bca39dbb48b0e210f0fb6685538ba9d1608a2493424086363f04be5e6a99e6eabb70946ed503448d6b282056f87a@198.244.213.85:30303?discport=30315,enode://e28fce95f52cf3368b7b624c6f83379dec858fcebf6a7ff07e97aa9b9445736a165bf1c51cad7bdf6e3167e2b00b11c7911fc330dabb484998d899a1b01d75cf@148.251.194.252:30303?discport=30892,enode://412fdb01125f6868a188f472cf15f07c8f93d606395b909dd5010f2a4a2702739102cea18abb6437fbacd12e695982a77f28edd9bbdd36635b04e9b3c2948f8d@34.203.27.246:30303?discport=30388,enode://9703d9591cb1013b4fa6ea889e8effe7579aa59c324a6e019d690a13e108ef9b4419698347e4305f05291e644a713518a91b0fc32a3442c1394619e2a9b8251e@79.127.216.33:30303?discport=30349 \ + --port {{.Ports.BackendP2P}} \ + --http \ + --http.addr {{.Env.RPCBindHost}} \ + --http.port {{.Ports.BackendHttp}} \ + --http.api eth,net,web3,debug,txpool,bor \ + --http.vhosts '*' \ + --http.corsdomain '*' \ + --ws \ + --ws.addr {{.Env.RPCBindHost}} \ + --ws.port {{.Ports.BackendRPC}} \ + --ws.api eth,net,web3,debug,txpool,bor \ + --ws.origins '*' \ + --txlookuplimit 0 \ + --cache 4096 + +{{end}} diff --git a/build/templates/backend/scripts/polygon_heimdall.sh b/build/templates/backend/scripts/polygon_heimdall.sh new file mode 100644 index 0000000000..c267c1bbed --- /dev/null +++ b/build/templates/backend/scripts/polygon_heimdall.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +{{define "main" -}} + +set -e + +INSTALL_DIR={{.Env.BackendInstallPath}}/{{.Coin.Alias}} +DATA_DIR={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend + +HEIMDALL_BIN=$INSTALL_DIR/heimdalld +HOME_DIR=$DATA_DIR +CONFIG_DIR=$HOME_DIR/config + +if [ ! -d "$CONFIG_DIR" ]; then + # init chain + $HEIMDALL_BIN init $(hostname -s) --home $HOME_DIR --chain-id heimdallv2-137 +fi + +# --bor_rpc_url: backend-polygon-bor ports.backend_http +# --eth_rpc_url: backend-ethereum ports.backend_http +$HEIMDALL_BIN start \ + --home $HOME_DIR \ + --rpc.laddr tcp://127.0.0.1:{{.Ports.BackendRPC}} \ + --p2p.laddr tcp://0.0.0.0:{{.Ports.BackendP2P}} \ + --grpc_server tcp://127.0.0.1:{{.Ports.BackendHttp}} \ + --p2p.seeds "e019e16d4e376723f3adc58eb1761809fea9bee0@35.234.150.253:26656,7f3049e88ac7f820fd86d9120506aaec0dc54b27@34.89.75.187:26656,1f5aff3b4f3193404423c3dd1797ce60cd9fea43@34.142.43.240:26656,2d5484feef4257e56ece025633a6ea132d8cadca@35.246.99.203:26656,17e9efcbd173e81a31579310c502e8cdd8b8ff2e@35.197.233.249:26656,72a83490309f9f63fdca3a0bef16c290e5cbb09c@35.246.95.65:26656,00677b1b2c6282fb060b7bb6e9cc7d2d05cdd599@34.105.180.11:26656,721dd4cebfc4b78760c7ee5d7b1b44d29a0aa854@34.147.169.102:26656,4760b3fc04648522a0bcb2d96a10aadee141ee89@34.89.55.74:26656" \ + --bor_rpc_url http://127.0.0.1:8170 \ + --eth_rpc_url http://127.0.0.1:8136 +{{end}} \ No newline at end of file diff --git a/build/templates/blockbook/blockchaincfg.json b/build/templates/blockbook/blockchaincfg.json index 525937c5be..937b4d1753 100644 --- a/build/templates/blockbook/blockchaincfg.json +++ b/build/templates/blockbook/blockchaincfg.json @@ -7,8 +7,11 @@ {{end}} "coin_name": "{{.Coin.Name}}", "coin_shortcut": "{{.Coin.Shortcut}}", +{{- if .Coin.Network}} + "network": "{{.Coin.Network}}",{{end}} "coin_label": "{{.Coin.Label}}", "rpc_url": "{{template "IPC.RPCURLTemplate" .}}", + "rpc_url_ws": "{{template "IPC.RPCURLWSTemplate" .}}", "rpc_user": "{{.IPC.RPCUser}}", "rpc_pass": "{{.IPC.RPCPass}}", "rpc_timeout": {{.IPC.RPCTimeout}}, @@ -23,6 +26,7 @@ {{end}} "mempool_workers": {{.Blockbook.BlockChain.MempoolWorkers}}, "mempool_sub_workers": {{.Blockbook.BlockChain.MempoolSubWorkers}}, + "mempool_resync_batch_size": {{.Blockbook.BlockChain.MempoolResyncBatchSize}}, "block_addresses_to_keep": {{.Blockbook.BlockChain.BlockAddressesToKeep}} } {{end}} diff --git a/build/templates/blockbook/debian/control b/build/templates/blockbook/debian/control index e596de0142..9185723a52 100644 --- a/build/templates/blockbook/debian/control +++ b/build/templates/blockbook/debian/control @@ -8,6 +8,6 @@ Standards-Version: 3.9.5 Package: {{.Blockbook.PackageName}} Architecture: {{.Env.Architecture}} -Depends: ${shlibs:Depends}, ${misc:Depends}, coreutils, passwd, findutils, psmisc, {{.Backend.PackageName}} +Depends: ${shlibs:Depends}, ${misc:Depends}, coreutils, passwd, findutils, psmisc Description: Satoshilabs blockbook server ({{.Coin.Name}}) {{end}} diff --git a/build/templates/blockbook/debian/service b/build/templates/blockbook/debian/service index e354121598..41f20e3e72 100644 --- a/build/templates/blockbook/debian/service +++ b/build/templates/blockbook/debian/service @@ -2,7 +2,9 @@ [Unit] Description=Blockbook daemon ({{.Coin.Name}}) After=network.target +{{if .Env.WantsBackendService}} Wants={{.Backend.PackageName}}.service +{{end}} [Service] ExecStart={{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/bin/blockbook -blockchaincfg={{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/config/blockchaincfg.json -datadir={{.Env.BlockbookDataPath}}/{{.Coin.Alias}}/blockbook/db -sync -internal={{template "Blockbook.InternalBindingTemplate" .}} -public={{template "Blockbook.PublicBindingTemplate" .}} -certfile={{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/cert/blockbook -explorer={{.Blockbook.ExplorerURL}} -log_dir={{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/logs {{.Blockbook.AdditionalParams}} diff --git a/build/tools/templates.go b/build/tools/templates.go index 42b16c03ca..76b908a0d3 100644 --- a/build/tools/templates.go +++ b/build/tools/templates.go @@ -5,11 +5,15 @@ import ( "encoding/json" "fmt" "io" + "net" + "net/url" "os" "os/exec" "path/filepath" "reflect" "runtime" + "sort" + "strings" "text/template" "time" ) @@ -21,11 +25,13 @@ type Backend struct { SystemUser string `json:"system_user"` Version string `json:"version"` BinaryURL string `json:"binary_url"` + DockerImage string `json:"docker_image"` VerificationType string `json:"verification_type"` VerificationSource string `json:"verification_source"` ExtractCommand string `json:"extract_command"` ExcludeFiles []string `json:"exclude_files"` ExecCommandTemplate string `json:"exec_command_template"` + ExecScript string `json:"exec_script"` LogrotateFilesTemplate string `json:"logrotate_files_template"` PostinstScriptTemplate string `json:"postinst_script_template"` ServiceType string `json:"service_type"` @@ -43,8 +49,10 @@ type Config struct { Coin struct { Name string `json:"name"` Shortcut string `json:"shortcut"` + Network string `json:"network,omitempty"` Label string `json:"label"` Alias string `json:"alias"` + TestName string `json:"test_name,omitempty"` } `json:"coin"` Ports struct { BackendRPC int `json:"backend_rpc"` @@ -57,6 +65,7 @@ type Config struct { } `json:"ports"` IPC struct { RPCURLTemplate string `json:"rpc_url_template"` + RPCURLWSTemplate string `json:"rpc_url_ws_template"` RPCUser string `json:"rpc_user"` RPCPass string `json:"rpc_pass"` RPCTimeout int `json:"rpc_timeout"` @@ -71,16 +80,17 @@ type Config struct { ExplorerURL string `json:"explorer_url"` AdditionalParams string `json:"additional_params"` BlockChain struct { - Parse bool `json:"parse,omitempty"` - Subversion string `json:"subversion,omitempty"` - AddressFormat string `json:"address_format,omitempty"` - MempoolWorkers int `json:"mempool_workers"` - MempoolSubWorkers int `json:"mempool_sub_workers"` - BlockAddressesToKeep int `json:"block_addresses_to_keep"` - XPubMagic uint32 `json:"xpub_magic,omitempty"` - XPubMagicSegwitP2sh uint32 `json:"xpub_magic_segwit_p2sh,omitempty"` - XPubMagicSegwitNative uint32 `json:"xpub_magic_segwit_native,omitempty"` - Slip44 uint32 `json:"slip44,omitempty"` + Parse bool `json:"parse,omitempty"` + Subversion string `json:"subversion,omitempty"` + AddressFormat string `json:"address_format,omitempty"` + MempoolWorkers int `json:"mempool_workers"` + MempoolSubWorkers int `json:"mempool_sub_workers"` + MempoolResyncBatchSize int `json:"mempool_resync_batch_size,omitempty"` + BlockAddressesToKeep int `json:"block_addresses_to_keep"` + XPubMagic uint32 `json:"xpub_magic,omitempty"` + XPubMagicSegwitP2sh uint32 `json:"xpub_magic_segwit_p2sh,omitempty"` + XPubMagicSegwitNative uint32 `json:"xpub_magic_segwit_native,omitempty"` + Slip44 uint32 `json:"slip44,omitempty"` AdditionalParams map[string]json.RawMessage `json:"additional_params"` } `json:"block_chain"` @@ -97,9 +107,24 @@ type Config struct { BlockbookInstallPath string `json:"blockbook_install_path"` BlockbookDataPath string `json:"blockbook_data_path"` Architecture string `json:"architecture"` + RPCBindHost string `json:"-"` // Derived from BB_RPC_BIND_HOST_* to keep default RPC exposure local. + RPCAllowIP string `json:"-"` // Derived to align rpcallowip with RPC bind host intent. + WantsBackendService bool `json:"-"` // Derived from the effective RPC URL so systemd only wants a local backend. } `json:"-"` } +const ( + buildEnvVar = "BB_BUILD_ENV" + buildEnvDev = "dev" + buildEnvProd = "prod" + devRPCURLHTTPPrefix = "BB_DEV_RPC_URL_HTTP_" + devRPCURLWSPrefix = "BB_DEV_RPC_URL_WS_" + devMQURLPrefix = "BB_DEV_MQ_URL_" + prodRPCURLHTTPPrefix = "BB_PROD_RPC_URL_HTTP_" + prodRPCURLWSPrefix = "BB_PROD_RPC_URL_WS_" + prodMQURLPrefix = "BB_PROD_MQ_URL_" +) + func jsonToString(msg json.RawMessage) (string, error) { d, err := msg.MarshalJSON() if err != nil { @@ -119,10 +144,180 @@ func generateRPCAuth(user, pass string) (string, error) { return out.String(), nil } +func validateRPCEnvVars(configsDir string) error { + // Use coin aliases as the source of truth so env naming matches coin config and deployment conventions. + validAliases, err := loadCoinAliases(configsDir) + if err != nil { + return err + } + unknown := collectUnknownRPCEnvVars(validAliases, rpcEnvPrefixes()) + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + return fmt.Errorf("RPC env vars reference unknown coin aliases: %s", strings.Join(unknown, ", ")) +} + +type coinAliasHolder struct { + Coin struct { + Alias string `json:"alias"` + } `json:"coin"` +} + +func loadCoinAliases(configsDir string) (map[string]struct{}, error) { + coinsDir := filepath.Join(configsDir, "coins") + entries, err := os.ReadDir(coinsDir) + if err != nil { + return nil, fmt.Errorf("read coins directory for RPC env validation: %w", err) + } + + validAliases := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + alias, err := readCoinAlias(filepath.Join(coinsDir, name)) + if err != nil { + return nil, err + } + if alias == "" { + alias = strings.TrimSuffix(name, ".json") + } + if alias != "" { + validAliases[alias] = struct{}{} + if strings.Contains(alias, "-") { + validAliases[strings.ReplaceAll(alias, "-", "_")] = struct{}{} + } + } + } + + return validAliases, nil +} + +func readCoinAlias(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("read coin alias from %s: %w", path, err) + } + defer f.Close() + + var holder coinAliasHolder + if err := json.NewDecoder(f).Decode(&holder); err != nil { + return "", fmt.Errorf("decode coin alias from %s: %w", path, err) + } + return holder.Coin.Alias, nil +} + +func rpcEnvPrefixes() []string { + return []string{ + devRPCURLWSPrefix, + devRPCURLHTTPPrefix, + devMQURLPrefix, + prodRPCURLWSPrefix, + prodRPCURLHTTPPrefix, + prodMQURLPrefix, + "BB_RPC_BIND_HOST_", + "BB_RPC_ALLOW_IP_", + } +} + +func collectUnknownRPCEnvVars(validAliases map[string]struct{}, prefixes []string) []string { + var unknown []string + for _, env := range os.Environ() { + key, _, _ := strings.Cut(env, "=") + for _, prefix := range prefixes { + if !strings.HasPrefix(key, prefix) { + continue + } + alias := strings.TrimPrefix(key, prefix) + if alias == "" { + unknown = append(unknown, fmt.Sprintf("(empty alias from %s)", key)) // Empty suffix is always invalid. + break + } + if _, ok := validAliases[alias]; !ok { + unknown = append(unknown, fmt.Sprintf("%s (from %s)", alias, key)) + } + break + } + } + return unknown +} + +func resolveBuildEnv() (string, error) { + buildEnv := strings.ToLower(strings.TrimSpace(os.Getenv(buildEnvVar))) + if buildEnv == "" { + return buildEnvDev, nil + } + switch buildEnv { + case buildEnvDev, buildEnvProd: + return buildEnv, nil + default: + return "", fmt.Errorf("invalid %s value %q, expected %q or %q", buildEnvVar, buildEnv, buildEnvDev, buildEnvProd) + } +} + +func rpcURLPrefixesForBuildEnv(buildEnv string) (string, string) { + switch buildEnv { + case buildEnvProd: + return prodRPCURLHTTPPrefix, prodRPCURLWSPrefix + default: + return devRPCURLHTTPPrefix, devRPCURLWSPrefix + } +} + +func mqURLPrefixForBuildEnv(buildEnv string) string { + switch buildEnv { + case buildEnvProd: + return prodMQURLPrefix + default: + return devMQURLPrefix + } +} + +func renderConfigTemplate(config *Config, name string) (string, error) { + templ := config.ParseTemplate() + var out bytes.Buffer + if err := templ.ExecuteTemplate(&out, name, config); err != nil { + return "", err + } + return out.String(), nil +} + +func rpcURLUsesLoopback(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return false + } + host := parsed.Hostname() + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func wantsBackendService(config *Config) (bool, error) { + if isEmpty(config, "backend") { + return false, nil + } + + renderedRPCURL, err := renderConfigTemplate(config, "IPC.RPCURLTemplate") + if err != nil { + return false, err + } + + return rpcURLUsesLoopback(renderedRPCURL), nil +} + // ParseTemplate parses the template func (c *Config) ParseTemplate() *template.Template { templates := map[string]string{ "IPC.RPCURLTemplate": c.IPC.RPCURLTemplate, + "IPC.RPCURLWSTemplate": c.IPC.RPCURLWSTemplate, "IPC.MessageQueueBindingTemplate": c.IPC.MessageQueueBindingTemplate, "Backend.ExecCommandTemplate": c.Backend.ExecCommandTemplate, "Backend.LogrotateFilesTemplate": c.Backend.LogrotateFilesTemplate, @@ -160,6 +355,15 @@ func copyNonZeroBackendFields(toValue *Backend, fromValue *Backend) { func LoadConfig(configsDir, coin string) (*Config, error) { config := new(Config) + // Fail fast if RPC override variables reference coins that do not exist in configs/coins. + if err := validateRPCEnvVars(configsDir); err != nil { + return nil, err + } + buildEnv, err := resolveBuildEnv() + if err != nil { + return nil, err + } + f, err := os.Open(filepath.Join(configsDir, "coins", coin+".json")) if err != nil { return nil, err @@ -183,6 +387,32 @@ func LoadConfig(configsDir, coin string) (*Config, error) { config.Meta.BuildDatetime = time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") config.Env.Architecture = runtime.GOARCH + rpcBindKey := "BB_RPC_BIND_HOST_" + config.Coin.Alias // Bind host is per coin alias to match deployment naming. + config.Env.RPCBindHost = "127.0.0.1" // Default to localhost to avoid unintended remote exposure. + if bindHost, ok := os.LookupEnv(rpcBindKey); ok && bindHost != "" { + config.Env.RPCBindHost = bindHost + } + rpcAllowKey := "BB_RPC_ALLOW_IP_" + config.Coin.Alias // Allow list defaults to loopback unless explicitly overridden. + config.Env.RPCAllowIP = "127.0.0.1" + if allowIP, ok := os.LookupEnv(rpcAllowKey); ok && allowIP != "" { + config.Env.RPCAllowIP = allowIP + } + + rpcURLHTTPPrefix, rpcURLWSPrefix := rpcURLPrefixesForBuildEnv(buildEnv) + mqURLPrefix := mqURLPrefixForBuildEnv(buildEnv) + + // Resolve RPC env by exact alias first and fall back to *_archive for shared test/deploy wiring. + if rpcURL, ok := lookupEnvWithArchiveFallback(rpcURLHTTPPrefix, config.Coin.Alias); ok { + // Prefer explicit env override so package generation/tests can target hosted RPC endpoints without editing JSON. + config.IPC.RPCURLTemplate = rpcURL + } + if rpcURLWS, ok := lookupEnvWithArchiveFallback(rpcURLWSPrefix, config.Coin.Alias); ok { + config.IPC.RPCURLWSTemplate = rpcURLWS + } + if mqURL, ok := lookupEnvWithArchiveFallback(mqURLPrefix, config.Coin.Alias); ok { + config.IPC.MessageQueueBindingTemplate = mqURL + } + if !isEmpty(config, "backend") { // set platform specific fields to config platform, found := config.Backend.Platforms[runtime.GOARCH] @@ -202,11 +432,17 @@ func LoadConfig(configsDir, coin string) (*Config, error) { case "gpg": case "sha256": case "gpg-sha256": + case "docker": default: return nil, fmt.Errorf("Invalid verification type: %s", config.Backend.VerificationType) } } + config.Env.WantsBackendService, err = wantsBackendService(config) + if err != nil { + return nil, err + } + return config, nil } @@ -221,6 +457,58 @@ func isEmpty(config *Config, target string) bool { } } +const archiveSuffix = "_archive" + +func lookupEnvWithArchiveFallback(prefix, alias string) (string, bool) { + if alias == "" { + return "", false + } + + for _, candidate := range aliasCandidates(alias) { + if value, ok := os.LookupEnv(prefix + candidate); ok && value != "" { + return value, true + } + } + return "", false +} + +func aliasCandidates(alias string) []string { + candidates := []string{alias} + if strings.Contains(alias, archiveSuffix) { + return withEnvAliasVariants(candidates) + } + + candidates = append(candidates, alias+archiveSuffix) + + if idx := strings.Index(alias, "_"); idx != -1 { + infix := alias[:idx] + archiveSuffix + alias[idx:] + if infix != alias && infix != alias+archiveSuffix { + candidates = append(candidates, infix) + } + } + + return withEnvAliasVariants(candidates) +} + +func withEnvAliasVariants(candidates []string) []string { + seen := make(map[string]struct{}, len(candidates)*2) + var out []string + for _, candidate := range candidates { + if _, ok := seen[candidate]; !ok { + seen[candidate] = struct{}{} + out = append(out, candidate) + } + if strings.Contains(candidate, "-") { + normalized := strings.ReplaceAll(candidate, "-", "_") + if _, ok := seen[normalized]; !ok { + seen[normalized] = struct{}{} + out = append(out, normalized) + } + } + } + return out +} + // GeneratePackageDefinitions generate the package definitions from the config func GeneratePackageDefinitions(config *Config, templateDir, outputDir string) error { templ := config.ParseTemplate() @@ -280,11 +568,15 @@ func GeneratePackageDefinitions(config *Config, templateDir, outputDir string) e } if !isEmpty(config, "backend") { - err = writeBackendServerConfigFile(config, outputDir) - if err == nil { - err = writeBackendClientConfigFile(config, outputDir) + if err := writeBackendServerConfigFile(config, outputDir); err != nil { + return err } - if err != nil { + + if err := writeBackendClientConfigFile(config, outputDir); err != nil { + return err + } + + if err := writeBackendExecScript(config, outputDir); err != nil { return err } } @@ -354,3 +646,24 @@ func writeBackendClientConfigFile(config *Config, outputDir string) error { _, err = io.Copy(out, in) return err } + +func writeBackendExecScript(config *Config, outputDir string) error { + if config.Backend.ExecScript == "" { + return nil + } + + out, err := os.OpenFile(filepath.Join(outputDir, "backend/exec.sh"), os.O_CREATE|os.O_WRONLY, 0777) + if err != nil { + return err + } + defer out.Close() + + in, err := os.Open(filepath.Join(outputDir, "backend/scripts", config.Backend.ExecScript)) + if err != nil { + return err + } + defer in.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/build/tools/templates_test.go b/build/tools/templates_test.go new file mode 100644 index 0000000000..5e626b2d4f --- /dev/null +++ b/build/tools/templates_test.go @@ -0,0 +1,360 @@ +package build + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "text/template" +) + +func TestResolveBuildEnvDefaultsToDev(t *testing.T) { + t.Setenv(buildEnvVar, "") + + got, err := resolveBuildEnv() + if err != nil { + t.Fatalf("resolveBuildEnv() error = %v", err) + } + if got != buildEnvDev { + t.Fatalf("resolveBuildEnv() = %q, want %q", got, buildEnvDev) + } +} + +func TestResolveBuildEnvUsesExplicitProd(t *testing.T) { + t.Setenv(buildEnvVar, buildEnvProd) + + got, err := resolveBuildEnv() + if err != nil { + t.Fatalf("resolveBuildEnv() error = %v", err) + } + if got != buildEnvProd { + t.Fatalf("resolveBuildEnv() = %q, want %q", got, buildEnvProd) + } +} + +func TestResolveBuildEnvRejectsInvalidValue(t *testing.T) { + t.Setenv(buildEnvVar, "staging") + + if _, err := resolveBuildEnv(); err == nil { + t.Fatal("expected invalid BB_BUILD_ENV to fail") + } +} + +func TestLookupEnvWithArchiveFallback_PrefersExactAlias(t *testing.T) { + const prefix = "TEST_LOOKUP_PREFIX_" + t.Setenv(prefix+"base", "https://base") + t.Setenv(prefix+"base_archive", "https://base-archive") + + got, ok := lookupEnvWithArchiveFallback(prefix, "base") + if !ok { + t.Fatal("expected exact alias lookup to succeed") + } + if got != "https://base" { + t.Fatalf("expected exact alias to win, got %q", got) + } +} + +func TestLookupEnvWithArchiveFallback_UsesArchiveSuffixFallback(t *testing.T) { + const prefix = "TEST_LOOKUP_PREFIX_" + t.Setenv(prefix+"base_archive", "https://base-archive") + + got, ok := lookupEnvWithArchiveFallback(prefix, "base") + if !ok { + t.Fatal("expected suffix archive fallback to succeed") + } + if got != "https://base-archive" { + t.Fatalf("unexpected suffix fallback value %q", got) + } +} + +func TestLookupEnvWithArchiveFallback_UsesArchiveInfixFallback(t *testing.T) { + const prefix = "TEST_LOOKUP_PREFIX_" + t.Setenv(prefix+"polygon_archive_bor", "https://polygon-archive") + + got, ok := lookupEnvWithArchiveFallback(prefix, "polygon_bor") + if !ok { + t.Fatal("expected infix archive fallback to succeed") + } + if got != "https://polygon-archive" { + t.Fatalf("unexpected infix fallback value %q", got) + } +} + +func TestLookupEnvWithArchiveFallback_UsesUnderscoreVariantForHyphenAlias(t *testing.T) { + const prefix = "TEST_LOOKUP_PREFIX_" + t.Setenv(prefix+"ethereum_classic", "https://classic") + + got, ok := lookupEnvWithArchiveFallback(prefix, "ethereum-classic") + if !ok { + t.Fatal("expected underscore variant lookup to succeed") + } + if got != "https://classic" { + t.Fatalf("unexpected underscore variant value %q", got) + } +} + +func TestLookupEnvWithArchiveFallback_DoesNotDoubleArchive(t *testing.T) { + const prefix = "TEST_LOOKUP_PREFIX_" + t.Setenv(prefix+"polygon_archive_archive_bor", "https://invalid") + t.Setenv(prefix+"polygon_archive_bor_archive", "https://invalid") + + if _, ok := lookupEnvWithArchiveFallback(prefix, "polygon_archive_bor"); ok { + t.Fatal("unexpected lookup success for duplicate archive alias variants") + } +} + +func TestRPCURLUsesLoopback(t *testing.T) { + tests := []struct { + name string + raw string + want bool + }{ + {name: "localhost", raw: "http://localhost:8030", want: true}, + {name: "loopback-ipv4", raw: "http://127.0.0.1:8030", want: true}, + {name: "loopback-ipv6", raw: "http://[::1]:8030", want: true}, + {name: "remote", raw: "https://backend5.sldev.cz:8030", want: false}, + {name: "invalid", raw: "not-a-url", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rpcURLUsesLoopback(tt.raw); got != tt.want { + t.Fatalf("rpcURLUsesLoopback(%q) = %v, want %v", tt.raw, got, tt.want) + } + }) + } +} + +func TestLoadConfigSetsWantsBackendServiceFromEffectiveRPCURL(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + t.Run("default-loopback-template", func(t *testing.T) { + withTemporarilyUnsetEnv(t, + buildEnvVar, + devRPCURLHTTPPrefix+"bitcoin", + devRPCURLHTTPPrefix+"bitcoin_archive", + prodRPCURLHTTPPrefix+"bitcoin", + prodRPCURLHTTPPrefix+"bitcoin_archive", + ) + + config, err := LoadConfig(configsDir, "bitcoin") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !config.Env.WantsBackendService { + t.Fatal("expected WantsBackendService for default localhost RPC") + } + }) + + t.Run("remote-dev-override", func(t *testing.T) { + t.Setenv(buildEnvVar, buildEnvDev) + t.Setenv(devRPCURLHTTPPrefix+"bitcoin", "http://backend5.sldev.cz:8030") + + config, err := LoadConfig(configsDir, "bitcoin") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if config.Env.WantsBackendService { + t.Fatal("did not expect WantsBackendService for remote RPC override") + } + }) +} + +func TestLoadConfigOverridesMessageQueueBindingFromEnv(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + withTemporarilyUnsetEnv(t, + buildEnvVar, + devMQURLPrefix+"bitcoin", + devMQURLPrefix+"bitcoin_archive", + prodMQURLPrefix+"bitcoin", + prodMQURLPrefix+"bitcoin_archive", + ) + + t.Setenv(buildEnvVar, buildEnvDev) + t.Setenv(devMQURLPrefix+"bitcoin", "tcp://mq-dev.example:38330") + + config, err := LoadConfig(configsDir, "bitcoin") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + renderedMQ, err := renderConfigTemplate(config, "IPC.MessageQueueBindingTemplate") + if err != nil { + t.Fatalf("renderConfigTemplate(MessageQueueBindingTemplate) error = %v", err) + } + if renderedMQ != "tcp://mq-dev.example:38330" { + t.Fatalf("message_queue_binding = %q, want %q", renderedMQ, "tcp://mq-dev.example:38330") + } +} + +func TestLoadConfigUsesProdMQOverrideWhenBuildEnvIsProd(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + withTemporarilyUnsetEnv(t, + buildEnvVar, + devMQURLPrefix+"bitcoin", + prodMQURLPrefix+"bitcoin", + ) + + t.Setenv(buildEnvVar, buildEnvProd) + t.Setenv(devMQURLPrefix+"bitcoin", "tcp://mq-dev.example:38330") + t.Setenv(prodMQURLPrefix+"bitcoin", "tcp://mq-prod.example:48330") + + config, err := LoadConfig(configsDir, "bitcoin") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + renderedMQ, err := renderConfigTemplate(config, "IPC.MessageQueueBindingTemplate") + if err != nil { + t.Fatalf("renderConfigTemplate(MessageQueueBindingTemplate) error = %v", err) + } + if renderedMQ != "tcp://mq-prod.example:48330" { + t.Fatalf("message_queue_binding = %q, want %q", renderedMQ, "tcp://mq-prod.example:48330") + } +} + +func TestLoadConfigUsesUnderscoreMQOverrideForHyphenAlias(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + withTemporarilyUnsetEnv(t, + buildEnvVar, + devMQURLPrefix+"ethereum_classic", + prodMQURLPrefix+"ethereum_classic", + ) + + t.Setenv(buildEnvVar, buildEnvDev) + t.Setenv(devMQURLPrefix+"ethereum_classic", "tcp://mq-classic.example:9037") + + config, err := LoadConfig(configsDir, "ethereum-classic") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + renderedMQ, err := renderConfigTemplate(config, "IPC.MessageQueueBindingTemplate") + if err != nil { + t.Fatalf("renderConfigTemplate(MessageQueueBindingTemplate) error = %v", err) + } + if renderedMQ != "tcp://mq-classic.example:9037" { + t.Fatalf("message_queue_binding = %q, want %q", renderedMQ, "tcp://mq-classic.example:9037") + } +} + +func TestBlockbookServiceTemplateGatesWantsLine(t *testing.T) { + config := &Config{} + config.Coin.Name = "Bitcoin" + config.Coin.Alias = "bitcoin" + config.Backend.PackageName = "backend-bitcoin" + config.Blockbook.SystemUser = "blockbook" + config.Blockbook.ExplorerURL = "https://example.invalid" + config.Env.BlockbookInstallPath = "/opt/coins/blockbook" + config.Env.BlockbookDataPath = "/var/lib/blockbook" + config.Blockbook.InternalBindingTemplate = "127.0.0.1:9130" + config.Blockbook.PublicBindingTemplate = "127.0.0.1:9130" + + renderService := func(t *testing.T, wants bool) string { + t.Helper() + config.Env.WantsBackendService = wants + + templ := config.ParseTemplate() + templ = template.Must(templ.ParseFiles(filepath.Join("..", "templates", "blockbook", "debian", "service"))) + + var out bytes.Buffer + if err := templ.ExecuteTemplate(&out, "main", config); err != nil { + t.Fatalf("ExecuteTemplate() error = %v", err) + } + return out.String() + } + + if rendered := renderService(t, true); !strings.Contains(rendered, "Wants=backend-bitcoin.service") { + t.Fatalf("expected Wants line in rendered service:\n%s", rendered) + } + if rendered := renderService(t, false); strings.Contains(rendered, "Wants=backend-bitcoin.service") { + t.Fatalf("did not expect Wants line in rendered service:\n%s", rendered) + } +} + +func TestEthereumClassicRPCAndBackendHTTPPortStayAligned(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + withTemporarilyUnsetEnv(t, + buildEnvVar, + devRPCURLHTTPPrefix+"ethereum_classic", + devRPCURLWSPrefix+"ethereum_classic", + prodRPCURLHTTPPrefix+"ethereum_classic", + prodRPCURLWSPrefix+"ethereum_classic", + ) + + config, err := LoadConfig(configsDir, "ethereum-classic") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + templ := config.ParseTemplate() + templ = template.Must(templ.ParseFiles(filepath.Join("..", "templates", "blockbook", "blockchaincfg.json"))) + + var blockchainCfg bytes.Buffer + if err := templ.ExecuteTemplate(&blockchainCfg, "main", config); err != nil { + t.Fatalf("ExecuteTemplate(blockchaincfg) error = %v", err) + } + + var renderedCfg struct { + RPCURL string `json:"rpc_url"` + RPCURLWS string `json:"rpc_url_ws"` + } + if err := json.Unmarshal(blockchainCfg.Bytes(), &renderedCfg); err != nil { + t.Fatalf("json.Unmarshal(blockchaincfg) error = %v", err) + } + + if renderedCfg.RPCURL != "http://127.0.0.1:8037" { + t.Fatalf("rpc_url = %q, want %q", renderedCfg.RPCURL, "http://127.0.0.1:8037") + } + if renderedCfg.RPCURLWS != "ws://127.0.0.1:8037" { + t.Fatalf("rpc_url_ws = %q, want %q", renderedCfg.RPCURLWS, "ws://127.0.0.1:8037") + } + + templ = config.ParseTemplate() + templ = template.Must(templ.ParseFiles(filepath.Join("..", "templates", "backend", "debian", "service"))) + + var backendService bytes.Buffer + if err := templ.ExecuteTemplate(&backendService, "main", config); err != nil { + t.Fatalf("ExecuteTemplate(backend service) error = %v", err) + } + + if !strings.Contains(backendService.String(), "--http.port 8037") { + t.Fatalf("expected ETC backend service to render --http.port 8037:\n%s", backendService.String()) + } + if !strings.Contains(backendService.String(), "--ws.port 8037") { + t.Fatalf("expected ETC backend service to render --ws.port 8037:\n%s", backendService.String()) + } +} + +func withTemporarilyUnsetEnv(t *testing.T, keys ...string) { + t.Helper() + + restore := make(map[string]*string, len(keys)) + for _, key := range keys { + if value, ok := os.LookupEnv(key); ok { + valueCopy := value + restore[key] = &valueCopy + } else { + restore[key] = nil + } + if err := os.Unsetenv(key); err != nil { + t.Fatalf("Unsetenv(%q) error = %v", key, err) + } + } + + t.Cleanup(func() { + for key, value := range restore { + if value == nil { + _ = os.Unsetenv(key) + continue + } + _ = os.Setenv(key, *value) + } + }) +} diff --git a/build/tools/trezor-common/sync-coins.go b/build/tools/trezor-common/sync-coins.go index f4e90ba14e..acb5518e39 100644 --- a/build/tools/trezor-common/sync-coins.go +++ b/build/tools/trezor-common/sync-coins.go @@ -1,4 +1,4 @@ -//usr/bin/go run $0 $@ ; exit +// usr/bin/go run $0 $@ ; exit package main import ( diff --git a/build/tools/typescriptify/typescriptify.go b/build/tools/typescriptify/typescriptify.go new file mode 100644 index 0000000000..6260135634 --- /dev/null +++ b/build/tools/typescriptify/typescriptify.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "math/big" + "time" + + "github.com/tkrajina/typescriptify-golang-structs/typescriptify" + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/server" +) + +func main() { + t := typescriptify.New() + t.CreateInterface = true + t.Indent = " " + t.BackupDir = "" + + t.ManageType(api.Amount{}, typescriptify.TypeOptions{TSType: "string"}) + t.ManageType([]api.Amount{}, typescriptify.TypeOptions{TSType: "string[]"}) + t.ManageType([]*api.Amount{}, typescriptify.TypeOptions{TSType: "string[]"}) + t.ManageType(big.Int{}, typescriptify.TypeOptions{TSType: "number"}) + t.ManageType(time.Time{}, typescriptify.TypeOptions{TSType: "string", TSDoc: "Time in ISO 8601 YYYY-MM-DDTHH:mm:ss.sssZd"}) + + // API - REST and Websocket + t.Add(api.APIError{}) + t.Add(bchain.TronChainExtraData{}) + t.Add(api.Tx{}) + t.Add(api.FeeStats{}) + t.Add(api.Address{}) + t.Add(api.ContractInfoResult{}) + t.Add(api.Utxo{}) + t.Add(api.BalanceHistory{}) + t.Add(api.Blocks{}) + t.Add(api.Block{}) + t.Add(api.BlockRaw{}) + t.Add(api.SystemInfo{}) + t.Add(api.FiatTicker{}) + t.Add(api.FiatTickers{}) + t.Add(api.AvailableVsCurrencies{}) + + // Websocket specific + t.Add(server.WsReq{}) + t.Add(server.WsRes{}) + t.Add(server.WsAccountInfoReq{}) + t.Add(server.WsContractInfoReq{}) + t.Add(server.WsInfoRes{}) + t.Add(server.WsBlockHashReq{}) + t.Add(server.WsBlockHashRes{}) + t.Add(server.WsBlockReq{}) + t.Add(server.WsBlockFilterReq{}) + t.Add(server.WsBlockFiltersBatchReq{}) + t.Add(server.WsAccountUtxoReq{}) + t.Add(server.WsBalanceHistoryReq{}) + t.Add(server.WsTransactionReq{}) + t.Add(server.WsTransactionSpecificReq{}) + t.Add(server.WsEstimateFeeReq{}) + t.Add(server.WsEstimateFeeRes{}) + t.Add(server.WsLongTermFeeRateRes{}) + t.Add(server.WsSendTransactionReq{}) + t.Add(server.WsSubscribeAddressesReq{}) + t.Add(server.WsSubscribeFiatRatesReq{}) + t.Add(server.WsCurrentFiatRatesReq{}) + t.Add(server.WsFiatRatesForTimestampsReq{}) + t.Add(server.WsFiatRatesTickersListReq{}) + t.Add(server.WsMempoolFiltersReq{}) + t.Add(server.WsRpcCallReq{}) + t.Add(server.WsRpcCallRes{}) + t.Add(bchain.MempoolTxidFilterEntries{}) + + err := t.ConvertToFile("blockbook-api.ts") + if err != nil { + panic(err.Error()) + } + fmt.Println("OK") +} diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000000..aa630fa97c --- /dev/null +++ b/changelog.md @@ -0,0 +1,57 @@ +# Changelog + +## Unreleased + +### Performance and Scalability + +- **Concurrent Ethereum block processing path** ([#1383](https://github.com/trezor/blockbook/pull/1383)): runs event processing and internal data fetch in parallel; timeout/cancel control moved to caller for earlier aborts, reducing latency and wasted RPC work on failures. +- **Single-pass BTC block JSON parsing** ([#1385](https://github.com/trezor/blockbook/pull/1385)): removes duplicate block unmarshalling by parsing header and transactions in one pass, lowering ingestion overhead. +- **Batched ERC20 balance RPC calls** ([#1388](https://github.com/trezor/blockbook/pull/1388)): replaces per-contract `eth_call` with JSON-RPC batching for fungible token balances, adds configurable batch size and safe fallback for unsupported backends. +- **Dual HTTP/WS RPC client model for EVM chains** ([#1400](https://github.com/trezor/blockbook/pull/1400)): splits transport usage to HTTP for calls and WS for subscriptions. +- **Faster BTC mempool resync with batching + outpoint cache** ([#1403](https://github.com/trezor/blockbook/pull/1403)): adds optional batch tx fetch with bounded concurrency and a temporary resync outpoint cache to avoid repeated parent lookups. +- **Ethereum address-contract indexing micro-optimizations** ([#1417](https://github.com/trezor/blockbook/pull/1417)): adds hot-address LRU/index map to remove O(n) contract scans, bounds cache growth, and improves ERC20 aggregation hot-path overhead. +- **WebSocket/API perf + fiat observability improvements** ([#1423](https://github.com/trezor/blockbook/pull/1423)): batches fiat enrichment with robust fallback reasons, improves client-facing error behavior, and reduces log noise. + +### Reliability and Correctness + +- **UTXO reorg detection fix in raw-parse path** ([#1398](https://github.com/trezor/blockbook/pull/1398)): populates `BlockHeader.Prev` for raw-parsed blocks to prevent missed fork detection that can stall sync on wrong tips. +- **Base newHeads burst handling fix** ([#1407](https://github.com/trezor/blockbook/pull/1407)): coalesces head notifications as hints and enforces strictly increasing block-number processing with a catch-up loop. +- **Reliable SIGTERM shutdown + clean RocksDB close** ([#1408](https://github.com/trezor/blockbook/pull/1408)): reworks signal fan-out so main shutdown always runs, unblocks workers, and stops periodic state writes during shutdown. +- **Resync recovery on errors** ([#1409](https://github.com/trezor/blockbook/pull/1409)): detects errors in parallel/bulk sync and triggers controlled resync restarts on rollback/reorg to avoid infinite retry stalls. +- **Fixed scientific notation parsing error** ([#1429](https://github.com/trezor/blockbook/pull/1429)): `AmountToBigInt` now safely handles scientific notation (`e`/`E`), keeps a fast path for plain decimals, and rejects pathological exponent expansion. + +### Configuration and Deployment + +- **Configurable backend RPC endpoints for builds/tests** ([#1392](https://github.com/trezor/blockbook/pull/1392)): adds per-coin `BB_RPC_URL_*` overrides for non-local backends, `BB_RPC_BIND_HOST_*`/`BB_RPC_ALLOW_IP_*` for safer network exposure, plus `rpc_url_ws_template` and `BB_RPC_URL_WS_*` overrides. + +### Observability + +- **Syncing/caching Prometheus metrics** ([#1420](https://github.com/trezor/blockbook/pull/1420)): introduces many new metrics for syncing throughput and cache behavior. +- **WebSocket/API perf + fiat observability improvements** ([#1423](https://github.com/trezor/blockbook/pull/1423)): adds Prometheus metrics for fiat enrichment and API behavior. + +### Fiat Pipeline + +- **Fiat worker refactor + broader tests** ([#1424](https://github.com/trezor/blockbook/pull/1424)): extracts fiat logic from a large worker module, improves historical-fetch handling and deadline retry paths, and expands HTTP/WS fiat test coverage. + +### Testing + +- **API-level E2E suite + deploy workflow** ([#1426](https://github.com/trezor/blockbook/pull/1426)): adds E2E tests against live Blockbook endpoints plus GitHub Actions build/deploy stages that wait for sync and run filtered E2E validation after deploy. + +### Security + +- **Potential DoS fix for oversized pagination inputs** ([#1363](https://github.com/trezor/blockbook/pull/1363)): validates extreme `page` and `pageSize` values to prevent resource-exhaustion requests. +- **Security hardening: CSP + XSS fixes in templates** ([#1397](https://github.com/trezor/blockbook/pull/1397)): adds CSP headers and fixes XSS vulnerabilities in templates. +- **WebSocket origin allowlist** ([#1421](https://github.com/trezor/blockbook/pull/1421)): adds optional origin checks with explicit logging to reduce cross-origin websocket exposure when not protected by a proxy. +- **Request-size and template hardening** ([#1434](https://github.com/trezor/blockbook/pull/1434)): limits `/api/sendtx` body size, rejects oversized websocket messages, and avoids `template.JSStr`. + +### New Features and Chain Support + +- **ENS resolver support** ([#1289](https://github.com/trezor/blockbook/pull/1289)). +- **Zcash upgrade** ([#1402](https://github.com/trezor/blockbook/pull/1402)). +- **Tron network support** ([#1273](https://github.com/trezor/blockbook/pull/1273)): adds Tron support to Blockbook. +- **Opt-in ERC-4626 vault enrichment for EVM tokens** ([#1431](https://github.com/trezor/blockbook/pull/1431)): adds REST/WS `protocols=erc4626` batched vault detection and response enrichment under `protocols.erc4626`. +- **Contract metadata API with protocol enrichments**: adds REST/WS single-contract lookup so clients can fetch current contract metadata and optional protocol payloads without reloading full `accountInfo`. + +### Backend Compatibility + +- **Adjusted ZebraRPC for new zebrad backend version** ([#1377](https://github.com/trezor/blockbook/pull/1377)). diff --git a/common/config.go b/common/config.go new file mode 100644 index 0000000000..2252b602d3 --- /dev/null +++ b/common/config.go @@ -0,0 +1,42 @@ +package common + +import ( + "encoding/json" + "os" + + "github.com/juju/errors" +) + +// Config struct +type Config struct { + CoinName string `json:"coin_name"` + CoinShortcut string `json:"coin_shortcut"` + CoinLabel string `json:"coin_label"` + Network string `json:"network"` + FourByteSignatures string `json:"fourByteSignatures"` + FiatRates string `json:"fiat_rates"` + FiatRatesParams string `json:"fiat_rates_params"` + FiatRatesVsCurrencies string `json:"fiat_rates_vs_currencies"` + BlockGolombFilterP uint8 `json:"block_golomb_filter_p"` + BlockFilterScripts string `json:"block_filter_scripts"` + BlockFilterUseZeroedKey bool `json:"block_filter_use_zeroed_key"` +} + +// GetConfig loads and parses the config file and returns Config struct +func GetConfig(configFile string) (*Config, error) { + if configFile == "" { + return nil, errors.New("Missing blockchaincfg configuration parameter") + } + + configFileContent, err := os.ReadFile(configFile) + if err != nil { + return nil, errors.Errorf("Error reading file %v, %v", configFile, err) + } + + var cn Config + err = json.Unmarshal(configFileContent, &cn) + if err != nil { + return nil, errors.Annotatef(err, "Error parsing config file ") + } + return &cn, nil +} diff --git a/common/currencyrateticker.go b/common/currencyrateticker.go index f69fc25e76..65859e9657 100644 --- a/common/currencyrateticker.go +++ b/common/currencyrateticker.go @@ -7,9 +7,9 @@ import ( // CurrencyRatesTicker contains coin ticker data fetched from API type CurrencyRatesTicker struct { - Timestamp time.Time `json:"timestamp"` // return as unix timestamp in API - Rates map[string]float32 `json:"rates"` // rates of the base currency against a list of vs currencies - TokenRates map[string]float32 `json:"tokenRates"` // rates of the tokens (identified by the address of the contract) against the base currency + Timestamp time.Time `json:"timestamp"` // return as unix timestamp in API + Rates map[string]float32 `json:"rates"` // rates of the base currency against a list of vs currencies + TokenRates map[string]float32 `json:"tokenRates,omitempty"` // rates of the tokens (identified by the address of the contract) against the base currency } var ( @@ -20,10 +20,24 @@ var ( TickerTokenVsCurrency string ) +func (t *CurrencyRatesTicker) findTokenRate(token string) (float32, bool) { + if t.TokenRates == nil { + return 0, false + } + if rate, found := t.TokenRates[token]; found { + return rate, true + } + if rate, found := t.TokenRates[strings.ToLower(token)]; found { + return rate, true + } + + return 0, false +} + // Convert returns token rate in base currency func (t *CurrencyRatesTicker) GetTokenRate(token string) (float32, bool) { if t.TokenRates != nil { - rate, found := t.TokenRates[strings.ToLower(token)] + rate, found := t.findTokenRate(token) if !found { return 0, false } @@ -57,7 +71,7 @@ func (t *CurrencyRatesTicker) ConvertTokenToBase(value float64, token string) fl return 0 } -// ConvertTokenToBase converts token value to toCurrency currency +// ConvertToken converts token value to toCurrency currency func (t *CurrencyRatesTicker) ConvertToken(value float64, token string, toCurrency string) float64 { baseValue := t.ConvertTokenToBase(value, token) if baseValue > 0 { @@ -92,7 +106,7 @@ func IsSuitableTicker(ticker *CurrencyRatesTicker, vsCurrency string, token stri if ticker.TokenRates == nil { return false } - if _, found := ticker.TokenRates[token]; !found { + if _, found := ticker.findTokenRate(token); !found { return false } } diff --git a/common/currencyrateticker_test.go b/common/currencyrateticker_test.go index 70ddf1419b..076dbaa284 100644 --- a/common/currencyrateticker_test.go +++ b/common/currencyrateticker_test.go @@ -60,3 +60,43 @@ func TestCurrencyRatesTicker_ConvertToken(t *testing.T) { }) } } + +func TestCurrencyRatesTicker_GetTokenRate_UsesExactMatchForCaseSensitiveTokens(t *testing.T) { + const tronUSDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + + ticker := &CurrencyRatesTicker{ + Rates: map[string]float32{ + "trx": 1, + }, + TokenRates: map[string]float32{ + tronUSDT: 9, + }, + } + + got, found := ticker.GetTokenRate(tronUSDT) + if !found { + t.Fatalf("expected exact-match lookup to find tron token %q", tronUSDT) + } + if got != 9 { + t.Fatalf("unexpected tron token rate: got %v, want %v", got, float32(9)) + } +} + +func TestCurrencyRatesTicker_GetTokenRate_FallsBackToLowercaseForHexTokens(t *testing.T) { + ticker := &CurrencyRatesTicker{ + Rates: map[string]float32{ + "usd": 1, + }, + TokenRates: map[string]float32{ + "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3": 1.2, + }, + } + + got, found := ticker.GetTokenRate("0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3") + if !found { + t.Fatal("expected mixed-case hex token lookup to fall back to lowercase") + } + if got != 1.2 { + t.Fatalf("unexpected hex token rate: got %v, want %v", got, float32(1.2)) + } +} diff --git a/common/internalstate.go b/common/internalstate.go index 3e65d9a38e..5fb5273809 100644 --- a/common/internalstate.go +++ b/common/internalstate.go @@ -2,6 +2,7 @@ package common import ( "encoding/json" + "slices" "sort" "sync" "sync/atomic" @@ -23,76 +24,92 @@ var inShutdown int32 // InternalStateColumn contains the data of a db column type InternalStateColumn struct { - Name string `json:"name"` - Version uint32 `json:"version"` - Rows int64 `json:"rows"` - KeyBytes int64 `json:"keyBytes"` - ValueBytes int64 `json:"valueBytes"` - Updated time.Time `json:"updated"` + Name string `json:"name" ts_doc:"Name of the database column."` + Version uint32 `json:"version" ts_doc:"Version or schema version of the column."` + Rows int64 `json:"rows" ts_doc:"Number of rows stored in this column."` + KeyBytes int64 `json:"keyBytes" ts_doc:"Total size (in bytes) of keys stored in this column."` + ValueBytes int64 `json:"valueBytes" ts_doc:"Total size (in bytes) of values stored in this column."` + Updated time.Time `json:"updated" ts_doc:"Timestamp of the last update to this column."` } // BackendInfo is used to get information about blockchain type BackendInfo struct { - BackendError string `json:"error,omitempty"` - Chain string `json:"chain,omitempty"` - Blocks int `json:"blocks,omitempty"` - Headers int `json:"headers,omitempty"` - BestBlockHash string `json:"bestBlockHash,omitempty"` - Difficulty string `json:"difficulty,omitempty"` - SizeOnDisk int64 `json:"sizeOnDisk,omitempty"` - Version string `json:"version,omitempty"` - Subversion string `json:"subversion,omitempty"` - ProtocolVersion string `json:"protocolVersion,omitempty"` - Timeoffset float64 `json:"timeOffset,omitempty"` - Warnings string `json:"warnings,omitempty"` - ConsensusVersion string `json:"consensus_version,omitempty"` - Consensus interface{} `json:"consensus,omitempty"` + BackendError string `json:"error,omitempty" ts_doc:"Error message if something went wrong in the backend."` + Chain string `json:"chain,omitempty" ts_doc:"Name of the chain - e.g. 'main'."` + Blocks int `json:"blocks,omitempty" ts_doc:"Number of fully verified blocks in the chain."` + Headers int `json:"headers,omitempty" ts_doc:"Number of block headers in the chain."` + BestBlockHash string `json:"bestBlockHash,omitempty" ts_doc:"Hash of the best block in hex."` + Difficulty string `json:"difficulty,omitempty" ts_doc:"Current difficulty of the network."` + SizeOnDisk int64 `json:"sizeOnDisk,omitempty" ts_doc:"Size of the blockchain data on disk in bytes."` + Version string `json:"version,omitempty" ts_doc:"Version of the blockchain backend - e.g. '280000'."` + Subversion string `json:"subversion,omitempty" ts_doc:"Subversion of the blockchain backend - e.g. '/Satoshi:28.0.0/'."` + ProtocolVersion string `json:"protocolVersion,omitempty" ts_doc:"Protocol version of the blockchain backend - e.g. '70016'."` + Timeoffset float64 `json:"timeOffset,omitempty" ts_doc:"Time offset (in seconds) reported by the backend."` + Warnings string `json:"warnings,omitempty" ts_doc:"Any warnings given by the backend regarding the chain state."` + ConsensusVersion string `json:"consensus_version,omitempty" ts_doc:"Version or details of the consensus protocol in use."` + Consensus interface{} `json:"consensus,omitempty" ts_doc:"Additional chain-specific consensus data."` } // InternalState contains the data of the internal state type InternalState struct { - mux sync.Mutex + mux sync.Mutex `ts_doc:"Mutex for synchronized access to the internal state."` - Coin string `json:"coin"` - CoinShortcut string `json:"coinShortcut"` - CoinLabel string `json:"coinLabel"` - Host string `json:"host"` + Coin string `json:"coin" ts_doc:"Coin name (e.g. 'Bitcoin')."` + CoinShortcut string `json:"coinShortcut" ts_doc:"Short code for the coin (e.g. 'BTC')."` + CoinLabel string `json:"coinLabel" ts_doc:"Human-readable label for the coin (e.g. 'Bitcoin main')."` + Host string `json:"host" ts_doc:"Hostname of the node or backend."` + Network string `json:"network,omitempty" ts_doc:"Network name if different from CoinShortcut (e.g. 'testnet')."` - DbState uint32 `json:"dbState"` + DbState uint32 `json:"dbState" ts_doc:"State of the database (closed=0, open=1, inconsistent=2)."` + ExtendedIndex bool `json:"extendedIndex" ts_doc:"Indicates if an extended indexing strategy is used."` - LastStore time.Time `json:"lastStore"` + LastStore time.Time `json:"lastStore" ts_doc:"Time when the internal state was last stored/persisted."` // true if application is with flag --sync - SyncMode bool `json:"syncMode"` + SyncMode bool `json:"syncMode" ts_doc:"Flag indicating if the node is in sync mode."` - InitialSync bool `json:"initialSync"` - IsSynchronized bool `json:"isSynchronized"` - BestHeight uint32 `json:"bestHeight"` - LastSync time.Time `json:"lastSync"` - BlockTimes []uint32 `json:"-"` - AvgBlockPeriod uint32 `json:"-"` + InitialSync bool `json:"initialSync" ts_doc:"If true, the system is in the initial sync phase."` + IsSynchronized bool `json:"isSynchronized" ts_doc:"If true, the main index is fully synced to BestHeight."` + BestHeight uint32 `json:"bestHeight" ts_doc:"Current best block height known to the indexer."` + StartSync time.Time `json:"-" ts_doc:"Timestamp when sync started (not exposed via JSON)."` + LastSync time.Time `json:"lastSync" ts_doc:"Timestamp of the last successful sync."` + BlockTimes []uint32 `json:"-" ts_doc:"List of block timestamps (per height) for calculating historical stats (not exposed via JSON)."` + AvgBlockPeriod uint32 `json:"-" ts_doc:"Average time (in seconds) per block for the last 100 blocks (not exposed via JSON)."` - IsMempoolSynchronized bool `json:"isMempoolSynchronized"` - MempoolSize int `json:"mempoolSize"` - LastMempoolSync time.Time `json:"lastMempoolSync"` + IsMempoolSynchronized bool `json:"isMempoolSynchronized" ts_doc:"If true, mempool data is in sync."` + MempoolSize int `json:"mempoolSize" ts_doc:"Number of transactions in the current mempool."` + LastMempoolSync time.Time `json:"lastMempoolSync" ts_doc:"Timestamp of the last mempool sync."` - DbColumns []InternalStateColumn `json:"dbColumns"` + DbColumns []InternalStateColumn `json:"dbColumns" ts_doc:"List of database column statistics."` - UtxoChecked bool `json:"utxoChecked"` + HasFiatRates bool `json:"-" ts_doc:"True if fiat rates are supported (not exposed via JSON)."` + HasTokenFiatRates bool `json:"-" ts_doc:"True if token fiat rates are supported (not exposed via JSON)."` + HistoricalFiatRatesTime time.Time `json:"historicalFiatRatesTime" ts_doc:"Timestamp of the last historical fiat rates update."` + HistoricalTokenFiatRatesTime time.Time `json:"historicalTokenFiatRatesTime" ts_doc:"Timestamp of the last historical token fiat rates update."` - HasFiatRates bool `json:"-"` - HasTokenFiatRates bool `json:"-"` - HistoricalFiatRatesTime time.Time `json:"historicalFiatRatesTime"` - HistoricalTokenFiatRatesTime time.Time `json:"historicalTokenFiatRatesTime"` - CurrentTicker *CurrencyRatesTicker `json:"currentTicker"` + EnableSubNewTx bool `json:"-" ts_doc:"Internal flag controlling subscription to new transactions (not exposed)."` - BackendInfo BackendInfo `json:"-"` + BackendInfo BackendInfo `json:"-" ts_doc:"Information about the connected blockchain backend (not exposed in JSON)."` + + // database migrations + UtxoChecked bool `json:"utxoChecked" ts_doc:"Indicates if UTXO consistency checks have been performed."` + SortedAddressContracts bool `json:"sortedAddressContracts" ts_doc:"Indicates if address/contract sorting has been completed."` + + // golomb filter settings + BlockGolombFilterP uint8 `json:"block_golomb_filter_p" ts_doc:"Parameter P for building Golomb-Rice filters for blocks."` + BlockFilterScripts string `json:"block_filter_scripts" ts_doc:"Scripts included in block filters (e.g., 'p2pkh,p2sh')."` + BlockFilterUseZeroedKey bool `json:"block_filter_use_zeroed_key" ts_doc:"If true, uses a zeroed key for building block filters."` + + // allowed number of fetched accounts over websocket + WsGetAccountInfoLimit int `json:"-" ts_doc:"Limit of how many getAccountInfo calls can be made via WS (not exposed)."` + WsLimitExceedingIPs map[string]int `json:"-" ts_doc:"Tracks IP addresses exceeding the WS limit (not exposed)."` } // StartedSync signals start of synchronization func (is *InternalState) StartedSync() { is.mux.Lock() defer is.mux.Unlock() + is.StartSync = time.Now().UTC() is.IsSynchronized = false } @@ -102,7 +119,7 @@ func (is *InternalState) FinishedSync(bestHeight uint32) { defer is.mux.Unlock() is.IsSynchronized = true is.BestHeight = bestHeight - is.LastSync = time.Now() + is.LastSync = time.Now().UTC() } // UpdateBestHeight sets new best height, without changing IsSynchronized flag @@ -110,7 +127,7 @@ func (is *InternalState) UpdateBestHeight(bestHeight uint32) { is.mux.Lock() defer is.mux.Unlock() is.BestHeight = bestHeight - is.LastSync = time.Now() + is.LastSync = time.Now().UTC() } // FinishedSyncNoChange marks end of synchronization in case no index update was necessary, it does not update lastSync time @@ -121,10 +138,10 @@ func (is *InternalState) FinishedSyncNoChange() { } // GetSyncState gets the state of synchronization -func (is *InternalState) GetSyncState() (bool, uint32, time.Time) { +func (is *InternalState) GetSyncState() (bool, uint32, time.Time, time.Time) { is.mux.Lock() defer is.mux.Unlock() - return is.IsSynchronized, is.BestHeight, is.LastSync + return is.IsSynchronized, is.BestHeight, is.LastSync, is.StartSync } // StartedMempoolSync signals start of mempool synchronization @@ -186,7 +203,7 @@ func (is *InternalState) GetDBColumnStatValues(c int) (int64, int64, int64) { func (is *InternalState) GetAllDBColumnStats() []InternalStateColumn { is.mux.Lock() defer is.mux.Unlock() - return append(is.DbColumns[:0:0], is.DbColumns...) + return slices.Clone(is.DbColumns) } // DBSizeTotal sums the computed sizes of all columns @@ -224,17 +241,29 @@ func (is *InternalState) GetLastBlockTime() uint32 { func (is *InternalState) SetBlockTimes(blockTimes []uint32) uint32 { is.mux.Lock() defer is.mux.Unlock() - is.BlockTimes = blockTimes + if len(is.BlockTimes) < len(blockTimes) { + // no new block was set + is.BlockTimes = blockTimes + } else { + copy(is.BlockTimes, blockTimes) + } is.computeAvgBlockPeriod() glog.Info("set ", len(is.BlockTimes), " block times, average block period ", is.AvgBlockPeriod, "s") return is.AvgBlockPeriod } -// AppendBlockTime appends block time to BlockTimes, returns AvgBlockPeriod -func (is *InternalState) AppendBlockTime(time uint32) uint32 { +// SetBlockTime sets block time to BlockTimes, allocating the slice as necessary, returns AvgBlockPeriod +func (is *InternalState) SetBlockTime(height uint32, time uint32) uint32 { is.mux.Lock() defer is.mux.Unlock() - is.BlockTimes = append(is.BlockTimes, time) + if int(height) >= len(is.BlockTimes) { + extend := int(height) - len(is.BlockTimes) + 1 + for i := 0; i < extend; i++ { + is.BlockTimes = append(is.BlockTimes, time) + } + } else { + is.BlockTimes[height] = time + } is.computeAvgBlockPeriod() return is.AvgBlockPeriod } @@ -290,6 +319,15 @@ func (is *InternalState) computeAvgBlockPeriod() { is.AvgBlockPeriod = (is.BlockTimes[last] - is.BlockTimes[first]) / avgBlockPeriodSample } +// GetNetwork returns network. If not set returns the same value as CoinShortcut +func (is *InternalState) GetNetwork() string { + network := is.Network + if network == "" { + return is.CoinShortcut + } + return network +} + // SetBackendInfo sets new BackendInfo func (is *InternalState) SetBackendInfo(bi *BackendInfo) { is.mux.Lock() @@ -312,24 +350,6 @@ func (is *InternalState) Pack() ([]byte, error) { return json.Marshal(is) } -// GetCurrentTicker returns current ticker -func (is *InternalState) GetCurrentTicker(vsCurrency string, token string) *CurrencyRatesTicker { - is.mux.Lock() - currentTicker := is.CurrentTicker - is.mux.Unlock() - if currentTicker != nil && IsSuitableTicker(currentTicker, vsCurrency, token) { - return currentTicker - } - return nil -} - -// SetCurrentTicker sets current ticker -func (is *InternalState) SetCurrentTicker(t *CurrencyRatesTicker) { - is.mux.Lock() - defer is.mux.Unlock() - is.CurrentTicker = t -} - // UnpackInternalState unmarshals internal state from json func UnpackInternalState(buf []byte) (*InternalState, error) { var is InternalState @@ -348,3 +368,15 @@ func SetInShutdown() { func IsInShutdown() bool { return atomic.LoadInt32(&inShutdown) != 0 } + +func (is *InternalState) AddWsLimitExceedingIP(ip string) { + is.mux.Lock() + defer is.mux.Unlock() + is.WsLimitExceedingIPs[ip] = is.WsLimitExceedingIPs[ip] + 1 +} + +func (is *InternalState) ResetWsLimitExceedingIPs() { + is.mux.Lock() + defer is.mux.Unlock() + is.WsLimitExceedingIPs = make(map[string]int) +} diff --git a/common/jsonnumber.go b/common/jsonnumber.go index d209fbe29b..d6eab76c08 100644 --- a/common/jsonnumber.go +++ b/common/jsonnumber.go @@ -6,7 +6,9 @@ import ( ) // JSONNumber is used instead of json.Number after upgrade to go 1.14 -// to handle data which can be numbers in double quotes or possibly not numbers at all +// +// to handle data which can be numbers in double quotes or possibly not numbers at all +// // see https://github.com/golang/go/issues/37308 type JSONNumber string diff --git a/common/metrics.go b/common/metrics.go index 1769cbf1d5..cc746c0861 100644 --- a/common/metrics.go +++ b/common/metrics.go @@ -8,33 +8,57 @@ import ( // Metrics holds prometheus collectors for various metrics collected by Blockbook type Metrics struct { - SocketIORequests *prometheus.CounterVec - SocketIOSubscribes *prometheus.CounterVec - SocketIOClients prometheus.Gauge - SocketIOReqDuration *prometheus.HistogramVec - WebsocketRequests *prometheus.CounterVec - WebsocketSubscribes *prometheus.GaugeVec - WebsocketClients prometheus.Gauge - WebsocketReqDuration *prometheus.HistogramVec - IndexResyncDuration prometheus.Histogram - MempoolResyncDuration prometheus.Histogram - TxCacheEfficiency *prometheus.CounterVec - RPCLatency *prometheus.HistogramVec - IndexResyncErrors *prometheus.CounterVec - IndexDBSize prometheus.Gauge - ExplorerViews *prometheus.CounterVec - MempoolSize prometheus.Gauge - EstimatedFee *prometheus.GaugeVec - AvgBlockPeriod prometheus.Gauge - DbColumnRows *prometheus.GaugeVec - DbColumnSize *prometheus.GaugeVec - BlockbookAppInfo *prometheus.GaugeVec - BackendBestHeight prometheus.Gauge - BlockbookBestHeight prometheus.Gauge - ExplorerPendingRequests *prometheus.GaugeVec - WebsocketPendingRequests *prometheus.GaugeVec - SocketIOPendingRequests *prometheus.GaugeVec - XPubCacheSize prometheus.Gauge + WebsocketRequests *prometheus.CounterVec + WebsocketSubscribes *prometheus.GaugeVec + WebsocketClients prometheus.Gauge + WebsocketReqDuration *prometheus.HistogramVec + WebsocketChannelCloses *prometheus.CounterVec + WebsocketUnknownMethods *prometheus.CounterVec + WebsocketAddrNotifications *prometheus.CounterVec + WebsocketNewBlockTxs *prometheus.CounterVec + WebsocketNewBlockTxsDuration *prometheus.HistogramVec + BalanceHistoryFiatDuration *prometheus.HistogramVec + BalanceHistoryFiatFallback *prometheus.CounterVec + BalanceHistoryPoints *prometheus.HistogramVec + WebsocketEthReceipt *prometheus.CounterVec + WebsocketNewBlockTxsSubscriptions prometheus.Gauge + IndexResyncDuration prometheus.Histogram + MempoolResyncDuration prometheus.Histogram + MempoolResyncThroughput *prometheus.HistogramVec + TxCacheEfficiency *prometheus.CounterVec + RPCLatency *prometheus.HistogramVec + ChainDataFallbacks *prometheus.CounterVec + EthCallRequests *prometheus.CounterVec + EthCallErrors *prometheus.CounterVec + EthCallBatchSize prometheus.Histogram + EthCallContractInfo *prometheus.CounterVec + EthCallTokenURI *prometheus.CounterVec + EthCallStakingPool *prometheus.CounterVec + IndexResyncErrors *prometheus.CounterVec + IndexDBSize prometheus.Gauge + ExplorerViews *prometheus.CounterVec + MempoolSize prometheus.Gauge + EstimatedFee *prometheus.GaugeVec + AvgBlockPeriod prometheus.Gauge + SyncBlockStats *prometheus.GaugeVec + SyncHotnessStats *prometheus.GaugeVec + AddrContractsCacheEntries prometheus.Gauge + AddrContractsCacheBytes prometheus.Gauge + AddrContractsCacheHits prometheus.Counter + AddrContractsCacheMisses prometheus.Counter + AddrContractsCacheFlushes *prometheus.CounterVec + DbColumnRows *prometheus.GaugeVec + DbColumnSize *prometheus.GaugeVec + BlockbookAppInfo *prometheus.GaugeVec + BackendBestHeight prometheus.Gauge + BlockbookBestHeight prometheus.Gauge + ExplorerPendingRequests *prometheus.GaugeVec + WebsocketPendingRequests *prometheus.GaugeVec + XPubCacheSize prometheus.Gauge + CoingeckoRequests *prometheus.CounterVec + CoingeckoRangeRequests *prometheus.CounterVec + FiatRatesUpdateDuration *prometheus.HistogramVec + AlternativeFeeProviderRequests *prometheus.CounterVec } // Labels represents a collection of label name -> value mappings. @@ -44,75 +68,125 @@ type Labels = prometheus.Labels func GetMetrics(coin string) (*Metrics, error) { metrics := Metrics{} - metrics.SocketIORequests = prometheus.NewCounterVec( + metrics.WebsocketRequests = prometheus.NewCounterVec( prometheus.CounterOpts{ - Name: "blockbook_socketio_requests", - Help: "Total number of socketio requests by method and status", + Name: "blockbook_websocket_requests", + Help: "Total number of websocket requests by method and status", ConstLabels: Labels{"coin": coin}, }, []string{"method", "status"}, ) - metrics.SocketIOSubscribes = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "blockbook_socketio_subscribes", - Help: "Total number of socketio subscribes by channel and status", + metrics.WebsocketSubscribes = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "blockbook_websocket_subscribes", + Help: "Number of websocket subscriptions by method", ConstLabels: Labels{"coin": coin}, }, - []string{"channel", "status"}, + []string{"method"}, ) - metrics.SocketIOClients = prometheus.NewGauge( + metrics.WebsocketClients = prometheus.NewGauge( prometheus.GaugeOpts{ - Name: "blockbook_socketio_clients", - Help: "Number of currently connected socketio clients", + Name: "blockbook_websocket_clients", + Help: "Number of currently connected websocket clients", ConstLabels: Labels{"coin": coin}, }, ) - metrics.SocketIOReqDuration = prometheus.NewHistogramVec( + metrics.WebsocketReqDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ - Name: "blockbook_socketio_req_duration", - Help: "Socketio request duration by method (in microseconds)", - Buckets: []float64{1, 5, 10, 25, 50, 75, 100, 250}, + Name: "blockbook_websocket_req_duration", + Help: "Websocket request duration by method (in microseconds)", + Buckets: []float64{10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_0000_000}, ConstLabels: Labels{"coin": coin}, }, []string{"method"}, ) - metrics.WebsocketRequests = prometheus.NewCounterVec( + metrics.WebsocketChannelCloses = prometheus.NewCounterVec( prometheus.CounterOpts{ - Name: "blockbook_websocket_requests", - Help: "Total number of websocket requests by method and status", + Name: "blockbook_websocket_channel_closes", + Help: "Total number of websocket channel closes by reason", ConstLabels: Labels{"coin": coin}, }, - []string{"method", "status"}, + []string{"reason"}, ) - metrics.WebsocketSubscribes = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_websocket_subscribes", - Help: "Number of websocket subscriptions by method", + metrics.WebsocketUnknownMethods = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_websocket_unknown_methods", + Help: "Total number of websocket requests with unknown method", ConstLabels: Labels{"coin": coin}, }, []string{"method"}, ) - metrics.WebsocketClients = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "blockbook_websocket_clients", - Help: "Number of currently connected websocket clients", + metrics.WebsocketAddrNotifications = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_websocket_addr_notifications", + Help: "Total number of per-address websocket tx notifications by source", ConstLabels: Labels{"coin": coin}, }, + []string{"source"}, ) - metrics.WebsocketReqDuration = prometheus.NewHistogramVec( + metrics.WebsocketNewBlockTxs = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_websocket_new_block_txs", + Help: "Total number of websocket newBlockTxs events by stage and status", + ConstLabels: Labels{"coin": coin}, + }, + []string{"stage", "status"}, + ) + metrics.WebsocketNewBlockTxsDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ - Name: "blockbook_websocket_req_duration", - Help: "Websocket request duration by method (in microseconds)", - Buckets: []float64{1, 5, 10, 25, 50, 75, 100, 250}, + Name: "blockbook_websocket_new_block_txs_duration_seconds", + Help: "Duration of websocket newBlockTxs processing stages in seconds", + Buckets: []float64{0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10}, + ConstLabels: Labels{"coin": coin}, + }, + []string{"stage"}, + ) + metrics.BalanceHistoryFiatDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "blockbook_balance_history_fiat_duration_seconds", + Help: "Duration of balance history fiat lookup stage by request path and mode", + Buckets: []float64{0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20}, + ConstLabels: Labels{"coin": coin}, + }, + []string{"path", "mode", "status"}, + ) + metrics.BalanceHistoryFiatFallback = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_balance_history_fiat_fallback_total", + Help: "Number of balance history fiat lookup fallbacks by path and reason", + ConstLabels: Labels{"coin": coin}, + }, + []string{"path", "reason"}, + ) + metrics.BalanceHistoryPoints = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "blockbook_balance_history_points", + Help: "Number of output points in balance history responses by request path", + Buckets: []float64{1, 2, 5, 10, 20, 40, 80, 160, 320, 640, 1280}, + ConstLabels: Labels{"coin": coin}, + }, + []string{"path"}, + ) + metrics.WebsocketEthReceipt = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_websocket_eth_receipt", + Help: "Total number of websocket Ethereum receipt enrichment outcomes", + ConstLabels: Labels{"coin": coin}, + }, + []string{"status"}, + ) + metrics.WebsocketNewBlockTxsSubscriptions = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "blockbook_websocket_new_block_txs_subscriptions", + Help: "Number of websocket address subscriptions with newBlockTxs enabled", ConstLabels: Labels{"coin": coin}, }, - []string{"method"}, ) metrics.IndexResyncDuration = prometheus.NewHistogram( prometheus.HistogramOpts{ Name: "blockbook_index_resync_duration", Help: "Duration of index resync operation (in milliseconds)", - Buckets: []float64{50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 600, 700, 1000, 2000, 5000}, + Buckets: []float64{10, 100, 500, 1000, 2000, 5000, 10000}, ConstLabels: Labels{"coin": coin}, }, ) @@ -120,9 +194,18 @@ func GetMetrics(coin string) (*Metrics, error) { prometheus.HistogramOpts{ Name: "blockbook_mempool_resync_duration", Help: "Duration of mempool resync operation (in milliseconds)", - Buckets: []float64{10, 25, 50, 75, 100, 150, 250, 500, 750, 1000, 2000, 5000}, + Buckets: []float64{10, 100, 500, 1000, 2000, 5000, 10000}, + ConstLabels: Labels{"coin": coin}, + }, + ) + metrics.MempoolResyncThroughput = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "blockbook_mempool_resync_throughput_txs_per_second", + Help: "Effective mempool resync throughput in transactions per second", + Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000}, ConstLabels: Labels{"coin": coin}, }, + []string{"chain", "status"}, ) metrics.TxCacheEfficiency = prometheus.NewCounterVec( prometheus.CounterOpts{ @@ -141,6 +224,62 @@ func GetMetrics(coin string) (*Metrics, error) { }, []string{"method", "error"}, ) + metrics.ChainDataFallbacks = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_rpc_fallback_calls_total", + Help: "Total number of chain data fallback path uses by component and reason", + ConstLabels: Labels{"coin": coin}, + }, + []string{"component", "reason"}, + ) + metrics.EthCallRequests = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_eth_call_requests", + Help: "Total number of eth_call requests by mode", + ConstLabels: Labels{"coin": coin}, + }, + []string{"mode"}, + ) + metrics.EthCallErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_eth_call_errors", + Help: "Total number of eth_call errors by mode and type", + ConstLabels: Labels{"coin": coin}, + }, + []string{"mode", "type"}, + ) + metrics.EthCallBatchSize = prometheus.NewHistogram( + prometheus.HistogramOpts{ + Name: "blockbook_eth_call_batch_size", + Help: "Number of eth_call items per batch request", + Buckets: []float64{1, 2, 5, 10, 20, 50, 100, 200}, + ConstLabels: Labels{"coin": coin}, + }, + ) + metrics.EthCallContractInfo = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_eth_call_contract_info_requests", + Help: "Total number of eth_call requests for contract info fields", + ConstLabels: Labels{"coin": coin}, + }, + []string{"field"}, + ) + metrics.EthCallTokenURI = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_eth_call_token_uri_requests", + Help: "Total number of eth_call requests for token URI lookups", + ConstLabels: Labels{"coin": coin}, + }, + []string{"method"}, + ) + metrics.EthCallStakingPool = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_eth_call_staking_pool_requests", + Help: "Total number of eth_call requests for staking pool lookups", + ConstLabels: Labels{"coin": coin}, + }, + []string{"field"}, + ) metrics.IndexResyncErrors = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "blockbook_index_resync_errors", @@ -186,6 +325,58 @@ func GetMetrics(coin string) (*Metrics, error) { ConstLabels: Labels{"coin": coin}, }, ) + metrics.SyncBlockStats = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "blockbook_sync_block_stats", + Help: "Per-interval block stats for bulk sync and per-block stats at chain tip", + ConstLabels: Labels{"coin": coin}, + }, + []string{"scope", "kind"}, + ) + metrics.SyncHotnessStats = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "blockbook_sync_hotness_stats", + Help: "Hot address stats for bulk sync intervals and per-block chain tip processing (Ethereum-type only)", + ConstLabels: Labels{"coin": coin}, + }, + []string{"scope", "kind"}, + ) + metrics.AddrContractsCacheEntries = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "blockbook_addr_contracts_cache_entries", + Help: "Number of cached addressContracts entries", + ConstLabels: Labels{"coin": coin}, + }, + ) + metrics.AddrContractsCacheBytes = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "blockbook_addr_contracts_cache_bytes", + Help: "Estimated bytes in the addressContracts cache", + ConstLabels: Labels{"coin": coin}, + }, + ) + metrics.AddrContractsCacheHits = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "blockbook_addr_contracts_cache_hits_total", + Help: "Total number of addressContracts cache hits", + ConstLabels: Labels{"coin": coin}, + }, + ) + metrics.AddrContractsCacheMisses = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "blockbook_addr_contracts_cache_misses_total", + Help: "Total number of addressContracts cache misses", + ConstLabels: Labels{"coin": coin}, + }, + ) + metrics.AddrContractsCacheFlushes = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_addr_contracts_cache_flush_total", + Help: "Total number of addressContracts cache flushes by reason", + ConstLabels: Labels{"coin": coin}, + }, + []string{"reason"}, + ) metrics.DbColumnRows = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "blockbook_dbcolumn_rows", @@ -240,14 +431,6 @@ func GetMetrics(coin string) (*Metrics, error) { }, []string{"method"}, ) - metrics.SocketIOPendingRequests = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_socketio_pending_requests", - Help: "Number of unfinished requests in socketio interface", - ConstLabels: Labels{"coin": coin}, - }, - []string{"method"}, - ) metrics.XPubCacheSize = prometheus.NewGauge( prometheus.GaugeOpts{ Name: "blockbook_xpub_cache_size", @@ -255,6 +438,39 @@ func GetMetrics(coin string) (*Metrics, error) { ConstLabels: Labels{"coin": coin}, }, ) + metrics.CoingeckoRequests = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_coingecko_requests", + Help: "Total number of requests to coingecko", + ConstLabels: Labels{"coin": coin}, + }, + []string{"endpoint", "status"}, + ) + metrics.CoingeckoRangeRequests = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_coingecko_range_requests", + Help: "Total number of coingecko range queries by range kind", + ConstLabels: Labels{"coin": coin}, + }, + []string{"range"}, + ) + metrics.FiatRatesUpdateDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "blockbook_fiat_rates_update_duration_seconds", + Help: "Duration of fiat rates downloader update stages in seconds", + Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 30, 60, 120, 300}, + ConstLabels: Labels{"coin": coin}, + }, + []string{"stage", "status"}, + ) + metrics.AlternativeFeeProviderRequests = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "blockbook_alternative_fee_provider_requests", + Help: "Total number of requests to alternative fee providers by provider and status (ok, http_, network_error, decode_error)", + ConstLabels: Labels{"coin": coin}, + }, + []string{"provider", "status"}, + ) v := reflect.ValueOf(metrics) for i := 0; i < v.NumField(); i++ { diff --git a/common/utils.go b/common/utils.go index bfe8980bf0..4dee4686e0 100644 --- a/common/utils.go +++ b/common/utils.go @@ -1,7 +1,14 @@ package common import ( + "encoding/json" + "io" + "math" + "runtime/debug" "time" + + "github.com/golang/glog" + "github.com/juju/errors" ) // TickAndDebounce calls function f on trigger channel or with tickTime period (whatever is sooner) with debounce @@ -39,3 +46,63 @@ Loop: } } } + +// SafeDecodeResponseFromReader reads from io.ReadCloser safely, with recovery from panic +func SafeDecodeResponseFromReader(body io.ReadCloser, res interface{}) (err error) { + var data []byte + defer func() { + if r := recover(); r != nil { + glog.Error("unmarshal json recovered from panic: ", r, "; data: ", string(data)) + debug.PrintStack() + if len(data) > 0 && len(data) < 2048 { + err = errors.Errorf("Error: %v", string(data)) + } else { + err = errors.New("Internal error") + } + } + }() + data, err = io.ReadAll(body) + if err != nil { + return err + } + return json.Unmarshal(data, &res) +} + +// RoundToSignificantDigits rounds a float64 number `n` to the specified number of significant figures `digits`. +// For example, RoundToSignificantDigits(1234, 3) returns 1230 +// +// This function works by shifting the number's decimal point to make the desired significant figures +// into whole numbers, rounding, and then shifting back. +// +// Example for n = 1234, digits = 3: +// +// log10(1234) ≈ 3.09 → ceil = 4 +// power = 3 - 4 = -1 +// magnitude = 10^-1 = 0.1 +// n * magnitude = 1234 * 0.1 = 123.4 +// round(123.4) = 123 +// 123 / 0.1 = 1230 +// +// Returns the number rounded to the desired number of significant figures. +func RoundToSignificantDigits(n float64, digits int) float64 { + if n == 0 { + return 0 + } + + // Step 1: Compute how many digits are before the decimal point. + // For 1234 → log10(1234) ≈ 3.09 → ceil = 4 + d := math.Ceil(math.Log10(math.Abs(n))) + + // Step 2: Calculate how much we need to shift the number to bring + // the significant digits into the integer part. + // For digits=3 and d=4 → power = -1 + power := digits - int(d) + + // Step 3: Compute 10^power to scale the number + // 10^-1 = 0.1 + magnitude := math.Pow(10, float64(power)) + + // Step 4: Scale, round, and scale back + // 1234 * 0.1 = 123.4 → round = 123 → 123 / 0.1 = 1230 + return math.Round(n*magnitude) / magnitude +} diff --git a/common/utils_test.go b/common/utils_test.go new file mode 100644 index 0000000000..6076742030 --- /dev/null +++ b/common/utils_test.go @@ -0,0 +1,44 @@ +//go:build unittest + +package common + +import ( + "math" + "strconv" + "testing" +) + +func Test_RoundToSignificantDigits(t *testing.T) { + type testCase struct { + input float64 + digits int + want float64 + } + + tests := []testCase{ + {input: 1234.5678, digits: 3, want: 1230}, + {input: 1234.5678, digits: 4, want: 1235}, + {input: 1234.5678, digits: 5, want: 1234.6}, + {input: 0.0123456, digits: 3, want: 0.0123}, + {input: 98765.4321, digits: 3, want: 98800}, + {input: 1.99999, digits: 3, want: 2.00}, + {input: 999.999, digits: 3, want: 1000}, + {input: 0.0006789, digits: 3, want: 0.000679}, + {input: 5.123456, digits: 3, want: 5.12}, + {input: 4.456789, digits: 3, want: 4.46}, + {input: 3.789012, digits: 3, want: 3.79}, + {input: 2.012345, digits: 3, want: 2.01}, + } + + for _, tt := range tests { + t.Run(strconv.FormatFloat(tt.input, 'f', -1, 64), func(t *testing.T) { + got := RoundToSignificantDigits(tt.input, tt.digits) + + // Use relative epsilon for float comparison + epsilon := 1e-9 + if math.Abs(got-tt.want) > epsilon { + t.Errorf("RoundToSignificantDigits(%v, %d) = %v, want %v", tt.input, tt.digits, got, tt.want) + } + }) + } +} diff --git a/configs/coins/arbitrum.json b/configs/coins/arbitrum.json new file mode 100644 index 0000000000..c4e20183f2 --- /dev/null +++ b/configs/coins/arbitrum.json @@ -0,0 +1,68 @@ +{ + "coin": { + "name": "Arbitrum", + "shortcut": "ETH", + "network": "ARB", + "label": "Arbitrum", + "alias": "arbitrum" + }, + "ports": { + "backend_rpc": 8205, + "backend_p2p": 38405, + "backend_http": 8305, + "blockbook_internal": 9205, + "blockbook_public": 9305 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-arbitrum", + "package_revision": "satoshilabs-1", + "system_user": "arbitrum", + "version": "3.2.1", + "docker_image": "offchainlabs/nitro-node:v3.2.1-d81324d", + "verification_type": "docker", + "verification_source": "724ebdcca39cd0c28ffd025ecea8d1622a376f41344201b729afb60352cbc306", + "extract_command": "docker cp extract:/home/user/target backend/target; docker cp extract:/home/user/nitro-legacy backend/nitro-legacy; docker cp extract:/usr/local/bin/nitro backend/nitro", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/arbitrum_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "arbitrum.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "openssl rand -hex 32 > {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/jwtsecret", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-arbitrum", + "system_user": "blockbook-arbitrum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "averageBlockTimeMs": 250, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"arbitrum-one\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/arbitrum_archive.json b/configs/coins/arbitrum_archive.json new file mode 100644 index 0000000000..d368335bbe --- /dev/null +++ b/configs/coins/arbitrum_archive.json @@ -0,0 +1,75 @@ +{ + "coin": { + "name": "Arbitrum Archive", + "shortcut": "ETH", + "network": "ARB", + "label": "Arbitrum", + "alias": "arbitrum_archive", + "test_name": "arbitrum" + }, + "ports": { + "backend_rpc": 8306, + "backend_p2p": 38406, + "blockbook_internal": 9206, + "blockbook_public": 9306 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-arbitrum-archive", + "package_revision": "satoshilabs-1", + "system_user": "arbitrum", + "version": "3.2.1", + "docker_image": "offchainlabs/nitro-node:v3.2.1-d81324d", + "verification_type": "docker", + "verification_source": "724ebdcca39cd0c28ffd025ecea8d1622a376f41344201b729afb60352cbc306", + "extract_command": "docker cp extract:/home/user/target backend/target; docker cp extract:/home/user/nitro-legacy backend/nitro-legacy; docker cp extract:/usr/local/bin/nitro backend/nitro", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/arbitrum_archive_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "arbitrum_archive.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "openssl rand -hex 32 > {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/jwtsecret", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-arbitrum-archive", + "system_user": "blockbook-arbitrum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=1509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 600, + "additional_params": { + "averageBlockTimeMs": 250, + "address_aliases": true, + "eip1559Fees": true, + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/42161/suggestedGasFees\", \"periodSeconds\": 60}", + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "20s", + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"arbitrum-one\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/arbitrum_nova.json b/configs/coins/arbitrum_nova.json new file mode 100644 index 0000000000..01bdec3ae2 --- /dev/null +++ b/configs/coins/arbitrum_nova.json @@ -0,0 +1,67 @@ +{ + "coin": { + "name": "Arbitrum Nova", + "shortcut": "ETH", + "label": "Arbitrum Nova", + "alias": "arbitrum_nova" + }, + "ports": { + "backend_rpc": 8207, + "backend_p2p": 38407, + "backend_http": 8307, + "blockbook_internal": 9207, + "blockbook_public": 9307 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-arbitrum-nova", + "package_revision": "satoshilabs-1", + "system_user": "arbitrum", + "version": "3.2.1", + "docker_image": "offchainlabs/nitro-node:v3.2.1-d81324d", + "verification_type": "docker", + "verification_source": "724ebdcca39cd0c28ffd025ecea8d1622a376f41344201b729afb60352cbc306", + "extract_command": "docker cp extract:/home/user/target backend/target; docker cp extract:/home/user/nitro-legacy backend/nitro-legacy; docker cp extract:/usr/local/bin/nitro backend/nitro", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/arbitrum_nova_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "arbitrum_nova.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "openssl rand -hex 32 > {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/jwtsecret", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-arbitrum-nova", + "system_user": "blockbook-arbitrum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "averageBlockTimeMs": 250, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/arbitrum_nova_archive.json b/configs/coins/arbitrum_nova_archive.json new file mode 100644 index 0000000000..03954b6a69 --- /dev/null +++ b/configs/coins/arbitrum_nova_archive.json @@ -0,0 +1,71 @@ +{ + "coin": { + "name": "Arbitrum Nova Archive", + "shortcut": "ETH", + "label": "Arbitrum Nova", + "alias": "arbitrum_nova_archive", + "test_name": "arbitrum_nova" + }, + "ports": { + "backend_rpc": 8308, + "backend_p2p": 38408, + "blockbook_internal": 9208, + "blockbook_public": 9308 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-arbitrum-nova-archive", + "package_revision": "satoshilabs-1", + "system_user": "arbitrum", + "version": "3.2.1", + "docker_image": "offchainlabs/nitro-node:v3.2.1-d81324d", + "verification_type": "docker", + "verification_source": "724ebdcca39cd0c28ffd025ecea8d1622a376f41344201b729afb60352cbc306", + "extract_command": "docker cp extract:/home/user/target backend/target; docker cp extract:/home/user/nitro-legacy backend/nitro-legacy; docker cp extract:/usr/local/bin/nitro backend/nitro", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/arbitrum_nova_archive_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "arbitrum_nova_archive.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "openssl rand -hex 32 > {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/jwtsecret", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-arbitrum-nova-archive", + "system_user": "blockbook-arbitrum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=1509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 600, + "additional_params": { + "averageBlockTimeMs": 250, + "address_aliases": true, + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "20s", + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/avalanche.json b/configs/coins/avalanche.json index 110c16700d..8519ac3a5c 100644 --- a/configs/coins/avalanche.json +++ b/configs/coins/avalanche.json @@ -1,69 +1,71 @@ { - "coin": { - "name": "Avalanche", - "shortcut": "AVAX", - "label": "Avalanche", - "alias": "avalanche" - }, - "ports": { - "backend_rpc": 8098, - "backend_p2p": 38398, - "blockbook_internal": 9098, - "blockbook_public": 9198 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}/ext/bc/C/ws", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-avalanche", - "package_revision": "satoshilabs-1", - "system_user": "avalanche", - "version": "1.9.7", - "binary_url": "https://github.com/ava-labs/avalanchego/releases/download/v1.9.7/avalanchego-linux-amd64-v1.9.7.tar.gz", - "verification_type": "gpg", - "verification_source": "https://github.com/ava-labs/avalanchego/releases/download/v1.9.7/avalanchego-linux-amd64-v1.9.7.tar.gz.sig", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/avalanchego --data-dir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log-dir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --http-port {{.Ports.BackendRPC}} --staking-port {{.Ports.BackendP2P}} --public-ip 127.0.0.1 --staking-ephemeral-cert-enabled --chain-config-content ewogICJDIjp7CiAgICAiY29uZmlnIjoiZXdvZ0lDSmxkR2d0WVhCcGN5STZXd29nSUNBZ0ltVjBhQ0lzQ2lBZ0lDQWlaWFJvTFdacGJIUmxjaUlzQ2lBZ0lDQWlibVYwSWl3S0lDQWdJQ0prWldKMVp5MTBjbUZqWlhJaUxBb2dJQ0FnSW5kbFlqTWlMQW9nSUNBZ0ltbHVkR1Z5Ym1Gc0xXVjBhQ0lzQ2lBZ0lDQWlhVzUwWlhKdVlXd3RZbXh2WTJ0amFHRnBiaUlzQ2lBZ0lDQWlhVzUwWlhKdVlXd3RkSEpoYm5OaFkzUnBiMjRpTEFvZ0lDQWdJbWx1ZEdWeWJtRnNMWFI0TFhCdmIyd2lMQW9nSUNBZ0ltbHVkR1Z5Ym1Gc0xXUmxZblZuSWdvZ0lGMEtmUT09IgogIH0KfQ==", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/ava-labs/avalanchego/releases/download/v1.9.7/avalanchego-linux-arm64-v1.9.7.tar.gz", - "verification_source": "https://github.com/ava-labs/avalanchego/releases/download/v1.9.7/avalanchego-linux-arm64-v1.9.7.tar.gz.sig" - } + "coin": { + "name": "Avalanche", + "shortcut": "AVAX", + "label": "Avalanche", + "alias": "avalanche" + }, + "ports": { + "backend_rpc": 8098, + "backend_p2p": 38398, + "blockbook_internal": 9098, + "blockbook_public": 9198 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}/ext/bc/C/rpc", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}/ext/bc/C/ws", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-avalanche", + "package_revision": "satoshilabs-1", + "system_user": "avalanche", + "version": "1.13.2", + "binary_url": "https://github.com/ava-labs/avalanchego/releases/download/v1.13.2/avalanchego-linux-amd64-v1.13.2.tar.gz", + "verification_type": "gpg", + "verification_source": "https://github.com/ava-labs/avalanchego/releases/download/v1.13.2/avalanchego-linux-amd64-v1.13.2.tar.gz.sig", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": [], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/avalanchego --data-dir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log-dir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --http-port {{.Ports.BackendRPC}} --http-host {{.Env.RPCBindHost}} --staking-port {{.Ports.BackendP2P}} --public-ip 127.0.0.1 --staking-ephemeral-cert-enabled --chain-config-content ewogICJDIjp7CiAgICAiY29uZmlnIjoiZXdvZ0lDSmxkR2d0WVhCcGN5STZXd29nSUNBZ0ltVjBhQ0lzQ2lBZ0lDQWlaWFJvTFdacGJIUmxjaUlzQ2lBZ0lDQWlibVYwSWl3S0lDQWdJQ0prWldKMVp5MTBjbUZqWlhJaUxBb2dJQ0FnSW5kbFlqTWlMQW9nSUNBZ0ltbHVkR1Z5Ym1Gc0xXVjBhQ0lzQ2lBZ0lDQWlhVzUwWlhKdVlXd3RZbXh2WTJ0amFHRnBiaUlzQ2lBZ0lDQWlhVzUwWlhKdVlXd3RkSEpoYm5OaFkzUnBiMjRpTEFvZ0lDQWdJbWx1ZEdWeWJtRnNMWFI0TFhCdmIyd2lMQW9nSUNBZ0ltbHVkR1Z5Ym1Gc0xXUmxZblZuSWdvZ0lGMHNDaUFnSW5OMFlYUmxMWE41Ym1NdFpXNWhZbXhsWkNJNklHWmhiSE5sQ24wPSIKICB9Cn0=", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/ava-labs/avalanchego/releases/download/v1.13.2/avalanchego-linux-arm64-v1.13.2.tar.gz", + "verification_source": "https://github.com/ava-labs/avalanchego/releases/download/v1.13.2/avalanchego-linux-arm64-v1.13.2.tar.gz.sig" + } + } + }, + "blockbook": { + "package_name": "blockbook-avalanche", + "system_user": "blockbook-avalanche", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "averageBlockTimeMs": 2000, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"avalanche-2\",\"platformIdentifier\": \"avalanche\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-avalanche", - "system_user": "blockbook-avalanche", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "additional_params": { - "mempoolTxTimeoutHours": 48, - "queryBackendOnMempoolResync": false, - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"avalanche-2\",\"platformIdentifier\": \"avalanche\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} \ No newline at end of file +} diff --git a/configs/coins/avalanche_archive.json b/configs/coins/avalanche_archive.json index ca1db11a8c..3be7b0f159 100644 --- a/configs/coins/avalanche_archive.json +++ b/configs/coins/avalanche_archive.json @@ -1,72 +1,76 @@ { - "coin": { - "name": "Avalanche Archive", - "shortcut": "AVAX", - "label": "Avalanche", - "alias": "avalanche_archive" - }, - "ports": { - "backend_rpc": 8099, - "backend_p2p": 38399, - "blockbook_internal": 9099, - "blockbook_public": 9199 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}/ext/bc/C/ws", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-avalanche-archive", - "package_revision": "satoshilabs-1", - "system_user": "avalanche", - "version": "1.9.7", - "binary_url": "https://github.com/ava-labs/avalanchego/releases/download/v1.9.7/avalanchego-linux-amd64-v1.9.7.tar.gz", - "verification_type": "gpg", - "verification_source": "https://github.com/ava-labs/avalanchego/releases/download/v1.9.7/avalanchego-linux-amd64-v1.9.7.tar.gz.sig", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/avalanchego --data-dir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log-dir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --http-port {{.Ports.BackendRPC}} --staking-port {{.Ports.BackendP2P}} --public-ip 127.0.0.1 --staking-ephemeral-cert-enabled --chain-config-content ewogICJDIjp7CiAgICAiY29uZmlnIjoiZXdvZ0lDSmxkR2d0WVhCcGN5STZXd29nSUNBZ0ltVjBhQ0lzQ2lBZ0lDQWlaWFJvTFdacGJIUmxjaUlzQ2lBZ0lDQWlibVYwSWl3S0lDQWdJQ0prWldKMVp5MTBjbUZqWlhJaUxBb2dJQ0FnSW5kbFlqTWlMQW9nSUNBZ0ltbHVkR1Z5Ym1Gc0xXVjBhQ0lzQ2lBZ0lDQWlhVzUwWlhKdVlXd3RZbXh2WTJ0amFHRnBiaUlzQ2lBZ0lDQWlhVzUwWlhKdVlXd3RkSEpoYm5OaFkzUnBiMjRpTEFvZ0lDQWdJbWx1ZEdWeWJtRnNMWFI0TFhCdmIyd2lMQW9nSUNBZ0ltbHVkR1Z5Ym1Gc0xXUmxZblZuSWdvZ0lGMHNDaUFnSW5CeWRXNXBibWN0Wlc1aFlteGxaQ0k2Wm1Gc2MyVUtmUT09IgogIH0KfQ==", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/ava-labs/avalanchego/releases/download/v1.9.7/avalanchego-linux-arm64-v1.9.7.tar.gz", - "verification_source": "https://github.com/ava-labs/avalanchego/releases/download/v1.9.7/avalanchego-linux-arm64-v1.9.7.tar.gz.sig" - } + "coin": { + "name": "Avalanche Archive", + "shortcut": "AVAX", + "label": "Avalanche", + "alias": "avalanche_archive", + "test_name": "avalanche" + }, + "ports": { + "backend_rpc": 8099, + "backend_p2p": 38399, + "blockbook_internal": 9099, + "blockbook_public": 9199 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}/ext/bc/C/rpc", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}/ext/bc/C/ws", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-avalanche-archive", + "package_revision": "satoshilabs-1", + "system_user": "avalanche", + "version": "1.13.2", + "binary_url": "https://github.com/ava-labs/avalanchego/releases/download/v1.13.2/avalanchego-linux-amd64-v1.13.2.tar.gz", + "verification_type": "gpg", + "verification_source": "https://github.com/ava-labs/avalanchego/releases/download/v1.13.2/avalanchego-linux-amd64-v1.13.2.tar.gz.sig", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": [], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/avalanchego --data-dir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log-dir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --http-port {{.Ports.BackendRPC}} --http-host {{.Env.RPCBindHost}} --staking-port {{.Ports.BackendP2P}} --public-ip 127.0.0.1 --staking-ephemeral-cert-enabled --chain-config-content ewogICJDIjp7CiAgICAiY29uZmlnIjoiZXdvZ0lDSmxkR2d0WVhCcGN5STZXd29nSUNBZ0ltVjBhQ0lzQ2lBZ0lDQWlaWFJvTFdacGJIUmxjaUlzQ2lBZ0lDQWlibVYwSWl3S0lDQWdJQ0prWldKMVp5MTBjbUZqWlhJaUxBb2dJQ0FnSW5kbFlqTWlMQW9nSUNBZ0ltbHVkR1Z5Ym1Gc0xXVjBhQ0lzQ2lBZ0lDQWlhVzUwWlhKdVlXd3RZbXh2WTJ0amFHRnBiaUlzQ2lBZ0lDQWlhVzUwWlhKdVlXd3RkSEpoYm5OaFkzUnBiMjRpTEFvZ0lDQWdJbWx1ZEdWeWJtRnNMWFI0TFhCdmIyd2lMQW9nSUNBZ0ltbHVkR1Z5Ym1Gc0xXUmxZblZuSWdvZ0lGMHNDaUFnSW5CeWRXNXBibWN0Wlc1aFlteGxaQ0k2Wm1Gc2MyVXNDaUFnSW5OMFlYUmxMWE41Ym1NdFpXNWhZbXhsWkNJNklHWmhiSE5sQ24wPSIKICB9Cn0=", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/ava-labs/avalanchego/releases/download/v1.13.2/avalanchego-linux-arm64-v1.13.2.tar.gz", + "verification_source": "https://github.com/ava-labs/avalanchego/releases/download/v1.13.2/avalanchego-linux-arm64-v1.13.2.tar.gz.sig" + } + } + }, + "blockbook": { + "package_name": "blockbook-avalanche-archive", + "system_user": "blockbook-avalanche", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=1509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 600, + "additional_params": { + "averageBlockTimeMs": 2000, + "address_aliases": true, + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "20s", + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"avalanche-2\",\"platformIdentifier\": \"avalanche\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-avalanche-archive", - "system_user": "blockbook-avalanche", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "-workers=16", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 600, - "additional_params": { - "address_aliases": true, - "mempoolTxTimeoutHours": 48, - "processInternalTransactions": true, - "queryBackendOnMempoolResync": false, - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"avalanche-2\",\"platformIdentifier\": \"avalanche\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", - "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} \ No newline at end of file +} diff --git a/configs/coins/base.json b/configs/coins/base.json new file mode 100644 index 0000000000..218346c459 --- /dev/null +++ b/configs/coins/base.json @@ -0,0 +1,69 @@ +{ + "coin": { + "name": "Base", + "shortcut": "ETH", + "network": "BASE", + "label": "Base", + "alias": "base" + }, + "ports": { + "backend_rpc": 8309, + "backend_p2p": 38409, + "backend_http": 8209, + "backend_authrpc": 8409, + "blockbook_internal": 9209, + "blockbook_public": 9309 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-base", + "package_revision": "satoshilabs-1", + "system_user": "base", + "version": "1.101411.3", + "docker_image": "us-docker.pkg.dev/oplabs-tools-artifacts/images/op-geth:v1.101411.3", + "verification_type": "docker", + "verification_source": "aefecdb139d8e3ed3128e7e3c87abb71198dc6a44ef21f012f391af52679e2c5", + "extract_command": "docker cp extract:/usr/local/bin/geth backend/geth", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/base_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "base.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "openssl rand -hex 32 > {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/jwtsecret", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-base", + "system_user": "blockbook-base", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "averageBlockTimeMs": 2000, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"base\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/base_archive.json b/configs/coins/base_archive.json new file mode 100644 index 0000000000..f5af547c50 --- /dev/null +++ b/configs/coins/base_archive.json @@ -0,0 +1,77 @@ +{ + "coin": { + "name": "Base Archive", + "shortcut": "ETH", + "network": "BASE", + "label": "Base", + "alias": "base_archive", + "test_name": "base" + }, + "ports": { + "backend_rpc": 8211, + "backend_p2p": 38411, + "backend_http": 8311, + "backend_authrpc": 8411, + "blockbook_internal": 9211, + "blockbook_public": 9311 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-base-archive", + "package_revision": "satoshilabs-1", + "system_user": "base", + "version": "1.101411.3", + "docker_image": "us-docker.pkg.dev/oplabs-tools-artifacts/images/op-geth:v1.101411.3", + "verification_type": "docker", + "verification_source": "aefecdb139d8e3ed3128e7e3c87abb71198dc6a44ef21f012f391af52679e2c5", + "extract_command": "docker cp extract:/usr/local/bin/geth backend/geth", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/base_archive_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "base_archive.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "openssl rand -hex 32 > {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/jwtsecret", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-base-archive", + "system_user": "blockbook-base", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=1509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 600, + "additional_params": { + "averageBlockTimeMs": 2000, + "address_aliases": true, + "eip1559Fees": true, + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/8453/suggestedGasFees\", \"periodSeconds\": 60}", + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "20s", + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"base\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/base_archive_op_node.json b/configs/coins/base_archive_op_node.json new file mode 100644 index 0000000000..85a4c5dbe1 --- /dev/null +++ b/configs/coins/base_archive_op_node.json @@ -0,0 +1,38 @@ +{ + "coin": { + "name": "Base Archive Op-Node", + "shortcut": "ETH", + "label": "Base", + "alias": "base_archive_op_node" + }, + "ports": { + "backend_rpc": 8212, + "blockbook_internal": 9212, + "blockbook_public": 9312 + }, + "backend": { + "package_name": "backend-base-archive-op-node", + "package_revision": "satoshilabs-1", + "system_user": "base", + "version": "1.10.1", + "docker_image": "us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:v1.10.1", + "verification_type": "docker", + "verification_source": "8f40714868fbdc788f67251383a0c0b78a3a937f07b2303bc7d33df5df6297d9", + "extract_command": "docker cp extract:/usr/local/bin/op-node backend/op-node", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/base_archive_op_node_exec.sh 2>&1 >> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "base_archive_op_node.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} \ No newline at end of file diff --git a/configs/coins/base_op_node.json b/configs/coins/base_op_node.json new file mode 100644 index 0000000000..426d718069 --- /dev/null +++ b/configs/coins/base_op_node.json @@ -0,0 +1,38 @@ +{ + "coin": { + "name": "Base Op-Node", + "shortcut": "ETH", + "label": "Base", + "alias": "base_op_node" + }, + "ports": { + "backend_rpc": 8210, + "blockbook_internal": 9210, + "blockbook_public": 9310 + }, + "backend": { + "package_name": "backend-base-op-node", + "package_revision": "satoshilabs-1", + "system_user": "base", + "version": "1.10.1", + "docker_image": "us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:v1.10.1", + "verification_type": "docker", + "verification_source": "8f40714868fbdc788f67251383a0c0b78a3a937f07b2303bc7d33df5df6297d9", + "extract_command": "docker cp extract:/usr/local/bin/op-node backend/op-node", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/base_op_node_exec.sh 2>&1 >> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "base_op_node.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} \ No newline at end of file diff --git a/configs/coins/bcash.json b/configs/coins/bcash.json index 537c042561..c6fa901af5 100644 --- a/configs/coins/bcash.json +++ b/configs/coins/bcash.json @@ -1,68 +1,69 @@ { - "coin": { - "name": "Bcash", - "shortcut": "BCH", - "label": "Bitcoin Cash", - "alias": "bcash" - }, - "ports": { - "backend_rpc": 8031, - "backend_message_queue": 38331, - "blockbook_internal": 9031, - "blockbook_public": 9131 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-bcash", - "package_revision": "satoshilabs-1", - "system_user": "bcash", - "version": "26.0.0", - "binary_url": "https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v26.0.0/bitcoin-cash-node-26.0.0-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "e32e05fd63161f6f1fe717fca789448d2ee48e2017d3d4c6686b4222fe69497e", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/bitcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bcash.conf", - "client_config_file": "bitcoin_like_client.conf" - }, - "blockbook": { - "package_name": "blockbook-bcash", - "system_user": "blockbook-bcash", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "subversion": "/Bitcoin ABC Cash Node:22.1.0/", - "address_format": "cashaddr", - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "slip44": 145, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin-cash\", \"periodSeconds\": 900}" - } + "coin": { + "name": "Bcash", + "shortcut": "BCH", + "label": "Bitcoin Cash", + "alias": "bcash" + }, + "ports": { + "backend_rpc": 8031, + "backend_message_queue": 38331, + "blockbook_internal": 9031, + "blockbook_public": 9131 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bcash", + "package_revision": "satoshilabs-1", + "system_user": "bcash", + "version": "28.0.1", + "binary_url": "https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v29.0.0/bitcoin-cash-node-29.0.0-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "6125d1cbecc1db476f2b6b7b91da5acde92d2311b8e738124e3db64ca84b33e1", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bcash.conf", + "client_config_file": "bitcoin_like_client.conf" + }, + "blockbook": { + "package_name": "blockbook-bcash", + "system_user": "blockbook-bcash", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "subversion": "/Bitcoin ABC Cash Node:22.1.0/", + "address_format": "cashaddr", + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "slip44": 145, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"bitcoin-cash\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/bcash_testnet.json b/configs/coins/bcash_testnet.json index e13b5ebc05..bf41853789 100644 --- a/configs/coins/bcash_testnet.json +++ b/configs/coins/bcash_testnet.json @@ -1,66 +1,65 @@ { - "coin": { - "name": "Bcash Testnet", - "shortcut": "TBCH", - "label": "Bitcoin Cash Testnet", - "alias": "bcash_testnet" - }, - "ports": { - "backend_rpc": 18031, - "backend_message_queue": 48331, - "blockbook_internal": 19031, - "blockbook_public": 19131 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-bcash-testnet", - "package_revision": "satoshilabs-1", - "system_user": "bcash", - "version": "26.0.0", - "binary_url": "https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v26.0.0/bitcoin-cash-node-26.0.0-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "e32e05fd63161f6f1fe717fca789448d2ee48e2017d3d4c6686b4222fe69497e", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [ - "bin/bitcoin-qt" - ], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet3/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "bitcoin.conf", - "client_config_file": "bitcoin_client.conf" - }, - "blockbook": { - "package_name": "blockbook-bcash-testnet", - "system_user": "blockbook-bcash", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "subversion": "/Bitcoin ABC Cash Node:22.1.0/", - "address_format": "cashaddr", - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 70617039, - "slip44": 1, - "additional_params": {} + "coin": { + "name": "Bcash Testnet", + "shortcut": "TBCH", + "label": "Bitcoin Cash Testnet", + "alias": "bcash_testnet" + }, + "ports": { + "backend_rpc": 18031, + "backend_message_queue": 48331, + "blockbook_internal": 19031, + "blockbook_public": 19131 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bcash-testnet", + "package_revision": "satoshilabs-1", + "system_user": "bcash", + "version": "28.0.1", + "binary_url": "https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v29.0.0/bitcoin-cash-node-29.0.0-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "6125d1cbecc1db476f2b6b7b91da5acde92d2311b8e738124e3db64ca84b33e1", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet3/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "bcash.conf", + "client_config_file": "bitcoin_client.conf" + }, + "blockbook": { + "package_name": "blockbook-bcash-testnet", + "system_user": "blockbook-bcash", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "subversion": "/Bitcoin ABC Cash Node:22.1.0/", + "address_format": "cashaddr", + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 70617039, + "slip44": 1, + "additional_params": {} + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/bcashsv.json b/configs/coins/bcashsv.json index 88a4f8ded5..32fbbccccf 100644 --- a/configs/coins/bcashsv.json +++ b/configs/coins/bcashsv.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/bellcoin.json b/configs/coins/bellcoin.json index 8c645938d5..e5e4662cdb 100644 --- a/configs/coins/bellcoin.json +++ b/configs/coins/bellcoin.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -66,4 +67,4 @@ "package_maintainer": "ilmango-doge", "package_maintainer_email": "ilmango.doge@gmail.com" } -} \ No newline at end of file +} diff --git a/configs/coins/bgold.json b/configs/coins/bgold.json index e5afbb5fcf..f1daddf5a6 100644 --- a/configs/coins/bgold.json +++ b/configs/coins/bgold.json @@ -1,264 +1,265 @@ { - "coin": { - "name": "Bgold", - "shortcut": "BTG", - "label": "Bitcoin Gold", - "alias": "bgold" - }, - "ports": { - "backend_rpc": 8035, - "backend_message_queue": 38335, - "blockbook_internal": 9035, - "blockbook_public": 9135 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-bgold", - "package_revision": "satoshilabs-1", - "system_user": "bgold", - "version": "0.17.3", - "binary_url": "https://github.com/BTCGPU/BTCGPU/releases/download/v0.17.3/bitcoin-gold-0.17.3-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/BTCGPU/BTCGPU/releases/download/v0.17.3/SHA256SUMS.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/bitcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bgoldd -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "addnode": [ - "188.126.0.134", - "45.56.84.44", - "109.201.133.93:8338", - "178.63.11.246:8338", - "188.120.223.153:8338", - "79.137.64.158:8338", - "78.193.221.106:8338", - "139.59.151.13:8338", - "76.16.12.81:8338", - "172.104.157.62:8338", - "43.207.67.209:8338", - "178.63.11.246:8338", - "79.137.64.158:8338", - "78.193.221.106:8338", - "139.59.151.13:8338", - "172.104.157.62:8338", - "178.158.247.119:8338", - "109.201.133.93:8338", - "178.63.11.246:8338", - "139.59.151.13:8338", - "172.104.157.62:8338", - "188.120.223.153:8338", - "178.158.247.119:8338", - "78.193.221.106:8338", - "79.137.64.158:8338", - "76.16.12.81:8338", - "176.12.32.153:8338", - "178.158.247.122:8338", - "81.37.147.185:8338", - "176.12.32.153:8338", - "79.137.64.158:8338", - "178.158.247.122:8338", - "66.70.247.151:8338", - "89.18.27.165:8338", - "178.63.11.246:8338", - "91.222.17.86:8338", - "37.59.50.143:8338", - "91.50.219.221:8338", - "154.16.63.17:8338", - "213.136.76.42:8338", - "176.99.4.140:8338", - "176.9.48.36:8338", - "78.193.221.106:8338", - "34.236.228.99:8338", - "213.154.230.107:8338", - "111.231.66.252:8338", - "188.120.223.153:8338", - "219.89.122.82:8338", - "109.192.23.101:8338", - "98.114.91.222:8338", - "217.66.156.41:8338", - "172.104.157.62:8338", - "114.44.222.73:8338", - "91.224.140.216:8338", - "149.154.71.96:8338", - "107.181.183.242:8338", - "36.78.96.92:8338", - "46.22.7.74:8338", - "89.110.53.186:8338", - "73.243.220.85:8338", - "109.86.137.8:8338", - "77.78.12.89:8338", - "87.92.116.26:8338", - "93.78.122.48:8338", - "35.195.83.0:8338", - "46.147.75.220:8338", - "212.47.236.104:8338", - "95.220.100.230:8338", - "178.70.142.247:8338", - "45.76.136.149:8338", - "94.155.74.206:8338", - "178.70.142.247:8338", - "128.199.228.97:8338", - "77.171.144.207:8338", - "159.89.192.119:8338", - "136.63.238.170:8338", - "31.27.193.105:8338", - "176.107.192.240:8338", - "94.140.241.96:8338", - "66.108.15.5:8338", - "81.177.127.204:8338", - "88.18.69.174:8338", - "178.70.130.94:8338", - "78.98.162.140:8338", - "95.133.156.224:8338", - "46.188.16.96:8338", - "94.247.16.21:8338", - "eunode.pool.gold:8338", - "asianode.pool.gold:8338", - "45.56.84.44:8338", - "176.9.48.36:8338", - "93.57.253.121:8338", - "172.104.157.62:8338", - "176.12.32.153:8338", - "pool.serverpower.net:8338", - "213.154.229.126:8338", - "213.154.230.106:8338", - "213.154.230.107:8338", - "213.154.229.50:8338", - "145.239.0.50:8338", - "107.181.183.242:8338", - "109.201.133.93:8338", - "120.41.190.109:8338", - "120.41.191.224:8338", - "138.68.249.79:8338", - "13.95.223.202:8338", - "145.239.0.50:8338", - "149.56.95.26:8338", - "158.69.103.228:8338", - "159.89.192.119:8338", - "164.132.207.143:8338", - "171.100.141.106:8338", - "172.104.157.62:8338", - "173.176.95.92:8338", - "176.12.32.153:8338", - "178.239.54.250:8338", - "178.63.11.246:8338", - "185.139.2.140:8338", - "188.120.223.153:8338", - "190.46.2.92:8338", - "192.99.194.113:8338", - "199.229.248.218:8338", - "213.154.229.126:8338", - "213.154.229.50:8338", - "213.154.230.106:8338", - "213.154.230.107:8338", - "217.182.199.21", - "35.189.127.200:8338", - "35.195.83.0:8338", - "35.197.197.166:8338", - "35.200.168.155:8338", - "35.203.167.11:8338", - "37.59.50.143:8338", - "45.27.161.195:8338", - "45.32.234.160:8338", - "45.56.84.44:8338", - "46.188.16.96:8338", - "46.251.19.171:8338", - "5.157.119.109:8338", - "52.28.162.48:8338", - "54.153.140.202:8338", - "54.68.81.2:83388338", - "62.195.190.190:8338", - "62.216.5.136:8338", - "65.110.125.175:8338", - "67.68.226.130:8338", - "73.243.220.85:8338", - "77.78.12.89:8338", - "78.193.221.106:8338", - "78.98.162.140:8338", - "79.137.64.158:8338", - "84.144.177.238:8338", - "87.92.116.26:8338", - "89.115.139.117:8338", - "89.18.27.165:8338", - "91.50.219.221:8338", - "93.88.74.26", - "93.88.74.26:8338", - "94.155.74.206:8338", - "95.154.201.132:8338", - "98.29.248.131:8338", - "u2.my.to:8338", - "[2001:470:b:ce:dc70:83ff:fe7a:1e74]:8338", - "2001:7b8:61d:1:250:56ff:fe90:c89f:8338", - "2001:7b8:63a:1002:213:154:230:106:8338", - "2001:7b8:63a:1002:213:154:230:107:8338", - "45.56.84.44", - "109.201.133.93:8338", - "120.41.191.224:30607", - "138.68.249.79:50992", - "138.68.249.79:51314", - "172.104.157.62", - "178.63.11.246:8338", - "185.139.2.140:8338", - "199.229.248.218:28830", - "35.189.127.200:41220", - "35.189.127.200:48244", - "35.195.83.0:35172", - "35.195.83.0:35576", - "35.195.83.0:35798", - "35.197.197.166:32794", - "35.197.197.166:33112", - "35.197.197.166:33332", - "35.203.167.11:52158", - "37.59.50.143:35254", - "45.27.161.195:33852", - "45.27.161.195:36738", - "45.27.161.195:58628" - ], - "maxconnections": 250, - "mempoolexpiry": 72, - "timeout": 768 + "coin": { + "name": "Bgold", + "shortcut": "BTG", + "label": "Bitcoin Gold", + "alias": "bgold" + }, + "ports": { + "backend_rpc": 8035, + "backend_message_queue": 38335, + "blockbook_internal": 9035, + "blockbook_public": 9135 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bgold", + "package_revision": "satoshilabs-1", + "system_user": "bgold", + "version": "0.17.3", + "binary_url": "https://github.com/BTCGPU/BTCGPU/releases/download/v0.17.3/bitcoin-gold-0.17.3-x86_64-linux-gnu.tar.gz", + "verification_type": "gpg-sha256", + "verification_source": "https://github.com/BTCGPU/BTCGPU/releases/download/v0.17.3/SHA256SUMS.asc", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bgoldd -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "addnode": [ + "188.126.0.134", + "45.56.84.44", + "109.201.133.93:8338", + "178.63.11.246:8338", + "188.120.223.153:8338", + "79.137.64.158:8338", + "78.193.221.106:8338", + "139.59.151.13:8338", + "76.16.12.81:8338", + "172.104.157.62:8338", + "43.207.67.209:8338", + "178.63.11.246:8338", + "79.137.64.158:8338", + "78.193.221.106:8338", + "139.59.151.13:8338", + "172.104.157.62:8338", + "178.158.247.119:8338", + "109.201.133.93:8338", + "178.63.11.246:8338", + "139.59.151.13:8338", + "172.104.157.62:8338", + "188.120.223.153:8338", + "178.158.247.119:8338", + "78.193.221.106:8338", + "79.137.64.158:8338", + "76.16.12.81:8338", + "176.12.32.153:8338", + "178.158.247.122:8338", + "81.37.147.185:8338", + "176.12.32.153:8338", + "79.137.64.158:8338", + "178.158.247.122:8338", + "66.70.247.151:8338", + "89.18.27.165:8338", + "178.63.11.246:8338", + "91.222.17.86:8338", + "37.59.50.143:8338", + "91.50.219.221:8338", + "154.16.63.17:8338", + "213.136.76.42:8338", + "176.99.4.140:8338", + "176.9.48.36:8338", + "78.193.221.106:8338", + "34.236.228.99:8338", + "213.154.230.107:8338", + "111.231.66.252:8338", + "188.120.223.153:8338", + "219.89.122.82:8338", + "109.192.23.101:8338", + "98.114.91.222:8338", + "217.66.156.41:8338", + "172.104.157.62:8338", + "114.44.222.73:8338", + "91.224.140.216:8338", + "149.154.71.96:8338", + "107.181.183.242:8338", + "36.78.96.92:8338", + "46.22.7.74:8338", + "89.110.53.186:8338", + "73.243.220.85:8338", + "109.86.137.8:8338", + "77.78.12.89:8338", + "87.92.116.26:8338", + "93.78.122.48:8338", + "35.195.83.0:8338", + "46.147.75.220:8338", + "212.47.236.104:8338", + "95.220.100.230:8338", + "178.70.142.247:8338", + "45.76.136.149:8338", + "94.155.74.206:8338", + "178.70.142.247:8338", + "128.199.228.97:8338", + "77.171.144.207:8338", + "159.89.192.119:8338", + "136.63.238.170:8338", + "31.27.193.105:8338", + "176.107.192.240:8338", + "94.140.241.96:8338", + "66.108.15.5:8338", + "81.177.127.204:8338", + "88.18.69.174:8338", + "178.70.130.94:8338", + "78.98.162.140:8338", + "95.133.156.224:8338", + "46.188.16.96:8338", + "94.247.16.21:8338", + "eunode.pool.gold:8338", + "asianode.pool.gold:8338", + "45.56.84.44:8338", + "176.9.48.36:8338", + "93.57.253.121:8338", + "172.104.157.62:8338", + "176.12.32.153:8338", + "pool.serverpower.net:8338", + "213.154.229.126:8338", + "213.154.230.106:8338", + "213.154.230.107:8338", + "213.154.229.50:8338", + "145.239.0.50:8338", + "107.181.183.242:8338", + "109.201.133.93:8338", + "120.41.190.109:8338", + "120.41.191.224:8338", + "138.68.249.79:8338", + "13.95.223.202:8338", + "145.239.0.50:8338", + "149.56.95.26:8338", + "158.69.103.228:8338", + "159.89.192.119:8338", + "164.132.207.143:8338", + "171.100.141.106:8338", + "172.104.157.62:8338", + "173.176.95.92:8338", + "176.12.32.153:8338", + "178.239.54.250:8338", + "178.63.11.246:8338", + "185.139.2.140:8338", + "188.120.223.153:8338", + "190.46.2.92:8338", + "192.99.194.113:8338", + "199.229.248.218:8338", + "213.154.229.126:8338", + "213.154.229.50:8338", + "213.154.230.106:8338", + "213.154.230.107:8338", + "217.182.199.21", + "35.189.127.200:8338", + "35.195.83.0:8338", + "35.197.197.166:8338", + "35.200.168.155:8338", + "35.203.167.11:8338", + "37.59.50.143:8338", + "45.27.161.195:8338", + "45.32.234.160:8338", + "45.56.84.44:8338", + "46.188.16.96:8338", + "46.251.19.171:8338", + "5.157.119.109:8338", + "52.28.162.48:8338", + "54.153.140.202:8338", + "54.68.81.2:83388338", + "62.195.190.190:8338", + "62.216.5.136:8338", + "65.110.125.175:8338", + "67.68.226.130:8338", + "73.243.220.85:8338", + "77.78.12.89:8338", + "78.193.221.106:8338", + "78.98.162.140:8338", + "79.137.64.158:8338", + "84.144.177.238:8338", + "87.92.116.26:8338", + "89.115.139.117:8338", + "89.18.27.165:8338", + "91.50.219.221:8338", + "93.88.74.26", + "93.88.74.26:8338", + "94.155.74.206:8338", + "95.154.201.132:8338", + "98.29.248.131:8338", + "u2.my.to:8338", + "[2001:470:b:ce:dc70:83ff:fe7a:1e74]:8338", + "2001:7b8:61d:1:250:56ff:fe90:c89f:8338", + "2001:7b8:63a:1002:213:154:230:106:8338", + "2001:7b8:63a:1002:213:154:230:107:8338", + "45.56.84.44", + "109.201.133.93:8338", + "120.41.191.224:30607", + "138.68.249.79:50992", + "138.68.249.79:51314", + "172.104.157.62", + "178.63.11.246:8338", + "185.139.2.140:8338", + "199.229.248.218:28830", + "35.189.127.200:41220", + "35.189.127.200:48244", + "35.195.83.0:35172", + "35.195.83.0:35576", + "35.195.83.0:35798", + "35.197.197.166:32794", + "35.197.197.166:33112", + "35.197.197.166:33332", + "35.203.167.11:52158", + "37.59.50.143:35254", + "45.27.161.195:33852", + "45.27.161.195:36738", + "45.27.161.195:58628" + ], + "maxconnections": 250, + "mempoolexpiry": 72, + "timeout": 768 + } + }, + "blockbook": { + "package_name": "blockbook-bgold", + "system_user": "blockbook-bgold", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "subversion": "/Bitcoin Gold:0.17.3/", + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "xpub_magic_segwit_p2sh": 77429938, + "xpub_magic_segwit_native": 78792518, + "slip44": 156, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"bitcoin-gold\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "Jakub Matys", + "package_maintainer_email": "jakub.matys@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-bgold", - "system_user": "blockbook-bgold", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "subversion": "/Bitcoin Gold:0.17.3/", - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "xpub_magic_segwit_p2sh": 77429938, - "xpub_magic_segwit_native": 78792518, - "slip44": 156, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin-gold\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "Jakub Matys", - "package_maintainer_email": "jakub.matys@satoshilabs.com" - } } diff --git a/configs/coins/bgold_testnet.json b/configs/coins/bgold_testnet.json index 0038f87a4f..0f67c9b1dc 100644 --- a/configs/coins/bgold_testnet.json +++ b/configs/coins/bgold_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/bitcoin.json b/configs/coins/bitcoin.json index 996b5f1e53..f3b9d558f5 100644 --- a/configs/coins/bitcoin.json +++ b/configs/coins/bitcoin.json @@ -1,78 +1,87 @@ { - "coin": { - "name": "Bitcoin", - "shortcut": "BTC", - "label": "Bitcoin", - "alias": "bitcoin" - }, - "ports": { - "backend_rpc": 8030, - "backend_message_queue": 38330, - "blockbook_internal": 9030, - "blockbook_public": 9130 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-bitcoin", - "package_revision": "satoshilabs-1", - "system_user": "bitcoin", - "version": "24.0.1", - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-24.0.1/bitcoin-24.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "49df6e444515d457ea0b885d66f521f2a26ca92ccf73d5296082e633544253bf", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/bitcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin.conf", - "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" + "coin": { + "name": "Bitcoin", + "shortcut": "BTC", + "label": "Bitcoin", + "alias": "bitcoin" }, - "platforms": { - "arm64": { - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-23.0/bitcoin-23.0-aarch64-linux-gnu.tar.gz", - "verification_source": "06f4c78271a77752ba5990d60d81b1751507f77efda1e5981b4e92fd4d9969fb" - } - } - }, - "blockbook": { - "package_name": "blockbook-bitcoin", - "system_user": "blockbook-bitcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "-dbcache=1073741824", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "xpub_magic_segwit_p2sh": 77429938, - "xpub_magic_segwit_native": 78792518, - "additional_params": { - "alternative_estimate_fee": "whatthefee-disabled", - "alternative_estimate_fee_params": "{\"url\": \"https://whatthefee.io/data.json\", \"periodSeconds\": 60}", - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin\", \"periodSeconds\": 900}" - } + "ports": { + "backend_rpc": 8030, + "backend_message_queue": 38330, + "blockbook_internal": 9030, + "blockbook_public": 9130 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bitcoin", + "package_revision": "satoshilabs-1", + "system_user": "bitcoin", + "version": "29.2", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee", + "addnode": ["ove.palatinus.cz"] + }, + "platforms": { + "arm64": { + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", + "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + } + } + }, + "blockbook": { + "package_name": "blockbook-bitcoin", + "system_user": "blockbook-bitcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-dbcache=1073741824 -enablesubnewtx -extendedindex", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "mempool_resync_batch_size": 100, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "xpub_magic_segwit_p2sh": 77429938, + "xpub_magic_segwit_native": 78792518, + "additional_params": { + "alternative_estimate_fee": "mempoolspaceblock", + "alternative_estimate_fee_params": "{\"url\": \"https://mempool.space/api/v1/fees/mempool-blocks\", \"periodSeconds\": 20, \"feeRangeIndex\": 5, \"fallbackFeePerKB\": 1000}", + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"bitcoin\", \"periodSeconds\": 60}", + "block_golomb_filter_p": 20, + "block_filter_scripts": "taproot-noordinals", + "block_filter_use_zeroed_key": true, + "mempool_golomb_filter_p": 20, + "mempool_filter_scripts": "taproot", + "mempool_filter_use_zeroed_key": false + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/bitcoin_regtest.json b/configs/coins/bitcoin_regtest.json index b613ac2106..a35179db29 100644 --- a/configs/coins/bitcoin_regtest.json +++ b/configs/coins/bitcoin_regtest.json @@ -1,75 +1,83 @@ { - "coin": { - "name": "Regtest", - "shortcut": "rBTC", - "label": "Bitcoin Regtest", - "alias": "bitcoin_regtest" - }, - "ports": { - "backend_rpc": 18021, - "backend_message_queue": 48321, - "blockbook_internal": 19021, - "blockbook_public": 19121 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-bitcoin-regtest", - "package_revision": "satoshilabs-1", - "system_user": "bitcoin", - "version": "24.0.1", - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-24.0.1/bitcoin-24.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "49df6e444515d457ea0b885d66f521f2a26ca92ccf73d5296082e633544253bf", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [ - "bin/bitcoin-qt" - ], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/regtest/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "mainnet": false, - "protect_memory": true, - "server_config_file": "bitcoin_regtest.conf", - "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" + "coin": { + "name": "Regtest", + "shortcut": "rBTC", + "label": "Bitcoin Regtest", + "alias": "bitcoin_regtest" }, - "platforms": { - "arm64": { - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-24.0/bitcoin-24.0-aarch64-linux-gnu.tar.gz", - "verification_source": "904e103f08f776d03935118568411724f9e070e0e888e52c9e5692308fa47d49" - } - } - }, - "blockbook": { - "package_name": "blockbook-bitcoin-regtest", - "system_user": "blockbook-bitcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 70617039, - "xpub_magic_segwit_p2sh": 71979618, - "xpub_magic_segwit_native": 73342198, - "slip44": 1, - "additional_params": {} + "ports": { + "backend_rpc": 18021, + "backend_message_queue": 48321, + "blockbook_internal": 19021, + "blockbook_public": 19121 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bitcoin-regtest", + "package_revision": "satoshilabs-1", + "system_user": "bitcoin", + "version": "29.2", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/regtest/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "mainnet": false, + "protect_memory": true, + "server_config_file": "bitcoin_regtest.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + }, + "platforms": { + "arm64": { + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", + "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + } + } + }, + "blockbook": { + "package_name": "blockbook-bitcoin-regtest", + "system_user": "blockbook-bitcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 1, + "additional_params": { + "alternative_estimate_fee": "mempoolspaceblock", + "alternative_estimate_fee_params": "{\"url\": \"https://mempool.space/api/v1/fees/mempool-blocks\", \"periodSeconds\": 20, \"feeRangeIndex\": 5, \"fallbackFeePerKB\": 1000}", + "block_golomb_filter_p": 20, + "block_filter_scripts": "taproot-noordinals", + "block_filter_use_zeroed_key": true, + "mempool_golomb_filter_p": 20, + "mempool_filter_scripts": "taproot", + "mempool_filter_use_zeroed_key": false + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/bitcoin_signet.json b/configs/coins/bitcoin_signet.json index fc82b3e8c0..42fb3cd9b5 100644 --- a/configs/coins/bitcoin_signet.json +++ b/configs/coins/bitcoin_signet.json @@ -1,75 +1,74 @@ { - "coin": { - "name": "Signet", - "shortcut": "sBTC", - "label": "Bitcoin Signet", - "alias": "bitcoin_signet" - }, - "ports": { - "backend_rpc": 18020, - "backend_message_queue": 48320, - "blockbook_internal": 19020, - "blockbook_public": 19120 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-bitcoin-signet", - "package_revision": "satoshilabs-1", - "system_user": "bitcoin", - "version": "24.0.1", - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-24.0.1/bitcoin-24.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "49df6e444515d457ea0b885d66f521f2a26ca92ccf73d5296082e633544253bf", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [ - "bin/bitcoin-qt" - ], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/signet/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "bitcoin-signet.conf", - "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" + "coin": { + "name": "Signet", + "shortcut": "sBTC", + "label": "Bitcoin Signet", + "alias": "bitcoin_signet" }, - "platforms": { - "arm64": { - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-23.0/bitcoin-23.0-aarch64-linux-gnu.tar.gz", - "verification_source": "06f4c78271a77752ba5990d60d81b1751507f77efda1e5981b4e92fd4d9969fb" - } - } - }, - "blockbook": { - "package_name": "blockbook-bitcoin-signet", - "system_user": "blockbook-bitcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 70617039, - "xpub_magic_segwit_p2sh": 71979618, - "xpub_magic_segwit_native": 73342198, - "slip44": 1, - "additional_params": {} + "ports": { + "backend_rpc": 18020, + "backend_message_queue": 48320, + "blockbook_internal": 19020, + "blockbook_public": 19120 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bitcoin-signet", + "package_revision": "satoshilabs-1", + "system_user": "bitcoin", + "version": "29.2", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/signet/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "bitcoin_signet.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + }, + "platforms": { + "arm64": { + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", + "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + } + } + }, + "blockbook": { + "package_name": "blockbook-bitcoin-signet", + "system_user": "blockbook-bitcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 1, + "additional_params": {} + } + }, + "meta": { + "package_maintainer": "wakiyamap", + "package_maintainer_email": "wakiyamap@gmail.com" } - }, - "meta": { - "package_maintainer": "wakiyamap", - "package_maintainer_email": "wakiyamap@gmail.com" - } } diff --git a/configs/coins/bitcoin_testnet.json b/configs/coins/bitcoin_testnet.json index 13546f6b2f..9c6b68ce7e 100644 --- a/configs/coins/bitcoin_testnet.json +++ b/configs/coins/bitcoin_testnet.json @@ -1,75 +1,81 @@ { - "coin": { - "name": "Testnet", - "shortcut": "TEST", - "label": "Bitcoin Testnet", - "alias": "bitcoin_testnet" - }, - "ports": { - "backend_rpc": 18030, - "backend_message_queue": 48330, - "blockbook_internal": 19030, - "blockbook_public": 19130 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-bitcoin-testnet", - "package_revision": "satoshilabs-1", - "system_user": "bitcoin", - "version": "24.0.1", - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-24.0.1/bitcoin-24.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "49df6e444515d457ea0b885d66f521f2a26ca92ccf73d5296082e633544253bf", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [ - "bin/bitcoin-qt" - ], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet3/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "bitcoin.conf", - "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" + "coin": { + "name": "Testnet", + "shortcut": "TEST", + "label": "Bitcoin Testnet", + "alias": "bitcoin_testnet" }, - "platforms": { - "arm64": { - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-23.0/bitcoin-23.0-aarch64-linux-gnu.tar.gz", - "verification_source": "06f4c78271a77752ba5990d60d81b1751507f77efda1e5981b4e92fd4d9969fb" - } - } - }, - "blockbook": { - "package_name": "blockbook-bitcoin-testnet", - "system_user": "blockbook-bitcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 70617039, - "xpub_magic_segwit_p2sh": 71979618, - "xpub_magic_segwit_native": 73342198, - "slip44": 1, - "additional_params": {} + "ports": { + "backend_rpc": 18030, + "backend_message_queue": 48330, + "blockbook_internal": 19030, + "blockbook_public": 19130 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bitcoin-testnet", + "package_revision": "satoshilabs-1", + "system_user": "bitcoin", + "version": "29.2", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet3/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "bitcoin.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + }, + "platforms": { + "arm64": { + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", + "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + } + } + }, + "blockbook": { + "package_name": "blockbook-bitcoin-testnet", + "system_user": "blockbook-bitcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-enablesubnewtx -extendedindex", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 10000, + "xpub_magic": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 1, + "additional_params": { + "block_golomb_filter_p": 20, + "block_filter_scripts": "taproot-noordinals", + "block_filter_use_zeroed_key": true, + "mempool_golomb_filter_p": 20, + "mempool_filter_scripts": "taproot", + "mempool_filter_use_zeroed_key": false + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/bitcoin_testnet4.json b/configs/coins/bitcoin_testnet4.json new file mode 100644 index 0000000000..9d8b6a67c6 --- /dev/null +++ b/configs/coins/bitcoin_testnet4.json @@ -0,0 +1,83 @@ +{ + "coin": { + "name": "Testnet4", + "shortcut": "TEST", + "label": "Bitcoin Testnet4", + "alias": "bitcoin_testnet4" + }, + "ports": { + "backend_rpc": 18029, + "backend_message_queue": 48329, + "blockbook_internal": 19029, + "blockbook_public": 19129 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bitcoin-testnet4", + "package_revision": "satoshilabs-1", + "system_user": "bitcoin", + "version": "29.2", + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "1fd58d0ae94b8a9e21bbaeab7d53395a44976e82bd5492b0a894826c135f9009", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet4/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "bitcoin_testnet4.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + }, + "platforms": { + "arm64": { + "binary_url": "https://bitcoincore.org/bin/bitcoin-core-29.2/bitcoin-29.2-aarch64-linux-gnu.tar.gz", + "verification_source": "f88f72a3c5bf526581aae573be8c1f62133eaecfe3d34646c9ffca7b79dfdc7a" + } + } + }, + "blockbook": { + "package_name": "blockbook-bitcoin-testnet4", + "system_user": "blockbook-bitcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-enablesubnewtx -extendedindex", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 10000, + "xpub_magic": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 1, + "additional_params": { + "alternative_estimate_fee": "mempoolspace", + "alternative_estimate_fee_params": "{\"url\": \"https://mempool.space/testnet4/api/v1/fees/recommended\", \"periodSeconds\": 60}", + "block_golomb_filter_p": 20, + "block_filter_scripts": "taproot-noordinals", + "block_filter_use_zeroed_key": true, + "mempool_golomb_filter_p": 20, + "mempool_filter_scripts": "taproot", + "mempool_filter_use_zeroed_key": false + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/bitcore.json b/configs/coins/bitcore.json index 193ee7d2c0..5a04cbbf0e 100644 --- a/configs/coins/bitcore.json +++ b/configs/coins/bitcore.json @@ -1,72 +1,73 @@ { - "coin": { - "name": "Bitcore", - "shortcut": "BTX", - "label": "Bitcore", - "alias": "bitcore" - }, - "ports": { - "backend_rpc": 8054, - "backend_message_queue": 38354, - "blockbook_internal": 9054, - "blockbook_public": 9154 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-bitcore", - "package_revision": "satoshilabs-1", - "system_user": "bitcore", - "version": "0.15.2.1", - "binary_url": "https://github.com/dalijolijo/BitCore/releases/download/0.15.2.1/bitcore-0.15.2.1-x86_64-linux-gnu_no-wallet.tar.gz", - "verification_type": "sha256", - "verification_source": "4e47b33d5fa7d67151c9860f4cd19c99a55d42b27c170bd2391988c67aa24fc8", - "extract_command": "tar -C backend -xf", - "exclude_files": [ - "bin/bitcore-qt", - "bin/test_bitcore-qt", - "bin/bench_bitcore", - "bin/test_bitcore" - ], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcored -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "whitelist": "127.0.0.1" + "coin": { + "name": "Bitcore", + "shortcut": "BTX", + "label": "Bitcore", + "alias": "bitcore" + }, + "ports": { + "backend_rpc": 8054, + "backend_message_queue": 38354, + "blockbook_internal": 9054, + "blockbook_public": 9154 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-bitcore", + "package_revision": "satoshilabs-1", + "system_user": "bitcore", + "version": "0.15.2.1", + "binary_url": "https://github.com/dalijolijo/BitCore/releases/download/0.15.2.1/bitcore-0.15.2.1-x86_64-linux-gnu_no-wallet.tar.gz", + "verification_type": "sha256", + "verification_source": "4e47b33d5fa7d67151c9860f4cd19c99a55d42b27c170bd2391988c67aa24fc8", + "extract_command": "tar -C backend -xf", + "exclude_files": [ + "bin/bitcore-qt", + "bin/test_bitcore-qt", + "bin/bench_bitcore", + "bin/test_bitcore" + ], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcored -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "whitelist": "127.0.0.1" + } + }, + "blockbook": { + "package_name": "blockbook-bitcore", + "system_user": "blockbook-bitcore", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"bitcore\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "LIMXTEC", + "package_maintainer_email": "info@bitcore.cc" } - }, - "blockbook": { - "package_name": "blockbook-bitcore", - "system_user": "blockbook-bitcore", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcore\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "LIMXTEC", - "package_maintainer_email": "info@bitcore.cc" - } } diff --git a/configs/coins/bitzeny.json b/configs/coins/bitzeny.json index 5481e60679..9e61e93cc8 100644 --- a/configs/coins/bitzeny.json +++ b/configs/coins/bitzeny.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -66,4 +67,4 @@ "package_maintainer": "ilmango-doge", "package_maintainer_email": "ilmango.doge@gmail.com" } - } \ No newline at end of file + } diff --git a/configs/coins/bsc.json b/configs/coins/bsc.json new file mode 100644 index 0000000000..b9d2561cd1 --- /dev/null +++ b/configs/coins/bsc.json @@ -0,0 +1,74 @@ +{ + "coin": { + "name": "BNB Smart Chain", + "shortcut": "BNB", + "network": "BSC", + "label": "BNB Smart Chain", + "alias": "bsc" + }, + "ports": { + "backend_rpc": 8064, + "backend_p2p": 38364, + "backend_http": 8164, + "blockbook_internal": 9064, + "blockbook_public": 9164 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-bsc", + "package_revision": "satoshilabs-1", + "system_user": "bsc", + "version": "1.1.23", + "binary_url": "https://github.com/bnb-chain/bsc/releases/download/v1.1.23/geth_linux", + "verification_type": "sha256", + "verification_source": "6636c40d4e82017257467ab2cfc88b11990cf3bb35faeec9c5194ab90009a81f", + "extract_command": "mv ${ARCHIVE} backend/geth_linux && chmod +x backend/geth_linux && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bsc_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "bsc.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "wget https://github.com/bnb-chain/bsc/releases/download/v1.1.23/mainnet.zip -O {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/mainnet.zip && unzip -o -d {{.Env.BackendInstallPath}}/{{.Coin.Alias}} {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/mainnet.zip && rm -f {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/mainnet.zip && sed -i -e '/\\[Node.LogConfig\\]/,+5d' {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/config.toml", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/bnb-chain/bsc/releases/download/v1.1.23/geth-linux-arm64", + "verification_source": "74105d6b9b8483a92ab8311784315c5f65dac2213004e0b1433cdf9127bced35" + } + } + }, + "blockbook": { + "package_name": "blockbook-bsc", + "system_user": "blockbook-bsc", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "averageBlockTimeMs": 3000, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"binancecoin\",\"platformIdentifier\": \"binance-smart-chain\",\"platformVsCurrency\": \"bnb\",\"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/bsc_archive.json b/configs/coins/bsc_archive.json new file mode 100644 index 0000000000..22daaadc74 --- /dev/null +++ b/configs/coins/bsc_archive.json @@ -0,0 +1,83 @@ +{ + "coin": { + "name": "BNB Smart Chain Archive", + "shortcut": "BNB", + "network": "BSC", + "label": "BNB Smart Chain", + "alias": "bsc_archive", + "test_name": "bsc" + }, + "ports": { + "backend_rpc": 8065, + "backend_p2p": 38365, + "backend_http": 8165, + "blockbook_internal": 9065, + "blockbook_public": 9165 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 240 + }, + "backend": { + "package_name": "backend-bsc-archive", + "package_revision": "satoshilabs-1", + "system_user": "bsc", + "version": "1.1.23", + "binary_url": "https://github.com/bnb-chain/bsc/releases/download/v1.1.23/geth_linux", + "verification_type": "sha256", + "verification_source": "6636c40d4e82017257467ab2cfc88b11990cf3bb35faeec9c5194ab90009a81f", + "extract_command": "mv ${ARCHIVE} backend/geth_linux && chmod +x backend/geth_linux && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bsc_archive_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "bsc_archive.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "wget https://github.com/bnb-chain/bsc/releases/download/v1.1.23/mainnet.zip -O {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/mainnet.zip && unzip -o -d {{.Env.BackendInstallPath}}/{{.Coin.Alias}} {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/mainnet.zip && rm -f {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/mainnet.zip && sed -i -e '/\\[Node.LogConfig\\]/,+5d' {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/config.toml", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/bnb-chain/bsc/releases/download/v1.1.23/geth-linux-arm64", + "verification_source": "74105d6b9b8483a92ab8311784315c5f65dac2213004e0b1433cdf9127bced35" + } + } + }, + "blockbook": { + "package_name": "blockbook-bsc-archive", + "system_user": "blockbook-bsc", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=1509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 600, + "additional_params": { + "averageBlockTimeMs": 3000, + "address_aliases": true, + "eip1559Fees": true, + "alternative_estimate_fee": "infura-disabled", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/56/suggestedGasFees\", \"periodSeconds\": 60}", + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "20s", + "queryBackendOnMempoolResync": false, + "disableMempoolSync": true, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"binancecoin\",\"platformIdentifier\": \"binance-smart-chain\",\"platformVsCurrency\": \"bnb\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/cpuchain.json b/configs/coins/cpuchain.json index 30e9f6c902..68d6af5e25 100644 --- a/configs/coins/cpuchain.json +++ b/configs/coins/cpuchain.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/dash.json b/configs/coins/dash.json index 9b4dc6d2a7..46bcdbc863 100644 --- a/configs/coins/dash.json +++ b/configs/coins/dash.json @@ -1,70 +1,71 @@ { - "coin": { - "name": "Dash", - "shortcut": "DASH", - "label": "Dash", - "alias": "dash" - }, - "ports": { - "backend_rpc": 8033, - "backend_message_queue": 38333, - "blockbook_internal": 9033, - "blockbook_public": 9133 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-dash", - "package_revision": "satoshilabs-1", - "system_user": "dash", - "version": "18.2.1", - "binary_url": "https://github.com/dashpay/dash/releases/download/v18.2.1/dashcore-18.2.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/dashpay/dash/releases/download/v18.2.1/SHA256SUMS.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/dash-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/dashd -deprecatedrpc=estimatefee -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "mempoolexpiry": 72 + "coin": { + "name": "Dash", + "shortcut": "DASH", + "label": "Dash", + "alias": "dash" + }, + "ports": { + "backend_rpc": 8033, + "backend_message_queue": 38333, + "blockbook_internal": 9033, + "blockbook_public": 9133 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-dash", + "package_revision": "satoshilabs-1", + "system_user": "dash", + "version": "22.0.0", + "binary_url": "https://github.com/dashpay/dash/releases/download/v22.0.0/dashcore-22.0.0-x86_64-linux-gnu.tar.gz", + "verification_type": "gpg-sha256", + "verification_source": "https://github.com/dashpay/dash/releases/download/v22.0.0/SHA256SUMS.asc", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/dash-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/dashd -deprecatedrpc=estimatefee -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "mempoolexpiry": 72 + } + }, + "blockbook": { + "package_name": "blockbook-dash", + "system_user": "blockbook-dash", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "subversion": "/Dash Core:0.17.0.3/", + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 50221772, + "slip44": 5, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"dash\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT Admin", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-dash", - "system_user": "blockbook-dash", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "subversion": "/Dash Core:0.17.0.3/", - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 50221772, - "slip44": 5, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"dash\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "IT Admin", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/dash_testnet.json b/configs/coins/dash_testnet.json index be6f74f0e4..a0dc8bcd9d 100644 --- a/configs/coins/dash_testnet.json +++ b/configs/coins/dash_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-dash-testnet", "package_revision": "satoshilabs-1", "system_user": "dash", - "version": "18.2.1", - "binary_url": "https://github.com/dashpay/dash/releases/download/v18.2.1/dashcore-18.2.1-x86_64-linux-gnu.tar.gz", + "version": "22.0.0", + "binary_url": "https://github.com/dashpay/dash/releases/download/v22.0.0/dashcore-22.0.0-x86_64-linux-gnu.tar.gz", "verification_type": "gpg-sha256", - "verification_source": "https://github.com/dashpay/dash/releases/download/v18.2.1/SHA256SUMS.asc", + "verification_source": "https://github.com/dashpay/dash/releases/download/v22.0.0/SHA256SUMS.asc", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/dash-qt" @@ -65,4 +66,4 @@ "package_maintainer": "IT Admin", "package_maintainer_email": "it@satoshilabs.com" } -} \ No newline at end of file +} diff --git a/configs/coins/decred.json b/configs/coins/decred.json index d8e4e35fbe..6a9c5f780a 100644 --- a/configs/coins/decred.json +++ b/configs/coins/decred.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/decred_testnet.json b/configs/coins/decred_testnet.json index f9894b5fe0..e9acfda769 100644 --- a/configs/coins/decred_testnet.json +++ b/configs/coins/decred_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -28,7 +29,7 @@ "verification_source": "8be1894e6e61e9d0392f158b16055b8cec81d96ec3d0725d3494bc0a306c362b", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/dcrd --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --rpcuser={{.IPC.RPCUser}} --rpcpass={{.IPC.RPCPass}} -C={{.Env.BackendDataPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf --nofilelogging --appdata={{.Env.BackendDataPath}}/{{.Coin.Alias}} --notls --txindex --addrindex --testnet --rpclisten=[127.0.0.1]:18061", + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/dcrd --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --rpcuser={{.IPC.RPCUser}} --rpcpass={{.IPC.RPCPass}} -C={{.Env.BackendDataPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf --nofilelogging --appdata={{.Env.BackendDataPath}}/{{.Coin.Alias}} --notls --txindex --addrindex --testnet --rpclisten=[{{.Env.RPCBindHost}}]:{{.Ports.BackendRPC}}", "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", "postinst_script_template": "", "service_type": "simple", diff --git a/configs/coins/deeponion.json b/configs/coins/deeponion.json index fee9476791..4580dc5e59 100644 --- a/configs/coins/deeponion.json +++ b/configs/coins/deeponion.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/digibyte.json b/configs/coins/digibyte.json index 0c66750f39..8121724bfe 100644 --- a/configs/coins/digibyte.json +++ b/configs/coins/digibyte.json @@ -1,71 +1,72 @@ { - "coin": { - "name": "DigiByte", - "shortcut": "DGB", - "label": "DigiByte", - "alias": "digibyte" - }, - "ports": { - "backend_rpc": 8042, - "backend_message_queue": 38342, - "blockbook_internal": 9042, - "blockbook_public": 9142 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-digibyte", - "package_revision": "satoshilabs-1", - "system_user": "digibyte", - "version": "7.17.3", - "binary_url": "https://github.com/digibyte-core/digibyte/releases/download/v7.17.3/digibyte-7.17.3-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "b5cd8f590d359e4846dd5cbe60751221e54d773a6227ea9686d17c4890057f46", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/digibyte-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/digibyted -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "whitelist": "127.0.0.1" + "coin": { + "name": "DigiByte", + "shortcut": "DGB", + "label": "DigiByte", + "alias": "digibyte" + }, + "ports": { + "backend_rpc": 8042, + "backend_message_queue": 38342, + "blockbook_internal": 9042, + "blockbook_public": 9142 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-digibyte", + "package_revision": "satoshilabs-1", + "system_user": "digibyte", + "version": "7.17.3", + "binary_url": "https://github.com/digibyte-core/digibyte/releases/download/v7.17.3/digibyte-7.17.3-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "b5cd8f590d359e4846dd5cbe60751221e54d773a6227ea9686d17c4890057f46", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/digibyte-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/digibyted -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "whitelist": "127.0.0.1" + } + }, + "blockbook": { + "package_name": "blockbook-digibyte", + "system_user": "blockbook-digibyte", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "xpub_magic_segwit_p2sh": 77429938, + "xpub_magic_segwit_native": 78792518, + "slip44": 20, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"digibyte\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "Martin Bohm", + "package_maintainer_email": "martin.bohm@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-digibyte", - "system_user": "blockbook-digibyte", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "xpub_magic_segwit_p2sh": 77429938, - "xpub_magic_segwit_native": 78792518, - "slip44": 20, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"digibyte\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "Martin Bohm", - "package_maintainer_email": "martin.bohm@satoshilabs.com" - } } diff --git a/configs/coins/digibyte_testnet.json b/configs/coins/digibyte_testnet.json index f6e51216fd..5cc7e14452 100644 --- a/configs/coins/digibyte_testnet.json +++ b/configs/coins/digibyte_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/divi.json b/configs/coins/divi.json index d07c9d974d..a7cae6a155 100644 --- a/configs/coins/divi.json +++ b/configs/coins/divi.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "divirpc", "rpc_pass": "divipass", "rpc_timeout": 25, @@ -37,10 +38,7 @@ "protect_memory": false, "mainnet": true, "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "rpcallowip": "127.0.0.1" - } + "client_config_file": "bitcoin_like_client.conf" }, "blockbook": { "package_name": "blockbook-divi", diff --git a/configs/coins/dogecoin.json b/configs/coins/dogecoin.json index 5b8248422e..38f137e2f9 100644 --- a/configs/coins/dogecoin.json +++ b/configs/coins/dogecoin.json @@ -1,79 +1,80 @@ { - "coin": { - "name": "Dogecoin", - "shortcut": "DOGE", - "label": "Dogecoin", - "alias": "dogecoin" - }, - "ports": { - "backend_rpc": 8038, - "backend_message_queue": 38338, - "blockbook_internal": 9038, - "blockbook_public": 9138 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpcp", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-dogecoin", - "package_revision": "satoshilabs-1", - "system_user": "dogecoin", - "version": "1.14.6", - "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.6/dogecoin-1.14.6-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "fe9c9cdab946155866a5bd5a5127d2971a9eed3e0b65fb553fe393ad1daaebb0", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/dogecoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/dogecoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": false, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "discover": 0, - "rpcthreads": 16, - "upnp": 0, - "whitelist": "127.0.0.1" + "coin": { + "name": "Dogecoin", + "shortcut": "DOGE", + "label": "Dogecoin", + "alias": "dogecoin" }, - "platforms": { - "arm64": { - "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.6/dogecoin-1.14.6-aarch64-linux-gnu.tar.gz", - "verification_source": "87419c29607b2612746fccebd694037e4be7600fc32198c4989f919be20952db", - "exclude_files": [] - } - } - }, - "blockbook": { - "package_name": "blockbook-dogecoin", - "system_user": "blockbook-dogecoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "-resyncindexperiod=30011 -resyncmempoolperiod=2011", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 49990397, - "slip44": 3, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"dogecoin\", \"periodSeconds\": 900}" - } + "ports": { + "backend_rpc": 8038, + "backend_message_queue": 38338, + "blockbook_internal": 9038, + "blockbook_public": 9138 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpcp", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-dogecoin", + "package_revision": "satoshilabs-1", + "system_user": "dogecoin", + "version": "1.14.9", + "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.9/dogecoin-1.14.9-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "4f227117b411a7c98622c970986e27bcfc3f547a72bef65e7d9e82989175d4f8", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/dogecoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/dogecoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": false, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "discover": 0, + "rpcthreads": 16, + "upnp": 0, + "whitelist": "127.0.0.1" + }, + "platforms": { + "arm64": { + "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.9/dogecoin-1.14.9-aarch64-linux-gnu.tar.gz", + "verification_source": "6928c895a20d0bcb6d5c7dcec753d35c884a471aaf8ad4242a89a96acb4f2985", + "exclude_files": [] + } + } + }, + "blockbook": { + "package_name": "blockbook-dogecoin", + "system_user": "blockbook-dogecoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-resyncindexperiod=30011 -resyncmempoolperiod=2011", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 49990397, + "slip44": 3, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"dogecoin\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT Admin", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT Admin", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/dogecoin_testnet.json b/configs/coins/dogecoin_testnet.json index 115ac63c79..1d44c74bc9 100644 --- a/configs/coins/dogecoin_testnet.json +++ b/configs/coins/dogecoin_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpcp", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-dogecoin-testnet", "package_revision": "satoshilabs-1", "system_user": "dogecoin", - "version": "1.14.6", - "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.6/dogecoin-1.14.6-x86_64-linux-gnu.tar.gz", + "version": "1.14.9", + "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.9/dogecoin-1.14.9-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "fe9c9cdab946155866a5bd5a5127d2971a9eed3e0b65fb553fe393ad1daaebb0", + "verification_source": "4f227117b411a7c98622c970986e27bcfc3f547a72bef65e7d9e82989175d4f8", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/dogecoin-qt" @@ -47,8 +48,8 @@ }, "platforms": { "arm64": { - "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.6/dogecoin-1.14.6-aarch64-linux-gnu.tar.gz", - "verification_source": "87419c29607b2612746fccebd694037e4be7600fc32198c4989f919be20952db", + "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.9/dogecoin-1.14.9-aarch64-linux-gnu.tar.gz", + "verification_source": "6928c895a20d0bcb6d5c7dcec753d35c884a471aaf8ad4242a89a96acb4f2985", "exclude_files": [] } } diff --git a/configs/coins/ecash.json b/configs/coins/ecash.json index 562bb01bdd..821cff06d5 100644 --- a/configs/coins/ecash.json +++ b/configs/coins/ecash.json @@ -1,72 +1,73 @@ { - "coin": { - "name": "ECash", - "shortcut": "XEC", - "label": "eCash", - "alias": "ecash" - }, - "ports": { - "backend_rpc": 8097, - "backend_message_queue": 38397, - "blockbook_internal": 9097, - "blockbook_public": 9197 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-ecash", - "package_revision": "satoshilabs-1", - "system_user": "ecash", - "version": "0.26.1", - "binary_url": "https://download.bitcoinabc.org/0.26.1/linux/bitcoin-abc-0.26.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "64c799b339b2aa03f50ac605f7df0586341ff5a2d74321b424f4fe35d37da0be", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/bitcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bcash.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "listen": 1, - "avalanche": 1 + "coin": { + "name": "ECash", + "shortcut": "XEC", + "label": "eCash", + "alias": "ecash" + }, + "ports": { + "backend_rpc": 8097, + "backend_message_queue": 38397, + "blockbook_internal": 9097, + "blockbook_public": 9197 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-ecash", + "package_revision": "satoshilabs-1", + "system_user": "ecash", + "version": "0.27.3", + "binary_url": "https://download.bitcoinabc.org/0.27.3/linux/bitcoin-abc-0.27.3-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "390329fa9ad9e88319f5cf5239385268584116710144d6bc156fbcca7514710a", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/bitcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/bitcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bcash.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "listen": 1, + "avalanche": 1 + } + }, + "blockbook": { + "package_name": "blockbook-ecash", + "system_user": "blockbook-ecash", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "subversion": "/Bitcoin ABC:0.27.3(EB32.0)/", + "address_format": "cashaddr", + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "slip44": 899, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ecash\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "eCash", + "package_maintainer_email": "contact@e.cash" } - }, - "blockbook": { - "package_name": "blockbook-ecash", - "system_user": "blockbook-ecash", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "subversion": "/Bitcoin ABC:0.26.1(EB32.0)/", - "address_format": "cashaddr", - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "slip44": 899, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ecash\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "eCash", - "package_maintainer_email": "contact@e.cash" - } } diff --git a/configs/coins/ethereum-classic.json b/configs/coins/ethereum-classic.json index e54b70ef40..e20feccdbe 100644 --- a/configs/coins/ethereum-classic.json +++ b/configs/coins/ethereum-classic.json @@ -1,67 +1,69 @@ { - "coin": { - "name": "Ethereum Classic", - "shortcut": "ETC", - "label": "Ethereum Classic", - "alias": "ethereum-classic" - }, - "ports": { - "backend_rpc": 8037, - "backend_message_queue": 0, - "backend_p2p": 38337, - "backend_http": 8137, - "blockbook_internal": 9037, - "blockbook_public": 9137 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-classic", - "package_revision": "satoshilabs-1", - "system_user": "ethereum-classic", - "version": "1.12.10", - "binary_url": "https://github.com/etclabscore/core-geth/releases/download/v1.12.10/core-geth-linux-v1.12.10.zip", - "verification_type": "sha256", - "verification_source": "40f423fb19b36b9412388adb18353d78bfda31c71395be56a2c51772a12cbf81", - "extract_command": "unzip -d backend", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --classic --ipcdisable --txlookuplimit 0 --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --http --http.port {{.Ports.BackendHttp}} --http.addr 127.0.0.1 --http.corsdomain \"*\" 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "", - "client_config_file": "" - }, - "blockbook": { - "package_name": "blockbook-ethereum-classic", - "system_user": "blockbook-ethereum-classic", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 10000, - "additional_params": { - "address_aliases": true, - "mempoolTxTimeoutHours": 48, - "queryBackendOnMempoolResync": true, - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum-classic\", \"periodSeconds\": 900}", - "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" - } + "coin": { + "name": "Ethereum Classic", + "shortcut": "ETC", + "label": "Ethereum Classic", + "alias": "ethereum-classic" + }, + "ports": { + "backend_rpc": 8037, + "backend_message_queue": 0, + "backend_p2p": 38337, + "backend_http": 8137, + "blockbook_internal": 9037, + "blockbook_public": 9137 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-classic", + "package_revision": "satoshilabs-1", + "system_user": "ethereum-classic", + "version": "1.12.18", + "binary_url": "https://github.com/etclabscore/core-geth/releases/download/v1.12.18/core-geth-linux-v1.12.18.zip", + "verification_type": "sha256", + "verification_source": "2382a15a53ce364cb41d3985ff3c2941392d8898c6f869666a8d7d7914a5748a", + "extract_command": "unzip -d backend", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --classic --ipcdisable --txlookuplimit 0 --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr {{.Env.RPCBindHost}} --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --http --http.port {{.Ports.BackendRPC}} --http.addr {{.Env.RPCBindHost}} --http.corsdomain \"*\" --http.vhosts \"*\" 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-ethereum-classic", + "system_user": "blockbook-ethereum-classic", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 10000, + "additional_params": { + "averageBlockTimeMs": 13000, + "address_aliases": true, + "mempoolTxTimeoutHours": 48, + "queryBackendOnMempoolResync": true, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum-classic\", \"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum.json b/configs/coins/ethereum.json index e32587656b..e20a5d2a57 100644 --- a/configs/coins/ethereum.json +++ b/configs/coins/ethereum.json @@ -1,73 +1,78 @@ { - "coin": { - "name": "Ethereum", - "shortcut": "ETH", - "label": "Ethereum", - "alias": "ethereum" - }, - "ports": { - "backend_rpc": 8036, - "backend_message_queue": 0, - "backend_p2p": 38336, - "backend_http": 8136, - "backend_authrpc": 8536, - "blockbook_internal": 9036, - "blockbook_public": 9136 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "1.11.2-73b01f40", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --syncmode full --txlookuplimit 0 --ipcdisable --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --ws.api \"eth,net,web3,debug,txpool\" --http --http.port {{.Ports.BackendHttp}} --http.addr 127.0.0.1 --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz.asc" - } + "coin": { + "name": "Ethereum", + "shortcut": "ETH", + "label": "Ethereum", + "alias": "ethereum" + }, + "ports": { + "backend_rpc": 8036, + "backend_message_queue": 0, + "backend_p2p": 38336, + "backend_http": 8136, + "backend_authrpc": 8536, + "blockbook_internal": 9036, + "blockbook_public": 9136 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "3.3.10", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.10/erigon_v3.3.10_linux_amd64.tar.gz", + "verification_type": "sha256", + "verification_source": "b717a6fce275d02e517eb473fc5d4385a7b0cd1d9e9ed144e4ef712799a474b7", + "extract_command": "tar -C backend --strip-components=1 -xf", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/erigon --chain mainnet --snap.keepblocks --db.size.limit 15TB --db.pagesize 16KB --prune.mode full --externalcl --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/erigon --port {{.Ports.BackendP2P}} --ws --ws.port {{.Ports.BackendRPC}} --http --http.port {{.Ports.BackendRPC}} --http.addr {{.Env.RPCBindHost}} --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} --private.api.addr \"\" --torrent.port {{.Ports.BackendHttp}} --log.dir.path {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log.dir.prefix {{.Coin.Alias}}'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.10/erigon_v3.3.10_linux_arm64.tar.gz", + "verification_source": "d85460c6e4c287235939d77051cb3abf3606b21498e71b29b2d8f61f87dddf5b" + } + } + }, + "blockbook": { + "package_name": "blockbook-ethereum", + "system_user": "blockbook-ethereum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "averageBlockTimeMs": 12000, + "consensusNodeVersion": "http://localhost:7536/eth/v1/node/version", + "address_aliases": true, + "eip1559Fees": true, + "mempoolTxTimeoutHours": 48, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-ethereum", - "system_user": "blockbook-ethereum", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "additional_params": { - "consensusNodeVersion": "http://localhost:7536/eth/v1/node/version", - "mempoolTxTimeoutHours": 48, - "queryBackendOnMempoolResync": false, - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum_archive.json b/configs/coins/ethereum_archive.json index 4ba2ac43fb..1a529be89a 100644 --- a/configs/coins/ethereum_archive.json +++ b/configs/coins/ethereum_archive.json @@ -1,76 +1,82 @@ { - "coin": { - "name": "Ethereum Archive", - "shortcut": "ETH", - "label": "Ethereum", - "alias": "ethereum_archive" - }, - "ports": { - "backend_rpc": 8016, - "backend_message_queue": 0, - "backend_p2p": 38316, - "backend_http": 8116, - "backend_authrpc": 8516, - "blockbook_internal": 9016, - "blockbook_public": 9116 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-archive", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "1.11.2-73b01f40", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --syncmode full --gcmode archive --txlookuplimit 0 --ipcdisable --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --ws.api \"eth,net,web3,debug,txpool\" --http --http.port {{.Ports.BackendHttp}} --http.addr 127.0.0.1 --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz.asc" - } + "coin": { + "name": "Ethereum Archive", + "shortcut": "ETH", + "label": "Ethereum", + "alias": "ethereum_archive", + "test_name": "ethereum" + }, + "ports": { + "backend_rpc": 8016, + "backend_message_queue": 0, + "backend_p2p": 38316, + "backend_http": 8116, + "backend_authrpc": 8516, + "blockbook_internal": 9016, + "blockbook_public": 9116 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-archive", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "3.3.10", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.10/erigon_v3.3.10_linux_amd64.tar.gz", + "verification_type": "sha256", + "verification_source": "b717a6fce275d02e517eb473fc5d4385a7b0cd1d9e9ed144e4ef712799a474b7", + "extract_command": "tar -C backend --strip-components=1 -xf", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/erigon --chain mainnet --snap.keepblocks --db.size.limit 15TB --db.pagesize 16KB --prune.mode archive --externalcl --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/erigon --port {{.Ports.BackendP2P}} --ws --ws.port {{.Ports.BackendRPC}} --http --http.port {{.Ports.BackendRPC}} --http.addr {{.Env.RPCBindHost}} --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} --private.api.addr \"\" --torrent.port {{.Ports.BackendHttp}} --log.dir.path {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log.dir.prefix {{.Coin.Alias}}'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.10/erigon_v3.3.10_linux_arm64.tar.gz", + "verification_source": "d85460c6e4c287235939d77051cb3abf3606b21498e71b29b2d8f61f87dddf5b" + } + } + }, + "blockbook": { + "package_name": "blockbook-ethereum-archive", + "system_user": "blockbook-ethereum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 600, + "additional_params": { + "averageBlockTimeMs": 12000, + "consensusNodeVersion": "http://localhost:7516/eth/v1/node/version", + "address_aliases": true, + "eip1559Fees": true, + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees\", \"periodSeconds\": 60}", + "mempoolTxTimeoutHours": 48, + "processInternalTransactions": true, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-ethereum-archive", - "system_user": "blockbook-ethereum", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "-workers=16", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 600, - "additional_params": { - "consensusNodeVersion": "http://localhost:7516/eth/v1/node/version", - "address_aliases": true, - "mempoolTxTimeoutHours": 48, - "processInternalTransactions": true, - "queryBackendOnMempoolResync": false, - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", - "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum_archive_consensus.json b/configs/coins/ethereum_archive_consensus.json index c76976d824..d3d10379a5 100644 --- a/configs/coins/ethereum_archive_consensus.json +++ b/configs/coins/ethereum_archive_consensus.json @@ -1,48 +1,48 @@ { - "coin": { - "name": "Ethereum Archive", - "shortcut": "ETH", - "label": "Ethereum", - "alias": "ethereum_archive_consensus", - "execution_alias": "ethereum_archive" - }, - "ports": { - "backend_rpc": 8016, - "backend_message_queue": 0, - "backend_p2p": 38316, - "backend_http": 8116, - "backend_authrpc": 8516, - "blockbook_internal": 9016, - "blockbook_public": 9116 - }, - "backend": { - "package_name": "backend-ethereum-archive-consensus", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "3.2.0", - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.2.0/beacon-chain-v3.2.0-linux-amd64", - "verification_type": "sha256", - "verification_source": "e57fed14bc15a62ab38a6605a8f93c2cf29fbd7a6333dd3ad72781c3778e36fc", - "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --mainnet --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=7516 --rpc-port=7517 --monitoring-port=7518 --p2p-tcp-port=3516 --p2p-udp-port=2516 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_archive/backend/geth/jwtsecret 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.1.2/beacon-chain-v3.1.2-linux-arm64", - "verification_source": "1701df47dbb6598a9215f82a313e1531c211bb912618dc3d0cd33e6e67c5ebb5" - } + "coin": { + "name": "Ethereum Archive", + "shortcut": "ETH", + "label": "Ethereum", + "alias": "ethereum_archive_consensus", + "execution_alias": "ethereum_archive" + }, + "ports": { + "backend_rpc": 8016, + "backend_message_queue": 0, + "backend_p2p": 38316, + "backend_http": 8116, + "backend_authrpc": 8516, + "blockbook_internal": 9016, + "blockbook_public": 9116 + }, + "backend": { + "package_name": "backend-ethereum-archive-consensus", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "7.1.3", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.3/beacon-chain-v7.1.3-linux-amd64", + "verification_type": "sha256", + "verification_source": "6efcd238124e000783f55a4430f0962b324a32599cf33219415f7f6dece8363f", + "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --mainnet --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=7516 --rpc-port=7517 --monitoring-port=7518 --p2p-tcp-port=3516 --p2p-udp-port=2516 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_archive/backend/erigon/jwt.hex 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.3/beacon-chain-v7.1.3-linux-arm64", + "verification_source": "23ec40327ac81925dace24cdcb820632dd1c38031208ca8d1c30ebc884612a0c" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum_consensus.json b/configs/coins/ethereum_consensus.json index 437733e396..8e898d9619 100644 --- a/configs/coins/ethereum_consensus.json +++ b/configs/coins/ethereum_consensus.json @@ -1,48 +1,48 @@ { - "coin": { - "name": "Ethereum", - "shortcut": "ETH", - "label": "Ethereum", - "alias": "ethereum_consensus", - "execution_alias": "ethereum" - }, - "ports": { - "backend_rpc": 8036, - "backend_message_queue": 0, - "backend_p2p": 38336, - "backend_http": 8136, - "backend_authrpc": 8536, - "blockbook_internal": 9036, - "blockbook_public": 9136 - }, - "backend": { - "package_name": "backend-ethereum-consensus", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "3.2.0", - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.2.0/beacon-chain-v3.2.0-linux-amd64", - "verification_type": "sha256", - "verification_source": "e57fed14bc15a62ab38a6605a8f93c2cf29fbd7a6333dd3ad72781c3778e36fc", - "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --mainnet --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=7536 --rpc-port=7537 --monitoring-port=7538 --p2p-tcp-port=3536 --p2p-udp-port=2536 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum/backend/geth/jwtsecret 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.1.2/beacon-chain-v3.1.2-linux-arm64", - "verification_source": "1701df47dbb6598a9215f82a313e1531c211bb912618dc3d0cd33e6e67c5ebb5" - } + "coin": { + "name": "Ethereum", + "shortcut": "ETH", + "label": "Ethereum", + "alias": "ethereum_consensus", + "execution_alias": "ethereum" + }, + "ports": { + "backend_rpc": 8036, + "backend_message_queue": 0, + "backend_p2p": 38336, + "backend_http": 8136, + "backend_authrpc": 8536, + "blockbook_internal": 9036, + "blockbook_public": 9136 + }, + "backend": { + "package_name": "backend-ethereum-consensus", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "7.1.3", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.3/beacon-chain-v7.1.3-linux-amd64", + "verification_type": "sha256", + "verification_source": "6efcd238124e000783f55a4430f0962b324a32599cf33219415f7f6dece8363f", + "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --mainnet --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=7536 --rpc-port=7537 --monitoring-port=7538 --p2p-tcp-port=3536 --p2p-udp-port=2536 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum/backend/erigon/jwt.hex 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.3/beacon-chain-v7.1.3-linux-arm64", + "verification_source": "23ec40327ac81925dace24cdcb820632dd1c38031208ca8d1c30ebc884612a0c" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum_testnet_goerli.json b/configs/coins/ethereum_testnet_goerli.json deleted file mode 100644 index b4bc2a5fe3..0000000000 --- a/configs/coins/ethereum_testnet_goerli.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Goerli", - "shortcut": "tGOR", - "label": "Ethereum Goerli", - "alias": "ethereum_testnet_goerli" - }, - "ports": { - "backend_rpc": 18026, - "backend_message_queue": 0, - "backend_p2p": 48326, - "backend_http": 18126, - "backend_authrpc": 18526, - "blockbook_internal": 19026, - "blockbook_public": 19126 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-testnet-goerli", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "1.11.2-73b01f40", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --goerli --syncmode full --txlookuplimit 0 --ipcdisable --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --ws.api \"eth,net,web3,debug,txpool\" --http --http.port {{.Ports.BackendHttp}} -http.addr 127.0.0.1 --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz.asc" - } - } - }, - "blockbook": { - "package_name": "blockbook-ethereum-testnet-goerli", - "system_user": "blockbook-ethereum", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 3000, - "additional_params": { - "consensusNodeVersion": "http://localhost:17526/eth/v1/node/version", - "mempoolTxTimeoutHours": 12, - "queryBackendOnMempoolResync": false - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} diff --git a/configs/coins/ethereum_testnet_goerli_archive.json b/configs/coins/ethereum_testnet_goerli_archive.json deleted file mode 100644 index b444b33564..0000000000 --- a/configs/coins/ethereum_testnet_goerli_archive.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Goerli Archive", - "shortcut": "tGOR", - "label": "Ethereum Goerli", - "alias": "ethereum_testnet_goerli_archive" - }, - "ports": { - "backend_rpc": 18006, - "backend_message_queue": 0, - "backend_p2p": 48306, - "backend_http": 18106, - "backend_authrpc": 18506, - "blockbook_internal": 19006, - "blockbook_public": 19106 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-testnet-goerli-archive", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "1.11.2-73b01f40", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --goerli --syncmode full --gcmode archive --txlookuplimit 0 --ipcdisable --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --ws.api \"eth,net,web3,debug,txpool\" --http --http.port {{.Ports.BackendHttp}} -http.addr 127.0.0.1 --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz.asc" - } - } - }, - "blockbook": { - "package_name": "blockbook-ethereum-testnet-goerli-archive", - "system_user": "blockbook-ethereum", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "-workers=16", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 3000, - "additional_params": { - "consensusNodeVersion": "http://localhost:17506/eth/v1/node/version", - "address_aliases": true, - "mempoolTxTimeoutHours": 12, - "processInternalTransactions": true, - "queryBackendOnMempoolResync": false, - "fiat_rates-disabled": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", - "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} diff --git a/configs/coins/ethereum_testnet_goerli_archive_consensus.json b/configs/coins/ethereum_testnet_goerli_archive_consensus.json deleted file mode 100644 index 72ac980769..0000000000 --- a/configs/coins/ethereum_testnet_goerli_archive_consensus.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Goerli Archive", - "shortcut": "tGOR", - "label": "Ethereum Goerli", - "alias": "ethereum_testnet_goerli_archive_consensus", - "execution_alias": "ethereum_testnet_goerli_archive" - }, - "ports": { - "backend_rpc": 18006, - "backend_message_queue": 0, - "backend_p2p": 48306, - "backend_http": 18106, - "backend_authrpc": 18506, - "blockbook_internal": 19006, - "blockbook_public": 19106 - }, - "backend": { - "package_name": "backend-ethereum-testnet-goerli-archive-consensus", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "3.2.0", - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.2.0/beacon-chain-v3.2.0-linux-amd64", - "verification_type": "sha256", - "verification_source": "e57fed14bc15a62ab38a6605a8f93c2cf29fbd7a6333dd3ad72781c3778e36fc", - "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --prater --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17506 --rpc-port=17507 --monitoring-port=17508 --p2p-tcp-port=13506 --p2p-udp-port=12506 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_goerli_archive/backend/geth/jwtsecret --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "wget https://github.com/eth-clients/eth2-networks/raw/master/shared/prater/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.1.2/beacon-chain-v3.1.2-linux-arm64", - "verification_source": "1701df47dbb6598a9215f82a313e1531c211bb912618dc3d0cd33e6e67c5ebb5" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} diff --git a/configs/coins/ethereum_testnet_goerli_consensus.json b/configs/coins/ethereum_testnet_goerli_consensus.json deleted file mode 100644 index 49d0fc821e..0000000000 --- a/configs/coins/ethereum_testnet_goerli_consensus.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Goerli", - "shortcut": "tGOR", - "label": "Ethereum Goerli", - "alias": "ethereum_testnet_goerli_consensus", - "execution_alias": "ethereum_testnet_goerli" - }, - "ports": { - "backend_rpc": 18026, - "backend_message_queue": 0, - "backend_p2p": 48326, - "backend_http": 18126, - "backend_authrpc": 18526, - "blockbook_internal": 19026, - "blockbook_public": 19126 - }, - "backend": { - "package_name": "backend-ethereum-testnet-goerli-consensus", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "3.2.0", - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.2.0/beacon-chain-v3.2.0-linux-amd64", - "verification_type": "sha256", - "verification_source": "e57fed14bc15a62ab38a6605a8f93c2cf29fbd7a6333dd3ad72781c3778e36fc", - "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --prater --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17526 --rpc-port=17527 --monitoring-port=17528 --p2p-tcp-port=13526 --p2p-udp-port=12526 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_goerli/backend/geth/jwtsecret --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "wget https://github.com/eth-clients/eth2-networks/raw/master/shared/prater/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.1.2/beacon-chain-v3.1.2-linux-arm64", - "verification_source": "1701df47dbb6598a9215f82a313e1531c211bb912618dc3d0cd33e6e67c5ebb5" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} diff --git a/configs/coins/ethereum_testnet_hoodi.json b/configs/coins/ethereum_testnet_hoodi.json new file mode 100644 index 0000000000..a472c05002 --- /dev/null +++ b/configs/coins/ethereum_testnet_hoodi.json @@ -0,0 +1,73 @@ +{ + "coin": { + "name": "Ethereum Testnet Hoodi", + "shortcut": "tHOD", + "label": "Ethereum Hoodi", + "alias": "ethereum_testnet_hoodi" + }, + "ports": { + "backend_rpc": 18006, + "backend_message_queue": 0, + "backend_p2p": 48306, + "backend_http": 18106, + "backend_authrpc": 18506, + "blockbook_internal": 19006, + "blockbook_public": 19106 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-testnet-hoodi", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "3.3.3", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.3/erigon_v3.3.3_linux_amd64.tar.gz", + "verification_type": "sha256", + "verification_source": "f72e38acbd4581f8e652f11923c0b72d67a53ce1770894a2bdb881d64722b097", + "extract_command": "tar -C backend --strip-components=1 -xf", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/erigon --chain hoodi --snap.keepblocks --db.size.limit 15TB --db.pagesize 16KB --prune.mode full --externalcl --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/erigon --port {{.Ports.BackendP2P}} --ws --ws.port {{.Ports.BackendRPC}} --http --http.port {{.Ports.BackendRPC}} --http.addr {{.Env.RPCBindHost}} --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} --private.api.addr \"\" --torrent.port {{.Ports.BackendHttp}} --log.dir.path {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log.dir.prefix {{.Coin.Alias}}'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.3/erigon_v3.3.3_linux_arm64.tar.gz", + "verification_source": "00fd630731eb95fd4c70bb921c6335b4a1c1a92ab60032c8bbc40a9497eae1b9" + } + } + }, + "blockbook": { + "package_name": "blockbook-ethereum-testnet-hoodi", + "system_user": "blockbook-ethereum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 3000, + "additional_params": { + "averageBlockTimeMs": 12000, + "consensusNodeVersion": "http://localhost:17506/eth/v1/node/version", + "eip1559Fees": true, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/ethereum_testnet_hoodi_archive.json b/configs/coins/ethereum_testnet_hoodi_archive.json new file mode 100644 index 0000000000..65e39a4022 --- /dev/null +++ b/configs/coins/ethereum_testnet_hoodi_archive.json @@ -0,0 +1,80 @@ +{ + "coin": { + "name": "Ethereum Testnet Hoodi Archive", + "shortcut": "tHOD", + "label": "Ethereum Hoodi", + "alias": "ethereum_testnet_hoodi_archive", + "test_name": "ethereum_testnet_hoodi" + }, + "ports": { + "backend_rpc": 18026, + "backend_message_queue": 0, + "backend_p2p": 48326, + "backend_http": 18126, + "backend_torrent": 18126, + "backend_authrpc": 18526, + "blockbook_internal": 19026, + "blockbook_public": 19126 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-testnet-hoodi-archive", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "3.3.3", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.3/erigon_v3.3.3_linux_amd64.tar.gz", + "verification_type": "sha256", + "verification_source": "f72e38acbd4581f8e652f11923c0b72d67a53ce1770894a2bdb881d64722b097", + "extract_command": "tar -C backend --strip-components=1 -xf", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/erigon --chain hoodi --snap.keepblocks --db.size.limit 15TB --db.pagesize 16KB --prune.mode archive --externalcl --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/erigon --port {{.Ports.BackendP2P}} --ws --ws.port {{.Ports.BackendRPC}} --http --http.port {{.Ports.BackendRPC}} --http.addr {{.Env.RPCBindHost}} --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} --private.api.addr \"\" --torrent.port {{.Ports.BackendHttp}} --log.dir.path {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log.dir.prefix {{.Coin.Alias}}'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.3/erigon_v3.3.3_linux_arm64.tar.gz", + "verification_source": "00fd630731eb95fd4c70bb921c6335b4a1c1a92ab60032c8bbc40a9497eae1b9" + } + } + }, + "blockbook": { + "package_name": "blockbook-ethereum-testnet-hoodi-archive", + "system_user": "blockbook-ethereum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 3000, + "additional_params": { + "averageBlockTimeMs": 12000, + "consensusNodeVersion": "http://localhost:17526/eth/v1/node/version", + "address_aliases": true, + "eip1559Fees": true, + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "queryBackendOnMempoolResync": false, + "fiat_rates-disabled": "coingecko", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/ethereum_testnet_hoodi_archive_consensus.json b/configs/coins/ethereum_testnet_hoodi_archive_consensus.json new file mode 100644 index 0000000000..3f9fc1a8e0 --- /dev/null +++ b/configs/coins/ethereum_testnet_hoodi_archive_consensus.json @@ -0,0 +1,53 @@ +{ + "coin": { + "name": "Ethereum Testnet Hoodi Archive", + "shortcut": "tHOD", + "label": "Ethereum Hoodi", + "alias": "ethereum_testnet_hoodi_archive_consensus", + "execution_alias": "ethereum_testnet_hoodi_archive" + }, + "ports": { + "backend_rpc": 18026, + "backend_message_queue": 0, + "backend_p2p": 48326, + "backend_http": 18126, + "backend_authrpc": 18526, + "blockbook_internal": 19026, + "blockbook_public": 19126 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-testnet-hoodi-archive-consensus", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "7.1.0", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.0/beacon-chain-v7.1.0-linux-amd64", + "verification_type": "sha256", + "verification_source": "a5402ea516d055f8ce150fff2ab4b73adbd8213789789e74c0e0a33eed3397ce", + "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --hoodi --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17526 --rpc-port=17527 --monitoring-port=17528 --p2p-tcp-port=13626 --p2p-udp-port=12626 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_hoodi_archive/backend/erigon/jwt.hex --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "wget https://github.com/eth-clients/hoodi/raw/main/metadata/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.0/beacon-chain-v7.1.0-linux-arm64", + "verification_source": "afc18b5d0810ec6ba716bfc41b7bd574962214688be4ab71afac91d03d46826c" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} \ No newline at end of file diff --git a/configs/coins/ethereum_testnet_hoodi_consensus.json b/configs/coins/ethereum_testnet_hoodi_consensus.json new file mode 100644 index 0000000000..58b869694b --- /dev/null +++ b/configs/coins/ethereum_testnet_hoodi_consensus.json @@ -0,0 +1,53 @@ +{ + "coin": { + "name": "Ethereum Testnet Hoodi", + "shortcut": "tHOD", + "label": "Ethereum Hoodi", + "alias": "ethereum_testnet_hoodi_consensus", + "execution_alias": "ethereum_testnet_hoodi" + }, + "ports": { + "backend_rpc": 18006, + "backend_message_queue": 0, + "backend_p2p": 48306, + "backend_http": 18106, + "backend_authrpc": 18506, + "blockbook_internal": 19006, + "blockbook_public": 19106 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-testnet-hoodi-consensus", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "7.1.0", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.0/beacon-chain-v7.1.0-linux-amd64", + "verification_type": "sha256", + "verification_source": "a5402ea516d055f8ce150fff2ab4b73adbd8213789789e74c0e0a33eed3397ce", + "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --hoodi --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17506 --rpc-port=17507 --monitoring-port=17508 --p2p-tcp-port=13506 --p2p-udp-port=12506 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_hoodi/backend/erigon/jwt.hex --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "wget https://github.com/eth-clients/holesky/raw/main/metadata/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.0/beacon-chain-v7.1.0-linux-arm64", + "verification_source": "afc18b5d0810ec6ba716bfc41b7bd574962214688be4ab71afac91d03d46826c" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} \ No newline at end of file diff --git a/configs/coins/ethereum_testnet_ropsten.json b/configs/coins/ethereum_testnet_ropsten.json deleted file mode 100644 index ee91692ece..0000000000 --- a/configs/coins/ethereum_testnet_ropsten.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Ropsten", - "shortcut": "tROP", - "label": "Ethereum Ropsten", - "alias": "ethereum_testnet_ropsten" - }, - "ports": { - "backend_rpc": 18036, - "backend_message_queue": 0, - "backend_p2p": 48336, - "backend_http": 18136, - "backend_authrpc": 18536, - "blockbook_internal": 19036, - "blockbook_public": 19136 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-testnet-ropsten", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "1.10.26-e5eb32ac", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.26-e5eb32ac.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.26-e5eb32ac.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --ropsten --syncmode full --txlookuplimit 0 --ipcdisable --cache 1024 --nat none --override.terminaltotaldifficulty 50000000000000000 --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --ws.api \"eth,net,web3,debug,txpool\" --http --http.port {{.Ports.BackendHttp}} -http.addr 127.0.0.1 --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz.asc" - } - } - }, - "blockbook": { - "package_name": "blockbook-ethereum-testnet-ropsten", - "system_user": "blockbook-ethereum", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 3000, - "additional_params": { - "consensusNodeVersion": "http://localhost:17536/eth/v1/node/version", - "mempoolTxTimeoutHours": 12, - "queryBackendOnMempoolResync": false - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} diff --git a/configs/coins/ethereum_testnet_ropsten_archive.json b/configs/coins/ethereum_testnet_ropsten_archive.json deleted file mode 100644 index 47408d1b27..0000000000 --- a/configs/coins/ethereum_testnet_ropsten_archive.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Ropsten Archive", - "shortcut": "tROP", - "label": "Ethereum Ropsten", - "alias": "ethereum_testnet_ropsten_archive" - }, - "ports": { - "backend_rpc": 18016, - "backend_message_queue": 0, - "backend_p2p": 48316, - "backend_http": 18116, - "backend_authrpc": 18516, - "blockbook_internal": 19016, - "blockbook_public": 19116 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-testnet-ropsten-archive", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "1.10.26-e5eb32ac", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.26-e5eb32ac.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.26-e5eb32ac.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --ropsten --syncmode full --gcmode archive --txlookuplimit 0 --ipcdisable --cache 1024 --nat none --override.terminaltotaldifficulty 50000000000000000 --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --ws.api \"eth,net,web3,debug,txpool\" --http --http.port {{.Ports.BackendHttp}} -http.addr 127.0.0.1 --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz.asc" - } - } - }, - "blockbook": { - "package_name": "blockbook-ethereum-testnet-ropsten-archive", - "system_user": "blockbook-ethereum", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "-workers=16", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 3000, - "additional_params": { - "consensusNodeVersion": "http://localhost:17516/eth/v1/node/version", - "address_aliases": true, - "mempoolTxTimeoutHours": 12, - "processInternalTransactions": true, - "queryBackendOnMempoolResync": false, - "fiat_rates-disabled": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", - "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} diff --git a/configs/coins/ethereum_testnet_ropsten_archive_consensus.json b/configs/coins/ethereum_testnet_ropsten_archive_consensus.json deleted file mode 100644 index a0137e7582..0000000000 --- a/configs/coins/ethereum_testnet_ropsten_archive_consensus.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Ropsten Archive", - "shortcut": "tROP", - "label": "Ethereum Ropsten", - "alias": "ethereum_testnet_ropsten_archive_consensus", - "execution_alias": "ethereum_testnet_ropsten_archive" - }, - "ports": { - "backend_rpc": 18016, - "backend_message_queue": 0, - "backend_p2p": 48316, - "backend_http": 18116, - "backend_authrpc": 18516, - "blockbook_internal": 19016, - "blockbook_public": 19116 - }, - "backend": { - "package_name": "backend-ethereum-testnet-ropsten-archive-consensus", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "3.2.0", - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.2.0/beacon-chain-v3.2.0-linux-amd64", - "verification_type": "sha256", - "verification_source": "e57fed14bc15a62ab38a6605a8f93c2cf29fbd7a6333dd3ad72781c3778e36fc", - "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --ropsten --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17516 --rpc-port=17517 --monitoring-port=17518 --p2p-tcp-port=13516 --p2p-udp-port=12516 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_ropsten_archive/backend/geth/jwtsecret --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "wget https://github.com/eth-clients/merge-testnets/raw/e4a6f0c181d24b28bc8651744f1d0e9ef74bda3f/ropsten-beacon-chain/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.1.2/beacon-chain-v3.1.2-linux-arm64", - "verification_source": "1701df47dbb6598a9215f82a313e1531c211bb912618dc3d0cd33e6e67c5ebb5" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} diff --git a/configs/coins/ethereum_testnet_ropsten_consensus.json b/configs/coins/ethereum_testnet_ropsten_consensus.json deleted file mode 100644 index 720784e21c..0000000000 --- a/configs/coins/ethereum_testnet_ropsten_consensus.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Ropsten", - "shortcut": "tROP", - "label": "Ethereum Ropsten", - "alias": "ethereum_testnet_ropsten_consensus", - "execution_alias": "ethereum_testnet_ropsten" - }, - "ports": { - "backend_rpc": 18036, - "backend_message_queue": 0, - "backend_p2p": 48336, - "backend_http": 18136, - "backend_authrpc": 18536, - "blockbook_internal": 19036, - "blockbook_public": 19136 - }, - "backend": { - "package_name": "backend-ethereum-testnet-ropsten-consensus", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "3.2.0", - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.2.0/beacon-chain-v3.2.0-linux-amd64", - "verification_type": "sha256", - "verification_source": "e57fed14bc15a62ab38a6605a8f93c2cf29fbd7a6333dd3ad72781c3778e36fc", - "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --ropsten --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17536 --rpc-port=17537 --monitoring-port=17538 --p2p-tcp-port=13536 --p2p-udp-port=12536 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_ropsten/backend/geth/jwtsecret --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "wget https://github.com/eth-clients/merge-testnets/raw/e4a6f0c181d24b28bc8651744f1d0e9ef74bda3f/ropsten-beacon-chain/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.1.2/beacon-chain-v3.1.2-linux-arm64", - "verification_source": "1701df47dbb6598a9215f82a313e1531c211bb912618dc3d0cd33e6e67c5ebb5" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} diff --git a/configs/coins/ethereum_testnet_sepolia.json b/configs/coins/ethereum_testnet_sepolia.json index 268fac2e43..b2a4d4fadd 100644 --- a/configs/coins/ethereum_testnet_sepolia.json +++ b/configs/coins/ethereum_testnet_sepolia.json @@ -1,70 +1,73 @@ { - "coin": { - "name": "Ethereum Testnet Sepolia", - "shortcut": "gSEP", - "label": "Ethereum Sepolia", - "alias": "ethereum_testnet_sepolia" - }, - "ports": { - "backend_rpc": 18076, - "backend_message_queue": 0, - "backend_p2p": 48376, - "backend_http": 18176, - "backend_authrpc": 18576, - "blockbook_internal": 19076, - "blockbook_public": 19176 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-testnet-sepolia", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "1.11.2-73b01f40", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --sepolia --syncmode full --txlookuplimit 0 --ipcdisable --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --ws.api \"eth,net,web3,debug,txpool\" --http --http.port {{.Ports.BackendHttp}} -http.addr 127.0.0.1 --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz.asc" - } + "coin": { + "name": "Ethereum Testnet Sepolia", + "shortcut": "tSEP", + "label": "Ethereum Sepolia", + "alias": "ethereum_testnet_sepolia" + }, + "ports": { + "backend_rpc": 18076, + "backend_message_queue": 0, + "backend_p2p": 48376, + "backend_http": 18176, + "backend_authrpc": 18576, + "blockbook_internal": 19076, + "blockbook_public": 19176 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-testnet-sepolia", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "3.3.10", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.10/erigon_v3.3.10_linux_amd64.tar.gz", + "verification_type": "sha256", + "verification_source": "b717a6fce275d02e517eb473fc5d4385a7b0cd1d9e9ed144e4ef712799a474b7", + "extract_command": "tar -C backend --strip-components=1 -xf", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/erigon --chain sepolia --snap.keepblocks --db.size.limit 15TB --db.pagesize 16KB --prune.mode full --externalcl --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/erigon --port {{.Ports.BackendP2P}} --ws --ws.port {{.Ports.BackendRPC}} --http --http.port {{.Ports.BackendRPC}} --http.addr {{.Env.RPCBindHost}} --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} --private.api.addr \"\" --torrent.port {{.Ports.BackendHttp}} --log.dir.path {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log.dir.prefix {{.Coin.Alias}}'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.10/erigon_v3.3.10_linux_arm64.tar.gz", + "verification_source": "d85460c6e4c287235939d77051cb3abf3606b21498e71b29b2d8f61f87dddf5b" + } + } + }, + "blockbook": { + "package_name": "blockbook-ethereum-testnet-sepolia", + "system_user": "blockbook-ethereum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 3000, + "additional_params": { + "averageBlockTimeMs": 12000, + "consensusNodeVersion": "http://localhost:17576/eth/v1/node/version", + "eip1559Fees": true, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-ethereum-testnet-sepolia", - "system_user": "blockbook-ethereum", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 3000, - "additional_params": { - "consensusNodeVersion": "http://localhost:17576/eth/v1/node/version", - "mempoolTxTimeoutHours": 12, - "queryBackendOnMempoolResync": false - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum_testnet_sepolia_archive.json b/configs/coins/ethereum_testnet_sepolia_archive.json index 8eb272bf45..05344018c3 100644 --- a/configs/coins/ethereum_testnet_sepolia_archive.json +++ b/configs/coins/ethereum_testnet_sepolia_archive.json @@ -1,75 +1,80 @@ { - "coin": { - "name": "Ethereum Testnet Sepolia Archive", - "shortcut": "gSEP", - "label": "Ethereum Sepolia", - "alias": "ethereum_testnet_sepolia_archive" - }, - "ports": { - "backend_rpc": 18086, - "backend_message_queue": 0, - "backend_p2p": 48386, - "backend_http": 18186, - "backend_authrpc": 18586, - "blockbook_internal": 19086, - "blockbook_public": 19186 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-testnet-sepolia-archive", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "1.11.2-73b01f40", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.11.2-73b01f40.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --sepolia --syncmode full --gcmode archive --txlookuplimit 0 --ipcdisable --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port {{.Ports.BackendP2P}} --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" --ws.api \"eth,net,web3,debug,txpool\" --http --http.port {{.Ports.BackendHttp}} -http.addr 127.0.0.1 --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-arm64-1.10.26-e5eb32ac.tar.gz.asc" - } + "coin": { + "name": "Ethereum Testnet Sepolia Archive", + "shortcut": "tSEP", + "label": "Ethereum Sepolia", + "alias": "ethereum_testnet_sepolia_archive", + "test_name": "ethereum_testnet_sepolia" + }, + "ports": { + "backend_rpc": 18086, + "backend_message_queue": 0, + "backend_p2p": 48386, + "backend_http": 18186, + "backend_torrent": 18186, + "backend_authrpc": 18586, + "blockbook_internal": 19086, + "blockbook_public": 19186 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-testnet-sepolia-archive", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "3.3.10", + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.10/erigon_v3.3.10_linux_amd64.tar.gz", + "verification_type": "sha256", + "verification_source": "b717a6fce275d02e517eb473fc5d4385a7b0cd1d9e9ed144e4ef712799a474b7", + "extract_command": "tar -C backend --strip-components=1 -xf", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/erigon --chain sepolia --snap.keepblocks --db.size.limit 15TB --db.pagesize 16KB --prune.mode archive --externalcl --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/erigon --port {{.Ports.BackendP2P}} --ws --ws.port {{.Ports.BackendRPC}} --http --http.port {{.Ports.BackendRPC}} --http.addr {{.Env.RPCBindHost}} --http.corsdomain \"*\" --http.vhosts \"*\" --http.api \"eth,net,web3,debug,txpool\" --authrpc.port {{.Ports.BackendAuthRpc}} --private.api.addr \"\" --torrent.port {{.Ports.BackendHttp}} --log.dir.path {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --log.dir.prefix {{.Coin.Alias}}'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/erigontech/erigon/releases/download/v3.3.10/erigon_v3.3.10_linux_arm64.tar.gz", + "verification_source": "d85460c6e4c287235939d77051cb3abf3606b21498e71b29b2d8f61f87dddf5b" + } + } + }, + "blockbook": { + "package_name": "blockbook-ethereum-testnet-sepolia-archive", + "system_user": "blockbook-ethereum", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 3000, + "additional_params": { + "averageBlockTimeMs": 12000, + "consensusNodeVersion": "http://localhost:17586/eth/v1/node/version", + "address_aliases": true, + "eip1559Fees": true, + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "queryBackendOnMempoolResync": false, + "fiat_rates-disabled": "coingecko", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-ethereum-testnet-sepolia-archive", - "system_user": "blockbook-ethereum", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "-workers=16", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 3000, - "additional_params": { - "consensusNodeVersion": "http://localhost:17586/eth/v1/node/version", - "address_aliases": true, - "mempoolTxTimeoutHours": 12, - "processInternalTransactions": true, - "queryBackendOnMempoolResync": false, - "fiat_rates-disabled": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\",\"platformIdentifier\": \"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", - "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum_testnet_sepolia_archive_consensus.json b/configs/coins/ethereum_testnet_sepolia_archive_consensus.json index 2be9ce54ba..5fdfdb29b8 100644 --- a/configs/coins/ethereum_testnet_sepolia_archive_consensus.json +++ b/configs/coins/ethereum_testnet_sepolia_archive_consensus.json @@ -1,52 +1,53 @@ { - "coin": { - "name": "Ethereum Testnet Sepolia Archive", - "shortcut": "gSEP", - "label": "Ethereum Sepolia", - "alias": "ethereum_testnet_sepolia_archive_consensus", - "execution_alias": "ethereum_testnet_sepolia_archive" - }, - "ports": { - "backend_rpc": 18086, - "backend_message_queue": 0, - "backend_p2p": 48386, - "backend_http": 18186, - "backend_authrpc": 18586, - "blockbook_internal": 19086, - "blockbook_public": 19186 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-testnet-sepolia-archive-consensus", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "3.2.0", - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.2.0/beacon-chain-v3.2.0-linux-amd64", - "verification_type": "sha256", - "verification_source": "e57fed14bc15a62ab38a6605a8f93c2cf29fbd7a6333dd3ad72781c3778e36fc", - "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --sepolia --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17586 --rpc-port=17587 --monitoring-port=17548 --p2p-tcp-port=13676 --p2p-udp-port=12676 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_sepolia_archive/backend/geth/jwtsecret --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "wget https://github.com/eth-clients/merge-testnets/raw/302fe27afdc7a9d15b1766a0c0a9d64319140255/sepolia/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.1.2/beacon-chain-v3.1.2-linux-arm64", - "verification_source": "1701df47dbb6598a9215f82a313e1531c211bb912618dc3d0cd33e6e67c5ebb5" - } + "coin": { + "name": "Ethereum Testnet Sepolia Archive", + "shortcut": "tSEP", + "label": "Ethereum Sepolia", + "alias": "ethereum_testnet_sepolia_archive_consensus", + "execution_alias": "ethereum_testnet_sepolia_archive" + }, + "ports": { + "backend_rpc": 18086, + "backend_message_queue": 0, + "backend_p2p": 48386, + "backend_http": 18186, + "backend_authrpc": 18586, + "blockbook_internal": 19086, + "blockbook_public": 19186 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-testnet-sepolia-archive-consensus", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "7.1.3", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.3/beacon-chain-v7.1.3-linux-amd64", + "verification_type": "sha256", + "verification_source": "6efcd238124e000783f55a4430f0962b324a32599cf33219415f7f6dece8363f", + "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --sepolia --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17586 --rpc-port=17587 --monitoring-port=17548 --p2p-tcp-port=13676 --p2p-udp-port=12676 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_sepolia_archive/backend/erigon/jwt.hex --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "wget https://github.com/eth-clients/sepolia/raw/main/metadata/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.3/beacon-chain-v7.1.3-linux-arm64", + "verification_source": "23ec40327ac81925dace24cdcb820632dd1c38031208ca8d1c30ebc884612a0c" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum_testnet_sepolia_consensus.json b/configs/coins/ethereum_testnet_sepolia_consensus.json index 635875b755..07ae25515f 100644 --- a/configs/coins/ethereum_testnet_sepolia_consensus.json +++ b/configs/coins/ethereum_testnet_sepolia_consensus.json @@ -1,52 +1,53 @@ { - "coin": { - "name": "Ethereum Testnet Sepolia", - "shortcut": "gSEP", - "label": "Ethereum Sepolia", - "alias": "ethereum_testnet_sepolia_consensus", - "execution_alias": "ethereum_testnet_sepolia" - }, - "ports": { - "backend_rpc": 18076, - "backend_message_queue": 0, - "backend_p2p": 48376, - "backend_http": 18176, - "backend_authrpc": 18576, - "blockbook_internal": 19076, - "blockbook_public": 19176 - }, - "ipc": { - "rpc_url_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_timeout": 25 - }, - "backend": { - "package_name": "backend-ethereum-testnet-sepolia-consensus", - "package_revision": "satoshilabs-1", - "system_user": "ethereum", - "version": "3.2.0", - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.2.0/beacon-chain-v3.2.0-linux-amd64", - "verification_type": "sha256", - "verification_source": "e57fed14bc15a62ab38a6605a8f93c2cf29fbd7a6333dd3ad72781c3778e36fc", - "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --sepolia --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17576 --rpc-port=17577 --monitoring-port=17578 --p2p-tcp-port=13576 --p2p-udp-port=12576 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_sepolia/backend/geth/jwtsecret --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", - "postinst_script_template": "wget https://github.com/eth-clients/merge-testnets/raw/302fe27afdc7a9d15b1766a0c0a9d64319140255/sepolia/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", - "service_type": "simple", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "", - "client_config_file": "", - "platforms": { - "arm64": { - "binary_url": "https://github.com/prysmaticlabs/prysm/releases/download/v3.1.2/beacon-chain-v3.1.2-linux-arm64", - "verification_source": "1701df47dbb6598a9215f82a313e1531c211bb912618dc3d0cd33e6e67c5ebb5" - } + "coin": { + "name": "Ethereum Testnet Sepolia", + "shortcut": "tSEP", + "label": "Ethereum Sepolia", + "alias": "ethereum_testnet_sepolia_consensus", + "execution_alias": "ethereum_testnet_sepolia" + }, + "ports": { + "backend_rpc": 18076, + "backend_message_queue": 0, + "backend_p2p": 48376, + "backend_http": 18176, + "backend_authrpc": 18576, + "blockbook_internal": 19076, + "blockbook_public": 19176 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-ethereum-testnet-sepolia-consensus", + "package_revision": "satoshilabs-1", + "system_user": "ethereum", + "version": "7.1.3", + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.3/beacon-chain-v7.1.3-linux-amd64", + "verification_type": "sha256", + "verification_source": "6efcd238124e000783f55a4430f0962b324a32599cf33219415f7f6dece8363f", + "extract_command": "mv ${ARCHIVE} backend/beacon-chain && chmod +x backend/beacon-chain && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/beacon-chain --sepolia --accept-terms-of-use --execution-endpoint=http://localhost:{{.Ports.BackendAuthRpc}} --grpc-gateway-port=17576 --rpc-port=17577 --monitoring-port=17578 --p2p-tcp-port=13576 --p2p-udp-port=12576 --datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --jwt-secret={{.Env.BackendDataPath}}/ethereum_testnet_sepolia/backend/erigon/jwt.hex --genesis-state={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz 2>>{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "wget https://github.com/eth-clients/holesky/raw/main/metadata/genesis.ssz -O {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/genesis.ssz", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/OffchainLabs/prysm/releases/download/v7.1.3/beacon-chain-v7.1.3-linux-arm64", + "verification_source": "23ec40327ac81925dace24cdcb820632dd1c38031208ca8d1c30ebc884612a0c" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/firo.json b/configs/coins/firo.json index 1e29446998..f514651fbd 100644 --- a/configs/coins/firo.json +++ b/configs/coins/firo.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-firo", "package_revision": "satoshilabs-1", "system_user": "firo", - "version": "0.14.11.1", - "binary_url": "https://github.com/firoorg/firo/releases/download/v0.14.11.1/firo-0.14.11.1-linux64.tar.gz", + "version": "0.14.15.3", + "binary_url": "https://github.com/firoorg/firo/releases/download/v0.14.15.3/firo-0.14.15.3-linux64.tar.gz", "verification_type": "sha256", - "verification_source": "8669ae8ce3356deee2512a4da133eab347c704cf47c865caf9ea10b46ba8b477", + "verification_source": "df6d0fb6abc8998909ecb3c3f4c5aa0b6bbf00474bb4739349d12079874754fc", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/firo-qt", diff --git a/configs/coins/flo.json b/configs/coins/flo.json index d8a9a2ae65..f3a77a6abd 100644 --- a/configs/coins/flo.json +++ b/configs/coins/flo.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/flo_testnet.json b/configs/coins/flo_testnet.json index a2c1960267..e31ff6f98e 100644 --- a/configs/coins/flo_testnet.json +++ b/configs/coins/flo_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/flux.json b/configs/coins/flux.json index 887f8fa8a9..595bf8378a 100644 --- a/configs/coins/flux.json +++ b/configs/coins/flux.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-flux", "package_revision": "satoshilabs-1", "system_user": "flux", - "version": "6.0.0", - "binary_url": "https://github.com/RunOnFlux/fluxd/releases/download/v6.0.0/Flux-amd64-v6.0.0.tar.gz", + "version": "9.0.0", + "binary_url": "https://github.com/RunOnFlux/fluxd/releases/download/v9.0.5/Flux-amd64-v9.0.5.tar.gz", "verification_type": "sha256", - "verification_source": "28717246a383018de8f6099a26afc3a4877da32f2d9531a3253b1664c22145e7", + "verification_source": "c2c7d2ef27937244d7b0df1819bd66275c57072e3d6f290f8fb179bc3e403cc0", "extract_command": "tar -C backend -xf", "exclude_files": [], "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/fluxd -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", @@ -39,10 +40,12 @@ "client_config_file": "bitcoin_like_client.conf", "additional_params": { "addnode": [ - "explorer.zel.cash", - "explorer2.zel.cash", - "explorer.zelcash.online", - "explorer-asia.zel.cash" + "explorer.runonflux.com", + "explorer.runonflux.io", + "blockbook.runonflux.com", + "blockbook.runonflux.io", + "explorer.flux.zelcore.io", + "blockbook.flux.zelcore.io" ] } }, diff --git a/configs/coins/fujicoin.json b/configs/coins/fujicoin.json index c3188e660d..c2cf8ca7dc 100644 --- a/configs/coins/fujicoin.json +++ b/configs/coins/fujicoin.json @@ -1,71 +1,72 @@ { - "coin": { - "name": "Fujicoin", - "shortcut": "FJC", - "label": "Fujicoin", - "alias": "fujicoin" - }, - "ports": { - "backend_rpc": 8048, - "backend_message_queue": 38348, - "blockbook_internal": 9048, - "blockbook_public": 9148 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-fujicoin", - "package_revision": "satoshilabs-1", - "system_user": "fujicoin", - "version": "22.0", - "binary_url": "https://download.fujicoin.org/fujicoin-v22.0/x86_64-linux-gnu/fujicoin-22.0-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "8aa699f3fbd6681391b90f744a25155d21a94f5ca63d6cc3b85172f3aca6e2a0", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/fujicoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/fujicoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee" + "coin": { + "name": "Fujicoin", + "shortcut": "FJC", + "label": "Fujicoin", + "alias": "fujicoin" + }, + "ports": { + "backend_rpc": 8048, + "backend_message_queue": 38348, + "blockbook_internal": 9048, + "blockbook_public": 9148 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-fujicoin", + "package_revision": "satoshilabs-1", + "system_user": "fujicoin", + "version": "22.0", + "binary_url": "https://download.fujicoin.org/fujicoin-v22.0/x86_64-linux-gnu/fujicoin-22.0-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "8aa699f3fbd6681391b90f744a25155d21a94f5ca63d6cc3b85172f3aca6e2a0", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/fujicoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/fujicoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + } + }, + "blockbook": { + "package_name": "blockbook-fujicoin", + "system_user": "blockbook-fujicoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "xpub_magic_segwit_p2sh": 77429938, + "xpub_magic_segwit_native": 78792518, + "slip44": 75, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"fujicoin\", \"periodSeconds\": 600}" + } + } + }, + "meta": { + "package_maintainer": "Motty", + "package_maintainer_email": "fujicoin@gmail.com" } - }, - "blockbook": { - "package_name": "blockbook-fujicoin", - "system_user": "blockbook-fujicoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "xpub_magic_segwit_p2sh": 77429938, - "xpub_magic_segwit_native": 78792518, - "slip44": 75, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"fujicoin\", \"periodSeconds\": 600}" - } - } - }, - "meta": { - "package_maintainer": "Motty", - "package_maintainer_email": "fujicoin@gmail.com" - } } diff --git a/configs/coins/gamecredits.json b/configs/coins/gamecredits.json index 848f231dfd..439f5a083d 100644 --- a/configs/coins/gamecredits.json +++ b/configs/coins/gamecredits.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -66,4 +67,4 @@ "package_maintainer": "Samad Sajanlal", "package_maintainer_email": "samad@gamecredits.org" } -} \ No newline at end of file +} diff --git a/configs/coins/groestlcoin.json b/configs/coins/groestlcoin.json index 5fdfd42bc4..ccb44707e7 100644 --- a/configs/coins/groestlcoin.json +++ b/configs/coins/groestlcoin.json @@ -1,72 +1,84 @@ { - "coin": { - "name": "Groestlcoin", - "shortcut": "GRS", - "label": "Groestlcoin", - "alias": "groestlcoin" - }, - "ports": { - "backend_rpc": 8045, - "backend_message_queue": 38345, - "blockbook_internal": 9045, - "blockbook_public": 9145 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-groestlcoin", - "package_revision": "satoshilabs-1", - "system_user": "groestlcoin", - "version": "24.0.1", - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v24.0.1/groestlcoin-24.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "4b69743190e2697d7b7772bf6f63cde595d590ff6664abf15a7201dab2a6098b", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/groestlcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/groestlcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin.conf", - "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee", - "whitelist": "127.0.0.1" + "coin": { + "name": "Groestlcoin", + "shortcut": "GRS", + "label": "Groestlcoin", + "alias": "groestlcoin" + }, + "ports": { + "backend_rpc": 8045, + "backend_message_queue": 38345, + "blockbook_internal": 9045, + "blockbook_public": 9145 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-groestlcoin", + "package_revision": "satoshilabs-1", + "system_user": "groestlcoin", + "version": "29.0", + "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v29.0/groestlcoin-29.0-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "e0b3e3d96caf908060779c0d9964c777ccc4b7364af54404ff1768e018e56768", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/groestlcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/groestlcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + }, + "platforms": { + "arm64": { + "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v29.0/groestlcoin-29.0-aarch64-linux-gnu.tar.gz", + "verification_source": "43b67b0945eb63c26bf0106ce3e302d4fe0720900cd8658e84f5d7954899a2a8" + } + } + }, + "blockbook": { + "package_name": "blockbook-groestlcoin", + "system_user": "blockbook-groestlcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-enablesubnewtx -extendedindex", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "xpub_magic_segwit_p2sh": 77429938, + "xpub_magic_segwit_native": 78792518, + "slip44": 17, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"groestlcoin\", \"periodSeconds\": 900}", + "block_golomb_filter_p": 20, + "block_filter_scripts": "taproot-noordinals", + "block_filter_use_zeroed_key": true, + "mempool_golomb_filter_p": 20, + "mempool_filter_scripts": "taproot", + "mempool_filter_use_zeroed_key": false + } + } + }, + "meta": { + "package_maintainer": "Groestlcoin Development Team", + "package_maintainer_email": "jackie@groestlcoin.org" } - }, - "blockbook": { - "package_name": "blockbook-groestlcoin", - "system_user": "blockbook-groestlcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "xpub_magic_segwit_p2sh": 77429938, - "xpub_magic_segwit_native": 78792518, - "slip44": 17, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"groestlcoin\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "Groestlcoin Development Team", - "package_maintainer_email": "jackie@groestlcoin.org" - } } diff --git a/configs/coins/groestlcoin_regtest.json b/configs/coins/groestlcoin_regtest.json index c85b30bf82..9640b7c73c 100644 --- a/configs/coins/groestlcoin_regtest.json +++ b/configs/coins/groestlcoin_regtest.json @@ -1,73 +1,74 @@ { - "coin": { - "name": "Groestlcoin Regtest", - "shortcut": "rGRS", - "label": "Groestlcoin Regtest", - "alias": "groestlcoin_regtest" - }, - "ports": { - "backend_rpc": 18046, - "backend_message_queue": 48346, - "blockbook_internal": 19046, - "blockbook_public": 19146 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-groestlcoin-regtest", - "package_revision": "satoshilabs-1", - "system_user": "groestlcoin", - "version": "24.0.1", - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v24.0.1/groestlcoin-24.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "4b69743190e2697d7b7772bf6f63cde595d590ff6664abf15a7201dab2a6098b", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/groestlcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/groestlcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/regtest/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "bitcoin_regtest.conf", - "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee", - "whitelist": "127.0.0.1" + "coin": { + "name": "Groestlcoin Regtest", + "shortcut": "rGRS", + "label": "Groestlcoin Regtest", + "alias": "groestlcoin_regtest" }, - "platforms": { - "arm64": { - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v24.0.1/groestlcoin-24.0.1-aarch64-linux-gnu.tar.gz", - "verification_source": "ca316c369728348406778c30b2b567bb2ede1ebcc87fb0305c0bed3dacae762b" - } - } - }, - "blockbook": { - "package_name": "blockbook-groestlcoin-regtest", - "system_user": "blockbook-groestlcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 70617039, - "xpub_magic_segwit_p2sh": 71979618, - "xpub_magic_segwit_native": 73342198, - "slip44": 1 + "ports": { + "backend_rpc": 18046, + "backend_message_queue": 48346, + "blockbook_internal": 19046, + "blockbook_public": 19146 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-groestlcoin-regtest", + "package_revision": "satoshilabs-1", + "system_user": "groestlcoin", + "version": "29.0", + "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v29.0/groestlcoin-29.0-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "e0b3e3d96caf908060779c0d9964c777ccc4b7364af54404ff1768e018e56768", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/groestlcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/groestlcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/regtest/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "mainnet": false, + "protect_memory": true, + "server_config_file": "bitcoin_regtest.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + }, + "platforms": { + "arm64": { + "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v29.0/groestlcoin-29.0-aarch64-linux-gnu.tar.gz", + "verification_source": "43b67b0945eb63c26bf0106ce3e302d4fe0720900cd8658e84f5d7954899a2a8" + } + } + }, + "blockbook": { + "package_name": "blockbook-groestlcoin-regtest", + "system_user": "blockbook-groestlcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 1, + "additional_params": {} + } + }, + "meta": { + "package_maintainer": "Groestlcoin Development Team", + "package_maintainer_email": "jackie@groestlcoin.org" } - }, - "meta": { - "package_maintainer": "Groestlcoin Development Team", - "package_maintainer_email": "jackie@groestlcoin.org" - } } diff --git a/configs/coins/groestlcoin_signet.json b/configs/coins/groestlcoin_signet.json index 7859a6908b..cc7ebda29d 100644 --- a/configs/coins/groestlcoin_signet.json +++ b/configs/coins/groestlcoin_signet.json @@ -1,67 +1,74 @@ { - "coin": { - "name": "Groestlcoin Signet", - "shortcut": "sGRS", - "label": "Groestlcoin Signet", - "alias": "groestlcoin_signet" - }, - "ports": { - "backend_rpc": 18047, - "backend_message_queue": 48347, - "blockbook_internal": 19047, - "blockbook_public": 19147 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-groestlcoin-signet", - "package_revision": "satoshilabs-1", - "system_user": "groestlcoin", - "version": "24.0.1", - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v24.0.1/groestlcoin-24.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "4b69743190e2697d7b7772bf6f63cde595d590ff6664abf15a7201dab2a6098b", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/groestlcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/groestlcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/signet/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "bitcoin-signet.conf", - "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee", - "whitelist": "127.0.0.1" + "coin": { + "name": "Groestlcoin Signet", + "shortcut": "sGRS", + "label": "Groestlcoin Signet", + "alias": "groestlcoin_signet" + }, + "ports": { + "backend_rpc": 18047, + "backend_message_queue": 48347, + "blockbook_internal": 19047, + "blockbook_public": 19147 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-groestlcoin-signet", + "package_revision": "satoshilabs-1", + "system_user": "groestlcoin", + "version": "29.0", + "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v29.0/groestlcoin-29.0-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "e0b3e3d96caf908060779c0d9964c777ccc4b7364af54404ff1768e018e56768", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/groestlcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/groestlcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/signet/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "bitcoin_signet.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + }, + "platforms": { + "arm64": { + "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v29.0/groestlcoin-29.0-aarch64-linux-gnu.tar.gz", + "verification_source": "43b67b0945eb63c26bf0106ce3e302d4fe0720900cd8658e84f5d7954899a2a8" + } + } + }, + "blockbook": { + "package_name": "blockbook-groestlcoin-signet", + "system_user": "blockbook-groestlcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 1, + "additional_params": {} + } + }, + "meta": { + "package_maintainer": "Groestlcoin Development Team", + "package_maintainer_email": "jackie@groestlcoin.org" } - }, - "blockbook": { - "package_name": "blockbook-groestlcoin-signet", - "system_user": "blockbook-groestlcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 70617039, - "xpub_magic_segwit_p2sh": 71979618, - "xpub_magic_segwit_native": 73342198, - "slip44": 1 - } - }, - "meta": { - "package_maintainer": "Groestlcoin Development Team", - "package_maintainer_email": "jackie@groestlcoin.org" - } } diff --git a/configs/coins/groestlcoin_testnet.json b/configs/coins/groestlcoin_testnet.json index 05a67c2548..be02227f56 100644 --- a/configs/coins/groestlcoin_testnet.json +++ b/configs/coins/groestlcoin_testnet.json @@ -1,67 +1,81 @@ { - "coin": { - "name": "Groestlcoin Testnet", - "shortcut": "tGRS", - "label": "Groestlcoin Testnet", - "alias": "groestlcoin_testnet" - }, - "ports": { - "backend_rpc": 18045, - "backend_message_queue": 48345, - "blockbook_internal": 19045, - "blockbook_public": 19145 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-groestlcoin-testnet", - "package_revision": "satoshilabs-1", - "system_user": "groestlcoin", - "version": "24.0.1", - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v24.0.1/groestlcoin-24.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "4b69743190e2697d7b7772bf6f63cde595d590ff6664abf15a7201dab2a6098b", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/groestlcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/groestlcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet3/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "bitcoin.conf", - "client_config_file": "bitcoin_client.conf", - "additional_params": { - "deprecatedrpc": "estimatefee", - "whitelist": "127.0.0.1" + "coin": { + "name": "Groestlcoin Testnet", + "shortcut": "tGRS", + "label": "Groestlcoin Testnet", + "alias": "groestlcoin_testnet" + }, + "ports": { + "backend_rpc": 18045, + "backend_message_queue": 48345, + "blockbook_internal": 19045, + "blockbook_public": 19145 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-groestlcoin-testnet", + "package_revision": "satoshilabs-1", + "system_user": "groestlcoin", + "version": "29.0", + "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v29.0/groestlcoin-29.0-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "e0b3e3d96caf908060779c0d9964c777ccc4b7364af54404ff1768e018e56768", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/groestlcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/groestlcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet3/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "bitcoin.conf", + "client_config_file": "bitcoin_client.conf", + "additional_params": { + "deprecatedrpc": "estimatefee" + }, + "platforms": { + "arm64": { + "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v29.0/groestlcoin-29.0-aarch64-linux-gnu.tar.gz", + "verification_source": "43b67b0945eb63c26bf0106ce3e302d4fe0720900cd8658e84f5d7954899a2a8" + } + } + }, + "blockbook": { + "package_name": "blockbook-groestlcoin-testnet", + "system_user": "blockbook-groestlcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-enablesubnewtx -extendedindex", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 1, + "additional_params": { + "block_golomb_filter_p": 20, + "block_filter_scripts": "taproot-noordinals", + "block_filter_use_zeroed_key": true, + "mempool_golomb_filter_p": 20, + "mempool_filter_scripts": "taproot", + "mempool_filter_use_zeroed_key": false + } + } + }, + "meta": { + "package_maintainer": "Groestlcoin Development Team", + "package_maintainer_email": "jackie@groestlcoin.org" } - }, - "blockbook": { - "package_name": "blockbook-groestlcoin-testnet", - "system_user": "blockbook-groestlcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 70617039, - "xpub_magic_segwit_p2sh": 71979618, - "xpub_magic_segwit_native": 73342198, - "slip44": 1 - } - }, - "meta": { - "package_maintainer": "Groestlcoin Development Team", - "package_maintainer_email": "jackie@groestlcoin.org" - } } diff --git a/configs/coins/koto.json b/configs/coins/koto.json index 86a1fd78de..6114d8fd1d 100644 --- a/configs/coins/koto.json +++ b/configs/coins/koto.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/koto_testnet.json b/configs/coins/koto_testnet.json index 33e8df1f05..179ddf18c2 100644 --- a/configs/coins/koto_testnet.json +++ b/configs/coins/koto_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/liquid.json b/configs/coins/liquid.json index 92002623d0..60de43f95a 100644 --- a/configs/coins/liquid.json +++ b/configs/coins/liquid.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -65,4 +66,4 @@ "package_maintainer": "Martin Bohm", "package_maintainer_email": "martin.bohm@satoshilabs.com" } -} \ No newline at end of file +} diff --git a/configs/coins/litecoin.json b/configs/coins/litecoin.json index f965e4e813..4d1e43a9fa 100644 --- a/configs/coins/litecoin.json +++ b/configs/coins/litecoin.json @@ -1,77 +1,78 @@ { - "coin": { - "name": "Litecoin", - "shortcut": "LTC", - "label": "Litecoin", - "alias": "litecoin" - }, - "ports": { - "backend_rpc": 8034, - "backend_message_queue": 38334, - "blockbook_internal": 9034, - "blockbook_public": 9134 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-litecoin", - "package_revision": "satoshilabs-1", - "system_user": "litecoin", - "version": "0.21.2.1", - "binary_url": "https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg", - "verification_source": "https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-x86_64-linux-gnu.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/litecoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/litecoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "whitelist": "127.0.0.1" + "coin": { + "name": "Litecoin", + "shortcut": "LTC", + "label": "Litecoin", + "alias": "litecoin" }, - "platforms": { - "arm64": { - "binary_url": "https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-aarch64-linux-gnu.tar.gz", - "verification_source": "https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-aarch64-linux-gnu.tar.gz.asc" - } - } - }, - "blockbook": { - "package_name": "blockbook-litecoin", - "system_user": "blockbook-litecoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 27108450, - "xpub_magic_segwit_p2sh": 28471030, - "xpub_magic_segwit_native": 78792518, - "slip44": 2, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"litecoin\", \"periodSeconds\": 900}" - } + "ports": { + "backend_rpc": 8034, + "backend_message_queue": 38334, + "blockbook_internal": 9034, + "blockbook_public": 9134 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-litecoin", + "package_revision": "satoshilabs-1", + "system_user": "litecoin", + "version": "0.21.4", + "binary_url": "https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-x86_64-linux-gnu.tar.gz", + "verification_type": "gpg", + "verification_source": "https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-x86_64-linux-gnu.tar.gz.asc", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/litecoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/litecoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "whitelist": "127.0.0.1" + }, + "platforms": { + "arm64": { + "binary_url": "https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-aarch64-linux-gnu.tar.gz", + "verification_source": "https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-aarch64-linux-gnu.tar.gz.asc" + } + } + }, + "blockbook": { + "package_name": "blockbook-litecoin", + "system_user": "blockbook-litecoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 27108450, + "xpub_magic_segwit_p2sh": 28471030, + "xpub_magic_segwit_native": 78792518, + "slip44": 2, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"litecoin\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/litecoin_testnet.json b/configs/coins/litecoin_testnet.json index fb23dbde04..8a15853335 100644 --- a/configs/coins/litecoin_testnet.json +++ b/configs/coins/litecoin_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-litecoin-testnet", "package_revision": "satoshilabs-1", "system_user": "litecoin", - "version": "0.21.2.1", - "binary_url": "https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-x86_64-linux-gnu.tar.gz", + "version": "0.21.4", + "binary_url": "https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-x86_64-linux-gnu.tar.gz", "verification_type": "gpg", - "verification_source": "https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-x86_64-linux-gnu.tar.gz.asc", + "verification_source": "https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-x86_64-linux-gnu.tar.gz.asc", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/litecoin-qt" @@ -44,8 +45,8 @@ }, "platforms": { "arm64": { - "binary_url": "https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-aarch64-linux-gnu.tar.gz", - "verification_source": "https://download.litecoin.org/litecoin-0.21.2.1/linux/litecoin-0.21.2.1-aarch64-linux-gnu.tar.gz.asc" + "binary_url": "https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-aarch64-linux-gnu.tar.gz", + "verification_source": "https://download.litecoin.org/litecoin-0.21.4/linux/litecoin-0.21.4-aarch64-linux-gnu.tar.gz.asc" } } }, diff --git a/configs/coins/monacoin.json b/configs/coins/monacoin.json index 7cf2715ac8..615ab0c55a 100644 --- a/configs/coins/monacoin.json +++ b/configs/coins/monacoin.json @@ -1,71 +1,72 @@ { - "coin": { - "name": "Monacoin", - "shortcut": "MONA", - "label": "Monacoin", - "alias": "monacoin" - }, - "ports": { - "backend_rpc": 8041, - "backend_message_queue": 38341, - "blockbook_internal": 9041, - "blockbook_public": 9141 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-monacoin", - "package_revision": "satoshilabs-1", - "system_user": "monacoin", - "version": "0.20.3", - "binary_url": "https://github.com/monacoinproject/monacoin/releases/download/v0.20.3/monacoin-0.20.3-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/monacoinproject/monacoin/releases/download/v0.20.3/monacoin-0.20.3-signatures.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/monacoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/monacoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "whitelist": "127.0.0.1" + "coin": { + "name": "Monacoin", + "shortcut": "MONA", + "label": "Monacoin", + "alias": "monacoin" + }, + "ports": { + "backend_rpc": 8041, + "backend_message_queue": 38341, + "blockbook_internal": 9041, + "blockbook_public": 9141 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-monacoin", + "package_revision": "satoshilabs-1", + "system_user": "monacoin", + "version": "0.20.4", + "binary_url": "https://github.com/monacoinproject/monacoin/releases/download/v0.20.4/monacoin-0.20.4-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "94f8fe7400d23a9bad10af3dfc3f800e333be0aa4d61e5c8cfc5f338253d9451", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/monacoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/monacoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "whitelist": "127.0.0.1" + } + }, + "blockbook": { + "package_name": "blockbook-monacoin", + "system_user": "blockbook-monacoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "xpub_magic_segwit_p2sh": 77429938, + "xpub_magic_segwit_native": 78792518, + "slip44": 22, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"monacoin\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "wakiyamap", + "package_maintainer_email": "wakiyamap@gmail.com" } - }, - "blockbook": { - "package_name": "blockbook-monacoin", - "system_user": "blockbook-monacoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "xpub_magic_segwit_p2sh": 77429938, - "xpub_magic_segwit_native": 78792518, - "slip44": 22, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"monacoin\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "wakiyamap", - "package_maintainer_email": "wakiyamap@gmail.com" - } } diff --git a/configs/coins/monacoin_testnet.json b/configs/coins/monacoin_testnet.json index c867057e7e..08c2d29a28 100644 --- a/configs/coins/monacoin_testnet.json +++ b/configs/coins/monacoin_testnet.json @@ -1,69 +1,70 @@ { - "coin": { - "name": "Monacoin Testnet", - "shortcut": "TMONA", - "label": "Monacoin Testnet", - "alias": "monacoin_testnet" - }, - "ports": { - "backend_rpc": 18041, - "backend_message_queue": 48341, - "blockbook_internal": 19041, - "blockbook_public": 19141 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-monacoin-testnet", - "package_revision": "satoshilabs-1", - "system_user": "monacoin", - "version": "0.20.3", - "binary_url": "https://github.com/monacoinproject/monacoin/releases/download/v0.20.3/monacoin-0.20.3-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/monacoinproject/monacoin/releases/download/v0.20.3/monacoin-0.20.3-signatures.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [ - "bin/monacoin-qt" - ], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/monacoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet4/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "server_config_file": "bitcoin.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "whitelist": "127.0.0.1" + "coin": { + "name": "Monacoin Testnet", + "shortcut": "TMONA", + "label": "Monacoin Testnet", + "alias": "monacoin_testnet" + }, + "ports": { + "backend_rpc": 18041, + "backend_message_queue": 48341, + "blockbook_internal": 19041, + "blockbook_public": 19141 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-monacoin-testnet", + "package_revision": "satoshilabs-1", + "system_user": "monacoin", + "version": "0.20.4", + "binary_url": "https://github.com/monacoinproject/monacoin/releases/download/v0.20.4/monacoin-0.20.4-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "94f8fe7400d23a9bad10af3dfc3f800e333be0aa4d61e5c8cfc5f338253d9451", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": [ + "bin/monacoin-qt" + ], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/monacoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/testnet4/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "bitcoin.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "whitelist": "127.0.0.1" + } + }, + "blockbook": { + "package_name": "blockbook-monacoin-testnet", + "system_user": "blockbook-monacoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 1, + "additional_params": {} + } + }, + "meta": { + "package_maintainer": "wakiyamap", + "package_maintainer_email": "wakiyamap@gmail.com" } - }, - "blockbook": { - "package_name": "blockbook-monacoin-testnet", - "system_user": "blockbook-monacoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 70617039, - "xpub_magic_segwit_p2sh": 71979618, - "xpub_magic_segwit_native": 73342198, - "slip44": 1, - "additional_params": {} - } - }, - "meta": { - "package_maintainer": "wakiyamap", - "package_maintainer_email": "wakiyamap@gmail.com" - } } diff --git a/configs/coins/monetaryunit.json b/configs/coins/monetaryunit.json index 1a067e8333..a3aeb74d87 100644 --- a/configs/coins/monetaryunit.json +++ b/configs/coins/monetaryunit.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "monetaryunitrpc", "rpc_timeout": 25, diff --git a/configs/coins/myriad.json b/configs/coins/myriad.json index bf74a40e1e..c1809599a8 100644 --- a/configs/coins/myriad.json +++ b/configs/coins/myriad.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/namecoin.json b/configs/coins/namecoin.json index d9675283c5..ce1422519f 100644 --- a/configs/coins/namecoin.json +++ b/configs/coins/namecoin.json @@ -1,74 +1,75 @@ { - "coin": { - "name": "Namecoin", - "shortcut": "NMC", - "label": "Namecoin", - "alias": "namecoin" - }, - "ports": { - "backend_rpc": 8039, - "backend_message_queue": 38339, - "blockbook_internal": 9039, - "blockbook_public": 9139 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-namecoin", - "package_revision": "satoshilabs-1", - "system_user": "namecoin", - "version": "0.21.0.1", - "binary_url": "https://www.namecoin.org/files/namecoin-core/namecoin-core-0.21.0.1/namecoin-nc0.21.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "1e7f06030881fac5b8a6d33f497f1cab9a120189741ec81bc21e58d5cd93fa6f", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/namecoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/namecoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "addnode": ["45.24.110.177:8334"], - "discover": 0, - "listenonion": 0, - "upnp": 0, - "whitelist": "127.0.0.1", - "whitelistrelay": 1 + "coin": { + "name": "Namecoin", + "shortcut": "NMC", + "label": "Namecoin", + "alias": "namecoin" + }, + "ports": { + "backend_rpc": 8039, + "backend_message_queue": 38339, + "blockbook_internal": 9039, + "blockbook_public": 9139 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-namecoin", + "package_revision": "satoshilabs-1", + "system_user": "namecoin", + "version": "0.21.0.1", + "binary_url": "https://www.namecoin.org/files/namecoin-core/namecoin-core-0.21.0.1/namecoin-nc0.21.0.1-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "1e7f06030881fac5b8a6d33f497f1cab9a120189741ec81bc21e58d5cd93fa6f", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/namecoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/namecoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "addnode": ["45.24.110.177:8334"], + "discover": 0, + "listenonion": 0, + "upnp": 0, + "whitelist": "127.0.0.1", + "whitelistrelay": 1 + } + }, + "blockbook": { + "package_name": "blockbook-namecoin", + "system_user": "blockbook-namecoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "slip44": 7, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"namecoin\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT Admin", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-namecoin", - "system_user": "blockbook-namecoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "slip44": 7, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"namecoin\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "IT Admin", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/nuls.json b/configs/coins/nuls.json index 9cd2de33d7..217dd65252 100644 --- a/configs/coins/nuls.json +++ b/configs/coins/nuls.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -64,4 +65,4 @@ "package_maintainer": "NULS Core Team", "package_maintainer_email": "ln@nuls.io" } -} \ No newline at end of file +} diff --git a/configs/coins/omotenashicoin.json b/configs/coins/omotenashicoin.json index 4a27eab55e..1040692cf5 100644 --- a/configs/coins/omotenashicoin.json +++ b/configs/coins/omotenashicoin.json @@ -1,69 +1,70 @@ { - "coin": { - "name": "Omotenashicoin", - "shortcut": "MTNS", - "label": "Omotenashicoin", - "alias": "omotenashicoin" - }, - "ports": { - "blockbook_internal": 9094, - "blockbook_public": 9194, - "backend_rpc": 8094, - "backend_message_queue": 38394 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "mtnsrpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-mtns", - "package_revision": "satoshilabs-1", - "system_user": "mtns", - "version": "1.7.3", - "binary_url": "https://github.com/omotenashicoin-project/OmotenashiCoin-HDwalletbinaries/raw/master/stable/omotenashicoin-x86_64-linux-gnu.tar.gz", - "verification_type": "", - "verification_source": "", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/omotenashicoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/omotenashicoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": false, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "whitelist": "127.0.0.1" + "coin": { + "name": "Omotenashicoin", + "shortcut": "MTNS", + "label": "Omotenashicoin", + "alias": "omotenashicoin" + }, + "ports": { + "blockbook_internal": 9094, + "blockbook_public": 9194, + "backend_rpc": 8094, + "backend_message_queue": 38394 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "mtnsrpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-mtns", + "package_revision": "satoshilabs-1", + "system_user": "mtns", + "version": "1.7.3", + "binary_url": "https://github.com/omotenashicoin-project/OmotenashiCoin-HDwalletbinaries/raw/master/stable/omotenashicoin-x86_64-linux-gnu.tar.gz", + "verification_type": "", + "verification_source": "", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/omotenashicoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/omotenashicoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": false, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "whitelist": "127.0.0.1" + } + }, + "blockbook": { + "package_name": "blockbook-mtns", + "system_user": "blockbook-mtns", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 61052245, + "slip44": 341, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"omotenashicoin\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "omotenashicoin dev", + "package_maintainer_email": "git@omotenashicoin.site" } - }, - "blockbook": { - "package_name": "blockbook-mtns", - "system_user": "blockbook-mtns", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 61052245, - "slip44": 341, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"omotenashicoin\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "omotenashicoin dev", - "package_maintainer_email": "git@omotenashicoin.site" - } } diff --git a/configs/coins/omotenashicoin_testnet.json b/configs/coins/omotenashicoin_testnet.json index 993d3abe34..9e160ddd31 100644 --- a/configs/coins/omotenashicoin_testnet.json +++ b/configs/coins/omotenashicoin_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "mtnsrpc", "rpc_timeout": 25, diff --git a/configs/coins/optimism.json b/configs/coins/optimism.json new file mode 100644 index 0000000000..8cd1a4cd0c --- /dev/null +++ b/configs/coins/optimism.json @@ -0,0 +1,69 @@ +{ + "coin": { + "name": "Optimism", + "shortcut": "ETH", + "network": "OP", + "label": "Optimism", + "alias": "optimism" + }, + "ports": { + "backend_rpc": 8200, + "backend_p2p": 38400, + "backend_http": 8300, + "backend_authrpc": 8400, + "blockbook_internal": 9200, + "blockbook_public": 9300 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-optimism", + "package_revision": "satoshilabs-1", + "system_user": "optimism", + "version": "1.101315.1", + "binary_url": "https://github.com/ethereum-optimism/op-geth/archive/refs/tags/v1.101315.1.tar.gz", + "verification_type": "sha256", + "verification_source": "f0f31ef2982f87f9e3eb90f2b603f5fcd9d680e487d35f5bdcf5aeba290b153f", + "extract_command": "mkdir backend/source && tar -C backend/source --strip 1 -xf v1.101315.1.tar.gz && cd backend/source && make geth && mv build/bin/geth ../ && rm -rf ../source && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/optimism_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "optimism.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "openssl rand -hex 32 > {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/jwtsecret", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-optimism", + "system_user": "blockbook-optimism", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "averageBlockTimeMs": 2000, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"optimistic-ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/optimism_archive.json b/configs/coins/optimism_archive.json new file mode 100644 index 0000000000..9fe5fe418d --- /dev/null +++ b/configs/coins/optimism_archive.json @@ -0,0 +1,77 @@ +{ + "coin": { + "name": "Optimism Archive", + "shortcut": "ETH", + "network": "OP", + "label": "Optimism", + "alias": "optimism_archive", + "test_name": "optimism" + }, + "ports": { + "backend_rpc": 8202, + "backend_p2p": 38402, + "backend_http": 8302, + "backend_authrpc": 8402, + "blockbook_internal": 9202, + "blockbook_public": 9302 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-optimism-archive", + "package_revision": "satoshilabs-1", + "system_user": "optimism", + "version": "1.101315.1", + "binary_url": "https://github.com/ethereum-optimism/op-geth/archive/refs/tags/v1.101315.1.tar.gz", + "verification_type": "sha256", + "verification_source": "f0f31ef2982f87f9e3eb90f2b603f5fcd9d680e487d35f5bdcf5aeba290b153f", + "extract_command": "mkdir backend/source && tar -C backend/source --strip 1 -xf v1.101315.1.tar.gz && cd backend/source && make geth && mv build/bin/geth ../ && rm -rf ../source && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/optimism_archive_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "optimism_archive.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "openssl rand -hex 32 > {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/jwtsecret", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-optimism-archive", + "system_user": "blockbook-optimism", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=1509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 600, + "additional_params": { + "averageBlockTimeMs": 2000, + "address_aliases": true, + "eip1559Fees": true, + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/10/suggestedGasFees\", \"periodSeconds\": 60}", + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "20s", + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"ethereum\",\"platformIdentifier\": \"optimistic-ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/optimism_archive_legacy_geth.json b/configs/coins/optimism_archive_legacy_geth.json new file mode 100644 index 0000000000..7a9379d95f --- /dev/null +++ b/configs/coins/optimism_archive_legacy_geth.json @@ -0,0 +1,40 @@ +{ + "coin": { + "name": "Optimism Archive Legacy Geth", + "shortcut": "ETH", + "label": "Optimism", + "alias": "optimism_archive_legacy_geth" + }, + "ports": { + "backend_rpc": 8204, + "backend_http": 8304, + "backend_p2p": 38404, + "blockbook_internal": 9204, + "blockbook_public": 9304 + }, + "backend": { + "package_name": "backend-optimism-archive-legacy-geth", + "package_revision": "satoshilabs-1", + "system_user": "optimism", + "version": "0.5.31", + "binary_url": "https://github.com/ethereum-optimism/optimism-legacy/archive/refs/heads/develop.zip", + "verification_type": "sha256", + "verification_source": "367b32b3f4c1450a57fa57650a0abdfb74ae58c09123d94b161aaec90fd6b883", + "extract_command": "mkdir backend/source && unzip -d backend/source", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/optimism_archive_legacy_geth_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "optimism_archive_legacy_geth.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "cd {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/source/optimism-legacy-devlop/l2geth && make geth && mv build/bin/geth {{.Env.BackendInstallPath}}/{{.Coin.Alias}} && rm -rf {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/source", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} \ No newline at end of file diff --git a/configs/coins/optimism_archive_op_node.json b/configs/coins/optimism_archive_op_node.json new file mode 100644 index 0000000000..a16c412246 --- /dev/null +++ b/configs/coins/optimism_archive_op_node.json @@ -0,0 +1,38 @@ +{ + "coin": { + "name": "Optimism Archive Op-Node", + "shortcut": "ETH", + "label": "Optimism", + "alias": "optimism_archive_op_node" + }, + "ports": { + "backend_rpc": 8203, + "blockbook_internal": 9203, + "blockbook_public": 9303 + }, + "backend": { + "package_name": "backend-optimism-archive-op-node", + "package_revision": "satoshilabs-1", + "system_user": "optimism", + "version": "1.7.6", + "binary_url": "https://github.com/ethereum-optimism/optimism/archive/refs/tags/op-node/v1.7.6.tar.gz", + "verification_type": "sha256", + "verification_source": "91384e4834f0d0776d1c3e19613b5c50a904f6e5814349e444d42d9e8be5a7ab", + "extract_command": "mkdir backend/source && tar -C backend/source --strip 1 -xf v1.7.6.tar.gz && cd backend/source/op-node && go build -o ../../op-node ./cmd && rm -rf ../../source && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/optimism_archive_op_node_exec.sh 2>&1 >> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "optimism_archive_op_node.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} \ No newline at end of file diff --git a/configs/coins/optimism_op_node.json b/configs/coins/optimism_op_node.json new file mode 100644 index 0000000000..e2b6cc1740 --- /dev/null +++ b/configs/coins/optimism_op_node.json @@ -0,0 +1,38 @@ +{ + "coin": { + "name": "Optimism Op-Node", + "shortcut": "ETH", + "label": "Optimism", + "alias": "optimism_op_node" + }, + "ports": { + "backend_rpc": 8201, + "blockbook_internal": 9201, + "blockbook_public": 9301 + }, + "backend": { + "package_name": "backend-optimism-op-node", + "package_revision": "satoshilabs-1", + "system_user": "optimism", + "version": "1.7.6", + "binary_url": "https://github.com/ethereum-optimism/optimism/archive/refs/tags/op-node/v1.7.6.tar.gz", + "verification_type": "sha256", + "verification_source": "91384e4834f0d0776d1c3e19613b5c50a904f6e5814349e444d42d9e8be5a7ab", + "extract_command": "mkdir backend/source && tar -C backend/source --strip 1 -xf v1.7.6.tar.gz && cd backend/source/op-node && go build -o ../../op-node ./cmd && rm -rf ../../source && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/optimism_op_node_exec.sh 2>&1 >> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "optimism_op_node.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} \ No newline at end of file diff --git a/configs/coins/pivx.json b/configs/coins/pivx.json index 96d1531f32..57b83e2077 100644 --- a/configs/coins/pivx.json +++ b/configs/coins/pivx.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "pivxrpc", "rpc_timeout": 25, @@ -22,19 +23,19 @@ "package_name": "backend-pivx", "package_revision": "satoshilabs-1", "system_user": "pivx", - "version": "4.0.0", - "binary_url": "https://github.com/PIVX-Project/PIVX/releases/download/v4.0.0/pivx-4.0.0-x86_64-linux-gnu.tar.gz", + "version": "5.6.1", + "binary_url": "https://github.com/PIVX-Project/PIVX/releases/download/v5.6.1/pivx-5.6.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "6cb1f608ec0e106ea6bbb455ec8b85c7cad05ca52ab43011d3db80557816b79e", + "verification_source": "6704625c63ff73da8c57f0fbb1dab6f1e4bd8f62c17467e05f52a64012a0ee2f", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/pivx-qt" ], "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/pivxd -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", + "postinst_script_template": "cd {{.Env.BackendInstallPath}}/{{.Coin.Alias}} && HOME={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/install-params.sh", "service_type": "forking", - "service_additional_params_template": "", + "service_additional_params_template": "Environment=\"HOME={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend\"", "protect_memory": false, "mainnet": true, "server_config_file": "bitcoin_like.conf", @@ -64,4 +65,4 @@ "package_maintainer": "rikardwissing", "package_maintainer_email": "rikard@coinid.org" } -} \ No newline at end of file +} diff --git a/configs/coins/pivx_testnet.json b/configs/coins/pivx_testnet.json index 325700d2a5..334f7f4ab5 100644 --- a/configs/coins/pivx_testnet.json +++ b/configs/coins/pivx_testnet.json @@ -13,19 +13,20 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "pivxrpc", "rpc_timeout": 25, "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" }, "backend": { - "package_name": "backend-pivx", + "package_name": "backend-pivx-testnet", "package_revision": "satoshilabs-1", "system_user": "pivx", - "version": "4.0.0", - "binary_url": "https://github.com/PIVX-Project/PIVX/releases/download/v4.0.0/pivx-4.0.0-x86_64-linux-gnu.tar.gz", + "version": "5.6.1", + "binary_url": "https://github.com/PIVX-Project/PIVX/releases/download/v5.6.1/pivx-5.6.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "6cb1f608ec0e106ea6bbb455ec8b85c7cad05ca52ab43011d3db80557816b79e", + "verification_source": "6704625c63ff73da8c57f0fbb1dab6f1e4bd8f62c17467e05f52a64012a0ee2f", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/pivx-qt" @@ -64,4 +65,4 @@ "package_maintainer": "PIVX team", "package_maintainer_email": "random.zebra@protonmail.com" } -} \ No newline at end of file +} diff --git a/configs/coins/polis.json b/configs/coins/polis.json index ae69c7fb26..cc10a1294d 100644 --- a/configs/coins/polis.json +++ b/configs/coins/polis.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/polygon.json b/configs/coins/polygon.json new file mode 100644 index 0000000000..17b8388037 --- /dev/null +++ b/configs/coins/polygon.json @@ -0,0 +1,74 @@ +{ + "coin": { + "name": "Polygon", + "shortcut": "POL", + "network": "POL", + "label": "Polygon", + "alias": "polygon_bor" + }, + "ports": { + "backend_rpc": 8070, + "backend_p2p": 38370, + "backend_http": 8170, + "blockbook_internal": 9070, + "blockbook_public": 9170 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-polygon-bor", + "package_revision": "satoshilabs-1", + "system_user": "polygon", + "version": "2.2.9", + "binary_url": "https://github.com/maticnetwork/bor/releases/download/v2.2.9/bor-v2.2.9-amd64.deb", + "verification_type": "sha256", + "verification_source": "8125ae8f2c5e2485ba112e065bcbfa40468a113a41a3dfa34871dd239fd12f6e", + "extract_command": "mkdir -p backend && dpkg --fsys-tarfile ${ARCHIVE} | tar -xO ./usr/bin/bor > backend/bor && chmod +x backend/bor && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/polygon_bor_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "polygon_bor.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "wget https://raw.githubusercontent.com/maticnetwork/bor/v2.2.9/builder/files/genesis-mainnet-v1.json -O {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/genesis.json", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/maticnetwork/bor/releases/download/v2.2.9/bor-v2.2.9-arm64.deb", + "verification_source": "344bbd01a230250a43373ee559cb596bc8afb95026ce4aa9652c46077740414f" + } + } + }, + "blockbook": { + "package_name": "blockbook-polygon-bor", + "system_user": "blockbook-polygon", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "additional_params": { + "averageBlockTimeMs": 2000, + "mempoolTxTimeoutHours": 12, + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"matic-network\",\"platformIdentifier\": \"polygon-pos\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/polygon_archive.json b/configs/coins/polygon_archive.json new file mode 100644 index 0000000000..9b5c8961c4 --- /dev/null +++ b/configs/coins/polygon_archive.json @@ -0,0 +1,82 @@ +{ + "coin": { + "name": "Polygon Archive", + "shortcut": "POL", + "network": "POL", + "label": "Polygon", + "alias": "polygon_archive_bor", + "test_name": "polygon" + }, + "ports": { + "backend_rpc": 8072, + "backend_p2p": 38372, + "backend_http": 8172, + "blockbook_internal": 9072, + "blockbook_public": 9172 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_timeout": 25 + }, + "backend": { + "package_name": "backend-polygon-archive-bor", + "package_revision": "satoshilabs-1", + "system_user": "polygon", + "version": "2.2.9", + "binary_url": "https://github.com/maticnetwork/bor/releases/download/v2.2.9/bor-v2.2.9-amd64.deb", + "verification_type": "sha256", + "verification_source": "8125ae8f2c5e2485ba112e065bcbfa40468a113a41a3dfa34871dd239fd12f6e", + "extract_command": "mkdir -p backend && dpkg --fsys-tarfile ${ARCHIVE} | tar -xO ./usr/bin/bor > backend/bor && chmod +x backend/bor && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/polygon_archive_bor_exec.sh 2>> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "polygon_archive_bor.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "wget https://raw.githubusercontent.com/maticnetwork/bor/v2.2.9/builder/files/genesis-mainnet-v1.json -O {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/genesis.json", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "", + "platforms": { + "arm64": { + "binary_url": "https://github.com/maticnetwork/bor/releases/download/v2.2.9/bor-v2.2.9-arm64.deb", + "verification_source": "344bbd01a230250a43373ee559cb596bc8afb95026ce4aa9652c46077740414f" + } + } + }, + "blockbook": { + "package_name": "blockbook-polygon-archive-bor", + "system_user": "blockbook-polygon", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-workers=16 -resyncindexdebounce=1509", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 600, + "additional_params": { + "averageBlockTimeMs": 2000, + "address_aliases": true, + "eip1559Fees": true, + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/137/suggestedGasFees\", \"periodSeconds\": 60}", + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "20s", + "queryBackendOnMempoolResync": false, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"matic-network\",\"platformIdentifier\": \"polygon-pos\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/polygon_heimdall.json b/configs/coins/polygon_heimdall.json new file mode 100644 index 0000000000..7c05497569 --- /dev/null +++ b/configs/coins/polygon_heimdall.json @@ -0,0 +1,39 @@ +{ + "coin": { + "name": "Polygon Heimdall", + "shortcut": "MATIC", + "label": "Polygon", + "alias": "polygon_heimdall" + }, + "ports": { + "backend_rpc": 8071, + "backend_p2p": 38371, + "backend_http": 8171, + "blockbook_internal": 9071, + "blockbook_public": 9171 + }, + "backend": { + "package_name": "backend-polygon-heimdall", + "package_revision": "satoshilabs-1", + "system_user": "polygon", + "version": "0.2.16", + "binary_url": "https://github.com/0xPolygon/heimdall-v2/releases/download/v0.2.16/heimdall-v0.2.16-amd64.deb", + "verification_type": "sha256", + "verification_source": "1682bade3065065a4b660a162e06c843b4a3079af829cec300a05e9577c9389b", + "extract_command": "mkdir -p backend && dpkg --fsys-tarfile ${ARCHIVE} | tar -xO ./usr/bin/heimdalld > backend/heimdalld && chmod +x backend/heimdalld && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/polygon_heimdall_exec.sh 2>&1 >> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "polygon_heimdall.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/polygon_heimdall_archive.json b/configs/coins/polygon_heimdall_archive.json new file mode 100644 index 0000000000..96826703db --- /dev/null +++ b/configs/coins/polygon_heimdall_archive.json @@ -0,0 +1,39 @@ +{ + "coin": { + "name": "Polygon Archive Heimdall", + "shortcut": "MATIC", + "label": "Polygon", + "alias": "polygon_archive_heimdall" + }, + "ports": { + "backend_rpc": 8073, + "backend_p2p": 38373, + "backend_http": 8173, + "blockbook_internal": 9073, + "blockbook_public": 9173 + }, + "backend": { + "package_name": "backend-polygon-archive-heimdall", + "package_revision": "satoshilabs-1", + "system_user": "polygon", + "version": "0.2.16", + "binary_url": "https://github.com/0xPolygon/heimdall-v2/releases/download/v0.2.16/heimdall-v0.2.16-amd64.deb", + "verification_type": "sha256", + "verification_source": "1682bade3065065a4b660a162e06c843b4a3079af829cec300a05e9577c9389b", + "extract_command": "mkdir -p backend && dpkg --fsys-tarfile ${ARCHIVE} | tar -xO ./usr/bin/heimdalld > backend/heimdalld && chmod +x backend/heimdalld && echo", + "exclude_files": [], + "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/polygon_archive_heimdall_exec.sh 2>&1 >> {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log'", + "exec_script": "polygon_archive_heimdall.sh", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/qtum.json b/configs/coins/qtum.json index f8383ded24..207f9a11f9 100644 --- a/configs/coins/qtum.json +++ b/configs/coins/qtum.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-qtum", "package_revision": "satoshilabs-1", "system_user": "qtum", - "version": "22.1", - "binary_url": "https://github.com/qtumproject/qtum/releases/download/v22.1/qtum-22.1-x86_64-linux-gnu.tar.gz", + "version": "29.1", + "binary_url": "https://github.com/qtumproject/qtum/releases/download/v29.1/qtum-29.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "34f2c6ca10026cc1600cfb3fbc1e606b7f163a15d98781866be6fc34e7269ea0", + "verification_source": "c04e3f49c8e21a7c910b2373f9a540794eca262c83a5afbe040e38b3f5b2da4b", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/qtum-qt" diff --git a/configs/coins/qtum_testnet.json b/configs/coins/qtum_testnet.json index a374c8a493..ecd9084346 100644 --- a/configs/coins/qtum_testnet.json +++ b/configs/coins/qtum_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-qtum-testnet", "package_revision": "satoshilabs-1", "system_user": "qtum", - "version": "22.1", - "binary_url": "https://github.com/qtumproject/qtum/releases/download/v22.1/qtum-22.1-x86_64-linux-gnu.tar.gz", + "version": "29.1", + "binary_url": "https://github.com/qtumproject/qtum/releases/download/v29.1/qtum-29.1-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "34f2c6ca10026cc1600cfb3fbc1e606b7f163a15d98781866be6fc34e7269ea0", + "verification_source": "c04e3f49c8e21a7c910b2373f9a540794eca262c83a5afbe040e38b3f5b2da4b", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/qtum-qt" diff --git a/configs/coins/ravencoin.json b/configs/coins/ravencoin.json index 5fe9543c67..bf7d0c75f5 100644 --- a/configs/coins/ravencoin.json +++ b/configs/coins/ravencoin.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-ravencoin", "package_revision": "satoshilabs-1", "system_user": "ravencoin", - "version": "4.2.1.0", - "binary_url": "https://github.com/RavenProject/Ravencoin/releases/download/v4.2.1/raven-4.2.1.0-x86_64-linux-gnu.tar.gz", + "version": "4.6.1.0", + "binary_url": "https://github.com/RavenProject/Ravencoin/releases/download/v4.6.1/raven-4.6.1-7864c39c2-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "5a86f806e2444c6e6d612fd315f3a1369521fe50863617d5f52c3b1c1e70af76", + "verification_source": "6c6ac6382cf594b218ec50dd9662892dc2d9a493ce151acb2d7feb500436c197", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/raven-qt" diff --git a/configs/coins/ritocoin.json b/configs/coins/ritocoin.json index 5e71ecb484..1b96237f79 100644 --- a/configs/coins/ritocoin.json +++ b/configs/coins/ritocoin.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/snowgem.json b/configs/coins/snowgem.json index 0550ae346d..e89fa7fb2d 100644 --- a/configs/coins/snowgem.json +++ b/configs/coins/snowgem.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, diff --git a/configs/coins/trezarcoin.json b/configs/coins/trezarcoin.json index 83fe5e5452..11de15844b 100644 --- a/configs/coins/trezarcoin.json +++ b/configs/coins/trezarcoin.json @@ -1,69 +1,70 @@ { - "coin": { - "name": "Trezarcoin", - "shortcut": "TZC", - "label": "Trezarcoin", - "alias": "trezarcoin" - }, - "ports": { - "backend_rpc": 8096, - "backend_message_queue": 38396, - "blockbook_internal": 9096, - "blockbook_public": 9196 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-trezarcoin", - "package_revision": "satoshilabs-1", - "system_user": "trezarcoin", - "version": "2.1.1", - "binary_url": "https://github.com/TrezarCoin/TrezarCoin/releases/download/v2.1.1.0/trezarcoin-2.1.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "4b41c4fecf36a870d6bb7298d85b211f61d9f2bcc6c1bef3167f3ef772bc6fdf", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/trezarcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/trezarcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "whitelist": "127.0.0.1" + "coin": { + "name": "Trezarcoin", + "shortcut": "TZC", + "label": "Trezarcoin", + "alias": "trezarcoin" + }, + "ports": { + "backend_rpc": 8096, + "backend_message_queue": 38396, + "blockbook_internal": 9096, + "blockbook_public": 9196 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-trezarcoin", + "package_revision": "satoshilabs-1", + "system_user": "trezarcoin", + "version": "2.1.1", + "binary_url": "https://github.com/TrezarCoin/TrezarCoin/releases/download/v2.1.1.0/trezarcoin-2.1.1-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "4b41c4fecf36a870d6bb7298d85b211f61d9f2bcc6c1bef3167f3ef772bc6fdf", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/trezarcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/trezarcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "whitelist": "127.0.0.1" + } + }, + "blockbook": { + "package_name": "blockbook-trezarcoin", + "system_user": "blockbook-trezarcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 27108450, + "slip44": 232, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"trezarcoin\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-trezarcoin", - "system_user": "blockbook-trezarcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 27108450, - "slip44": 232, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"trezarcoin\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/tron.json b/configs/coins/tron.json new file mode 100644 index 0000000000..c53945cbb9 --- /dev/null +++ b/configs/coins/tron.json @@ -0,0 +1,68 @@ +{ + "coin": { + "name": "Tron", + "shortcut": "TRX", + "label": "Tron", + "alias": "tron" + }, + "ports": { + "backend_rpc": 8545, + "backend_message_queue": 5555, + "backend_p2p": 1111, + "blockbook_internal": 9212, + "blockbook_public": 9312 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}/jsonrpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-tron", + "package_revision": "latest", + "system_user": "tron", + "version": "4.7.7", + "binary_url": "https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.7.7/FullNode.jar", + "verification_type": "sha256", + "verification_source": "d41a5ddec03c3f9647f46ed443129688c091803ae91b0c61e685180da418316e", + "extract_command": "mv ${ARCHIVE} backend/ && wget -q https://raw.githubusercontent.com/tronprotocol/tron-deployment/master/main_net_config.conf -O main_net_config.conf && sed -i 's/^[ \\t]*#*[ \\t]*httpFullNodeEnable.*/httpFullNodeEnable = true/' main_net_config.conf && sed -i 's/^[ \\t]*#*[ \\t]*httpFullNodePort.*/httpFullNodePort = 8545/' main_net_config.conf && sed -i '/triggerName *= *\"block\"/{n;s/enable *= *.*/enable = true/;}' main_net_config.conf && sed -i 's/^[ \\t]*supportConstant[ \\t]*=[ \\t]*false/supportConstant = true/' main_net_config.conf && mv main_net_config.conf backend/ && echo ", + "exclude_files": [], + "exec_command_template": "/usr/bin/java -Xms32G -Xmx32G -XX:ReservedCodeCacheSize=256m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1G -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/gc.log -XX:+UseConcMarkSweepGC -XX:NewRatio=2 -XX:+CMSScavengeBeforeRemark -XX:+ParallelRefProcEnabled -XX:+HeapDumpOnOutOfMemoryError -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -jar {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/FullNode.jar --es --output-directory {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/output-directory -c {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/main_net_config.conf", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "mkdir -p {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/logs && chown {{.Backend.SystemUser}}:{{.Backend.SystemUser}} {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/logs", + "service_type": "simple", + "service_additional_params_template": "StandardOutput=append:{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log\nStandardError=inherit", + "protect_memory": false, + "mainnet": true, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-tron", + "system_user": "blockbook-tron", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 0, + "mempool_sub_workers": 0, + "block_addresses_to_keep": 10000, + "additional_params": { + "address_aliases": true, + "mempoolTxTimeoutHours": 4, + "queryBackendOnMempoolResync": true, + "averageBlockTimeMs": 3000, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "USD,EUR,CNY", + "fiat_rates_params": "{\"coin\": \"tron\",\"platformIdentifier\": \"tron\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/tron_testnet_nile.json b/configs/coins/tron_testnet_nile.json new file mode 100644 index 0000000000..12cba39282 --- /dev/null +++ b/configs/coins/tron_testnet_nile.json @@ -0,0 +1,69 @@ +{ + "coin": { + "name": "Tron Testnet Nile", + "network": "tTRX", + "shortcut": "tTRX", + "label": "Tron Nile", + "alias": "tron_testnet_nile" + }, + "ports": { + "backend_rpc": 8545, + "backend_message_queue": 5555, + "backend_p2p": 18888, + "blockbook_internal": 19090, + "blockbook_public": 19190 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}/jsonrpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-tron-testnet-nile", + "package_revision": "latest", + "system_user": "tron", + "version": "4.8.1-build4", + "binary_url": "https://github.com/tron-nile-testnet/nile-testnet/releases/download/GreatVoyage-Nile-v4.8.1-build4/FullNode-Nile-x64-4.8.1-build4.jar", + "verification_type": "sha256", + "verification_source": "fe71b6ea77d4506ff53cb34a8d8c0d8230bace2d4ab1c79bc32e4458c6531878", + "extract_command": "mv ${ARCHIVE} backend/ && wget -q https://raw.githubusercontent.com/tron-nile-testnet/nile-testnet/refs/heads/master/framework/src/main/resources/config-nile.conf -O test_net_config.conf && sed -i 's/^[ \\t]*#*[ \\t]*httpFullNodeEnable.*/httpFullNodeEnable = true/' test_net_config.conf && sed -i 's/^[ \\t]*#*[ \\t]*httpFullNodePort.*/httpFullNodePort = 8545/' test_net_config.conf && sed -i '/triggerName *= *\"block\"/{n;s/enable *= *.*/enable = true/;}' test_net_config.conf && sed -i 's/^[ \\t]*supportConstant[ \\t]*=[ \\t]*false/supportConstant = true/' test_net_config.conf && mv test_net_config.conf backend/ && echo ", + "exclude_files": [], + "exec_command_template": "/usr/bin/java -Xms9G -Xmx16G -XX:ReservedCodeCacheSize=256m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1G -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/gc.log -XX:+UseConcMarkSweepGC -XX:NewRatio=2 -XX:+CMSScavengeBeforeRemark -XX:+ParallelRefProcEnabled -XX:+HeapDumpOnOutOfMemoryError -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=70 -jar {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/FullNode-Nile-x64-4.8.1-build4.jar --es --output-directory {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/output-directory -c {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/test_net_config.conf", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log", + "postinst_script_template": "mkdir -p {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/logs && chown {{.Backend.SystemUser}}:{{.Backend.SystemUser}} {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/logs", + "service_type": "simple", + "service_additional_params_template": "StandardOutput=append:{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/{{.Coin.Alias}}.log\nStandardError=inherit", + "protect_memory": false, + "mainnet": false, + "server_config_file": "", + "client_config_file": "" + }, + "blockbook": { + "package_name": "blockbook-tron-testnet-nile", + "system_user": "blockbook-tron", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 0, + "mempool_sub_workers": 0, + "block_addresses_to_keep": 10000, + "additional_params": { + "address_aliases": true, + "mempoolTxTimeoutHours": 4, + "queryBackendOnMempoolResync": true, + "averageBlockTimeMs": 3000, + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "USD,EUR,CNY", + "fiat_rates_params": "{\"coin\": \"tron\",\"platformIdentifier\": \"tron\",\"platformVsCurrency\": \"usd\",\"periodSeconds\": 900}", + "fourByteSignatures": "https://www.4byte.directory/api/v1/signatures/" + } + } + }, + "meta": { + "package_maintainer": "IT", + "package_maintainer_email": "it@satoshilabs.com" + } +} diff --git a/configs/coins/unobtanium.json b/configs/coins/unobtanium.json index 7c845b1279..8f09e11057 100644 --- a/configs/coins/unobtanium.json +++ b/configs/coins/unobtanium.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpcp", "rpc_timeout": 25, diff --git a/configs/coins/vertcoin.json b/configs/coins/vertcoin.json index 23a3436512..074f377edb 100644 --- a/configs/coins/vertcoin.json +++ b/configs/coins/vertcoin.json @@ -1,71 +1,72 @@ { - "coin": { - "name": "Vertcoin", - "shortcut": "VTC", - "label": "Vertcoin", - "alias": "vertcoin" - }, - "ports": { - "backend_rpc": 8040, - "backend_message_queue": 38340, - "blockbook_internal": 9040, - "blockbook_public": 9140 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-vertcoin", - "package_revision": "satoshilabs-1", - "system_user": "vertcoin", - "version": "22.1", - "binary_url": "https://github.com/vertcoin-project/vertcoin-core/releases/download/v22.1/vertcoin-22.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "aab3068e02d55128326801cdbcbfcb175be96291e024edf5ab12f3af6f4433c0", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": ["bin/vertcoin-qt"], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/vertcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "whitelist": "127.0.0.1" + "coin": { + "name": "Vertcoin", + "shortcut": "VTC", + "label": "Vertcoin", + "alias": "vertcoin" + }, + "ports": { + "backend_rpc": 8040, + "backend_message_queue": 38340, + "blockbook_internal": 9040, + "blockbook_public": 9140 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-vertcoin", + "package_revision": "satoshilabs-1", + "system_user": "vertcoin", + "version": "23.2", + "binary_url": "https://github.com/vertcoin-project/vertcoin-core/releases/download/v23.2/vertcoin-23.2-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "51d01d1c7e1307edc0a88f44c3bd73ae8e088633ae85c56b08855b50882ee876", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": ["bin/vertcoin-qt"], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/vertcoind -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "whitelist": "127.0.0.1" + } + }, + "blockbook": { + "package_name": "blockbook-vertcoin", + "system_user": "blockbook-vertcoin", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "parse": true, + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 1000, + "xpub_magic": 76067358, + "xpub_magic_segwit_p2sh": 77429938, + "xpub_magic_segwit_native": 78792518, + "slip44": 28, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"vertcoin\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "Petr Kracik", + "package_maintainer_email": "petr.kracik@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-vertcoin", - "system_user": "blockbook-vertcoin", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 1000, - "xpub_magic": 76067358, - "xpub_magic_segwit_p2sh": 77429938, - "xpub_magic_segwit_native": 78792518, - "slip44": 28, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"vertcoin\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "Petr Kracik", - "package_maintainer_email": "petr.kracik@satoshilabs.com" - } } diff --git a/configs/coins/vertcoin_testnet.json b/configs/coins/vertcoin_testnet.json index 680f30b705..506b180821 100644 --- a/configs/coins/vertcoin_testnet.json +++ b/configs/coins/vertcoin_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -22,10 +23,10 @@ "package_name": "backend-vertcoin-testnet", "package_revision": "satoshilabs-1", "system_user": "vertcoin", - "version": "22.1", - "binary_url": "https://github.com/vertcoin-project/vertcoin-core/releases/download/v22.1/vertcoin-22.1-x86_64-linux-gnu.tar.gz", + "version": "23.2", + "binary_url": "https://github.com/vertcoin-project/vertcoin-core/releases/download/v23.2/vertcoin-23.2-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "aab3068e02d55128326801cdbcbfcb175be96291e024edf5ab12f3af6f4433c0", + "verification_source": "51d01d1c7e1307edc0a88f44c3bd73ae8e088633ae85c56b08855b50882ee876", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/vertcoin-qt" diff --git a/configs/coins/viacoin.json b/configs/coins/viacoin.json index 95e7aecb5f..aed1e65a02 100644 --- a/configs/coins/viacoin.json +++ b/configs/coins/viacoin.json @@ -13,8 +13,9 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", - "rpc_pass": "rpcp", + "rpc_pass": "rpc", "rpc_timeout": 25, "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" }, @@ -22,10 +23,10 @@ "package_name": "backend-viacoin", "package_revision": "satoshilabs-1", "system_user": "viacoin", - "version": "1.14-beta-1", - "binary_url": "https://github.com/viacoin/viacoin/releases/download/v0.15.2/viacoin-0.15.2-x86_64-linux-gnu.tar.gz", + "version": "0.16.3", + "binary_url": "https://github.com/viacoin/viacoin/releases/download/v0.16.3/viacoin-0.16.3-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "bdbd432645a8b4baadddb7169ea4bef3d03f80dc2ce53dce5783d8582ac63bab", + "verification_source": "4b84d8f1485d799fdff6cb4b1a316c00056b8869b53a702cd8ce2cc581bae59a", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/viacoin-qt" @@ -41,6 +42,7 @@ "client_config_file": "bitcoin_like_client.conf", "additional_params": { "discover": 0, + "deprecatedrpc": "estimatefee", "rpcthreads": 16, "upnp": 0, "whitelist": "127.0.0.1" @@ -62,11 +64,15 @@ "xpub_magic_segwit_p2sh": 77429938, "xpub_magic_segwit_native": 78792518, "slip44": 14, - "additional_params": {} + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"viacoin\", \"periodSeconds\": 900}" + } } }, "meta": { "package_maintainer": "Romano", - "package_maintainer_email": "romanornr@gmail.com" + "package_maintainer_email": "viacoin@protonmail.com" } -} \ No newline at end of file +} diff --git a/configs/coins/vipstarcoin.json b/configs/coins/vipstarcoin.json index 6d86a1bb41..5769cfe893 100644 --- a/configs/coins/vipstarcoin.json +++ b/configs/coins/vipstarcoin.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -66,4 +67,4 @@ "package_maintainer": "y-chan", "package_maintainer_email": "yuto_tetuota@yahoo.co.jp" } -} \ No newline at end of file +} diff --git a/configs/coins/zcash.json b/configs/coins/zcash.json index b080239f23..634aa96652 100644 --- a/configs/coins/zcash.json +++ b/configs/coins/zcash.json @@ -1,69 +1,67 @@ { - "coin": { - "name": "Zcash", - "shortcut": "ZEC", - "label": "Zcash", - "alias": "zcash" - }, - "ports": { - "backend_rpc": 8032, - "backend_message_queue": 38332, - "blockbook_internal": 9032, - "blockbook_public": 9132 - }, - "ipc": { - "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", - "rpc_user": "rpc", - "rpc_pass": "rpc", - "rpc_timeout": 25, - "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" - }, - "backend": { - "package_name": "backend-zcash", - "package_revision": "satoshilabs-1", - "system_user": "zcash", - "version": "5.4.1", - "binary_url": "https://z.cash/downloads/zcash-5.4.1-linux64-debian-bullseye.tar.gz", - "verification_type": "sha256", - "verification_source": "237e35ae9c6751f66dfd0d0d93f2844664609cc32580077d5f055c8497568313", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/zcashd -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", - "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", - "postinst_script_template": "HOME={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/zcash-fetch-params", - "service_type": "forking", - "service_additional_params_template": "Environment=\"HOME={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend\"", - "protect_memory": false, - "mainnet": true, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "addnode": ["mainnet.z.cash"] + "coin": { + "name": "Zcash", + "shortcut": "ZEC", + "label": "Zcash", + "alias": "zcash" + }, + "ports": { + "backend_rpc": 8032, + "backend_message_queue": 38332, + "blockbook_internal": 9032, + "blockbook_public": 9132 + }, + "ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_user": "rpc", + "rpc_pass": "rpc", + "rpc_timeout": 25, + "message_queue_binding_template": "tcp://127.0.0.1:{{.Ports.BackendMessageQueue}}" + }, + "backend": { + "package_name": "backend-zcash", + "package_revision": "satoshilabs-1", + "system_user": "zcash", + "version": "4.4.1", + "docker_image": "zfnd/zebra:4.4.1", + "verification_type": "docker", + "verification_source": "96149af0257d1f52612544b68f160f8c1bd1d229a47aced203bfa35f4925137d", + "extract_command": "mkdir backend/bin && docker cp extract:/usr/local/bin/zebrad backend/bin/zebrad", + "exclude_files": [], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/zebrad --config {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/zcash.conf start", + "logrotate_files_template": "{{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend/*.log", + "postinst_script_template": "", + "service_type": "simple", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": true, + "server_config_file": "zcash.conf", + "client_config_file": "bitcoin_like_client.conf" + }, + "blockbook": { + "package_name": "blockbook-zcash", + "system_user": "blockbook-zcash", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "-resyncindexperiod=50000 -resyncmempoolperiod=3000", + "block_chain": { + "parse": true, + "mempool_workers": 4, + "mempool_sub_workers": 8, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "slip44": 133, + "additional_params": { + "fiat_rates": "coingecko", + "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", + "fiat_rates_params": "{\"coin\": \"zcash\", \"periodSeconds\": 900}" + } + } + }, + "meta": { + "package_maintainer": "IT Admin", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-zcash", - "system_user": "blockbook-zcash", - "internal_binding_template": ":{{.Ports.BlockbookInternal}}", - "public_binding_template": ":{{.Ports.BlockbookPublic}}", - "explorer_url": "", - "additional_params": "", - "block_chain": { - "parse": true, - "mempool_workers": 4, - "mempool_sub_workers": 8, - "block_addresses_to_keep": 300, - "xpub_magic": 76067358, - "slip44": 133, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_vs_currencies": "AED,ARS,AUD,BDT,BHD,BMD,BRL,CAD,CHF,CLP,CNY,CZK,DKK,EUR,GBP,HKD,HUF,IDR,ILS,INR,JPY,KRW,KWD,LKR,MMK,MXN,MYR,NGN,NOK,NZD,PHP,PKR,PLN,RUB,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,VEF,VND,ZAR,BTC,ETH", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"zcash\", \"periodSeconds\": 900}" - } - } - }, - "meta": { - "package_maintainer": "IT Admin", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/zcash_testnet.json b/configs/coins/zcash_testnet.json index 9fd3650173..60ede9efac 100644 --- a/configs/coins/zcash_testnet.json +++ b/configs/coins/zcash_testnet.json @@ -13,6 +13,7 @@ }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", + "rpc_url_ws_template": "ws://127.0.0.1:{{.Ports.BackendRPC}}", "rpc_user": "rpc", "rpc_pass": "rpc", "rpc_timeout": 25, @@ -21,10 +22,10 @@ "backend": { "package_name": "backend-zcash-testnet", "package_revision": "satoshilabs-1", - "version": "5.4.1", - "binary_url": "https://z.cash/downloads/zcash-5.4.1-linux64-debian-bullseye.tar.gz", + "version": "6.2.0", + "binary_url": "https://download.z.cash/downloads/zcash-6.2.0-linux64-debian-bullseye.tar.gz", "verification_type": "sha256", - "verification_source": "237e35ae9c6751f66dfd0d0d93f2844664609cc32580077d5f055c8497568313", + "verification_source": "71cf378c27582a4b9f9d57cafc2b5a57a46e9e52a5eda33be112dc9790c64c6f", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [], "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/zcashd -datadir={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend -conf={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf -pid=/run/{{.Coin.Alias}}/{{.Coin.Alias}}.pid", @@ -39,7 +40,9 @@ "additional_params": { "addnode": [ "testnet.z.cash" - ] + + ], + "i-am-aware-zcashd-will-be-replaced-by-zebrad-and-zallet-in-2025": 1 } }, "blockbook": { diff --git a/configs/contract-fix/ethereum.json b/configs/contract-fix/ethereum.json new file mode 100644 index 0000000000..ff1b8d0d5e --- /dev/null +++ b/configs/contract-fix/ethereum.json @@ -0,0 +1,42 @@ +[ + { + "standard": "ERC20", + "contract": "0xC19B6A4Ac7C7Cc24459F08984Bbd09664af17bD1", + "name": "Sensorium", + "symbol": "SENSO", + "decimals": 0, + "createdInBlock": 11098997 + }, + { + "standard": "ERC20", + "contract": "0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa", + "name": "mETH", + "symbol": "mETH", + "decimals": 18, + "createdInBlock": 18290587 + }, + { + "standard": "ERC20", + "contract": "0xE6829d9a7eE3040e1276Fa75293Bde931859e8fA", + "name": "cmETH", + "symbol": "cmETH", + "decimals": 18, + "createdInBlock": 20439180 + }, + { + "type": "ERC20", + "standard": "ERC20", + "contract": "0x6f40d4A6237C257fff2dB00FA0510DeEECd303eb", + "name": "Fluid", + "symbol": "FLUID", + "decimals": 18, + "createdInBlock": 12183236 + }, + { + "standard": "ERC20", + "contract": "0x7cf9a80db3b29ee8efe3710aadb7b95270572d47", + "name": "Nillion", + "symbol": "NIL", + "decimals": 6 + } +] diff --git a/configs/environ.json b/configs/environ.json index 529ac6404f..b1e38d6012 100644 --- a/configs/environ.json +++ b/configs/environ.json @@ -1,7 +1,7 @@ { - "version": "0.4.0", - "backend_install_path": "/opt/coins/nodes", - "backend_data_path": "/opt/coins/data", - "blockbook_install_path": "/opt/coins/blockbook", - "blockbook_data_path": "/opt/coins/data" + "version": "0.6.0", + "backend_install_path": "/opt/coins/nodes", + "backend_data_path": "/opt/coins/data", + "blockbook_install_path": "/opt/coins/blockbook", + "blockbook_data_path": "/opt/coins/data" } diff --git a/contrib/scripts/backend-deploy-and-test.sh b/contrib/scripts/backend-deploy-and-test.sh index 4a52120476..c26617695a 100755 --- a/contrib/scripts/backend-deploy-and-test.sh +++ b/contrib/scripts/backend-deploy-and-test.sh @@ -1,33 +1,79 @@ #!/usr/bin/env bash +set -euo pipefail + +readonly LOG_PREFIX="CI/CD Pipeline:" +readonly SCRIPT_NAME="[backend-deploy]" + +log() { + printf '%s %s %s\n' "$LOG_PREFIX" "$SCRIPT_NAME" "$*" >&2 +} + +die() { + printf '%s error: %s\n' "$LOG_PREFIX" "$*" >&2 + exit 1 +} if [ $# -ne 1 ] && [ $# -ne 4 ] then - echo -e "Usage:\n\n$(basename $(readlink -f $0)) coin service_name coin_test backend_log_file\n\nor\n\n$(basename $(readlink -f $0)) coin\nin which case service_name, coin_test and backend_log_file are derived from coin or default" 1>&2 - exit 1 + die "usage: $(basename $(readlink -f "$0")) coin service_name coin_test backend_log_file OR $(basename $(readlink -f "$0")) coin" fi +command -v jq >/dev/null 2>&1 || die "jq is required" + COIN=$1 -SERVICE=$2 -COIN_TEST=$3 -LOGFILE=$4 +SERVICE=${2:-} +COIN_TEST=${3:-} +LOGFILE=${4:-} +CONFIG="configs/coins/${COIN}.json" +BACKEND_TIMEOUT="${BACKEND_TIMEOUT:-}" [ -z "${BACKEND_TIMEOUT}" ] && BACKEND_TIMEOUT=15s [ -z "${SERVICE}" ] && SERVICE="${COIN}" [ -z "${COIN_TEST}" ] && COIN_TEST="${COIN}=main" -[ -z "${LOGFILE}" ] && LOGFILE=debug.log +if [[ -z "${LOGFILE}" ]]; then + if [[ -f "${CONFIG}" ]]; then + alias="$(jq -r '.coin.alias // empty' "${CONFIG}")" + if [[ -n "${alias}" ]]; then + LOGFILE="${alias}.log" + else + LOGFILE=debug.log + fi + else + LOGFILE=debug.log + fi +fi -echo "Running: $(basename $(readlink -f $0)) ${COIN} ${SERVICE} ${COIN_TEST} ${LOGFILE}" +log "running: $(basename $(readlink -f "$0")) ${COIN} ${SERVICE} ${COIN_TEST} ${LOGFILE}" -rm build/*.deb -make "deb-backend-${COIN}" +rm -f build/*.deb +log "building backend package for ${COIN}" +make PORTABLE=1 "deb-backend-${COIN}" -PACKAGE=$(ls ./build/backend-${SERVICE}*.deb) -[ -z "${PACKAGE}" ] && echo "Package not found" && exit 1 +shopt -s nullglob +packages=(./build/backend-"${SERVICE}"*.deb) +shopt -u nullglob +if [[ "${#packages[@]}" -eq 0 ]]; then + die "package not found for backend-${SERVICE}" +fi +PACKAGE="${packages[0]}" -sudo /usr/bin/dpkg -i "${PACKAGE}" || exit 1 -sudo /bin/systemctl restart "backend-${SERVICE}" || exit 1 +log "installing ${PACKAGE}" +sudo /usr/bin/dpkg -i "${PACKAGE}" +log "restarting backend-${SERVICE}" +sudo /bin/systemctl restart "backend-${SERVICE}" -echo "Waiting for backend startup for ${BACKEND_TIMEOUT}" -sudo -u bitcoin /usr/bin/timeout ${BACKEND_TIMEOUT} /usr/bin/tail -f "/opt/coins/data/${COIN}/backend/${LOGFILE}" +log "waiting for backend startup for ${BACKEND_TIMEOUT}" +set +e +sudo -u bitcoin /usr/bin/timeout "${BACKEND_TIMEOUT}" /usr/bin/tail -f "/opt/coins/data/${COIN}/backend/${LOGFILE}" +status=$? +set -e +if [[ "$status" -ne 0 && "$status" -ne 124 ]]; then + if [[ "$status" -eq 1 ]]; then + log "backend log ${LOGFILE} is not available yet, continuing to integration tests" + else + die "backend startup log wait failed with exit code ${status}" + fi +fi -make test-integration ARGS="-v -run=TestIntegration/${COIN_TEST}" +log "running integration tests: TestIntegration/${COIN_TEST}" +make PORTABLE=1 test-integration ARGS="-v -run=TestIntegration/${COIN_TEST}" diff --git a/contrib/scripts/backend_status.sh b/contrib/scripts/backend_status.sh new file mode 100755 index 0000000000..7a553db46c --- /dev/null +++ b/contrib/scripts/backend_status.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "error: $1" >&2; exit 1; } + +[[ $# -ge 1 ]] || die "missing coin argument. usage: blockbook_backend_status.sh " +coin="$1" +build_env="${BB_BUILD_ENV:-dev}" +build_env="${build_env,,}" +case "$build_env" in + dev) var="BB_DEV_RPC_URL_HTTP_${coin}" ;; + prod) var="BB_PROD_RPC_URL_HTTP_${coin}" ;; + *) die "invalid BB_BUILD_ENV value '$build_env', expected 'dev' or 'prod'" ;; +esac +url="${!var-}" +[[ -n "$url" ]] || die "environment variable ${var} is not set" +user_var="BB_RPC_USER" +pass_var="BB_RPC_PASS" +user="${!user_var-}" +pass="${!pass_var-}" +auth=() +if [[ -n "$user" || -n "$pass" ]]; then + [[ -n "$user" && -n "$pass" ]] || die "set both ${user_var} and ${pass_var}" + auth=(-u "${user}:${pass}") +fi +command -v curl >/dev/null 2>&1 || die "curl is not installed" +command -v jq >/dev/null 2>&1 || die "jq is not installed" + +rpc() { curl -skS "${auth[@]}" -H 'content-type: application/json' --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$1\",\"params\":${2:-[]}}" "$url"; } + +resp="$(rpc eth_syncing)" +if echo "$resp" | jq -e '.error|not' >/dev/null 2>&1; then + if echo "$resp" | jq -e '.result == false' >/dev/null 2>&1; then + bn="$(rpc eth_blockNumber)" + echo "$bn" | jq -e '.error|not' >/dev/null 2>&1 || die "eth_blockNumber failed" + hex="$(echo "$bn" | jq -r '.result')" + [[ -n "$hex" && "$hex" != "null" ]] || die "eth_blockNumber returned empty result" + height=$((16#${hex#0x})) + jq -n --argjson height "$height" '{backend:"evm", is_synced:true, height:$height}' + else + cur_hex="$(echo "$resp" | jq -r '.result.currentBlock')" + high_hex="$(echo "$resp" | jq -r '.result.highestBlock')" + [[ -n "$cur_hex" && "$cur_hex" != "null" ]] || die "eth_syncing returned empty currentBlock" + [[ -n "$high_hex" && "$high_hex" != "null" ]] || die "eth_syncing returned empty highestBlock" + cur=$((16#${cur_hex#0x})) + high=$((16#${high_hex#0x})) + jq -n --argjson height "$cur" --argjson highest "$high" \ + '{backend:"evm", is_synced:false, height:$height, highest:$highest}' + fi + exit 0 +fi + +resp="$(rpc getblockchaininfo)" +if echo "$resp" | jq -e '.result and (.error|not)' >/dev/null 2>&1; then + echo "$resp" | jq '{backend:"utxo", is_synced:(.result.initialblockdownload|not), height:.result.blocks, getblockchaininfo:.}' + exit 0 +fi + +die "backend did not return a valid eth_syncing or getblockchaininfo response" diff --git a/contrib/scripts/blockbook_status.sh b/contrib/scripts/blockbook_status.sh new file mode 100755 index 0000000000..43c359b4d5 --- /dev/null +++ b/contrib/scripts/blockbook_status.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { echo "error: $1" >&2; exit 1; } +[[ $# -ge 1 ]] || die "missing coin argument. usage: blockbook_status.sh [hostname]" +coin="$1" +if [[ -n "${2-}" ]]; then + host="$2" +else + host="localhost" +fi + +var="BB_DEV_API_URL_HTTP_${coin}" +base_url="${!var-}" +[[ -n "$base_url" ]] || die "environment variable ${var} is not set" +command -v curl >/dev/null 2>&1 || die "curl is not installed" +command -v jq >/dev/null 2>&1 || die "jq is not installed" + +# Preserve legacy host override argument by replacing host in the configured base URL. +if [[ -n "${2-}" ]]; then + if [[ "$base_url" =~ ^(https?://)([^/@]+@)?([^/:]+)(:[0-9]+)?(.*)$ ]]; then + base_url="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${host}${BASH_REMATCH[4]}${BASH_REMATCH[5]}" + else + die "invalid URL in ${var}: ${base_url}" + fi +fi + +status_url="${base_url%/}/api/status" +curl -skv "$status_url" | jq diff --git a/contrib/scripts/build-blockbook-local.sh b/contrib/scripts/build-blockbook-local.sh new file mode 100755 index 0000000000..1cb45058d3 --- /dev/null +++ b/contrib/scripts/build-blockbook-local.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly LOG_PREFIX="CI/CD Pipeline:" +readonly SCRIPT_NAME="[build-local]" + +log() { + printf '%s %s %s\n' "$LOG_PREFIX" "$SCRIPT_NAME" "$*" >&2 +} + +die() { + printf '%s error: %s\n' "$LOG_PREFIX" "$*" >&2 + exit 1 +} + +if [[ $# -lt 1 ]]; then + die "usage: $(basename "$0") [ ...]" +fi + +command -v jq >/dev/null 2>&1 || die "jq is required" + +coins=("$@") +package_names=() +make_targets=() + +log "requested coins: ${coins[*]}" + +for coin in "${coins[@]}"; do + config="configs/coins/${coin}.json" + if [[ ! -f "$config" ]]; then + die "missing coin config $config" + fi + + package_name="$(jq -r '.blockbook.package_name // empty' "$config")" + if [[ -z "$package_name" ]]; then + die "coin '$coin' does not define blockbook.package_name" + fi + + package_names+=("$package_name") + make_targets+=("deb-blockbook-${coin}") + log "validated ${coin}: package_name=${package_name}, target=deb-blockbook-${coin}" + log "removing previous packages matching build/${package_name}_*.deb" + rm -f "build/${package_name}"_*.deb +done + +log "starting build: make PORTABLE=1 ${make_targets[*]}" +make PORTABLE=1 "${make_targets[@]}" 1>&2 +log "build finished" + +for i in "${!coins[@]}"; do + coin="${coins[$i]}" + package_name="${package_names[$i]}" + package_file="$(ls -1t build/${package_name}_*.deb 2>/dev/null | head -n1 || true)" + if [[ -z "$package_file" ]]; then + die "built package for '$coin' was not found (pattern build/${package_name}_*.deb)" + fi + + log "built ${coin} via ${package_file}" + printf '%s\n' "$package_file" +done diff --git a/contrib/scripts/check-and-generate-port-registry.go b/contrib/scripts/check-and-generate-port-registry.go index 048a28f31d..9b2eebc6f8 100755 --- a/contrib/scripts/check-and-generate-port-registry.go +++ b/contrib/scripts/check-and-generate-port-registry.go @@ -1,4 +1,4 @@ -//usr/bin/go run $0 $@ ; exit +// usr/bin/go run $0 $@ ; exit package main import ( @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "math" "os" "path/filepath" @@ -36,15 +35,19 @@ type Config struct { Coin struct { Name string `json:"name"` Label string `json:"label"` + Alias string `json:"alias"` + } + Ports map[string]uint16 `json:"ports"` + Blockbook struct { + PackageName string `json:"package_name"` } - Ports map[string]uint16 `json:"ports"` } func checkPorts() int { ports := make(map[uint16][]string) status := 0 - files, err := ioutil.ReadDir(inputDir) + files, err := os.ReadDir(inputDir) if err != nil { panic(err) } @@ -69,21 +72,22 @@ func checkPorts() int { } if _, ok := v.Ports["blockbook_internal"]; !ok { - fmt.Printf("%s: missing blockbook_internal port\n", v.Coin.Name) + fmt.Printf("%s (%s): missing blockbook_internal port\n", v.Coin.Name, v.Coin.Alias) status = 1 } if _, ok := v.Ports["blockbook_public"]; !ok { - fmt.Printf("%s: missing blockbook_public port\n", v.Coin.Name) + fmt.Printf("%s (%s): missing blockbook_public port\n", v.Coin.Name, v.Coin.Alias) status = 1 } if _, ok := v.Ports["backend_rpc"]; !ok { - fmt.Printf("%s: missing backend_rpc port\n", v.Coin.Name) + fmt.Printf("%s (%s): missing backend_rpc port\n", v.Coin.Name, v.Coin.Alias) status = 1 } for _, port := range v.Ports { - if port > 0 { - ports[port] = append(ports[port], v.Coin.Name) + // ignore duplicities caused by configs that do not serve blockbook directly (consensus layers) + if port > 0 && v.Blockbook.PackageName == "" { + ports[port] = append(ports[port], v.Coin.Alias) } } } @@ -132,7 +136,7 @@ func main() { } func loadPortInfo(dir string) (PortInfoSlice, error) { - files, err := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil { return nil, err } @@ -158,26 +162,31 @@ func loadPortInfo(dir string) (PortInfoSlice, error) { return nil, fmt.Errorf("%s: json: %s", path, err) } + // skip configs that do not have blockbook (consensus layers) + if v.Blockbook.PackageName == "" { + continue + } name := v.Coin.Label - if len(name) == 0 { + // exceptions when to use Name instead of Label so that the table looks good + if len(name) == 0 || strings.Contains(v.Coin.Name, "Ethereum") || strings.Contains(v.Coin.Name, "Archive") { name = v.Coin.Name } item := &PortInfo{CoinName: name, BackendServicePorts: map[string]uint16{}} - for k, v := range v.Ports { - if v == 0 { + for k, p := range v.Ports { + if p == 0 { continue } switch k { case "blockbook_internal": - item.BlockbookInternalPort = v + item.BlockbookInternalPort = p case "blockbook_public": - item.BlockbookPublicPort = v + item.BlockbookPublicPort = p case "backend_rpc": - item.BackendRPCPort = v + item.BackendRPCPort = p default: if len(k) > 8 && k[:8] == "backend_" { - item.BackendServicePorts[k[8:]] = v + item.BackendServicePorts[k[8:]] = p } } } @@ -233,10 +242,10 @@ func writeMarkdown(output string, slice PortInfoSlice) error { fmt.Fprintf(&buf, "# Registry of ports\n\n") - header := []string{"coin", "blockbook internal port", "blockbook public port", "backend rpc port", "backend service ports (zmq)"} + header := []string{"coin", "blockbook public", "blockbook internal", "backend rpc", "backend service ports (zmq)"} writeTable(&buf, header, slice) - fmt.Fprintf(&buf, "\n> NOTE: This document is generated from coin definitions in `configs/coins`.\n") + fmt.Fprintf(&buf, "\n> NOTE: This document is generated from coin definitions in `configs/coins` using command `go run contrib/scripts/check-and-generate-port-registry.go -w`.\n") out := os.Stdout if output != "stdout" { @@ -263,11 +272,11 @@ func writeTable(w io.Writer, header []string, slice PortInfoSlice) { for i, item := range slice { row := make([]string, len(header)) row[0] = item.CoinName - if item.BlockbookInternalPort > 0 { - row[1] = fmt.Sprintf("%d", item.BlockbookInternalPort) - } if item.BlockbookPublicPort > 0 { - row[2] = fmt.Sprintf("%d", item.BlockbookPublicPort) + row[1] = fmt.Sprintf("%d", item.BlockbookPublicPort) + } + if item.BlockbookInternalPort > 0 { + row[2] = fmt.Sprintf("%d", item.BlockbookInternalPort) } if item.BackendRPCPort > 0 { row[3] = fmt.Sprintf("%d", item.BackendRPCPort) @@ -284,6 +293,7 @@ func writeTable(w io.Writer, header []string, slice PortInfoSlice) { svcPorts = append(svcPorts, s) } + sort.Strings(svcPorts) row[4] = strings.Join(svcPorts, ", ") rows[i] = row @@ -294,7 +304,7 @@ func writeTable(w io.Writer, header []string, slice PortInfoSlice) { padding[column] = len(header[column]) for _, row := range rows { - padding[column] = maxInt(padding[column], len(row[column])) + padding[column] = max(padding[column], len(row[column])) } } @@ -312,13 +322,6 @@ func writeTable(w io.Writer, header []string, slice PortInfoSlice) { } } -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - func paddedRow(row []string, padding []int) []string { out := make([]string, len(row)) for i := 0; i < len(row); i++ { diff --git a/contrib/scripts/deploy-bb-and-backend.sh b/contrib/scripts/deploy-bb-and-backend.sh new file mode 100755 index 0000000000..3a1611fcf4 --- /dev/null +++ b/contrib/scripts/deploy-bb-and-backend.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly LOG_PREFIX="CI/CD Pipeline:" +readonly SCRIPT_NAME="[deploy-bb-backend]" + +log() { + printf '%s %s %s\n' "$LOG_PREFIX" "$SCRIPT_NAME" "$*" >&2 +} + +die() { + printf '%s error: %s\n' "$LOG_PREFIX" "$*" >&2 + exit 1 +} + +if [[ $# -lt 1 ]]; then + die "usage: $(basename "$0") [--force-confnew]" +fi + +coin="" +force_confnew=0 + +for arg in "$@"; do + case "$arg" in + --force-confnew) + force_confnew=1 + ;; + -*) + die "unknown option: $arg" + ;; + *) + if [[ -n "$coin" ]]; then + die "usage: $(basename "$0") [--force-confnew]" + fi + coin="$arg" + ;; + esac +done + +if [[ -z "$coin" ]]; then + die "usage: $(basename "$0") [--force-confnew]" +fi + +config="configs/coins/${coin}.json" +if [[ ! -f "$config" ]]; then + die "missing coin config $config" +fi + +policy_output="$( + python3 ./.github/scripts/backend_decision.py "$coin" +)" +eval "$policy_output" + +deploy_backend="$BACKEND_SHOULD_BUILD" +backend_reason="$BACKEND_REASON" +rpc_env="$BACKEND_RPC_ENV" +rpc_host="$BACKEND_RPC_HOST" +build_env="$BACKEND_BUILD_ENV" + +log "coin=${coin}, alias=${BACKEND_COIN_ALIAS}" +log "backend deploy rule: deploy unless the selected BB_{DEV|PROD}_RPC_URL_HTTP_ is non-empty and non-local" +log "backend decision: deploy_backend=${deploy_backend}, reason=${backend_reason}, rpc_env=${rpc_env}, rpc_host=${rpc_host:-}" + +if [[ "$deploy_backend" -eq 1 ]]; then + log "deploying backend first" + ./contrib/scripts/backend-deploy-and-test.sh "$coin" +else + log "backend deploy skipped: ${backend_reason}" +fi + +if [[ "$force_confnew" -eq 1 ]]; then + ./contrib/scripts/deploy-blockbook-local.sh "$coin" --force-confnew +else + ./contrib/scripts/deploy-blockbook-local.sh "$coin" +fi diff --git a/contrib/scripts/deploy-blockbook-local.sh b/contrib/scripts/deploy-blockbook-local.sh new file mode 100755 index 0000000000..b7cb4f7288 --- /dev/null +++ b/contrib/scripts/deploy-blockbook-local.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly LOG_PREFIX="CI/CD Pipeline:" +readonly SCRIPT_NAME="[deploy-local]" + +log() { + printf '%s %s %s\n' "$LOG_PREFIX" "$SCRIPT_NAME" "$*" >&2 +} + +die() { + printf '%s error: %s\n' "$LOG_PREFIX" "$*" >&2 + exit 1 +} + +if [[ $# -lt 1 ]]; then + die "usage: $(basename "$0") [--force-confnew]" +fi + +coin="" +force_confnew=0 + +for arg in "$@"; do + case "$arg" in + --force-confnew) + force_confnew=1 + ;; + -*) + die "unknown option: $arg" + ;; + *) + if [[ -n "$coin" ]]; then + die "usage: $(basename "$0") [--force-confnew]" + fi + coin="$arg" + ;; + esac +done + +if [[ -z "$coin" ]]; then + die "usage: $(basename "$0") [--force-confnew]" +fi + +config="configs/coins/${coin}.json" + +if [[ ! -f "$config" ]]; then + die "missing coin config $config" +fi + +command -v jq >/dev/null 2>&1 || die "jq is required" + +package_name="$(jq -r '.blockbook.package_name // empty' "$config")" +if [[ -z "$package_name" ]]; then + die "coin '$coin' does not define blockbook.package_name" +fi + +log "coin=${coin}, package_name=${package_name}" +log "building package" +package_file="$(./contrib/scripts/build-blockbook-local.sh "$coin" | tail -n1)" +if [[ -z "$package_file" ]]; then + die "build helper did not return a package path for '$coin'" +fi + +package_path="$(readlink -f "$package_file")" +service_name="${package_name}.service" +log "resolved package path: ${package_path}" +log "target service: ${service_name}" + +show_service_diagnostics() { + sudo systemctl status --no-pager --full "$service_name" || true + sudo journalctl -u "$service_name" -n 100 --no-pager || true +} + +log "installing ${package_path}" +dpkg_install_cmd=( + sudo DEBIAN_FRONTEND=noninteractive dpkg -i +) + +if [[ "$force_confnew" -eq 1 ]]; then + dpkg_install_cmd=(sudo DEBIAN_FRONTEND=noninteractive dpkg --force-confnew -i) +fi + +dpkg_install_cmd+=("$package_path") +"${dpkg_install_cmd[@]}" + +log "restarting ${service_name}" +if ! sudo systemctl restart "$service_name"; then + show_service_diagnostics + die "failed to restart ${service_name}" +fi + +log "waiting for ${service_name} to become active" +for attempt in $(seq 1 30); do + if sudo systemctl is-active --quiet "$service_name"; then + log "service became active on attempt ${attempt}" + log "deployed ${coin} via ${package_path}" + exit 0 + fi + log "service not active yet (attempt ${attempt}/30)" + sleep 1 +done + +show_service_diagnostics +die "${service_name} did not become active within 30 seconds" diff --git a/contrib/scripts/deploy-dev.sh b/contrib/scripts/deploy-dev.sh index 7ca08f23dc..8f5de9028a 100755 --- a/contrib/scripts/deploy-dev.sh +++ b/contrib/scripts/deploy-dev.sh @@ -27,7 +27,7 @@ status=0 for coin in $COINS do scp build/blockbook-${coin}_${VERSION}_amd64.deb ${HOST}: \ - && ssh ${HOST} "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --reinstall ./blockbook-${coin}_${VERSION}_amd64.deb && sudo systemctl restart blockbook-${coin}.service" \ + && ssh ${HOST} "pkg=\$PWD/blockbook-${coin}_${VERSION}_amd64.deb && sudo DEBIAN_FRONTEND=noninteractive apt install -y --reinstall \"\$pkg\" && sudo systemctl restart blockbook-${coin}.service" \ || status=$? if [ ${status} == 0 ] diff --git a/contrib/scripts/reset_eth_token_rates.sh b/contrib/scripts/reset_eth_token_rates.sh new file mode 100644 index 0000000000..3ed62a4960 --- /dev/null +++ b/contrib/scripts/reset_eth_token_rates.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# +# reset_eth_token_rates.sh +# +# Clears all historical fiat-rate data from a running Ethereum Archive Blockbook instance +# so that Blockbook re-fetches it from the configured provider after restart. +# +# What gets deleted: +# - Special tickers in the `default` column family: +# CurrentTickers, HourlyTickers, FiveMinutesTickers +# - Historical bootstrap markers in the `default` column family: +# HistoricalFiatBootstrapComplete, HistoricalFiatBootstrapAttempts +# - All entries in the `fiatRates` column family (14-byte ASCII timestamps +# keyed as YYYYMMDDhhmmss). +# +# A full rsync backup of the DB directory is taken before any delete so the +# operation is reversible by restoring the backup. +# +# After Blockbook restarts, historical bootstrap runs on the very next +# downloader cycle as long as UTC hour is > 0 (fiat/fiat_rates.go:479,540 — +# lastHistoricalTickers starts at Go's zero time, so the day-difference check +# is trivially true on first run). If you restart at, say, 00:30 UTC, bootstrap +# waits until 01:00 UTC. Current / hourly / 5-minute tickers refresh on the +# normal schedule regardless. +# +# Usage: +# sudo ./reset_eth_token_rates.sh [--dry-run] [--yes] [--skip-backup] +# +# Env overrides (defaults in parentheses): +# DB (/opt/coins/data/ethereum_archive/blockbook/db) +# LDB (/opt/coins/blockbook/ethereum_archive/bin/ldb) +# CFG (/opt/coins/blockbook/ethereum_archive/config/blockchaincfg.json) +# SERVICE (blockbook-ethereum-archive) +# BB_USER (blockbook-ethereum) — user that owns the DB / runs the service +# SKIP_CFG_CHECK (0) — set to 1 to bypass the platformVsCurrency guard + +set -euo pipefail + +DB="${DB:-/opt/coins/data/ethereum_archive/blockbook/db}" +LDB="${LDB:-/opt/coins/blockbook/ethereum_archive/bin/ldb}" +CFG="${CFG:-/opt/coins/blockbook/ethereum_archive/config/blockchaincfg.json}" +SERVICE="${SERVICE:-blockbook-ethereum-archive}" +BB_USER="${BB_USER:-blockbook-ethereum}" +SKIP_CFG_CHECK="${SKIP_CFG_CHECK:-0}" + +DRY_RUN=0 +ASSUME_YES=0 +SKIP_BACKUP=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --yes|-y) ASSUME_YES=1 ;; + --skip-backup) SKIP_BACKUP=1 ;; + -h|--help) + sed -n '2,30p' "$0" + exit 0 + ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } + +run() { + if [ "$DRY_RUN" -eq 1 ]; then + printf ' DRY-RUN: %s\n' "$*" + else + eval "$@" + fi +} + +# ldb must run as the blockbook user so RocksDB-owned files (LOG, LOCK, +# *.sst, *.log) stay owned by that user; otherwise blockbook cannot reopen +# the DB after restart. +ldb_as_bb() { + if [ "$DRY_RUN" -eq 1 ]; then + printf ' DRY-RUN: sudo -u %s %s --db=%s %s\n' "$BB_USER" "$LDB" "$DB" "$*" + else + sudo -u "$BB_USER" "$LDB" --db="$DB" "$@" + fi +} + +# --- sanity checks ------------------------------------------------------------ + +[ "$(id -u)" -eq 0 ] || { echo "must run as root (uses systemctl + sudo -u)"; exit 1; } +[ -d "$DB" ] || { echo "DB dir not found: $DB"; exit 1; } +[ -x "$LDB" ] || { echo "ldb not found / not executable: $LDB"; exit 1; } +id -u "$BB_USER" >/dev/null 2>&1 || { echo "user not found: $BB_USER"; exit 1; } + +log "DB = $DB" +log "LDB = $LDB" +log "CFG = $CFG" +log "SERVICE = $SERVICE" +log "BB_USER = $BB_USER" +log "DRY_RUN = $DRY_RUN" +log "SKIP_BACKUP = $SKIP_BACKUP" + +# --- config guard ------------------------------------------------------------- +# +# The deployed blockchaincfg.json is what the running blockbook actually uses. +# If platformVsCurrency is still the old (broken) value, wiping fiat history +# and restarting will just rebuild the same broken state. Fail fast here so +# the operator fixes the config first. +# +# fiat_rates_params is serialized as an ESCAPED JSON STRING inside the outer +# JSON (common/config.go:18 — FiatRatesParams is `string`, not nested object). +# The deployed file literally contains text like: +# "fiat_rates_params":"{\"platformVsCurrency\": \"usd\", ...}" +# Prefer jq to parse the inner JSON; fall back to a regex on the escaped form. +if [ "$SKIP_CFG_CHECK" -ne 1 ]; then + [ -r "$CFG" ] || { echo "config not readable: $CFG (set SKIP_CFG_CHECK=1 to bypass)"; exit 1; } + cfg_ok=0 + if command -v jq >/dev/null 2>&1; then + if jq -er '.fiat_rates_params | fromjson | .platformVsCurrency == "usd"' "$CFG" >/dev/null 2>&1; then + cfg_ok=1 + fi + else + # escaped-JSON-in-JSON: \"platformVsCurrency\": \"usd\" + if grep -Eq '\\"platformVsCurrency\\"[[:space:]]*:[[:space:]]*\\"usd\\"' "$CFG"; then + cfg_ok=1 + fi + fi + if [ "$cfg_ok" -ne 1 ]; then + echo "ABORT: $CFG does not have platformVsCurrency=\"usd\" in fiat_rates_params." >&2 + echo "Wiping fiat history before changing the deployed config will just" >&2 + echo "rebuild rates with the old platformVsCurrency value." >&2 + echo "Fix the config (and redeploy if needed), then re-run. Bypass with SKIP_CFG_CHECK=1." >&2 + exit 1 + fi + log "config guard OK (platformVsCurrency=\"usd\")" +fi + +if [ "$ASSUME_YES" -ne 1 ] && [ "$DRY_RUN" -ne 1 ]; then + if [ "$SKIP_BACKUP" -eq 1 ]; then + prompt="This will stop $SERVICE and wipe fiat-rate data WITHOUT taking a DB backup. Continue? [y/N] " + else + prompt="This will stop $SERVICE and wipe fiat-rate data. Continue? [y/N] " + fi + read -r -p "$prompt" ans + case "$ans" in y|Y|yes|YES) ;; *) echo "aborted"; exit 1 ;; esac +fi + +# --- stop blockbook ----------------------------------------------------------- + +log "stopping $SERVICE" +run "systemctl stop '$SERVICE'" + +# --- backup ------------------------------------------------------------------- + +BACKUP="" +if [ "$SKIP_BACKUP" -eq 1 ]; then + log "SKIPPING backup (--skip-backup). There will be NO way to restore the DB if this goes wrong." +else + BACKUP="${DB}.backup-$(date +%F-%H%M%S)" + log "backing up $DB -> $BACKUP" + run "rsync -a --delete '$DB/' '$BACKUP/'" +fi + +# --- verify the fiatRates column family exists ------------------------------- + +log "listing column families" +if [ "$DRY_RUN" -eq 0 ]; then + cf_list="$(sudo -u "$BB_USER" "$LDB" --db="$DB" list_column_families)" + printf '%s\n' "$cf_list" + # ldb prints the whole list inside ONE pair of braces, comma-separated, e.g. + # Column families in /.../db: + # {default, height, ..., fiatRates, ...} + # (tools/ldb_cmd.cc ListColumnFamiliesCommand::DoCommand). Match with + # word-boundary so it works regardless of position in that list. + if ! printf '%s\n' "$cf_list" | grep -qw fiatRates; then + echo "fiatRates column family not found in $DB — aborting" >&2 + log "restart $SERVICE manually once the cause is understood" + exit 1 + fi +fi + +# --- delete special-ticker + bootstrap keys in the default CF ----------------- + +for key in CurrentTickers HourlyTickers FiveMinutesTickers \ + HistoricalFiatBootstrapComplete HistoricalFiatBootstrapAttempts; do + log "delete default:$key" + ldb_as_bb delete "$key" || log " (key $key not present, ignoring)" +done + +# --- wipe the fiatRates column family ---------------------------------------- + +# fiatRates keys are 14-byte ASCII timestamps (YYYYMMDDhhmmss). Under RocksDB's +# default bytewise comparator, any such key has first byte in [0x30, 0x39], so +# the 1-byte range [0x00, 0xFF) covers all of them. deleterange's end key is +# exclusive, which is fine here — no real key equals 0xFF. +# +# NOTE: the ldb subcommand is spelled `deleterange` (no underscore) — see +# `ldb --help`. The `--hex` flag REQUIRES the "0x" prefix on keys +# (tools/ldb_cmd.cc HexToString: "Invalid hex input ... Must start with 0x"). +log "deleterange on fiatRates CF (all historical rates)" +ldb_as_bb --column_family=fiatRates --hex deleterange 0x00 0xFF + +# Optional compaction so the space is actually reclaimed before restart. +log "compact on fiatRates CF" +ldb_as_bb --column_family=fiatRates compact || log " (compact failed, non-fatal)" + +# --- start blockbook ---------------------------------------------------------- + +log "starting $SERVICE" +run "systemctl start '$SERVICE'" + +if [ -n "$BACKUP" ]; then + log "done. backup kept at: $BACKUP" +else + log "done. (no backup was taken)" +fi +log "note: historical bootstrap runs on the next downloader cycle as long as" +log " current UTC hour > 0 (fiat/fiat_rates.go:479,540). If you restarted" +log " before 01:00 UTC, expect the historical pass at the top of the hour." +log " Current/hourly/5-min tickers refresh on the normal schedule." diff --git a/db/address_alias_cache.go b/db/address_alias_cache.go new file mode 100644 index 0000000000..23fccbfaa8 --- /dev/null +++ b/db/address_alias_cache.go @@ -0,0 +1,71 @@ +package db + +import ( + "container/list" + "sync" +) + +// cachedAddressAliasRecordsLRUMaxSize bounds the package-level address alias cache. +// At ~140 B per entry, 100k caps the cache around ~14 MB. +const cachedAddressAliasRecordsLRUMaxSize = 100_000 + +type addressAliasLRUEntry struct { + key string + value string +} + +type addressAliasLRU struct { + mu sync.Mutex + capacity int + order *list.List + items map[string]*list.Element +} + +func newAddressAliasLRU(capacity int) *addressAliasLRU { + if capacity <= 0 { + return nil + } + return &addressAliasLRU{ + capacity: capacity, + order: list.New(), + items: make(map[string]*list.Element, capacity), + } +} + +func (c *addressAliasLRU) get(key string) (string, bool) { + if c == nil { + return "", false + } + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[key] + if !ok { + return "", false + } + c.order.MoveToFront(el) + return el.Value.(*addressAliasLRUEntry).value, true +} + +func (c *addressAliasLRU) add(key, value string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + el.Value.(*addressAliasLRUEntry).value = value + c.order.MoveToFront(el) + return + } + el := c.order.PushFront(&addressAliasLRUEntry{key: key, value: value}) + c.items[key] = el + if c.order.Len() <= c.capacity { + return + } + oldest := c.order.Back() + if oldest == nil { + return + } + c.order.Remove(oldest) + delete(c.items, oldest.Value.(*addressAliasLRUEntry).key) +} diff --git a/db/address_hotness.go b/db/address_hotness.go new file mode 100644 index 0000000000..b28a7dc088 --- /dev/null +++ b/db/address_hotness.go @@ -0,0 +1,190 @@ +package db + +import ( + "container/list" + "fmt" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +type hotAddressConfigProvider interface { + HotAddressConfig() (minContracts, lruSize, minHits int) +} + +type addressContractsCacheConfigProvider interface { + AddressContractsCacheConfig() eth.AddressContractsCacheConfig +} + +type addressHotnessKey [eth.EthereumTypeAddressDescriptorLen]byte + +func addressHotnessKeyFromDesc(addr bchain.AddressDescriptor) (addressHotnessKey, bool) { + var key addressHotnessKey + if len(addr) != len(key) { + return key, false + } + copy(key[:], addr) + return key, true +} + +type addressHotness struct { + minContracts int + minHits int + lru *hotAddressLRU + onEvict func(addressHotnessKey) + // hits tracks per-block lookup counts so we can decide when an address is hot. + // It is cleared at BeginBlock to avoid unbounded growth. + hits map[addressHotnessKey]uint16 + // block stats (reset after reporting) to keep logging cheap. + // blockEligibleLookups counts lookups with contractCount >= minContracts (i.e., eligible for hotness). + blockEligibleLookups uint64 + // blockLRUHits counts eligible lookups that hit an already-hot address in the LRU. + blockLRUHits uint64 + // blockPromotions counts addresses promoted to hot (minHits reached) in the current block. + blockPromotions uint64 + // blockEvictions counts LRU evictions triggered by promotions in the current block. + blockEvictions uint64 +} + +func newAddressHotness(minContracts, lruSize, minHits int) *addressHotness { + if minContracts <= 0 || lruSize <= 0 || minHits <= 0 { + return nil + } + return &addressHotness{ + minContracts: minContracts, + minHits: minHits, + lru: newHotAddressLRU(lruSize), + // Pre-size the per-block hit map to avoid reallocs on busy blocks. + hits: make(map[addressHotnessKey]uint16), + } +} + +func newAddressHotnessFromParser(parser bchain.BlockChainParser) *addressHotness { + cfg, ok := parser.(hotAddressConfigProvider) + if !ok { + return nil + } + minContracts, lruSize, minHits := cfg.HotAddressConfig() + return newAddressHotness(minContracts, lruSize, minHits) +} + +func (h *addressHotness) BeginBlock() { + if h == nil { + return + } + // Reset per-block hit counts; LRU survives across blocks. + clear(h.hits) + // Reset per-block stats counters. + h.blockEligibleLookups = 0 + h.blockLRUHits = 0 + h.blockPromotions = 0 + h.blockEvictions = 0 +} + +func (h *addressHotness) ShouldUseIndex(addrKey addressHotnessKey, contractCount int) bool { + if h == nil || contractCount < h.minContracts { + return false + } + h.blockEligibleLookups++ + // Rule B: once an address is hot, reuse the index immediately. + if h.lru != nil && h.lru.touch(addrKey) { + h.blockLRUHits++ + return true + } + // Count hits within the current block; once minHits is reached, promote to LRU. + hits := h.hits[addrKey] + 1 + if hits < uint16(h.minHits) { + h.hits[addrKey] = hits + return false + } + delete(h.hits, addrKey) + if h.lru != nil { + // Promotion: once hot, an address stays hot until evicted by LRU capacity. + if evictedKey, evicted := h.lru.add(addrKey); evicted { + h.blockEvictions++ + if h.onEvict != nil { + h.onEvict(evictedKey) + } + } + h.blockPromotions++ + } + return true +} + +func (h *addressHotness) LogSuffix() string { + if h == nil { + return "" + } + if h.blockEligibleLookups == 0 && h.blockLRUHits == 0 && h.blockPromotions == 0 && h.blockEvictions == 0 { + return "" + } + hitRate := 0.0 + if h.blockEligibleLookups > 0 { + hitRate = float64(h.blockLRUHits) / float64(h.blockEligibleLookups) + } + return fmt.Sprintf(", hotness[eligible_lookups=%d, lru_hits=%d, promotions=%d, evictions=%d, hit_rate=%.3f]", + h.blockEligibleLookups, h.blockLRUHits, h.blockPromotions, h.blockEvictions, hitRate) +} + +func (h *addressHotness) Stats() (eligible, hits, promotions, evictions uint64) { + if h == nil { + return 0, 0, 0, 0 + } + return h.blockEligibleLookups, h.blockLRUHits, h.blockPromotions, h.blockEvictions +} + +type hotAddressLRU struct { + capacity int + order *list.List + items map[addressHotnessKey]*list.Element +} + +func newHotAddressLRU(capacity int) *hotAddressLRU { + if capacity <= 0 { + return nil + } + return &hotAddressLRU{ + capacity: capacity, + order: list.New(), + // items maps address -> list element; the list order is MRU->LRU. + items: make(map[addressHotnessKey]*list.Element, capacity), + } +} + +func (l *hotAddressLRU) touch(key addressHotnessKey) bool { + if l == nil { + return false + } + if el, ok := l.items[key]; ok { + // Hot: move to front so it won't be evicted soon. + l.order.MoveToFront(el) + return true + } + return false +} + +func (l *hotAddressLRU) add(key addressHotnessKey) (addressHotnessKey, bool) { + var zero addressHotnessKey + if l == nil { + return zero, false + } + if el, ok := l.items[key]; ok { + // Already hot; refresh recency. + l.order.MoveToFront(el) + return zero, false + } + el := l.order.PushFront(key) + l.items[key] = el + if l.order.Len() <= l.capacity { + return zero, false + } + // Evict the least-recently used hot address. + oldest := l.order.Back() + if oldest == nil { + return zero, false + } + evictedKey := oldest.Value.(addressHotnessKey) + l.order.Remove(oldest) + delete(l.items, evictedKey) + return evictedKey, true +} diff --git a/db/address_hotness_test.go b/db/address_hotness_test.go new file mode 100644 index 0000000000..f7c781d014 --- /dev/null +++ b/db/address_hotness_test.go @@ -0,0 +1,209 @@ +//go:build unittest + +package db + +import "testing" + +func makeHotKey(seed byte) addressHotnessKey { + var key addressHotnessKey + for i := range key { + key[i] = seed + } + return key +} + +func Test_newAddressHotness_Disabled(t *testing.T) { + if got := newAddressHotness(0, 1, 1); got != nil { + t.Fatal("expected nil when minContracts is disabled") + } + if got := newAddressHotness(1, 0, 1); got != nil { + t.Fatal("expected nil when lruSize is disabled") + } + if got := newAddressHotness(1, 1, 0); got != nil { + t.Fatal("expected nil when minHits is disabled") + } +} + +func Test_addressHotness_MinContractsGate(t *testing.T) { + hot := newAddressHotness(5, 4, 1) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + key := makeHotKey(1) + + if hot.ShouldUseIndex(key, 4) { + t.Fatal("expected contractCount below minContracts to skip index") + } + if !hot.ShouldUseIndex(key, 5) { + t.Fatal("expected hot address to use index once minContracts is met") + } +} + +func Test_addressHotness_HitsPromotionAndBeginBlock(t *testing.T) { + hot := newAddressHotness(2, 4, 3) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + key := makeHotKey(2) + hot.BeginBlock() + + if hot.ShouldUseIndex(key, 2) { + t.Fatal("expected first hit to stay cold") + } + if hot.ShouldUseIndex(key, 2) { + t.Fatal("expected second hit to stay cold") + } + if !hot.ShouldUseIndex(key, 2) { + t.Fatal("expected third hit to promote to hot") + } + + hot.BeginBlock() + if !hot.ShouldUseIndex(key, 2) { + t.Fatal("expected hot address to stay hot across blocks") + } +} + +func Test_addressHotness_LRUEviction(t *testing.T) { + hot := newAddressHotness(1, 2, 1) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + a := makeHotKey(10) + b := makeHotKey(11) + c := makeHotKey(12) + hot.BeginBlock() + + if !hot.ShouldUseIndex(a, 1) || !hot.ShouldUseIndex(b, 1) { + t.Fatal("expected A and B to be promoted to hot") + } + // Touch A so B becomes the least-recently used. + if !hot.ShouldUseIndex(a, 1) { + t.Fatal("expected A to remain hot after touch") + } + // Promote C; should evict B. + if !hot.ShouldUseIndex(c, 1) { + t.Fatal("expected C to be promoted to hot") + } + if _, ok := hot.lru.items[b]; ok { + t.Fatal("expected LRU eviction of B after promoting C") + } + if _, ok := hot.lru.items[a]; !ok { + t.Fatal("expected A to remain hot after eviction") + } + if _, ok := hot.lru.items[c]; !ok { + t.Fatal("expected C to be hot after promotion") + } +} + +func Test_addressHotness_LRUEvictionHook(t *testing.T) { + hot := newAddressHotness(1, 1, 1) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + a := makeHotKey(13) + b := makeHotKey(14) + var evicted []addressHotnessKey + hot.onEvict = func(key addressHotnessKey) { + evicted = append(evicted, key) + } + + if !hot.ShouldUseIndex(a, 1) { + t.Fatal("expected A to be promoted to hot") + } + if len(evicted) != 0 { + t.Fatal("did not expect eviction before LRU is full") + } + if !hot.ShouldUseIndex(b, 1) { + t.Fatal("expected B to be promoted to hot") + } + if len(evicted) != 1 || evicted[0] != a { + t.Fatalf("expected A to be evicted, got %v", evicted) + } +} + +func Test_addressHotness_Specs(t *testing.T) { + t.Run("it should reset per-block hits", func(t *testing.T) { + hot := newAddressHotness(1, 2, 2) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + key := makeHotKey(20) + hot.BeginBlock() + if hot.ShouldUseIndex(key, 1) { + t.Fatal("expected first hit to stay cold") + } + hot.BeginBlock() + if hot.ShouldUseIndex(key, 1) { + t.Fatal("expected hit count to reset between blocks") + } + }) + + t.Run("it should report a non-empty log suffix after activity", func(t *testing.T) { + hot := newAddressHotness(1, 2, 1) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + key := makeHotKey(24) + hot.BeginBlock() + if !hot.ShouldUseIndex(key, 1) { + t.Fatal("expected promotion to happen") + } + if got := hot.LogSuffix(); got == "" { + t.Fatal("expected log suffix to be non-empty after activity") + } + }) + + t.Run("it should not use index below minContracts even if hot", func(t *testing.T) { + hot := newAddressHotness(3, 2, 1) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + key := makeHotKey(21) + hot.BeginBlock() + if !hot.ShouldUseIndex(key, 3) { + t.Fatal("expected address to become hot at minContracts") + } + if hot.ShouldUseIndex(key, 2) { + t.Fatal("expected address below minContracts to skip index") + } + }) + + t.Run("it should promote immediately when minHits is one", func(t *testing.T) { + hot := newAddressHotness(1, 2, 1) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + key := makeHotKey(22) + hot.BeginBlock() + if !hot.ShouldUseIndex(key, 1) { + t.Fatal("expected immediate promotion when minHits is one") + } + if _, ok := hot.lru.items[key]; !ok { + t.Fatal("expected key to be present in LRU after promotion") + } + }) + + t.Run("it should not add to LRU before minHits", func(t *testing.T) { + hot := newAddressHotness(1, 2, 3) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + key := makeHotKey(23) + hot.BeginBlock() + if hot.ShouldUseIndex(key, 1) { + t.Fatal("expected first hit to stay cold") + } + if len(hot.lru.items) != 0 { + t.Fatal("expected LRU to remain empty before promotion") + } + if hot.hits[key] != 1 { + t.Fatal("expected hit counter to increment before promotion") + } + }) + + t.Run("it should reject short address descriptors", func(t *testing.T) { + if _, ok := addressHotnessKeyFromDesc([]byte{1, 2}); ok { + t.Fatal("expected short address descriptor to be rejected") + } + }) +} diff --git a/db/bulkconnect.go b/db/bulkconnect.go index b510fe3bfb..3ea420b355 100644 --- a/db/bulkconnect.go +++ b/db/bulkconnect.go @@ -1,11 +1,13 @@ package db import ( + "fmt" "time" "github.com/golang/glog" "github.com/linxGnu/grocksdb" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" ) // bulk connect @@ -27,9 +29,12 @@ type BulkConnect struct { bulkAddressesCount int ethBlockTxs []ethBlockTx txAddressesMap map[string]*TxAddresses + blockFilters map[string][]byte balances map[string]*AddrBalance - addressContracts map[string]*AddrContracts + addressContracts map[string]*unpackedAddrContracts height uint32 + bulkStats bulkConnectStats + bulkHotness bulkHotnessStats } const ( @@ -40,8 +45,37 @@ const ( partialStoreBalances = maxBulkBalances / 10 maxBulkAddrContracts = 1200000 partialStoreAddrContracts = maxBulkAddrContracts / 10 + maxBlockFilters = 1000 ) +type bulkConnectStats struct { + blocks uint64 + txs uint64 + tokenTransfers uint64 + internalTransfers uint64 + vin uint64 + vout uint64 +} + +type bulkHotnessStats struct { + eligible uint64 + hits uint64 + promotions uint64 + evictions uint64 +} + +func (b *BulkConnect) releaseBulkMemory() { + b.bulkAddresses = nil + b.bulkAddressesCount = 0 + b.ethBlockTxs = nil + b.txAddressesMap = nil + b.blockFilters = nil + b.balances = nil + b.addressContracts = nil + b.bulkStats = bulkConnectStats{} + b.bulkHotness = bulkHotnessStats{} +} + // InitBulkConnect initializes bulk connect and switches DB to inconsistent state func (d *RocksDB) InitBulkConnect() (*BulkConnect, error) { b := &BulkConnect{ @@ -49,11 +83,15 @@ func (d *RocksDB) InitBulkConnect() (*BulkConnect, error) { chainType: d.chainParser.GetChainType(), txAddressesMap: make(map[string]*TxAddresses), balances: make(map[string]*AddrBalance), - addressContracts: make(map[string]*AddrContracts), + addressContracts: make(map[string]*unpackedAddrContracts), + blockFilters: make(map[string][]byte), } if err := d.SetInconsistentState(true); err != nil { return nil, err } + if b.chainType == bchain.ChainEthereumType { + d.addrContractsCacheMaxBytes = d.bulkAddrContractsCacheMaxBytes + } glog.Info("rocksdb: bulk connect init, db set to inconsistent state") return b, nil } @@ -170,9 +208,93 @@ func (b *BulkConnect) storeBulkAddresses(wb *grocksdb.WriteBatch) error { return nil } +func (b *BulkConnect) storeBulkBlockFilters(wb *grocksdb.WriteBatch) error { + for blockHash, blockFilter := range b.blockFilters { + if err := b.d.storeBlockFilter(wb, blockHash, blockFilter); err != nil { + return err + } + } + b.blockFilters = make(map[string][]byte) + return nil +} + +func (b *BulkConnect) addEthereumStats(blockTxs []ethBlockTx) { + b.bulkStats.blocks++ + b.bulkStats.txs += uint64(len(blockTxs)) + for i := range blockTxs { + b.bulkStats.tokenTransfers += uint64(len(blockTxs[i].contracts)) + if blockTxs[i].internalData != nil { + b.bulkStats.internalTransfers += uint64(len(blockTxs[i].internalData.transfers)) + } + } + if b.d.hotAddrTracker != nil { + eligible, hits, promotions, evictions := b.d.hotAddrTracker.Stats() + b.bulkHotness.eligible += eligible + b.bulkHotness.hits += hits + b.bulkHotness.promotions += promotions + b.bulkHotness.evictions += evictions + } +} + +func (b *BulkConnect) addBitcoinStats(block *bchain.Block) { + b.bulkStats.blocks++ + b.bulkStats.txs += uint64(len(block.Txs)) + for i := range block.Txs { + b.bulkStats.vin += uint64(len(block.Txs[i].Vin)) + b.bulkStats.vout += uint64(len(block.Txs[i].Vout)) + } +} + +func (b *BulkConnect) updateSyncMetrics(scope string) { + if b.d.metrics == nil { + return + } + b.d.metrics.SyncBlockStats.With(common.Labels{"scope": scope, "kind": "blocks"}).Set(float64(b.bulkStats.blocks)) + b.d.metrics.SyncBlockStats.With(common.Labels{"scope": scope, "kind": "txs"}).Set(float64(b.bulkStats.txs)) + b.d.metrics.SyncBlockStats.With(common.Labels{"scope": scope, "kind": "token_transfers"}).Set(float64(b.bulkStats.tokenTransfers)) + b.d.metrics.SyncBlockStats.With(common.Labels{"scope": scope, "kind": "internal_transfers"}).Set(float64(b.bulkStats.internalTransfers)) + b.d.metrics.SyncBlockStats.With(common.Labels{"scope": scope, "kind": "vin"}).Set(float64(b.bulkStats.vin)) + b.d.metrics.SyncBlockStats.With(common.Labels{"scope": scope, "kind": "vout"}).Set(float64(b.bulkStats.vout)) + b.d.metrics.SyncHotnessStats.With(common.Labels{"scope": scope, "kind": "eligible_lookups"}).Set(float64(b.bulkHotness.eligible)) + b.d.metrics.SyncHotnessStats.With(common.Labels{"scope": scope, "kind": "lru_hits"}).Set(float64(b.bulkHotness.hits)) + b.d.metrics.SyncHotnessStats.With(common.Labels{"scope": scope, "kind": "promotions"}).Set(float64(b.bulkHotness.promotions)) + b.d.metrics.SyncHotnessStats.With(common.Labels{"scope": scope, "kind": "evictions"}).Set(float64(b.bulkHotness.evictions)) +} + +func (b *BulkConnect) statsLogSuffix() string { + if b.bulkStats.txs == 0 && b.bulkStats.tokenTransfers == 0 && b.bulkStats.internalTransfers == 0 && b.bulkStats.vin == 0 && b.bulkStats.vout == 0 { + return "" + } + if b.bulkStats.tokenTransfers == 0 && b.bulkStats.internalTransfers == 0 && b.bulkStats.vin == 0 && b.bulkStats.vout == 0 { + return fmt.Sprintf(", txs=%d", b.bulkStats.txs) + } + if b.bulkStats.tokenTransfers == 0 && b.bulkStats.internalTransfers == 0 { + return fmt.Sprintf(", txs=%d vin=%d vout=%d", b.bulkStats.txs, b.bulkStats.vin, b.bulkStats.vout) + } + if b.bulkStats.vin == 0 && b.bulkStats.vout == 0 { + return fmt.Sprintf(", txs=%d token_transfers=%d internal_transfers=%d", + b.bulkStats.txs, b.bulkStats.tokenTransfers, b.bulkStats.internalTransfers) + } + return fmt.Sprintf(", txs=%d token_transfers=%d internal_transfers=%d vin=%d vout=%d", + b.bulkStats.txs, b.bulkStats.tokenTransfers, b.bulkStats.internalTransfers, b.bulkStats.vin, b.bulkStats.vout) +} + +func (b *BulkConnect) resetStats() { + b.bulkStats = bulkConnectStats{} + b.bulkHotness = bulkHotnessStats{} +} + func (b *BulkConnect) connectBlockBitcoinType(block *bchain.Block, storeBlockTxs bool) error { + b.addBitcoinStats(block) addresses := make(addressesMap) - if err := b.d.processAddressesBitcoinType(block, addresses, b.txAddressesMap, b.balances); err != nil { + gf, err := bchain.NewGolombFilter(b.d.is.BlockGolombFilterP, b.d.is.BlockFilterScripts, block.BlockHeader.Hash, b.d.is.BlockFilterUseZeroedKey) + if err != nil { + glog.Error("connectBlockBitcoinType golomb filter error ", err) + gf = nil + } else if gf != nil && !gf.Enabled { + gf = nil + } + if err := b.d.processAddressesBitcoinType(block, addresses, b.txAddressesMap, b.balances, gf); err != nil { return err } var storeAddressesChan, storeBalancesChan chan error @@ -199,8 +321,11 @@ func (b *BulkConnect) connectBlockBitcoinType(block *bchain.Block, storeBlockTxs addresses: addresses, }) b.bulkAddressesCount += len(addresses) + if gf != nil { + b.blockFilters[block.BlockHeader.Hash] = gf.Compute() + } // open WriteBatch only if going to write - if sa || b.bulkAddressesCount > maxBulkAddresses || storeBlockTxs { + if sa || b.bulkAddressesCount > maxBulkAddresses || storeBlockTxs || len(b.blockFilters) > maxBlockFilters { start := time.Now() wb := grocksdb.NewWriteBatch() defer wb.Destroy() @@ -215,11 +340,22 @@ func (b *BulkConnect) connectBlockBitcoinType(block *bchain.Block, storeBlockTxs return err } } + if len(b.blockFilters) > maxBlockFilters { + if err := b.storeBulkBlockFilters(wb); err != nil { + return err + } + } if err := b.d.WriteBatch(wb); err != nil { return err } if bac > b.bulkAddressesCount { - glog.Info("rocksdb: height ", b.height, ", stored ", bac, " addresses, done in ", time.Since(start)) + suffix := b.statsLogSuffix() + if b.d.hotAddrTracker != nil { + suffix += b.d.hotAddrTracker.LogSuffix() + } + glog.Info("rocksdb: height ", b.height, ", stored ", bac, " addresses, done in ", time.Since(start), suffix) + b.updateSyncMetrics("bulk") + b.resetStats() } } if storeAddressesChan != nil { @@ -236,12 +372,12 @@ func (b *BulkConnect) connectBlockBitcoinType(block *bchain.Block, storeBlockTxs } func (b *BulkConnect) storeAddressContracts(wb *grocksdb.WriteBatch, all bool) (int, error) { - var ac map[string]*AddrContracts + var ac map[string]*unpackedAddrContracts if all { ac = b.addressContracts - b.addressContracts = make(map[string]*AddrContracts) + b.addressContracts = make(map[string]*unpackedAddrContracts) } else { - ac = make(map[string]*AddrContracts) + ac = make(map[string]*unpackedAddrContracts) // store some random address contracts for k, a := range b.addressContracts { ac[k] = a @@ -251,7 +387,7 @@ func (b *BulkConnect) storeAddressContracts(wb *grocksdb.WriteBatch, all bool) ( } } } - if err := b.d.storeAddressContracts(wb, ac); err != nil { + if err := b.d.storeUnpackedAddressContracts(wb, ac); err != nil { return 0, err } return len(ac), nil @@ -281,6 +417,7 @@ func (b *BulkConnect) connectBlockEthereumType(block *bchain.Block, storeBlockTx if err != nil { return err } + b.addEthereumStats(blockTxs) b.ethBlockTxs = append(b.ethBlockTxs, blockTxs...) var storeAddrContracts chan error var sa bool @@ -327,7 +464,13 @@ func (b *BulkConnect) connectBlockEthereumType(block *bchain.Block, storeBlockTx return err } if bac > b.bulkAddressesCount { - glog.Info("rocksdb: height ", b.height, ", stored ", bac, " addresses, done in ", time.Since(start)) + suffix := b.statsLogSuffix() + if b.d.hotAddrTracker != nil { + suffix += b.d.hotAddrTracker.LogSuffix() + } + glog.Info("rocksdb: height ", b.height, ", stored ", bac, " addresses, done in ", time.Since(start), suffix) + b.updateSyncMetrics("bulk") + b.resetStats() } } else { // if there are blockSpecificData, store them @@ -363,9 +506,18 @@ func (b *BulkConnect) ConnectBlock(block *bchain.Block, storeBlockTxs bool) erro return b.d.ConnectBlock(block) } -// Close flushes the cached data and switches DB from inconsistent state open +// Close flushes the cached data, restores tip cache sizing, and switches DB from inconsistent state open // after Close, the BulkConnect cannot be used func (b *BulkConnect) Close() error { + bulkClosed := false + if b.d != nil && b.chainType == bchain.ChainEthereumType { + defer func(db *RocksDB) { + db.addrContractsCacheMaxBytes = db.tipAddrContractsCacheMaxBytes + if bulkClosed { + db.flushAddrContractsCacheIfOverCap() + } + }(b.d) + } glog.Info("rocksdb: bulk connect closing") start := time.Now() var storeTxAddressesChan, storeBalancesChan, storeAddressContractsChan chan error @@ -380,14 +532,27 @@ func (b *BulkConnect) Close() error { } wb := grocksdb.NewWriteBatch() defer wb.Destroy() + if err := b.d.storeInternalDataEthereumType(wb, b.ethBlockTxs); err != nil { + return err + } + b.ethBlockTxs = b.ethBlockTxs[:0] bac := b.bulkAddressesCount if err := b.storeBulkAddresses(wb); err != nil { return err } + if err := b.storeBulkBlockFilters(wb); err != nil { + return err + } if err := b.d.WriteBatch(wb); err != nil { return err } - glog.Info("rocksdb: height ", b.height, ", stored ", bac, " addresses, done in ", time.Since(start)) + suffix := b.statsLogSuffix() + if b.d.hotAddrTracker != nil { + suffix += b.d.hotAddrTracker.LogSuffix() + } + glog.Info("rocksdb: height ", b.height, ", stored ", bac, " addresses, done in ", time.Since(start), suffix) + b.updateSyncMetrics("bulk") + b.resetStats() if storeTxAddressesChan != nil { if err := <-storeTxAddressesChan; err != nil { return err @@ -403,19 +568,26 @@ func (b *BulkConnect) Close() error { return err } } - bt, err := b.d.loadBlockTimes() - if err != nil { - return err - } - avg := b.d.is.SetBlockTimes(bt) - if b.d.metrics != nil { - b.d.metrics.AvgBlockPeriod.Set(float64(avg)) - } - if err := b.d.SetInconsistentState(false); err != nil { return err } glog.Info("rocksdb: bulk connect closed, db set to open state") + + // set block times asynchronously (if not in unit test), it slows server startup for chains with large number of blocks + d := b.d + if d.is.Coin == "coin-unittest" { + d.setBlockTimes() + } else { + // Keep async block-time refresh tracked so RocksDB.Close() waits for iterator teardown. + d.setBlockTimesWG.Add(1) + go func(db *RocksDB) { + defer db.setBlockTimesWG.Done() + db.setBlockTimes() + }(d) + } + + bulkClosed = true + b.releaseBulkMemory() b.d = nil return nil } diff --git a/db/contract_info_cache.go b/db/contract_info_cache.go new file mode 100644 index 0000000000..6582328eae --- /dev/null +++ b/db/contract_info_cache.go @@ -0,0 +1,106 @@ +package db + +import ( + "container/list" + "sync" + + "github.com/trezor/blockbook/bchain" +) + +// cachedContractsLRUMaxSize bounds the package-level ContractInfo cache. +// At ~250 B per entry, 50k caps the cache around ~12 MB. +const cachedContractsLRUMaxSize = 50_000 + +type contractInfoLRUEntry struct { + key string + value *bchain.ContractInfo + reorgGen uint64 + protocolGen uint64 +} + +type contractInfoLRU struct { + mu sync.Mutex + capacity int + order *list.List + items map[string]*list.Element +} + +func newContractInfoLRU(capacity int) *contractInfoLRU { + if capacity <= 0 { + return nil + } + return &contractInfoLRU{ + capacity: capacity, + order: list.New(), + items: make(map[string]*list.Element, capacity), + } +} + +// get returns the cached entry only if it was populated under the same +// (reorgGen, protocolGen) the caller now observes. A mismatch on either +// counter misses lazily, so: +// - a populate-after-delete race during a disconnect (reorgGen bumped) and +// - a populate-after-write race during a protocol mutation (protocolGen bumped) +// +// both cause the stale entry to be evicted on the next read. +func (c *contractInfoLRU) get(key string, reorgGen, protocolGen uint64) (*bchain.ContractInfo, bool) { + if c == nil { + return nil, false + } + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[key] + if !ok { + return nil, false + } + entry := el.Value.(*contractInfoLRUEntry) + if entry.reorgGen != reorgGen || entry.protocolGen != protocolGen { + c.order.Remove(el) + delete(c.items, key) + return nil, false + } + c.order.MoveToFront(el) + return entry.value, true +} + +// add stamps the entry with both counters sampled before the underlying CF +// reads; a subsequent disconnect (reorgGen bump) or protocol write +// (protocolGen bump) forces a miss on the next read. +func (c *contractInfoLRU) add(key string, value *bchain.ContractInfo, reorgGen, protocolGen uint64) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + entry := el.Value.(*contractInfoLRUEntry) + entry.value = value + entry.reorgGen = reorgGen + entry.protocolGen = protocolGen + c.order.MoveToFront(el) + return + } + el := c.order.PushFront(&contractInfoLRUEntry{key: key, value: value, reorgGen: reorgGen, protocolGen: protocolGen}) + c.items[key] = el + if c.order.Len() <= c.capacity { + return + } + oldest := c.order.Back() + if oldest == nil { + return + } + c.order.Remove(oldest) + delete(c.items, oldest.Value.(*contractInfoLRUEntry).key) +} + +func (c *contractInfoLRU) delete(key string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[key]; ok { + c.order.Remove(el) + delete(c.items, key) + } +} diff --git a/db/dboptions.go b/db/dboptions.go index 90f4b02eb2..ced33a2b94 100644 --- a/db/dboptions.go +++ b/db/dboptions.go @@ -2,6 +2,7 @@ package db // #include "rocksdb/c.h" import "C" +import "flag" import "github.com/linxGnu/grocksdb" /* @@ -38,6 +39,10 @@ func boolToChar(b bool) C.uchar { } */ +var ( + noCompression = flag.Bool("noCompression", false, "disable rocksdb compression when rocksdb library can't find compression library linked with binary") +) + func createAndSetDBOptions(bloomBits int, c *grocksdb.Cache, maxOpenFiles int) *grocksdb.Options { blockOpts := grocksdb.NewDefaultBlockBasedTableOptions() blockOpts.SetBlockSize(32 << 10) // 32kB @@ -57,6 +62,11 @@ func createAndSetDBOptions(bloomBits int, c *grocksdb.Cache, maxOpenFiles int) * opts.SetWriteBufferSize(1 << 27) // 128MB opts.SetMaxBytesForLevelBase(1 << 27) // 128MB opts.SetMaxOpenFiles(maxOpenFiles) - opts.SetCompression(grocksdb.LZ4HCCompression) + if *noCompression { + // resolve error rocksDB: Invalid argument: Compression type LZ4HC is not linked with the binary + opts.SetCompression(grocksdb.NoCompression) + } else { + opts.SetCompression(grocksdb.LZ4HCCompression) + } return opts } diff --git a/db/fiat.go b/db/fiat.go index f4392f2d34..f3ef3ac122 100644 --- a/db/fiat.go +++ b/db/fiat.go @@ -1,9 +1,10 @@ package db import ( + "bytes" "encoding/binary" + "encoding/json" "math" - "sync" "time" vlq "github.com/bsm/go-vlq" @@ -16,8 +17,8 @@ import ( // FiatRatesTimeFormat is a format string for storing FiatRates timestamps in rocksdb const FiatRatesTimeFormat = "20060102150405" // YYYYMMDDhhmmss -var lastTickerInDB *common.CurrencyRatesTicker -var lastTickerInDBMux sync.Mutex +const historicalFiatBootstrapStateKey = "HistoricalFiatBootstrapComplete" +const historicalFiatBootstrapAttemptsKey = "HistoricalFiatBootstrapAttempts" func packTimestamp(t *time.Time) []byte { return []byte(t.UTC().Format(FiatRatesTimeFormat)) @@ -87,20 +88,6 @@ func unpackCurrencyRatesTicker(buf []byte) (*common.CurrencyRatesTicker, error) return &ticker, nil } -// FiatRatesConvertDate checks if the date is in correct format and returns the Time object. -// Possible formats are: YYYYMMDDhhmmss, YYYYMMDDhhmm, YYYYMMDDhh, YYYYMMDD -func FiatRatesConvertDate(date string) (*time.Time, error) { - for format := FiatRatesTimeFormat; len(format) >= 8; format = format[:len(format)-2] { - convertedDate, err := time.Parse(format, date) - if err == nil { - return &convertedDate, nil - } - } - msg := "Date \"" + date + "\" does not match any of available formats. " - msg += "Possible formats are: YYYYMMDDhhmmss, YYYYMMDDhhmm, YYYYMMDDhh, YYYYMMDD" - return nil, errors.New(msg) -} - // FiatRatesStoreTicker stores ticker data at the specified time func (d *RocksDB) FiatRatesStoreTicker(wb *grocksdb.WriteBatch, ticker *common.CurrencyRatesTicker) error { if len(ticker.Rates) == 0 { @@ -148,22 +135,6 @@ func (d *RocksDB) FiatRatesGetTicker(tickerTime *time.Time) (*common.CurrencyRat // FiatRatesFindTicker gets FiatRates data closest to the specified timestamp, of the base currency, vsCurrency or the token if specified func (d *RocksDB) FiatRatesFindTicker(tickerTime *time.Time, vsCurrency string, token string) (*common.CurrencyRatesTicker, error) { - currentTicker := d.is.GetCurrentTicker("", "") - lastTickerInDBMux.Lock() - dbTicker := lastTickerInDB - lastTickerInDBMux.Unlock() - if currentTicker != nil { - if !tickerTime.Before(currentTicker.Timestamp) || (dbTicker != nil && tickerTime.After(dbTicker.Timestamp)) { - f := true - if token != "" && currentTicker.TokenRates != nil { - _, f = currentTicker.TokenRates[token] - } - if f { - return currentTicker, nil - } - } - } - tickerTimeFormatted := tickerTime.UTC().Format(FiatRatesTimeFormat) it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) defer it.Close() @@ -181,6 +152,88 @@ func (d *RocksDB) FiatRatesFindTicker(tickerTime *time.Time, vsCurrency string, return nil, nil } +// FiatRatesFindTickers gets FiatRates data closest to each specified timestamp. +// The method is optimized for timestamps sorted in ascending order. +func (d *RocksDB) FiatRatesFindTickers(timestamps []int64, vsCurrency string, token string) ([]*common.CurrencyRatesTicker, error) { + tickers := make([]*common.CurrencyRatesTicker, len(timestamps)) + if len(timestamps) == 0 { + return tickers, nil + } + if len(timestamps) == 1 { + ts := time.Unix(timestamps[0], 0).UTC() + ticker, err := d.FiatRatesFindTicker(&ts, vsCurrency, token) + if err != nil { + return nil, err + } + tickers[0] = ticker + return tickers, nil + } + + it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) + defer it.Close() + + first := true + // Cache decoding result for the current iterator key. For sparse token rates, + // multiple timestamps often resolve to the same key; avoid re-decoding it. + var decodedKey []byte + var decodedTicker *common.CurrencyRatesTicker + hasDecodedKey := false + for i, ts := range timestamps { + seekKey := []byte(time.Unix(ts, 0).UTC().Format(FiatRatesTimeFormat)) + if first { + it.Seek(seekKey) + first = false + } else if it.Valid() && bytes.Compare(it.Key().Data(), seekKey) < 0 { + it.Seek(seekKey) + } + + for ; it.Valid(); it.Next() { + keyData := it.Key().Data() + if hasDecodedKey && bytes.Equal(keyData, decodedKey) { + if decodedTicker != nil { + tickers[i] = decodedTicker + break + } + continue + } + + ticker, err := getTickerFromIterator(it, vsCurrency, token) + if err != nil { + glog.Error("FiatRatesFindTickers error: ", err) + return nil, err + } + decodedKey = append(decodedKey[:0], keyData...) + decodedTicker = ticker + hasDecodedKey = true + if ticker != nil { + tickers[i] = ticker + break + } + } + } + return tickers, nil +} + +// FiatRatesGetAllTickers gets FiatRates data closest to the specified timestamp, of the base currency, vsCurrency or the token if specified +func (d *RocksDB) FiatRatesGetAllTickers(fn func(ticker *common.CurrencyRatesTicker) error) error { + it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) + defer it.Close() + + for it.SeekToFirst(); it.Valid(); it.Next() { + ticker, err := getTickerFromIterator(it, "", "") + if err != nil { + return err + } + if ticker == nil { + return errors.New("FiatRatesGetAllTickers got nil ticker") + } + if err = fn(ticker); err != nil { + return err + } + } + return nil +} + // FiatRatesFindLastTicker gets the last FiatRates record, of the base currency, vsCurrency or the token if specified func (d *RocksDB) FiatRatesFindLastTicker(vsCurrency string, token string) (*common.CurrencyRatesTicker, error) { it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) @@ -193,14 +246,87 @@ func (d *RocksDB) FiatRatesFindLastTicker(vsCurrency string, token string) (*com return nil, err } if ticker != nil { - // if without filter, store the ticker for later use - if vsCurrency == "" && token == "" { - lastTickerInDBMux.Lock() - lastTickerInDB = ticker - lastTickerInDBMux.Unlock() - } return ticker, nil } } return nil, nil } + +func (d *RocksDB) FiatRatesGetSpecialTickers(key string) (*[]common.CurrencyRatesTicker, error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(key)) + if err != nil { + return nil, err + } + defer val.Free() + data := val.Data() + if data == nil { + return nil, nil + } + var tickers []common.CurrencyRatesTicker + if err := json.Unmarshal(data, &tickers); err != nil { + return nil, err + } + return &tickers, nil +} + +func (d *RocksDB) FiatRatesStoreSpecialTickers(key string, tickers *[]common.CurrencyRatesTicker) error { + data, err := json.Marshal(tickers) + if err != nil { + return err + } + return d.db.PutCF(d.wo, d.cfh[cfDefault], []byte(key), data) +} + +// FiatRatesGetHistoricalBootstrapComplete gets persisted historical bootstrap completion state. +// found=false means no state was stored yet (legacy deployments or pre-bootstrap). +func (d *RocksDB) FiatRatesGetHistoricalBootstrapComplete() (complete bool, found bool, err error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(historicalFiatBootstrapStateKey)) + if err != nil { + return false, false, err + } + defer val.Free() + data := val.Data() + if data == nil { + return false, false, nil + } + if err := json.Unmarshal(data, &complete); err != nil { + return false, false, err + } + return complete, true, nil +} + +// FiatRatesSetHistoricalBootstrapComplete stores historical bootstrap completion state. +func (d *RocksDB) FiatRatesSetHistoricalBootstrapComplete(complete bool) error { + data, err := json.Marshal(complete) + if err != nil { + return err + } + return d.db.PutCF(d.wo, d.cfh[cfDefault], []byte(historicalFiatBootstrapStateKey), data) +} + +// FiatRatesGetHistoricalBootstrapAttempts gets persisted number of failed bootstrap attempts. +// found=false means no attempt counter was stored yet. +func (d *RocksDB) FiatRatesGetHistoricalBootstrapAttempts() (attempts int, found bool, err error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(historicalFiatBootstrapAttemptsKey)) + if err != nil { + return 0, false, err + } + defer val.Free() + data := val.Data() + if data == nil { + return 0, false, nil + } + if err := json.Unmarshal(data, &attempts); err != nil { + return 0, false, err + } + return attempts, true, nil +} + +// FiatRatesSetHistoricalBootstrapAttempts stores number of failed bootstrap attempts. +func (d *RocksDB) FiatRatesSetHistoricalBootstrapAttempts(attempts int) error { + data, err := json.Marshal(attempts) + if err != nil { + return err + } + return d.db.PutCF(d.wo, d.cfh[cfDefault], []byte(historicalFiatBootstrapAttemptsKey), data) +} diff --git a/db/fiat_test.go b/db/fiat_test.go index 1e5f55fb70..3d743d36db 100644 --- a/db/fiat_test.go +++ b/db/fiat_test.go @@ -17,22 +17,6 @@ func TestRocksTickers(t *testing.T) { }) defer closeAndDestroyRocksDB(t, d) - // Test valid formats - for _, date := range []string{"20190130", "2019013012", "201901301250", "20190130125030"} { - _, err := FiatRatesConvertDate(date) - if err != nil { - t.Errorf("%v", err) - } - } - - // Test invalid formats - for _, date := range []string{"01102019", "10201901", "", "abc", "20190130xxx"} { - _, err := FiatRatesConvertDate(date) - if err == nil { - t.Errorf("Wrongly-formatted date \"%v\" marked as valid!", date) - } - } - // Test storing & finding tickers pastKey, _ := time.Parse(FiatRatesTimeFormat, "20190627000000") futureKey, _ := time.Parse(FiatRatesTimeFormat, "20190630000000") @@ -158,22 +142,140 @@ func TestRocksTickers(t *testing.T) { t.Errorf("Ticker %v found unexpectedly for aud vsCurrency", ticker) } - ticker = d.is.GetCurrentTicker("", "") - if ticker != nil { - t.Errorf("FiatRatesGetCurrentTicker %v found unexpectedly", ticker) + queries := []struct { + name string + vsCurrency string + token string + }{ + {name: "base", vsCurrency: "", token: ""}, + {name: "eur", vsCurrency: "eur", token: ""}, + {name: "token", vsCurrency: "", token: "0x6B175474E89094C44Da98b954EedeAC495271d0F"}, + } + timestamps := []int64{ + pastKey.Unix(), + ts1.Unix(), + ts1.Unix() + 3600, + ts2.Unix(), + futureKey.Unix(), + } + for _, q := range queries { + got, err := d.FiatRatesFindTickers(timestamps, q.vsCurrency, q.token) + if err != nil { + t.Fatalf("FiatRatesFindTickers(%s) returned error: %v", q.name, err) + } + if len(got) != len(timestamps) { + t.Fatalf("FiatRatesFindTickers(%s) returned %d items, want %d", q.name, len(got), len(timestamps)) + } + for i, ts := range timestamps { + tsTime := time.Unix(ts, 0).UTC() + want, err := d.FiatRatesFindTicker(&tsTime, q.vsCurrency, q.token) + if err != nil { + t.Fatalf("FiatRatesFindTicker(%s) returned error: %v", q.name, err) + } + if !reflect.DeepEqual(got[i], want) { + t.Fatalf("FiatRatesFindTickers(%s) mismatch at index %d: got %+v, want %+v", q.name, i, got[i], want) + } + } + } + +} + +func TestFiatRatesFindTickersSparseTokenGaps(t *testing.T) { + d := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + ts1, _ := time.Parse(FiatRatesTimeFormat, "20190628000000") + ts2, _ := time.Parse(FiatRatesTimeFormat, "20190629000000") + ts3, _ := time.Parse(FiatRatesTimeFormat, "20190630000000") + + token := "0x82dF128257A7d7556262E1AB7F1f639d9775B85E" + + ticker1 := &common.CurrencyRatesTicker{ + Timestamp: ts1, + Rates: map[string]float32{ + "usd": 20000, + }, + TokenRates: map[string]float32{ + "0x6B175474E89094C44Da98b954EedeAC495271d0F": 17.2, + }, + } + ticker2 := &common.CurrencyRatesTicker{ + Timestamp: ts2, + Rates: map[string]float32{ + "usd": 30000, + }, + } + ticker3 := &common.CurrencyRatesTicker{ + Timestamp: ts3, + Rates: map[string]float32{ + "usd": 40000, + }, + TokenRates: map[string]float32{ + token: 13.1, + }, + } + + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.FiatRatesStoreTicker(wb, ticker1); err != nil { + t.Fatalf("failed storing ticker1: %v", err) + } + if err := d.FiatRatesStoreTicker(wb, ticker2); err != nil { + t.Fatalf("failed storing ticker2: %v", err) + } + if err := d.FiatRatesStoreTicker(wb, ticker3); err != nil { + t.Fatalf("failed storing ticker3: %v", err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatalf("failed writing batch: %v", err) } - d.is.SetCurrentTicker(ticker1) - ticker = d.is.GetCurrentTicker("", "") + timestamps := []int64{ + ts1.Unix() - 1, + ts1.Unix(), + ts1.Unix() + 3600, + ts2.Unix(), + ts2.Unix() + 3600, + ts3.Unix(), + ts3.Unix() + 3600, + } + + got, err := d.FiatRatesFindTickers(timestamps, "", token) if err != nil { - t.Errorf("TestRocksTickers err: %+v", err) - } else if ticker == nil { - t.Errorf("Ticker not found") - } else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker1.Timestamp.Format(FiatRatesTimeFormat) { - t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp) + t.Fatalf("FiatRatesFindTickers returned error: %v", err) + } + if len(got) != len(timestamps) { + t.Fatalf("FiatRatesFindTickers returned %d items, want %d", len(got), len(timestamps)) + } + + for i := 0; i < len(timestamps)-1; i++ { + if got[i] == nil { + t.Fatalf("expected ticker at index %d, got nil", i) + } + if got[i].Timestamp.Unix() != ts3.Unix() { + t.Fatalf("unexpected timestamp at index %d: got %d, want %d", i, got[i].Timestamp.Unix(), ts3.Unix()) + } + if got[i].TokenRates[token] != 13.1 { + t.Fatalf("unexpected token rate at index %d: got %v, want %v", i, got[i].TokenRates[token], float32(13.1)) + } + } + if got[len(got)-1] != nil { + t.Fatalf("expected nil for timestamp after last suitable ticker, got %+v", got[len(got)-1]) } - d.is.SetCurrentTicker(nil) + // Keep parity with single-item lookup semantics. + for i, ts := range timestamps { + tsTime := time.Unix(ts, 0).UTC() + want, err := d.FiatRatesFindTicker(&tsTime, "", token) + if err != nil { + t.Fatalf("FiatRatesFindTicker returned error at index %d: %v", i, err) + } + if !reflect.DeepEqual(got[i], want) { + t.Fatalf("FiatRatesFindTickers mismatch at index %d: got %+v, want %+v", i, got[i], want) + } + } } func Test_packUnpackCurrencyRatesTicker(t *testing.T) { diff --git a/db/rocksdb.go b/db/rocksdb.go index 783cdd120e..db2ae34cd4 100644 --- a/db/rocksdb.go +++ b/db/rocksdb.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "sync" + "sync/atomic" "time" "unsafe" @@ -22,7 +23,7 @@ import ( "github.com/trezor/blockbook/common" ) -const dbVersion = 6 +const dbVersion = 7 const packedHeightBytes = 4 const maxAddrDescLen = 1024 @@ -57,19 +58,46 @@ const ( addressBalanceDetailUTXOIndexed = 2 ) +const addrContractsCacheMinSize = 300_000 // limit for caching address contracts in memory to speed up indexing + // RocksDB handle type RocksDB struct { - path string - db *grocksdb.DB - wo *grocksdb.WriteOptions - ro *grocksdb.ReadOptions - cfh []*grocksdb.ColumnFamilyHandle - chainParser bchain.BlockChainParser - is *common.InternalState - metrics *common.Metrics - cache *grocksdb.Cache - maxOpenFiles int - cbs connectBlockStats + path string + db *grocksdb.DB + wo *grocksdb.WriteOptions + ro *grocksdb.ReadOptions + cfh []*grocksdb.ColumnFamilyHandle + chainParser bchain.BlockChainParser + is *common.InternalState + metrics *common.Metrics + cache *grocksdb.Cache + maxOpenFiles int + cbs connectBlockStats + extendedIndex bool + connectBlockMux sync.Mutex + // reorgGen advances on every successful Ethereum-type disconnect; embed + // in cache keys so a same-height reorg invalidates them lazily. + reorgGen atomic.Uint64 + // protocolGen advances on every successful per-protocol row write + // (cfErcProtocols). cachedContracts stamps entries with this counter + // so a populate-after-write race (reader reads cfErcProtocols before the + // row exists, then caches the stale negative under an unchanged reorgGen) + // is invalidated lazily on the next read. + protocolGen atomic.Uint64 + addrContractsCacheMux sync.Mutex + addrContractsCache map[string]*unpackedAddrContracts + // addrContractsCacheMinSize is the packed size threshold (bytes) before we cache an entry. + addrContractsCacheMinSize int + // tipAddrContractsCacheMaxBytes is the configured non-bulk cap. + tipAddrContractsCacheMaxBytes int64 + // bulkAddrContractsCacheMaxBytes is the configured bulk-connect cap. + bulkAddrContractsCacheMaxBytes int64 + // addrContractsCacheMaxBytes is a soft cap; when exceeded we flush and clear the cache. + addrContractsCacheMaxBytes int64 + // addrContractsCacheBytes tracks cached size based on the packed size at insertion time. + addrContractsCacheBytes int64 + hotAddrTracker *addressHotness + setBlockTimesWG sync.WaitGroup } const ( @@ -82,6 +110,7 @@ const ( // BitcoinType cfAddressBalance cfTxAddresses + cfBlockFilter __break__ @@ -94,6 +123,10 @@ const ( // TODO move to common section cfAddressAliases + + // cfErcProtocols stores per-protocol detection records keyed by contract; + // decoupled from cfContracts so API writes never collide with sync. + cfErcProtocols ) // common columns @@ -101,8 +134,8 @@ var cfNames []string var cfBaseNames = []string{"default", "height", "addresses", "blockTxs", "transactions", "fiatRates"} // type specific columns -var cfNamesBitcoinType = []string{"addressBalance", "txAddresses"} -var cfNamesEthereumType = []string{"addressContracts", "internalData", "contracts", "functionSignatures", "blockInternalDataErrors", "addressAliases"} +var cfNamesBitcoinType = []string{"addressBalance", "txAddresses", "blockFilter"} +var cfNamesEthereumType = []string{"addressContracts", "internalData", "contracts", "functionSignatures", "blockInternalDataErrors", "addressAliases", "ercProtocols"} func openDB(path string, c *grocksdb.Cache, openFiles int) (*grocksdb.DB, []*grocksdb.ColumnFamilyHandle, error) { // opts with bloom filter @@ -126,7 +159,7 @@ func openDB(path string, c *grocksdb.Cache, openFiles int) (*grocksdb.DB, []*gro // NewRocksDB opens an internal handle to RocksDB environment. Close // needs to be called to release it. -func NewRocksDB(path string, cacheSize, maxOpenFiles int, parser bchain.BlockChainParser, metrics *common.Metrics) (d *RocksDB, err error) { +func NewRocksDB(path string, cacheSize, maxOpenFiles int, parser bchain.BlockChainParser, metrics *common.Metrics, extendedIndex bool) (d *RocksDB, err error) { glog.Infof("rocksdb: opening %s, required data version %v, cache size %v, max open files %v", path, dbVersion, cacheSize, maxOpenFiles) cfNames = append([]string{}, cfBaseNames...) @@ -135,6 +168,7 @@ func NewRocksDB(path string, cacheSize, maxOpenFiles int, parser bchain.BlockCha cfNames = append(cfNames, cfNamesBitcoinType...) } else if chainType == bchain.ChainEthereumType { cfNames = append(cfNames, cfNamesEthereumType...) + extendedIndex = false } else { return nil, errors.New("Unknown chain type") } @@ -146,10 +180,60 @@ func NewRocksDB(path string, cacheSize, maxOpenFiles int, parser bchain.BlockCha } wo := grocksdb.NewDefaultWriteOptions() ro := grocksdb.NewDefaultReadOptions() - return &RocksDB{path, db, wo, ro, cfh, parser, nil, metrics, c, maxOpenFiles, connectBlockStats{}}, nil + r := &RocksDB{ + path: path, + db: db, + wo: wo, + ro: ro, + cfh: cfh, + chainParser: parser, + is: nil, + metrics: metrics, + cache: c, + maxOpenFiles: maxOpenFiles, + cbs: connectBlockStats{}, + extendedIndex: extendedIndex, + connectBlockMux: sync.Mutex{}, + addrContractsCacheMux: sync.Mutex{}, + addrContractsCache: make(map[string]*unpackedAddrContracts), + addrContractsCacheMinSize: addrContractsCacheMinSize, + tipAddrContractsCacheMaxBytes: 0, + bulkAddrContractsCacheMaxBytes: 0, + addrContractsCacheMaxBytes: 0, + addrContractsCacheBytes: 0, + hotAddrTracker: nil, + } + if chainType == bchain.ChainEthereumType { + r.hotAddrTracker = newAddressHotnessFromParser(parser) + if r.hotAddrTracker != nil { + r.hotAddrTracker.onEvict = r.dropAddrContractsContractIndex + } + if cfg, ok := parser.(addressContractsCacheConfigProvider); ok { + cacheCfg := cfg.AddressContractsCacheConfig() + if cacheCfg.MinSize > 0 { + r.addrContractsCacheMinSize = cacheCfg.MinSize + } + if cacheCfg.TipMaxBytes > 0 { + r.tipAddrContractsCacheMaxBytes = cacheCfg.TipMaxBytes + r.addrContractsCacheMaxBytes = cacheCfg.TipMaxBytes + } + if cacheCfg.BulkMaxBytes > 0 { + r.bulkAddrContractsCacheMaxBytes = cacheCfg.BulkMaxBytes + } + } + if r.tipAddrContractsCacheMaxBytes == 0 { + r.tipAddrContractsCacheMaxBytes = r.addrContractsCacheMaxBytes + } + if r.bulkAddrContractsCacheMaxBytes == 0 { + r.bulkAddrContractsCacheMaxBytes = r.addrContractsCacheMaxBytes + } + go r.periodicStoreAddrContractsCache() + } + return r, nil } func (d *RocksDB) closeDB() error { + d.setBlockTimesWG.Wait() for _, h := range d.cfh { h.Destroy() } @@ -161,6 +245,10 @@ func (d *RocksDB) closeDB() error { // Close releases the RocksDB environment opened in NewRocksDB. func (d *RocksDB) Close() error { if d.db != nil { + // store cached address contracts + if d.chainParser.GetChainType() == bchain.ChainEthereumType { + d.storeAddrContractsCache() + } // store the internal state of the app if d.is != nil && d.is.DbState == common.DbStateOpen { d.is.DbState = common.DbStateClosed @@ -204,6 +292,11 @@ func (d *RocksDB) WriteBatch(wb *grocksdb.WriteBatch) error { return d.db.Write(d.wo, wb) } +// HasExtendedIndex returns true if the DB indexes input txids and spending data +func (d *RocksDB) HasExtendedIndex() bool { + return d.extendedIndex +} + // GetMemoryStats returns memory usage statistics as reported by RocksDB func (d *RocksDB) GetMemoryStats() string { var total, indexAndFilter, memtable uint64 @@ -323,11 +416,25 @@ const ( opDelete = 1 ) +// ReorgGeneration returns the current generation counter; bumps on disconnect. +func (d *RocksDB) ReorgGeneration() uint64 { + return d.reorgGen.Load() +} + // ConnectBlock indexes addresses in the block and stores them in db func (d *RocksDB) ConnectBlock(block *bchain.Block) error { + d.connectBlockMux.Lock() + defer d.connectBlockMux.Unlock() + wb := grocksdb.NewWriteBatch() defer wb.Destroy() + var tipTxs uint64 + var tipTokenTransfers uint64 + var tipInternalTransfers uint64 + var tipVin uint64 + var tipVout uint64 + if glog.V(2) { glog.Infof("rocksdb: insert %d %s", block.Height, block.Hash) } @@ -341,9 +448,23 @@ func (d *RocksDB) ConnectBlock(block *bchain.Block) error { if chainType == bchain.ChainBitcoinType { txAddressesMap := make(map[string]*TxAddresses) balances := make(map[string]*AddrBalance) - if err := d.processAddressesBitcoinType(block, addresses, txAddressesMap, balances); err != nil { + gf, err := bchain.NewGolombFilter(d.is.BlockGolombFilterP, d.is.BlockFilterScripts, block.BlockHeader.Hash, d.is.BlockFilterUseZeroedKey) + if err != nil { + glog.Error("ConnectBlock golomb filter error ", err) + gf = nil + } else if gf != nil && !gf.Enabled { + gf = nil + } + if err := d.processAddressesBitcoinType(block, addresses, txAddressesMap, balances, gf); err != nil { return err } + if d.metrics != nil { + tipTxs = uint64(len(block.Txs)) + for i := range block.Txs { + tipVin += uint64(len(block.Txs[i].Vin)) + tipVout += uint64(len(block.Txs[i].Vout)) + } + } if err := d.storeTxAddresses(wb, txAddressesMap); err != nil { return err } @@ -353,13 +474,28 @@ func (d *RocksDB) ConnectBlock(block *bchain.Block) error { if err := d.storeAndCleanupBlockTxs(wb, block); err != nil { return err } + if gf != nil { + blockFilter := gf.Compute() + if err := d.storeBlockFilter(wb, block.BlockHeader.Hash, blockFilter); err != nil { + return err + } + } } else if chainType == bchain.ChainEthereumType { - addressContracts := make(map[string]*AddrContracts) + addressContracts := make(map[string]*unpackedAddrContracts) blockTxs, err := d.processAddressesEthereumType(block, addresses, addressContracts) if err != nil { return err } - if err := d.storeAddressContracts(wb, addressContracts); err != nil { + if d.metrics != nil { + for i := range blockTxs { + tipTokenTransfers += uint64(len(blockTxs[i].contracts)) + if blockTxs[i].internalData != nil { + tipInternalTransfers += uint64(len(blockTxs[i].internalData.transfers)) + } + } + tipTxs = uint64(len(blockTxs)) + } + if err := d.storeUnpackedAddressContracts(wb, addressContracts); err != nil { return err } if err := d.storeInternalDataEthereumType(wb, blockTxs); err != nil { @@ -380,10 +516,28 @@ func (d *RocksDB) ConnectBlock(block *bchain.Block) error { if err := d.WriteBatch(wb); err != nil { return err } - avg := d.is.AppendBlockTime(uint32(block.Time)) + avg := d.is.SetBlockTime(block.Height, uint32(block.Time)) if d.metrics != nil { d.metrics.AvgBlockPeriod.Set(float64(avg)) } + if d.metrics != nil { + if chainType == bchain.ChainBitcoinType { + d.metrics.SyncBlockStats.With(common.Labels{"scope": "tip", "kind": "txs"}).Set(float64(tipTxs)) + d.metrics.SyncBlockStats.With(common.Labels{"scope": "tip", "kind": "vin"}).Set(float64(tipVin)) + d.metrics.SyncBlockStats.With(common.Labels{"scope": "tip", "kind": "vout"}).Set(float64(tipVout)) + } else if chainType == bchain.ChainEthereumType { + d.metrics.SyncBlockStats.With(common.Labels{"scope": "tip", "kind": "txs"}).Set(float64(tipTxs)) + d.metrics.SyncBlockStats.With(common.Labels{"scope": "tip", "kind": "token_transfers"}).Set(float64(tipTokenTransfers)) + d.metrics.SyncBlockStats.With(common.Labels{"scope": "tip", "kind": "internal_transfers"}).Set(float64(tipInternalTransfers)) + if d.hotAddrTracker != nil { + eligible, hits, promotions, evictions := d.hotAddrTracker.Stats() + d.metrics.SyncHotnessStats.With(common.Labels{"scope": "tip", "kind": "eligible_lookups"}).Set(float64(eligible)) + d.metrics.SyncHotnessStats.With(common.Labels{"scope": "tip", "kind": "lru_hits"}).Set(float64(hits)) + d.metrics.SyncHotnessStats.With(common.Labels{"scope": "tip", "kind": "promotions"}).Set(float64(promotions)) + d.metrics.SyncHotnessStats.With(common.Labels{"scope": "tip", "kind": "evictions"}).Set(float64(evictions)) + } + } + } return nil } @@ -408,6 +562,9 @@ type outpoint struct { type TxInput struct { AddrDesc bchain.AddressDescriptor ValueSat big.Int + // extended index properties + Txid string + Vout uint32 } // Addresses converts AddressDescriptor of the input to array of strings @@ -420,6 +577,10 @@ type TxOutput struct { AddrDesc bchain.AddressDescriptor Spent bool ValueSat big.Int + // extended index properties + SpentTxid string + SpentIndex uint32 + SpentHeight uint32 } // Addresses converts AddressDescriptor of the output to array of strings @@ -432,6 +593,8 @@ type TxAddresses struct { Height uint32 Inputs []TxInput Outputs []TxOutput + // extended index properties + VSize uint32 } // Utxo holds information about unspent transaction output @@ -574,7 +737,7 @@ func (d *RocksDB) GetAndResetConnectBlockStats() string { return s } -func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses addressesMap, txAddressesMap map[string]*TxAddresses, balances map[string]*AddrBalance) error { +func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses addressesMap, txAddressesMap map[string]*TxAddresses, balances map[string]*AddrBalance, gf *bchain.GolombFilter) error { blockTxIDs := make([][]byte, len(block.Txs)) blockTxAddresses := make([]*TxAddresses, len(block.Txs)) // first process all outputs so that inputs can refer to txs in this block @@ -586,13 +749,21 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses add } blockTxIDs[txi] = btxID ta := TxAddresses{Height: block.Height} + if d.extendedIndex { + if tx.VSize > 0 { + ta.VSize = uint32(tx.VSize) + } else { + ta.VSize = uint32(len(tx.Hex)) + } + } ta.Outputs = make([]TxOutput, len(tx.Vout)) txAddressesMap[string(btxID)] = &ta blockTxAddresses[txi] = &ta - for i, output := range tx.Vout { + for i := range tx.Vout { + output := &tx.Vout[i] tao := &ta.Outputs[i] tao.ValueSat = output.ValueSat - addrDesc, err := d.chainParser.GetAddrDescFromVout(&output) + addrDesc, err := d.chainParser.GetAddrDescFromVout(output) if err != nil || len(addrDesc) == 0 || len(addrDesc) > maxAddrDescLen { if err != nil { // do not log ErrAddressMissing, transactions can be without to address (for example eth contracts) @@ -604,6 +775,9 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses add } continue } + if gf != nil { + gf.AddAddrDesc(addrDesc, tx) + } tao.AddrDesc = addrDesc if d.chainParser.IsAddrDescIndexable(addrDesc) { strAddrDesc := string(addrDesc) @@ -642,7 +816,8 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses add ta := blockTxAddresses[txi] ta.Inputs = make([]TxInput, len(tx.Vin)) logged := false - for i, input := range tx.Vin { + for i := range tx.Vin { + input := &tx.Vin[i] tai := &ta.Inputs[i] btxID, err := d.chainParser.PackTxid(input.Txid) if err != nil { @@ -677,10 +852,20 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses add if spentOutput.Spent { glog.Warningf("rocksdb: height %d, tx %v, input tx %v vout %v is double spend", block.Height, tx.Txid, input.Txid, input.Vout) } + if gf != nil { + gf.AddAddrDesc(spentOutput.AddrDesc, tx) + } tai.AddrDesc = spentOutput.AddrDesc tai.ValueSat = spentOutput.ValueSat // mark the output as spent in tx spentOutput.Spent = true + if d.extendedIndex { + spentOutput.SpentTxid = tx.Txid + spentOutput.SpentIndex = uint32(i) + spentOutput.SpentHeight = block.Height + tai.Txid = input.Txid + tai.Vout = input.Vout + } if len(spentOutput.AddrDesc) == 0 { if !logged { glog.V(1).Infof("rocksdb: height %d, tx %v, input tx %v vout %v skipping empty address", block.Height, tx.Txid, input.Txid, input.Vout) @@ -743,6 +928,24 @@ func addToAddressesMap(addresses addressesMap, strAddrDesc string, btxID []byte, return false } +func (d *RocksDB) getTxIndexesForAddressAndBlock(addrDesc bchain.AddressDescriptor, height uint32) ([]txIndexes, error) { + key := packAddressKey(addrDesc, height) + val, err := d.db.GetCF(d.ro, d.cfh[cfAddresses], key) + if err != nil { + return nil, err + } + defer val.Free() + // nil data means the key was not found in DB + if val.Data() == nil { + return nil, nil + } + rv, err := d.unpackTxIndexes(val.Data()) + if err != nil { + return nil, err + } + return rv, nil +} + func (d *RocksDB) storeAddresses(wb *grocksdb.WriteBatch, height uint32, addresses addressesMap) error { for addrDesc, txi := range addresses { ba := bchain.AddressDescriptor(addrDesc) @@ -757,7 +960,7 @@ func (d *RocksDB) storeTxAddresses(wb *grocksdb.WriteBatch, am map[string]*TxAdd varBuf := make([]byte, maxPackedBigintBytes) buf := make([]byte, 1024) for txID, ta := range am { - buf = packTxAddresses(ta, buf, varBuf) + buf = d.packTxAddresses(ta, buf, varBuf) wb.PutCF(d.cfh[cfTxAddresses], []byte(txID), buf) } return nil @@ -791,10 +994,11 @@ func (d *RocksDB) cleanupBlockTxs(wb *grocksdb.WriteBatch, block *bchain.Block) } // nil data means the key was not found in DB if val.Data() == nil { + val.Free() break } val.Free() - d.db.DeleteCF(d.wo, d.cfh[cfBlockTxs], key) + wb.DeleteCF(d.cfh[cfBlockTxs], key) } } return nil @@ -901,7 +1105,7 @@ func (d *RocksDB) getTxAddresses(btxID []byte) (*TxAddresses, error) { if len(buf) < 3 { return nil, nil } - return unpackTxAddresses(buf) + return d.unpackTxAddresses(buf) } // GetTxAddresses returns TxAddresses for given txid or nil if not found @@ -932,34 +1136,63 @@ func (d *RocksDB) AddrDescForOutpoint(outpoint bchain.Outpoint) (bchain.AddressD return ta.Outputs[outpoint.Vout].AddrDesc, &ta.Outputs[outpoint.Vout].ValueSat } -func packTxAddresses(ta *TxAddresses, buf []byte, varBuf []byte) []byte { +func (d *RocksDB) packTxAddresses(ta *TxAddresses, buf []byte, varBuf []byte) []byte { buf = buf[:0] l := packVaruint(uint(ta.Height), varBuf) buf = append(buf, varBuf[:l]...) + if d.extendedIndex { + l = packVaruint(uint(ta.VSize), varBuf) + buf = append(buf, varBuf[:l]...) + } l = packVaruint(uint(len(ta.Inputs)), varBuf) buf = append(buf, varBuf[:l]...) for i := range ta.Inputs { - buf = appendTxInput(&ta.Inputs[i], buf, varBuf) + buf = d.appendTxInput(&ta.Inputs[i], buf, varBuf) } l = packVaruint(uint(len(ta.Outputs)), varBuf) buf = append(buf, varBuf[:l]...) for i := range ta.Outputs { - buf = appendTxOutput(&ta.Outputs[i], buf, varBuf) + buf = d.appendTxOutput(&ta.Outputs[i], buf, varBuf) } return buf } -func appendTxInput(txi *TxInput, buf []byte, varBuf []byte) []byte { +func (d *RocksDB) appendTxInput(txi *TxInput, buf []byte, varBuf []byte) []byte { la := len(txi.AddrDesc) - l := packVaruint(uint(la), varBuf) - buf = append(buf, varBuf[:l]...) - buf = append(buf, txi.AddrDesc...) - l = packBigint(&txi.ValueSat, varBuf) - buf = append(buf, varBuf[:l]...) + var l int + if d.extendedIndex { + if txi.Txid == "" { + // coinbase transaction + la = ^la + } + l = packVarint(la, varBuf) + buf = append(buf, varBuf[:l]...) + buf = append(buf, txi.AddrDesc...) + l = packBigint(&txi.ValueSat, varBuf) + buf = append(buf, varBuf[:l]...) + if la >= 0 { + btxID, err := d.chainParser.PackTxid(txi.Txid) + if err != nil { + if err != bchain.ErrTxidMissing { + glog.Error("Cannot pack txid ", txi.Txid) + } + btxID = make([]byte, d.chainParser.PackedTxidLen()) + } + buf = append(buf, btxID...) + l = packVaruint(uint(txi.Vout), varBuf) + buf = append(buf, varBuf[:l]...) + } + } else { + l = packVaruint(uint(la), varBuf) + buf = append(buf, varBuf[:l]...) + buf = append(buf, txi.AddrDesc...) + l = packBigint(&txi.ValueSat, varBuf) + buf = append(buf, varBuf[:l]...) + } return buf } -func appendTxOutput(txo *TxOutput, buf []byte, varBuf []byte) []byte { +func (d *RocksDB) appendTxOutput(txo *TxOutput, buf []byte, varBuf []byte) []byte { la := len(txo.AddrDesc) if txo.Spent { la = ^la @@ -969,6 +1202,20 @@ func appendTxOutput(txo *TxOutput, buf []byte, varBuf []byte) []byte { buf = append(buf, txo.AddrDesc...) l = packBigint(&txo.ValueSat, varBuf) buf = append(buf, varBuf[:l]...) + if d.extendedIndex && txo.Spent { + btxID, err := d.chainParser.PackTxid(txo.SpentTxid) + if err != nil { + if err != bchain.ErrTxidMissing { + glog.Error("Cannot pack txid ", txo.SpentTxid) + } + btxID = make([]byte, d.chainParser.PackedTxidLen()) + } + buf = append(buf, btxID...) + l = packVaruint(uint(txo.SpentIndex), varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(txo.SpentHeight), varBuf) + buf = append(buf, varBuf[:l]...) + } return buf } @@ -1020,7 +1267,7 @@ func packAddrBalance(ab *AddrBalance, buf, varBuf []byte) []byte { l = packBigint(&ab.BalanceSat, varBuf) buf = append(buf, varBuf[:l]...) for _, utxo := range ab.Utxos { - // if Vout < 0, utxo is marked as spent + // if Vout < 0, utxo is marked as spent and removed from the entry if utxo.Vout >= 0 { buf = append(buf, utxo.BtxID...) l = packVaruint(uint(utxo.Vout), varBuf) @@ -1034,34 +1281,62 @@ func packAddrBalance(ab *AddrBalance, buf, varBuf []byte) []byte { return buf } -func unpackTxAddresses(buf []byte) (*TxAddresses, error) { +func (d *RocksDB) unpackTxAddresses(buf []byte) (*TxAddresses, error) { ta := TxAddresses{} height, l := unpackVaruint(buf) ta.Height = uint32(height) + if d.extendedIndex { + vsize, ll := unpackVaruint(buf[l:]) + ta.VSize = uint32(vsize) + l += ll + } inputs, ll := unpackVaruint(buf[l:]) l += ll ta.Inputs = make([]TxInput, inputs) for i := uint(0); i < inputs; i++ { - l += unpackTxInput(&ta.Inputs[i], buf[l:]) + l += d.unpackTxInput(&ta.Inputs[i], buf[l:]) } outputs, ll := unpackVaruint(buf[l:]) l += ll ta.Outputs = make([]TxOutput, outputs) for i := uint(0); i < outputs; i++ { - l += unpackTxOutput(&ta.Outputs[i], buf[l:]) + l += d.unpackTxOutput(&ta.Outputs[i], buf[l:]) } return &ta, nil } -func unpackTxInput(ti *TxInput, buf []byte) int { - al, l := unpackVaruint(buf) - ti.AddrDesc = append([]byte(nil), buf[l:l+int(al)]...) - al += uint(l) - ti.ValueSat, l = unpackBigint(buf[al:]) - return l + int(al) +func (d *RocksDB) unpackTxInput(ti *TxInput, buf []byte) int { + if d.extendedIndex { + al, l := unpackVarint(buf) + var coinbase bool + if al < 0 { + coinbase = true + al = ^al + } + ti.AddrDesc = append([]byte(nil), buf[l:l+al]...) + al += l + ti.ValueSat, l = unpackBigint(buf[al:]) + al += l + if !coinbase { + l = d.chainParser.PackedTxidLen() + ti.Txid, _ = d.chainParser.UnpackTxid(buf[al : al+l]) + al += l + var i uint + i, l = unpackVaruint(buf[al:]) + ti.Vout = uint32(i) + al += l + } + return al + } else { + al, l := unpackVaruint(buf) + ti.AddrDesc = append([]byte(nil), buf[l:l+int(al)]...) + al += uint(l) + ti.ValueSat, l = unpackBigint(buf[al:]) + return l + int(al) + } } -func unpackTxOutput(to *TxOutput, buf []byte) int { +func (d *RocksDB) unpackTxOutput(to *TxOutput, buf []byte) int { al, l := unpackVarint(buf) if al < 0 { to.Spent = true @@ -1070,7 +1345,20 @@ func unpackTxOutput(to *TxOutput, buf []byte) int { to.AddrDesc = append([]byte(nil), buf[l:l+al]...) al += l to.ValueSat, l = unpackBigint(buf[al:]) - return l + al + al += l + if d.extendedIndex && to.Spent { + l = d.chainParser.PackedTxidLen() + to.SpentTxid, _ = d.chainParser.UnpackTxid(buf[al : al+l]) + al += l + var i uint + i, l = unpackVaruint(buf[al:]) + al += l + to.SpentIndex = uint32(i) + i, l = unpackVaruint(buf[al:]) + to.SpentHeight = uint32(i) + al += l + } + return al } func (d *RocksDB) packTxIndexes(txi []txIndexes) []byte { @@ -1092,6 +1380,34 @@ func (d *RocksDB) packTxIndexes(txi []txIndexes) []byte { return buf } +func (d *RocksDB) unpackTxIndexes(buf []byte) ([]txIndexes, error) { + var retval []txIndexes + txidUnpackedLen := d.chainParser.PackedTxidLen() + for len(buf) > txidUnpackedLen { + btxID := make([]byte, txidUnpackedLen) + copy(btxID, buf[:txidUnpackedLen]) + indexes := make([]int32, 0, 16) + buf = buf[txidUnpackedLen:] + for { + index, l := unpackVarint32(buf) + indexes = append(indexes, index>>1) + buf = buf[l:] + if index&1 == 1 { + break + } + } + retval = append(retval, txIndexes{ + btxID: btxID, + indexes: indexes, + }) + } + // reverse the return values, packTxIndexes is storing it in reverse + for i, j := 0, len(retval)-1; i < j; i, j = i+1, j-1 { + retval[i], retval[j] = retval[j], retval[i] + } + return retval, nil +} + func (d *RocksDB) packOutpoints(outpoints []outpoint) []byte { buf := make([]byte, 0, 32) bvout := make([]byte, vlq.MaxLen32) @@ -1256,32 +1572,25 @@ func (d *RocksDB) writeHeight(wb *grocksdb.WriteBatch, height uint32, bi *BlockI } // address alias support -var cachedAddressAliasRecords = make(map[string]string) -var cachedAddressAliasRecordsMux sync.Mutex - -// InitAddressAliasRecords loads all records to cache -func (d *RocksDB) InitAddressAliasRecords() (int, error) { - count := 0 - cachedAddressAliasRecordsMux.Lock() - defer cachedAddressAliasRecordsMux.Unlock() - it := d.db.NewIteratorCF(d.ro, d.cfh[cfAddressAliases]) - defer it.Close() - for it.SeekToFirst(); it.Valid(); it.Next() { - address := string(it.Key().Data()) - name := string(it.Value().Data()) - if address != "" && name != "" { - cachedAddressAliasRecords[address] = d.chainParser.FormatAddressAlias(address, name) - count++ - } - } - return count, nil -} +var cachedAddressAliasRecords = newAddressAliasLRU(cachedAddressAliasRecordsLRUMaxSize) func (d *RocksDB) GetAddressAlias(address string) string { - cachedAddressAliasRecordsMux.Lock() - name := cachedAddressAliasRecords[address] - cachedAddressAliasRecordsMux.Unlock() - return name + if formatted, ok := cachedAddressAliasRecords.get(address); ok { + return formatted + } + val, err := d.db.GetCF(d.ro, d.cfh[cfAddressAliases], []byte(address)) + if err != nil { + glog.Errorf("GetAddressAlias %v error %v", address, err) + return "" + } + defer val.Free() + name := val.Data() + if len(name) == 0 { + return "" + } + formatted := d.chainParser.FormatAddressAlias(address, string(name)) + cachedAddressAliasRecords.add(address, formatted) + return formatted } func (d *RocksDB) storeAddressAliasRecords(wb *grocksdb.WriteBatch, records []bchain.AddressAliasRecord) error { @@ -1290,9 +1599,7 @@ func (d *RocksDB) storeAddressAliasRecords(wb *grocksdb.WriteBatch, records []bc r := &records[i] if len(r.Name) > 0 { wb.PutCF(d.cfh[cfAddressAliases], []byte(r.Address), []byte(r.Name)) - cachedAddressAliasRecordsMux.Lock() - cachedAddressAliasRecords[r.Address] = d.chainParser.FormatAddressAlias(r.Address, r.Name) - cachedAddressAliasRecordsMux.Unlock() + cachedAddressAliasRecords.add(r.Address, d.chainParser.FormatAddressAlias(r.Address, r.Name)) } } } @@ -1388,6 +1695,19 @@ func (d *RocksDB) disconnectTxAddressesOutputs(wb *grocksdb.WriteBatch, btxID [] return nil } +func (d *RocksDB) disconnectBlockFilter(wb *grocksdb.WriteBatch, height uint32) error { + blockHash, err := d.GetBlockHash(height) + if err != nil { + return err + } + blockHashBytes, err := hex.DecodeString(blockHash) + if err != nil { + return err + } + wb.DeleteCF(d.cfh[cfBlockFilter], blockHashBytes) + return nil +} + func (d *RocksDB) disconnectBlock(height uint32, blockTxs []blockTxs) error { wb := grocksdb.NewWriteBatch() defer wb.Destroy() @@ -1473,6 +1793,9 @@ func (d *RocksDB) disconnectBlock(height uint32, blockTxs []blockTxs) error { wb.DeleteCF(d.cfh[cfTransactions], b) wb.DeleteCF(d.cfh[cfTxAddresses], b) } + if err := d.disconnectBlockFilter(wb, height); err != nil { + return err + } return d.WriteBatch(wb) } @@ -1535,13 +1858,26 @@ func dirSize(path string) (int64, error) { return size, err } +// limit the number of size on disk calculations by restricting it to once a minute +var databaseSizeOnDisk int64 +var nextDatabaseSizeOnDisk time.Time +var databaseSizeOnDiskMux sync.Mutex + // DatabaseSizeOnDisk returns size of the database in bytes func (d *RocksDB) DatabaseSizeOnDisk() int64 { + databaseSizeOnDiskMux.Lock() + defer databaseSizeOnDiskMux.Unlock() + now := time.Now().UTC() + if now.Before(nextDatabaseSizeOnDisk) { + return databaseSizeOnDisk + } size, err := dirSize(d.path) if err != nil { glog.Warning("rocksdb: DatabaseSizeOnDisk: ", err) return 0 } + databaseSizeOnDisk = size + nextDatabaseSizeOnDisk = now.Add(60 * time.Second) return size } @@ -1637,6 +1973,102 @@ func (d *RocksDB) loadBlockTimes() ([]uint32, error) { return times, nil } +func (d *RocksDB) setBlockTimes() { + start := time.Now() + bt, err := d.loadBlockTimes() + if err != nil { + glog.Error("rocksdb: cannot load block times ", err) + return + } + avg := d.is.SetBlockTimes(bt) + if d.metrics != nil { + d.metrics.AvgBlockPeriod.Set(float64(avg)) + } + glog.Info("rocksdb: processed block times in ", time.Since(start)) +} + +func (d *RocksDB) migrateVersion5To6(sc, nc *common.InternalStateColumn) error { + // upgrade of DB 5 to 6 for BitcoinType coins is possible + // columns transactions and fiatRates must be cleared as they are not compatible + if d.chainParser.GetChainType() == bchain.ChainBitcoinType { + if nc.Name == "transactions" { + d.db.DeleteRangeCF(d.wo, d.cfh[cfTransactions], []byte{0}, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) + } else if nc.Name == "fiatRates" { + d.db.DeleteRangeCF(d.wo, d.cfh[cfFiatRates], []byte{0}, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) + } + glog.Infof("Column %s upgraded from v%d to v%d", nc.Name, sc.Version, dbVersion) + } else { + return errors.Errorf("DB version %v of column '%v' does not match the required version %v. DB is not compatible.", sc.Version, sc.Name, dbVersion) + } + return nil +} + +func (d *RocksDB) migrateAddrContractsToV7(approxRows int64) error { + glog.Info("MigrateAddrContracts: starting, will process approximately ", approxRows, " rows") + var row int64 + var seekKey []byte + // do not use cache + ro := grocksdb.NewDefaultReadOptions() + defer ro.Destroy() + ro.SetFillCache(false) + for { + var addrDesc bchain.AddressDescriptor + it := d.db.NewIteratorCF(ro, d.cfh[cfAddressContracts]) + if row == 0 { + it.SeekToFirst() + } else { + glog.Info("MigrateAddrContracts: row ", row) + it.Seek(seekKey) + it.Next() + } + + wb := grocksdb.NewWriteBatch() + for count := 0; it.Valid() && count < refreshIterator; it.Next() { + addrDesc = append([]byte{}, it.Key().Data()...) + buf := it.Value().Data() + count++ + row++ + acs, err := unpackAddrContractsV6(buf, addrDesc) + if err != nil { + glog.Error(err, ", ", hex.EncodeToString(buf)) + acs = &AddrContracts{} + } + repacked := packAddrContracts(acs) + wb.PutCF(d.cfh[cfAddressContracts], addrDesc, repacked) + } + err := d.WriteBatch(wb) + wb.Destroy() + if err != nil { + return errors.Errorf("error storing repacked data %v", err) + } + + seekKey = addrDesc + valid := it.Valid() + it.Close() + if !valid { + break + } + } + glog.Info("MigrateAddrContracts: finished, migrated ", row, " rows") + return nil +} + +func (d *RocksDB) migrateVersion6To7(sc, nc *common.InternalStateColumn) error { + // DB v7 must migrate ethereum type column addressContracts + if d.chainParser.GetChainType() == bchain.ChainEthereumType { + if nc.Name == "addressContracts" { + err := d.migrateAddrContractsToV7(sc.Rows) + if err != nil { + return err + } + } else if nc.Name == "transactions" { + d.db.DeleteRangeCF(d.wo, d.cfh[cfTransactions], []byte{0}, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) + } + glog.Infof("Column %s migrated from v%d to v%d", nc.Name, sc.Version, dbVersion) + } + return nil +} + func (d *RocksDB) checkColumns(is *common.InternalState) ([]common.InternalStateColumn, error) { // make sure that column stats match the columns sc := is.DbColumns @@ -1648,15 +2080,16 @@ func (d *RocksDB) checkColumns(is *common.InternalState) ([]common.InternalState if sc[j].Name == nc[i].Name { // check the version of the column, if it does not match, the db is not compatible if sc[j].Version != dbVersion { - // upgrade of DB 5 to 6 for BitcoinType coins is possible - // columns transactions and fiatRates must be cleared as they are not compatible - if sc[j].Version == 5 && dbVersion == 6 && d.chainParser.GetChainType() == bchain.ChainBitcoinType { - if nc[i].Name == "transactions" { - d.db.DeleteRangeCF(d.wo, d.cfh[cfTransactions], []byte{0}, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) - } else if nc[i].Name == "fiatRates" { - d.db.DeleteRangeCF(d.wo, d.cfh[cfFiatRates], []byte{0}, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) + if sc[j].Version == 5 && dbVersion == 6 { + err := d.migrateVersion5To6(&sc[j], &nc[i]) + if err != nil { + return nil, err + } + } else if sc[j].Version == 6 && dbVersion == 7 { + err := d.migrateVersion6To7(&sc[j], &nc[i]) + if err != nil { + return nil, err } - glog.Infof("Column %s upgraded from v%d to v%d", nc[i].Name, sc[j].Version, dbVersion) } else { return nil, errors.Errorf("DB version %v of column '%v' does not match the required version %v. DB is not compatible.", sc[j].Version, sc[j].Name, dbVersion) } @@ -1673,7 +2106,7 @@ func (d *RocksDB) checkColumns(is *common.InternalState) ([]common.InternalState } // LoadInternalState loads from db internal state or initializes a new one if not yet stored -func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, error) { +func (d *RocksDB) LoadInternalState(config *common.Config) (*common.InternalState, error) { val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(internalStateKey)) if err != nil { return nil, err @@ -1682,7 +2115,15 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro data := val.Data() var is *common.InternalState if len(data) == 0 { - is = &common.InternalState{Coin: rpcCoin, UtxoChecked: true} + is = &common.InternalState{ + Coin: config.CoinName, + UtxoChecked: true, + SortedAddressContracts: true, + ExtendedIndex: d.extendedIndex, + BlockGolombFilterP: config.BlockGolombFilterP, + BlockFilterScripts: config.BlockFilterScripts, + BlockFilterUseZeroedKey: config.BlockFilterUseZeroedKey, + } } else { is, err = common.UnpackInternalState(data) if err != nil { @@ -1691,9 +2132,21 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro // verify that the rpc coin matches DB coin // running it mismatched would corrupt the database if is.Coin == "" { - is.Coin = rpcCoin - } else if is.Coin != rpcCoin { - return nil, errors.Errorf("Coins do not match. DB coin %v, RPC coin %v", is.Coin, rpcCoin) + is.Coin = config.CoinName + } else if is.Coin != config.CoinName { + return nil, errors.Errorf("Coins do not match. DB coin %v, RPC coin %v", is.Coin, config.CoinName) + } + if is.ExtendedIndex != d.extendedIndex { + return nil, errors.Errorf("ExtendedIndex setting does not match. DB extendedIndex %v, extendedIndex in options %v", is.ExtendedIndex, d.extendedIndex) + } + if is.BlockGolombFilterP != config.BlockGolombFilterP { + return nil, errors.Errorf("BlockGolombFilterP does not match. DB BlockGolombFilterP %v, config BlockGolombFilterP %v", is.BlockGolombFilterP, config.BlockGolombFilterP) + } + if is.BlockFilterScripts != config.BlockFilterScripts { + return nil, errors.Errorf("BlockFilterScripts does not match. DB BlockFilterScripts %v, config BlockFilterScripts %v", is.BlockFilterScripts, config.BlockFilterScripts) + } + if is.BlockFilterUseZeroedKey != config.BlockFilterUseZeroedKey { + return nil, errors.Errorf("BlockFilterUseZeroedKey does not match. DB BlockFilterUseZeroedKey %v, config BlockFilterUseZeroedKey %v", is.BlockFilterUseZeroedKey, config.BlockFilterUseZeroedKey) } } nc, err := d.checkColumns(is) @@ -1701,15 +2154,18 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro return nil, err } is.DbColumns = nc - bt, err := d.loadBlockTimes() - if err != nil { - return nil, err - } - avg := is.SetBlockTimes(bt) - if d.metrics != nil { - d.metrics.AvgBlockPeriod.Set(float64(avg)) - } + d.is = is + // set block times asynchronously (if not in unit test), it slows server startup for chains with large number of blocks + if is.Coin == "coin-unittest" { + d.setBlockTimes() + } else { + d.setBlockTimesWG.Add(1) + go func() { + defer d.setBlockTimesWG.Done() + d.setBlockTimes() + }() + } // after load, reset the synchronization data is.IsSynchronized = false is.IsMempoolSynchronized = false @@ -1717,13 +2173,13 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro is.LastMempoolSync = t is.SyncMode = false - if d.chainParser.UseAddressAliases() { - recordsCount, err := d.InitAddressAliasRecords() - if err != nil { - return nil, err - } - glog.Infof("loaded %d address alias records", recordsCount) + is.CoinShortcut = config.CoinShortcut + if config.CoinLabel == "" { + is.CoinLabel = config.CoinName + } else { + is.CoinLabel = config.CoinLabel } + is.Network = config.Network return is, nil } @@ -1777,6 +2233,7 @@ func (d *RocksDB) computeColumnSize(col int, stopCompute chan os.Signal) (int64, var seekKey []byte // do not use cache ro := grocksdb.NewDefaultReadOptions() + defer ro.Destroy() ro.SetFillCache(false) for { var key []byte @@ -1794,13 +2251,13 @@ func (d *RocksDB) computeColumnSize(col int, stopCompute chan os.Signal) (int64, return 0, 0, 0, errors.New("Interrupted") default: } - key = it.Key().Data() + key = append([]byte{}, it.Key().Data()...) count++ rows++ keysSum += int64(len(key)) valuesSum += int64(len(it.Value().Data())) } - seekKey = append([]byte{}, key...) + seekKey = key valid := it.Valid() it.Close() if !valid { @@ -1958,6 +2415,7 @@ func (d *RocksDB) FixUtxos(stop chan os.Signal) error { var seekKey []byte // do not use cache ro := grocksdb.NewDefaultReadOptions() + defer ro.Destroy() ro.SetFillCache(false) for { var addrDesc bchain.AddressDescriptor @@ -1975,7 +2433,7 @@ func (d *RocksDB) FixUtxos(stop chan os.Signal) error { return errors.New("Interrupted") default: } - addrDesc = it.Key().Data() + addrDesc = append([]byte{}, it.Key().Data()...) buf := it.Value().Data() count++ row++ @@ -2002,7 +2460,7 @@ func (d *RocksDB) FixUtxos(stop chan os.Signal) error { fixedCount++ } } - seekKey = append([]byte{}, addrDesc...) + seekKey = addrDesc valid := it.Valid() it.Close() if !valid { @@ -2013,6 +2471,32 @@ func (d *RocksDB) FixUtxos(stop chan os.Signal) error { return nil } +func (d *RocksDB) storeBlockFilter(wb *grocksdb.WriteBatch, blockHash string, blockFilter []byte) error { + blockHashBytes, err := hex.DecodeString(blockHash) + if err != nil { + return err + } + wb.PutCF(d.cfh[cfBlockFilter], blockHashBytes, blockFilter) + return nil +} + +func (d *RocksDB) GetBlockFilter(blockHash string) (string, error) { + blockHashBytes, err := hex.DecodeString(blockHash) + if err != nil { + return "", err + } + val, err := d.db.GetCF(d.ro, d.cfh[cfBlockFilter], blockHashBytes) + if err != nil { + return "", err + } + defer val.Free() + buf := val.Data() + if buf == nil { + return "", nil + } + return hex.EncodeToString(buf), nil +} + // Helpers func packAddressKey(addrDesc bchain.AddressDescriptor, height uint32) []byte { @@ -2139,6 +2623,10 @@ func packBigint(bi *big.Int, buf []byte) int { return fb + 1 } +func packedBigintLen(buf []byte) int { + return int(buf[0]) + 1 +} + func unpackBigint(buf []byte) (big.Int, int) { var r big.Int l := int(buf[0]) + 1 diff --git a/db/rocksdb_contracts.go b/db/rocksdb_contracts.go new file mode 100644 index 0000000000..62e62eadc6 --- /dev/null +++ b/db/rocksdb_contracts.go @@ -0,0 +1,188 @@ +package db + +import ( + vlq "github.com/bsm/go-vlq" + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/bchain" +) + +var cachedContracts = newContractInfoLRU(cachedContractsLRUMaxSize) + +func packContractInfo(contractInfo *bchain.ContractInfo) []byte { + buf := packString(contractInfo.Name) + buf = append(buf, packString(contractInfo.Symbol)...) + buf = append(buf, packString(string(contractInfo.Standard))...) + varBuf := make([]byte, vlq.MaxLen64) + l := packVaruint(uint(contractInfo.Decimals), varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(contractInfo.CreatedInBlock), varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(contractInfo.DestructedInBlock), varBuf) + buf = append(buf, varBuf[:l]...) + return buf +} + +func unpackContractInfo(buf []byte) (*bchain.ContractInfo, error) { + var contractInfo bchain.ContractInfo + var s string + var l int + var ui uint + contractInfo.Name, l = unpackString(buf) + buf = buf[l:] + contractInfo.Symbol, l = unpackString(buf) + buf = buf[l:] + s, l = unpackString(buf) + contractInfo.Standard = bchain.TokenStandardName(s) + contractInfo.Type = bchain.TokenStandardName(s) + buf = buf[l:] + ui, l = unpackVaruint(buf) + contractInfo.Decimals = int(ui) + buf = buf[l:] + ui, l = unpackVaruint(buf) + contractInfo.CreatedInBlock = uint32(ui) + buf = buf[l:] + ui, _ = unpackVaruint(buf) + contractInfo.DestructedInBlock = uint32(ui) + return &contractInfo, nil +} + +func unpackVaruintSafe(buf []byte) (uint, int, bool) { + if len(buf) == 0 { + return 0, 0, false + } + ui, l := unpackVaruint(buf) + if l <= 0 || l > len(buf) { + return 0, 0, false + } + return ui, l, true +} + +func unpackStringSafe(buf []byte) (string, int, bool) { + if len(buf) == 0 { + return "", 0, false + } + sl, l, ok := unpackVaruintSafe(buf) + if !ok { + return "", 0, false + } + so := l + int(sl) + if so < l || so > len(buf) { + return "", 0, false + } + return string(buf[l:so]), so, true +} + +func (d *RocksDB) GetContractInfoForAddress(address string) (*bchain.ContractInfo, error) { + contract, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil || contract == nil { + return nil, err + } + return d.GetContractInfo(contract, "") +} + +// GetContractInfo gets contract from cache or DB and possibly updates the standard from standardFromContext +// it is hard to guess the standard of the contract using API, it is easier to set it the first time the contract is processed in a tx +func (d *RocksDB) GetContractInfo(contract bchain.AddressDescriptor, standardFromContext bchain.TokenStandardName) (*bchain.ContractInfo, error) { + cacheKey := string(contract) + // Sample both counters before the CF reads. If a disconnect bumps reorgGen + // (populate-after-delete race) or a SetErcProtocol bumps protocolGen + // (populate-after-write race) during this call, the stamped entry will + // mismatch on the next get and miss. + reorgGen := d.reorgGen.Load() + protocolGen := d.protocolGen.Load() + contractInfo, found := cachedContracts.get(cacheKey, reorgGen, protocolGen) + if !found { + val, err := d.db.GetCF(d.ro, d.cfh[cfContracts], contract) + if err != nil { + return nil, err + } + defer val.Free() + buf := val.Data() + if len(buf) == 0 { + return nil, nil + } + contractInfo, _ = unpackContractInfo(buf) + addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(contract) + if len(addresses) > 0 { + contractInfo.Contract = addresses[0] + } + // if the standard is specified and stored contractInfo has unknown standard, set and store it + if standardFromContext != bchain.UnknownTokenStandard && contractInfo.Standard == bchain.UnknownTokenStandard { + contractInfo.Standard = standardFromContext + contractInfo.Type = standardFromContext + err = d.db.PutCF(d.wo, d.cfh[cfContracts], contract, packContractInfo(contractInfo)) + if err != nil { + return nil, err + } + } + // Merge ERC4626 detection from the per-protocol CF. + if assetContract, ok, err := d.GetContractInfoErc4626Vault(contract); err != nil { + return nil, err + } else if ok { + contractInfo.IsErc4626 = true + contractInfo.Erc4626AssetContract = assetContract + } + cachedContracts.add(cacheKey, contractInfo, reorgGen, protocolGen) + } + return contractInfo, nil +} + +// SetContractInfoErc4626Vault persists a detected vault's asset() address to +// the per-protocol CF. See SetErcProtocol for the persistHeight / +// observedBlockHash / observedReorgGen race rationale and refusal policy. +func (d *RocksDB) SetContractInfoErc4626Vault(address, assetContract string, persistHeight uint32, observedBlockHash string, observedReorgGen uint64) error { + contract, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil || contract == nil { + return err + } + return d.SetErcProtocol(contract, ErcProtocolErc4626, packString(assetContract), persistHeight, observedBlockHash, observedReorgGen) +} + +// GetContractInfoErc4626Vault returns the persisted asset() address, if any. +func (d *RocksDB) GetContractInfoErc4626Vault(contract bchain.AddressDescriptor) (assetContract string, ok bool, err error) { + payload, _, ok, err := d.GetErcProtocol(contract, ErcProtocolErc4626) + if err != nil || !ok { + return "", ok, err + } + asset, _, ok := unpackStringSafe(payload) + if !ok { + return "", false, nil + } + return asset, true, nil +} + +// StoreContractInfo stores contractInfo in DB +// if CreatedInBlock==0 and DestructedInBlock!=0, it is evaluated as a destruction of a contract, the contract info is updated +// in all other cases the contractInfo overwrites previously stored data in DB (however it should not really happen as contract is created only once) +func (d *RocksDB) StoreContractInfo(contractInfo *bchain.ContractInfo) error { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.storeContractInfo(wb, contractInfo); err != nil { + return err + } + return d.WriteBatch(wb) +} + +func (d *RocksDB) storeContractInfo(wb *grocksdb.WriteBatch, contractInfo *bchain.ContractInfo) error { + if contractInfo.Contract != "" { + key, err := d.chainParser.GetAddrDescFromAddress(contractInfo.Contract) + if err != nil { + return err + } + if contractInfo.CreatedInBlock == 0 && contractInfo.DestructedInBlock != 0 { + storedCI, err := d.GetContractInfo(key, "") + if err != nil { + return err + } + if storedCI == nil { + return nil + } + storedCI.DestructedInBlock = contractInfo.DestructedInBlock + contractInfo = storedCI + } + wb.PutCF(d.cfh[cfContracts], key, packContractInfo(contractInfo)) + cacheKey := string(key) + cachedContracts.delete(cacheKey) + } + return nil +} diff --git a/db/rocksdb_contracts_test.go b/db/rocksdb_contracts_test.go new file mode 100644 index 0000000000..0df1b9bcc0 --- /dev/null +++ b/db/rocksdb_contracts_test.go @@ -0,0 +1,61 @@ +//go:build unittest + +package db + +import ( + "reflect" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +// packContractInfo only carries the sync-owned core fields. ERC4626 detection +// data lives in the cfErcProtocols column family and is exercised +// separately in rocksdb_protocols_test.go. +func Test_packUnpackContractInfo(t *testing.T) { + tests := []struct { + name string + contractInfo bchain.ContractInfo + }{ + { + name: "empty", + contractInfo: bchain.ContractInfo{}, + }, + { + name: "unknown", + contractInfo: bchain.ContractInfo{ + Type: bchain.UnknownTokenStandard, + Standard: bchain.UnknownTokenStandard, + Name: "Test contract", + Symbol: "TCT", + Decimals: 18, + CreatedInBlock: 1234567, + DestructedInBlock: 234567890, + }, + }, + { + name: "ERC20", + contractInfo: bchain.ContractInfo{ + Type: bchain.ERC20TokenStandard, + Standard: bchain.ERC20TokenStandard, + Name: "GreenContract🟢", + Symbol: "🟢", + Decimals: 0, + CreatedInBlock: 1, + DestructedInBlock: 2, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := packContractInfo(&tt.contractInfo) + got, err := unpackContractInfo(buf) + if err != nil { + t.Fatalf("unpackContractInfo() err = %v", err) + } + if !reflect.DeepEqual(*got, tt.contractInfo) { + t.Errorf("packUnpackContractInfo() = %+v, want %+v", *got, tt.contractInfo) + } + }) + } +} diff --git a/db/rocksdb_ethereumtype.go b/db/rocksdb_ethereumtype.go index b16429b330..4603d48f63 100644 --- a/db/rocksdb_ethereumtype.go +++ b/db/rocksdb_ethereumtype.go @@ -4,27 +4,117 @@ import ( "bytes" "encoding/hex" "math/big" + "os" + "sort" "sync" + "time" - vlq "github.com/bsm/go-vlq" "github.com/golang/glog" "github.com/juju/errors" "github.com/linxGnu/grocksdb" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" ) const InternalTxIndexOffset = 1 const ContractIndexOffset = 2 +type AggregateFn = func(*big.Int, *big.Int) + +type Ids []big.Int + +func (s *Ids) sort() bool { + sorted := false + sort.Slice(*s, func(i, j int) bool { + isLess := (*s)[i].CmpAbs(&(*s)[j]) == -1 + if isLess == (i > j) { // it is necessary to swap - (id[i]j) or (id[i]>id[j] and i= 0 + }) +} + +// insert id in ascending order +func (s *Ids) insert(id big.Int) { + i := s.search(id) + if i == len(*s) { + *s = append(*s, id) + } else { + *s = append((*s)[:i+1], (*s)[i:]...) + (*s)[i] = id + } +} + +func (s *Ids) remove(id big.Int) { + i := s.search(id) + // remove id if found + if i < len(*s) && (*s)[i].CmpAbs(&id) == 0 { + *s = append((*s)[:i], (*s)[i+1:]...) + } +} + +type MultiTokenValues []bchain.MultiTokenValue + +func (s *MultiTokenValues) sort() bool { + sorted := false + sort.Slice(*s, func(i, j int) bool { + isLess := (*s)[i].Id.CmpAbs(&(*s)[j].Id) == -1 + if isLess == (i > j) { // it is necessary to swap - (id[i]j) or (id[i]>id[j] and i= 0 + }) +} + +func (s *MultiTokenValues) upsert(m bchain.MultiTokenValue, index int32, aggregate AggregateFn) { + i := s.search(m) + if i < len(*s) && (*s)[i].Id.CmpAbs(&m.Id) == 0 { + aggregate(&(*s)[i].Value, &m.Value) + // if transfer from, remove if the value is zero + if index < 0 && len((*s)[i].Value.Bits()) == 0 { + *s = append((*s)[:i], (*s)[i+1:]...) + } + return + } + if index >= 0 { + elem := bchain.MultiTokenValue{ + Id: m.Id, + Value: *new(big.Int).Set(&m.Value), + } + if i == len(*s) { + *s = append(*s, elem) + } else { + *s = append((*s)[:i+1], (*s)[i:]...) + (*s)[i] = elem + } + } +} + // AddrContract is Contract address with number of transactions done by given address type AddrContract struct { - Type bchain.TokenType + Standard bchain.TokenStandard Contract bchain.AddressDescriptor Txs uint - Value big.Int // single value of ERC20 - Ids []big.Int // multiple ERC721 tokens - MultiTokenValues []bchain.MultiTokenValue // multiple ERC1155 tokens + Value big.Int // single value of ERC20 + Ids Ids // multiple ERC721 tokens + MultiTokenValues MultiTokenValues // multiple ERC1155 tokens } // AddrContracts contains number of transactions and contracts for an address @@ -35,8 +125,8 @@ type AddrContracts struct { Contracts []AddrContract } -// packAddrContract packs AddrContracts into a byte buffer -func packAddrContracts(acs *AddrContracts) []byte { +// packAddrContracts packs AddrContracts into a byte buffer +func packAddrContractsV6(acs *AddrContracts) []byte { buf := make([]byte, 0, 128) varBuf := make([]byte, maxPackedBigintBytes) l := packVaruint(acs.TotalTxs, varBuf) @@ -47,12 +137,12 @@ func packAddrContracts(acs *AddrContracts) []byte { buf = append(buf, varBuf[:l]...) for _, ac := range acs.Contracts { buf = append(buf, ac.Contract...) - l = packVaruint(uint(ac.Type)+ac.Txs<<2, varBuf) + l = packVaruint(uint(ac.Standard)+ac.Txs<<2, varBuf) buf = append(buf, varBuf[:l]...) - if ac.Type == bchain.FungibleToken { + if ac.Standard == bchain.FungibleToken { l = packBigint(&ac.Value, varBuf) buf = append(buf, varBuf[:l]...) - } else if ac.Type == bchain.NonFungibleToken { + } else if ac.Standard == bchain.NonFungibleToken { l = packVaruint(uint(len(ac.Ids)), varBuf) buf = append(buf, varBuf[:l]...) for i := range ac.Ids { @@ -73,14 +163,114 @@ func packAddrContracts(acs *AddrContracts) []byte { return buf } -func unpackAddrContracts(buf []byte, addrDesc bchain.AddressDescriptor) (*AddrContracts, error) { +// packAddrContracts packs AddrContracts into a byte buffer +func packAddrContracts(acs *AddrContracts) []byte { + buf := make([]byte, 0, 8+len(acs.Contracts)*(eth.EthereumTypeAddressDescriptorLen+12)) + varBuf := make([]byte, maxPackedBigintBytes) + l := packVaruint(acs.TotalTxs, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(acs.NonContractTxs, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(acs.InternalTxs, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(len(acs.Contracts)), varBuf) + buf = append(buf, varBuf[:l]...) + for _, ac := range acs.Contracts { + buf = append(buf, ac.Contract...) + l = packVaruint(uint(ac.Standard)+ac.Txs<<2, varBuf) + buf = append(buf, varBuf[:l]...) + if ac.Standard == bchain.FungibleToken { + l = packBigint(&ac.Value, varBuf) + buf = append(buf, varBuf[:l]...) + } else if ac.Standard == bchain.NonFungibleToken { + l = packVaruint(uint(len(ac.Ids)), varBuf) + buf = append(buf, varBuf[:l]...) + for i := range ac.Ids { + l = packBigint(&ac.Ids[i], varBuf) + buf = append(buf, varBuf[:l]...) + } + } else { // bchain.ERC1155 + l = packVaruint(uint(len(ac.MultiTokenValues)), varBuf) + buf = append(buf, varBuf[:l]...) + for i := range ac.MultiTokenValues { + l = packBigint(&ac.MultiTokenValues[i].Id, varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(&ac.MultiTokenValues[i].Value, varBuf) + buf = append(buf, varBuf[:l]...) + } + } + } + return buf +} + +func unpackAddrContractsV6(buf []byte, addrDesc bchain.AddressDescriptor) (acs *AddrContracts, err error) { + tt, l := unpackVaruint(buf) + buf = buf[l:] + nct, l := unpackVaruint(buf) + buf = buf[l:] + ict, l := unpackVaruint(buf) + buf = buf[l:] + c := make([]AddrContract, 0, len(buf)/30+4) + for len(buf) > 0 { + if len(buf) < eth.EthereumTypeAddressDescriptorLen { + return nil, errors.New("Invalid data stored in cfAddressContracts for AddrDesc " + addrDesc.String()) + } + contract := append(bchain.AddressDescriptor(nil), buf[:eth.EthereumTypeAddressDescriptorLen]...) + txs, l := unpackVaruint(buf[eth.EthereumTypeAddressDescriptorLen:]) + buf = buf[eth.EthereumTypeAddressDescriptorLen+l:] + standard := bchain.TokenStandard(txs & 3) + txs >>= 2 + ac := AddrContract{ + Standard: standard, + Contract: contract, + Txs: txs, + } + if standard == bchain.FungibleToken { + b, ll := unpackBigint(buf) + buf = buf[ll:] + ac.Value = b + } else { + len, ll := unpackVaruint(buf) + buf = buf[ll:] + if standard == bchain.NonFungibleToken { + ac.Ids = make(Ids, len) + for i := uint(0); i < len; i++ { + b, ll := unpackBigint(buf) + buf = buf[ll:] + ac.Ids[i] = b + } + } else { + ac.MultiTokenValues = make(MultiTokenValues, len) + for i := uint(0); i < len; i++ { + b, ll := unpackBigint(buf) + buf = buf[ll:] + ac.MultiTokenValues[i].Id = b + b, ll = unpackBigint(buf) + buf = buf[ll:] + ac.MultiTokenValues[i].Value = b + } + } + } + c = append(c, ac) + } + return &AddrContracts{ + TotalTxs: tt, + NonContractTxs: nct, + InternalTxs: ict, + Contracts: c, + }, nil +} + +func unpackAddrContracts(buf []byte, addrDesc bchain.AddressDescriptor) (acs *AddrContracts, err error) { tt, l := unpackVaruint(buf) buf = buf[l:] nct, l := unpackVaruint(buf) buf = buf[l:] ict, l := unpackVaruint(buf) buf = buf[l:] - c := make([]AddrContract, 0, 4) + cl, l := unpackVaruint(buf) + buf = buf[l:] + c := make([]AddrContract, 0, cl) for len(buf) > 0 { if len(buf) < eth.EthereumTypeAddressDescriptorLen { return nil, errors.New("Invalid data stored in cfAddressContracts for AddrDesc " + addrDesc.String()) @@ -88,29 +278,29 @@ func unpackAddrContracts(buf []byte, addrDesc bchain.AddressDescriptor) (*AddrCo contract := append(bchain.AddressDescriptor(nil), buf[:eth.EthereumTypeAddressDescriptorLen]...) txs, l := unpackVaruint(buf[eth.EthereumTypeAddressDescriptorLen:]) buf = buf[eth.EthereumTypeAddressDescriptorLen+l:] - ttt := bchain.TokenType(txs & 3) + standard := bchain.TokenStandard(txs & 3) txs >>= 2 ac := AddrContract{ - Type: ttt, + Standard: standard, Contract: contract, Txs: txs, } - if ttt == bchain.FungibleToken { + if standard == bchain.FungibleToken { b, ll := unpackBigint(buf) buf = buf[ll:] ac.Value = b } else { len, ll := unpackVaruint(buf) buf = buf[ll:] - if ttt == bchain.NonFungibleToken { - ac.Ids = make([]big.Int, len) + if standard == bchain.NonFungibleToken { + ac.Ids = make(Ids, len) for i := uint(0); i < len; i++ { b, ll := unpackBigint(buf) buf = buf[ll:] ac.Ids[i] = b } } else { - ac.MultiTokenValues = make([]bchain.MultiTokenValue, len) + ac.MultiTokenValues = make(MultiTokenValues, len) for i := uint(0); i < len; i++ { b, ll := unpackBigint(buf) buf = buf[ll:] @@ -158,7 +348,7 @@ func (d *RocksDB) GetAddrDescContracts(addrDesc bchain.AddressDescriptor) (*Addr return unpackAddrContracts(buf, addrDesc) } -func findContractInAddressContracts(contract bchain.AddressDescriptor, contracts []AddrContract) (int, bool) { +func findContractInAddressContracts(contract bchain.AddressDescriptor, contracts []unpackedAddrContract) (int, bool) { for i := range contracts { if bytes.Equal(contract, contracts[i].Contract) { return i, true @@ -209,8 +399,8 @@ func addToAddressesMapEthereumType(addresses addressesMap, strAddrDesc string, b return false } -func addToContract(c *AddrContract, contractIndex int, index int32, contract bchain.AddressDescriptor, transfer *bchain.TokenTransfer, addTxCount bool) int32 { - var aggregate func(*big.Int, *big.Int) +func addToContract(c *unpackedAddrContract, contractIndex int, index int32, contract bchain.AddressDescriptor, transfer *bchain.TokenTransfer, addTxCount bool) int32 { + var aggregate AggregateFn // index 0 is for ETH transfers, index 1 (InternalTxIndexOffset) is for internal transfers, contract indexes start with 2 (ContractIndexOffset) if index < 0 { index = ^int32(contractIndex + ContractIndexOffset) @@ -227,43 +417,26 @@ func addToContract(c *AddrContract, contractIndex int, index int32, contract bch s.Add(s, v) } } - if transfer.Type == bchain.FungibleToken { - aggregate(&c.Value, &transfer.Value) - } else if transfer.Type == bchain.NonFungibleToken { - if index < 0 { - // remove token from the list - for i := range c.Ids { - if c.Ids[i].Cmp(&transfer.Value) == 0 { - c.Ids = append(c.Ids[:i], c.Ids[i+1:]...) - break - } + if transfer.Standard == bchain.FungibleToken { + // Skip ERC20 balance aggregation; ensure a zero value is available for packing. + if c.Value.Value == nil { // no decoded bigint yet; normalize before first use + if len(c.Value.Slice) != 0 { // packed value exists; drop it so we don't re-pack stale data + c.Value.Slice = nil } + c.Value.Value = new(big.Int) // initialize zero value + } else if len(c.Value.Slice) != 0 || c.Value.Value.Sign() != 0 { // packed or non-zero decoded value present; force zero + c.Value.Slice = nil + c.Value.Value.SetUint64(0) + } + } else if transfer.Standard == bchain.NonFungibleToken { + if index < 0 { + c.Ids.remove(transfer.Value) } else { - // add token to the list - c.Ids = append(c.Ids, transfer.Value) + c.Ids.insert(transfer.Value) } } else { // bchain.ERC1155 for _, t := range transfer.MultiTokenValues { - for i := range c.MultiTokenValues { - // find the token in the list - if c.MultiTokenValues[i].Id.Cmp(&t.Id) == 0 { - aggregate(&c.MultiTokenValues[i].Value, &t.Value) - // if transfer from, remove if the value is zero - if index < 0 && len(c.MultiTokenValues[i].Value.Bits()) == 0 { - c.MultiTokenValues = append(c.MultiTokenValues[:i], c.MultiTokenValues[i+1:]...) - } - goto nextTransfer - } - } - // if not found and transfer to, add to the list - // it is necessary to add a copy of the value so that subsequent calls to addToContract do not change the transfer value - if index >= 0 { - c.MultiTokenValues = append(c.MultiTokenValues, bchain.MultiTokenValue{ - Id: t.Id, - Value: *new(big.Int).Set(&t.Value), - }) - } - nextTransfer: + c.MultiTokenValues.upsert(t, index, aggregate) } } if addTxCount { @@ -272,17 +445,17 @@ func addToContract(c *AddrContract, contractIndex int, index int32, contract bch return index } -func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.AddressDescriptor, btxID []byte, index int32, contract bchain.AddressDescriptor, transfer *bchain.TokenTransfer, addTxCount bool, addresses addressesMap, addressContracts map[string]*AddrContracts) error { +func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.AddressDescriptor, btxID []byte, index int32, contract bchain.AddressDescriptor, transfer *bchain.TokenTransfer, addTxCount bool, addresses addressesMap, addressContracts map[string]*unpackedAddrContracts) error { var err error strAddrDesc := string(addrDesc) ac, e := addressContracts[strAddrDesc] if !e { - ac, err = d.GetAddrDescContracts(addrDesc) + ac, err = d.getUnpackedAddrDescContracts(addrDesc) if err != nil { return err } if ac == nil { - ac = &AddrContracts{} + ac = &unpackedAddrContracts{} } addressContracts[strAddrDesc] = ac d.cbs.balancesMiss++ @@ -301,13 +474,14 @@ func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.Address // do not store contracts for 0x0000000000000000000000000000000000000000 address if !isZeroAddress(addrDesc) { // locate the contract and set i to the index in the array of contracts - contractIndex, found := findContractInAddressContracts(contract, ac.Contracts) + contractIndex, found := ac.findContractIndex(addrDesc, contract, d.hotAddrTracker) if !found { contractIndex = len(ac.Contracts) - ac.Contracts = append(ac.Contracts, AddrContract{ + ac.Contracts = append(ac.Contracts, unpackedAddrContract{ Contract: contract, - Type: transfer.Type, + Standard: transfer.Standard, }) + ac.addContractIndex(contract, contractIndex) } c := &ac.Contracts[contractIndex] index = addToContract(c, contractIndex, index, contract, transfer, addTxCount) @@ -328,7 +502,7 @@ func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.Address type ethBlockTxContract struct { from, to, contract bchain.AddressDescriptor - transferType bchain.TokenType + transferStandard bchain.TokenStandard value big.Int idValues []bchain.MultiTokenValue } @@ -353,7 +527,7 @@ type ethBlockTx struct { internalData *ethInternalData } -func (d *RocksDB) processBaseTxData(blockTx *ethBlockTx, tx *bchain.Tx, addresses addressesMap, addressContracts map[string]*AddrContracts) error { +func (d *RocksDB) processBaseTxData(blockTx *ethBlockTx, tx *bchain.Tx, addresses addressesMap, addressContracts map[string]*unpackedAddrContracts) error { var from, to bchain.AddressDescriptor var err error // there is only one output address in EthereumType transaction, store it in format txid 0 @@ -388,7 +562,23 @@ func (d *RocksDB) processBaseTxData(blockTx *ethBlockTx, tx *bchain.Tx, addresse return nil } -func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bchain.EthereumInternalData, addresses addressesMap, addressContracts map[string]*AddrContracts) error { +func (d *RocksDB) setAddressTxIndexesToAddressMap(addrDesc bchain.AddressDescriptor, height uint32, addresses addressesMap) error { + strAddrDesc := string(addrDesc) + _, found := addresses[strAddrDesc] + if !found { + txIndexes, err := d.getTxIndexesForAddressAndBlock(addrDesc, height) + if err != nil { + return err + } + if len(txIndexes) > 0 { + addresses[strAddrDesc] = txIndexes + } + } + return nil +} + +// existingBlock signals that internal data are reconnected to already indexed block after they failed during standard sync +func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bchain.EthereumInternalData, addresses addressesMap, addressContracts map[string]*unpackedAddrContracts, existingBlock bool) error { blockTx.internalData = ðInternalData{ internalType: id.Type, errorMsg: id.Error, @@ -404,6 +594,11 @@ func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bc blockTx.internalData.internalType = bchain.CALL } else { blockTx.internalData.contract = to + if existingBlock { + if err = d.setAddressTxIndexesToAddressMap(to, tx.BlockHeight, addresses); err != nil { + return err + } + } if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, internalTransferTo, nil, nil, true, addresses, addressContracts); err != nil { return err } @@ -422,6 +617,11 @@ func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bc glog.Warningf("rocksdb: processInternalData: %v, tx %v, internal transfer %d to", err, tx.Txid, i) } } else { + if existingBlock { + if err = d.setAddressTxIndexesToAddressMap(to, tx.BlockHeight, addresses); err != nil { + return err + } + } if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, internalTransferTo, nil, nil, true, addresses, addressContracts); err != nil { return err } @@ -433,6 +633,11 @@ func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bc glog.Warningf("rocksdb: processInternalData: %v, tx %v, internal transfer %d from", err, tx.Txid, i) } } else { + if existingBlock { + if err = d.setAddressTxIndexesToAddressMap(from, tx.BlockHeight, addresses); err != nil { + return err + } + } if err = d.addToAddressesAndContractsEthereumType(from, blockTx.btxID, internalTransferFrom, nil, nil, !bytes.Equal(from, to), addresses, addressContracts); err != nil { return err } @@ -445,7 +650,7 @@ func (d *RocksDB) processInternalData(blockTx *ethBlockTx, tx *bchain.Tx, id *bc return nil } -func (d *RocksDB) processContractTransfers(blockTx *ethBlockTx, tx *bchain.Tx, addresses addressesMap, addressContracts map[string]*AddrContracts) error { +func (d *RocksDB) processContractTransfers(blockTx *ethBlockTx, tx *bchain.Tx, addresses addressesMap, addressContracts map[string]*unpackedAddrContracts) error { tokenTransfers, err := d.chainParser.EthereumTypeGetTokenTransfersFromTx(tx) if err != nil { glog.Warningf("rocksdb: processContractTransfers %v, tx %v", err, tx.Txid) @@ -472,7 +677,7 @@ func (d *RocksDB) processContractTransfers(blockTx *ethBlockTx, tx *bchain.Tx, a return err } bc := &blockTx.contracts[i] - bc.transferType = t.Type + bc.transferStandard = t.Standard bc.from = from bc.to = to bc.contract = contract @@ -482,7 +687,10 @@ func (d *RocksDB) processContractTransfers(blockTx *ethBlockTx, tx *bchain.Tx, a return nil } -func (d *RocksDB) processAddressesEthereumType(block *bchain.Block, addresses addressesMap, addressContracts map[string]*AddrContracts) ([]ethBlockTx, error) { +func (d *RocksDB) processAddressesEthereumType(block *bchain.Block, addresses addressesMap, addressContracts map[string]*unpackedAddrContracts) ([]ethBlockTx, error) { + if d.hotAddrTracker != nil { + d.hotAddrTracker.BeginBlock() + } blockTxs := make([]ethBlockTx, len(block.Txs)) for txi := range block.Txs { tx := &block.Txs[txi] @@ -498,7 +706,7 @@ func (d *RocksDB) processAddressesEthereumType(block *bchain.Block, addresses ad // process internal data eid, _ := tx.CoinSpecificData.(bchain.EthereumSpecificData) if eid.InternalData != nil { - if err = d.processInternalData(blockTx, tx, eid.InternalData, addresses, addressContracts); err != nil { + if err = d.processInternalData(blockTx, tx, eid.InternalData, addresses, addressContracts, false); err != nil { return nil, err } } @@ -510,6 +718,59 @@ func (d *RocksDB) processAddressesEthereumType(block *bchain.Block, addresses ad return blockTxs, nil } +// ReconnectInternalDataToBlockEthereumType adds missing internal data to the block and stores them in db +func (d *RocksDB) ReconnectInternalDataToBlockEthereumType(block *bchain.Block) error { + d.connectBlockMux.Lock() + defer d.connectBlockMux.Unlock() + + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if d.chainParser.GetChainType() != bchain.ChainEthereumType { + return errors.New("Unsupported chain type") + } + if d.hotAddrTracker != nil { + d.hotAddrTracker.BeginBlock() + } + + addresses := make(addressesMap) + addressContracts := make(map[string]*unpackedAddrContracts) + + // process internal data + blockTxs := make([]ethBlockTx, len(block.Txs)) + for txi := range block.Txs { + tx := &block.Txs[txi] + eid, _ := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if eid.InternalData != nil { + btxID, err := d.chainParser.PackTxid(tx.Txid) + if err != nil { + return err + } + blockTx := &blockTxs[txi] + blockTx.btxID = btxID + tx.BlockHeight = block.Height + if err = d.processInternalData(blockTx, tx, eid.InternalData, addresses, addressContracts, true); err != nil { + return err + } + } + } + + if err := d.storeUnpackedAddressContracts(wb, addressContracts); err != nil { + return err + } + if err := d.storeInternalDataEthereumType(wb, blockTxs); err != nil { + return err + } + if err := d.storeAddresses(wb, block.Height, addresses); err != nil { + return err + } + // remove the block from the internal errors table + wb.DeleteCF(d.cfh[cfBlockInternalDataErrors], packUint(block.Height)) + if err := d.WriteBatch(wb); err != nil { + return err + } + return nil +} + var ethZeroAddress []byte = make([]byte, eth.EthereumTypeAddressDescriptorLen) func appendAddress(buf []byte, a bchain.AddressDescriptor) []byte { @@ -700,126 +961,6 @@ func (d *RocksDB) storeInternalDataEthereumType(wb *grocksdb.WriteBatch, blockTx return nil } -var cachedContracts = make(map[string]*bchain.ContractInfo) -var cachedContractsMux sync.Mutex - -func packContractInfo(contractInfo *bchain.ContractInfo) []byte { - buf := packString(contractInfo.Name) - buf = append(buf, packString(contractInfo.Symbol)...) - buf = append(buf, packString(string(contractInfo.Type))...) - varBuf := make([]byte, vlq.MaxLen64) - l := packVaruint(uint(contractInfo.Decimals), varBuf) - buf = append(buf, varBuf[:l]...) - l = packVaruint(uint(contractInfo.CreatedInBlock), varBuf) - buf = append(buf, varBuf[:l]...) - l = packVaruint(uint(contractInfo.DestructedInBlock), varBuf) - buf = append(buf, varBuf[:l]...) - return buf -} - -func unpackContractInfo(buf []byte) (*bchain.ContractInfo, error) { - var contractInfo bchain.ContractInfo - var s string - var l int - var ui uint - contractInfo.Name, l = unpackString(buf) - buf = buf[l:] - contractInfo.Symbol, l = unpackString(buf) - buf = buf[l:] - s, l = unpackString(buf) - contractInfo.Type = bchain.TokenTypeName(s) - buf = buf[l:] - ui, l = unpackVaruint(buf) - contractInfo.Decimals = int(ui) - buf = buf[l:] - ui, l = unpackVaruint(buf) - contractInfo.CreatedInBlock = uint32(ui) - buf = buf[l:] - ui, l = unpackVaruint(buf) - contractInfo.DestructedInBlock = uint32(ui) - return &contractInfo, nil -} - -func (d *RocksDB) GetContractInfoForAddress(address string) (*bchain.ContractInfo, error) { - contract, err := d.chainParser.GetAddrDescFromAddress(address) - if err != nil || contract == nil { - return nil, err - } - return d.GetContractInfo(contract, "") -} - -// GetContractInfo gets contract from cache or DB and possibly updates the type from typeFromContext -// it is hard to guess the type of the contract using API, it is easier to set it the first time the contract is processed in a tx -func (d *RocksDB) GetContractInfo(contract bchain.AddressDescriptor, typeFromContext bchain.TokenTypeName) (*bchain.ContractInfo, error) { - cacheKey := string(contract) - cachedContractsMux.Lock() - contractInfo, found := cachedContracts[cacheKey] - cachedContractsMux.Unlock() - if !found { - val, err := d.db.GetCF(d.ro, d.cfh[cfContracts], contract) - if err != nil { - return nil, err - } - defer val.Free() - buf := val.Data() - if len(buf) == 0 { - return nil, nil - } - contractInfo, err = unpackContractInfo(buf) - addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(contract) - if len(addresses) > 0 { - contractInfo.Contract = addresses[0] - } - // if the type is specified and stored contractInfo has unknown type, set and store it - if typeFromContext != bchain.UnknownTokenType && contractInfo.Type == bchain.UnknownTokenType { - contractInfo.Type = typeFromContext - err = d.db.PutCF(d.wo, d.cfh[cfContracts], contract, packContractInfo(contractInfo)) - } - cachedContractsMux.Lock() - cachedContracts[cacheKey] = contractInfo - cachedContractsMux.Unlock() - } - return contractInfo, nil -} - -// StoreContractInfo stores contractInfo in DB -// if CreatedInBlock==0 and DestructedInBlock!=0, it is evaluated as a destruction of a contract, the contract info is updated -// in all other cases the contractInfo overwrites previously stored data in DB (however it should not really happen as contract is created only once) -func (d *RocksDB) StoreContractInfo(contractInfo *bchain.ContractInfo) error { - wb := grocksdb.NewWriteBatch() - defer wb.Destroy() - if err := d.storeContractInfo(wb, contractInfo); err != nil { - return err - } - return d.WriteBatch(wb) -} - -func (d *RocksDB) storeContractInfo(wb *grocksdb.WriteBatch, contractInfo *bchain.ContractInfo) error { - if contractInfo.Contract != "" { - key, err := d.chainParser.GetAddrDescFromAddress(contractInfo.Contract) - if err != nil { - return err - } - if contractInfo.CreatedInBlock == 0 && contractInfo.DestructedInBlock != 0 { - storedCI, err := d.GetContractInfo(key, "") - if err != nil { - return err - } - if storedCI == nil { - return nil - } - storedCI.DestructedInBlock = contractInfo.DestructedInBlock - contractInfo = storedCI - } - wb.PutCF(d.cfh[cfContracts], key, packContractInfo(contractInfo)) - cacheKey := string(key) - cachedContractsMux.Lock() - delete(cachedContracts, cacheKey) - cachedContractsMux.Unlock() - } - return nil -} - func packBlockTx(buf []byte, blockTx *ethBlockTx) []byte { varBuf := make([]byte, maxPackedBigintBytes) buf = append(buf, blockTx.btxID...) @@ -834,9 +975,9 @@ func packBlockTx(buf []byte, blockTx *ethBlockTx) []byte { buf = appendAddress(buf, c.from) buf = appendAddress(buf, c.to) buf = appendAddress(buf, c.contract) - l = packVaruint(uint(c.transferType), varBuf) + l = packVaruint(uint(c.transferStandard), varBuf) buf = append(buf, varBuf[:l]...) - if c.transferType == bchain.MultiToken { + if c.transferStandard == bchain.MultiToken { l = packVaruint(uint(len(c.idValues)), varBuf) buf = append(buf, varBuf[:l]...) for i := range c.idValues { @@ -864,8 +1005,9 @@ func (d *RocksDB) storeAndCleanupBlockTxsEthereumType(wb *grocksdb.WriteBatch, b return d.cleanupBlockTxs(wb, block) } -func (d *RocksDB) storeBlockInternalDataErrorEthereumType(wb *grocksdb.WriteBatch, block *bchain.Block, message string) error { +func (d *RocksDB) StoreBlockInternalDataErrorEthereumType(wb *grocksdb.WriteBatch, block *bchain.Block, message string, retryCount uint8) error { key := packUint(block.Height) + // TODO: this supposes that Txid and block hash are the same size txid, err := d.chainParser.PackTxid(block.Hash) if err != nil { return err @@ -874,18 +1016,66 @@ func (d *RocksDB) storeBlockInternalDataErrorEthereumType(wb *grocksdb.WriteBatc buf := make([]byte, 0, len(txid)+len(m)+1) // the stored structure is txid+retry count (1 byte)+error message buf = append(buf, txid...) - buf = append(buf, 0) + buf = append(buf, retryCount) buf = append(buf, m...) wb.PutCF(d.cfh[cfBlockInternalDataErrors], key, buf) return nil } +type BlockInternalDataError struct { + Height uint32 + Hash string + Retries uint8 + ErrorMessage string +} + +func (d *RocksDB) unpackBlockInternalDataError(val []byte) (string, uint8, string, error) { + txidUnpackedLen := d.chainParser.PackedTxidLen() + var hash, message string + var retries uint8 + var err error + if len(val) > txidUnpackedLen+1 { + hash, err = d.chainParser.UnpackTxid(val[:txidUnpackedLen]) + if err != nil { + return "", 0, "", err + } + val = val[txidUnpackedLen:] + retries = val[0] + message = string(val[1:]) + } + return hash, retries, message, nil +} + +func (d *RocksDB) GetBlockInternalDataErrorsEthereumType() ([]BlockInternalDataError, error) { + retval := []BlockInternalDataError{} + if d.chainParser.GetChainType() == bchain.ChainEthereumType { + it := d.db.NewIteratorCF(d.ro, d.cfh[cfBlockInternalDataErrors]) + defer it.Close() + for it.SeekToFirst(); it.Valid(); it.Next() { + height := unpackUint(it.Key().Data()) + val := it.Value().Data() + hash, retires, message, err := d.unpackBlockInternalDataError(val) + if err != nil { + glog.Error("GetBlockInternalDataErrorsEthereumType height ", height, ", unpack error ", err) + continue + } + retval = append(retval, BlockInternalDataError{ + Height: height, + Hash: hash, + Retries: retires, + ErrorMessage: message, + }) + } + } + return retval, nil +} + func (d *RocksDB) storeBlockSpecificDataEthereumType(wb *grocksdb.WriteBatch, block *bchain.Block) error { blockSpecificData, _ := block.CoinSpecificData.(*bchain.EthereumBlockSpecificData) if blockSpecificData != nil { if blockSpecificData.InternalDataError != "" { glog.Info("storeBlockSpecificDataEthereumType ", block.Height, ": ", blockSpecificData.InternalDataError) - if err := d.storeBlockInternalDataErrorEthereumType(wb, block, blockSpecificData.InternalDataError); err != nil { + if err := d.StoreBlockInternalDataErrorEthereumType(wb, block, blockSpecificData.InternalDataError, 0); err != nil { return err } } @@ -949,9 +1139,9 @@ func unpackBlockTx(buf []byte, pos int) (*ethBlockTx, int, error) { return nil, 0, err } cc, l = unpackVaruint(buf[pos:]) - c.transferType = bchain.TokenType(cc) + c.transferStandard = bchain.TokenStandard(cc) pos += l - if c.transferType == bchain.MultiToken { + if c.transferStandard == bchain.MultiToken { cc, l = unpackVaruint(buf[pos:]) pos += l c.idValues = make([]bchain.MultiTokenValue, cc) @@ -998,7 +1188,7 @@ func (d *RocksDB) getBlockTxsEthereumType(height uint32) ([]ethBlockTx, error) { return bt, nil } -func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain.AddressDescriptor, btxContract *ethBlockTxContract, addresses map[string]map[string]struct{}, contracts map[string]*AddrContracts) error { +func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain.AddressDescriptor, btxContract *ethBlockTxContract, addresses map[string]map[string]struct{}, contracts map[string]*unpackedAddrContracts) error { var err error // do not process empty address if len(addrDesc) == 0 { @@ -1020,7 +1210,7 @@ func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain } addrContracts, fc := contracts[s] if !fc { - addrContracts, err = d.GetAddrDescContracts(addrDesc) + addrContracts, err = d.getUnpackedAddrDescContracts(addrDesc) if err != nil { return err } @@ -1047,7 +1237,7 @@ func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain } } } else { - contractIndex, found := findContractInAddressContracts(btxContract.contract, addrContracts.Contracts) + contractIndex, found := addrContracts.findContractIndex(addrDesc, btxContract.contract, nil) if found { addrContract := &addrContracts.Contracts[contractIndex] if addrContract.Txs > 0 { @@ -1055,6 +1245,7 @@ func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain if addrContract.Txs == 0 { // no transactions, remove the contract addrContracts.Contracts = append(addrContracts.Contracts[:contractIndex], addrContracts.Contracts[contractIndex+1:]...) + addrContracts.markContractIndexDirty() } else { // update the values of the contract, reverse the direction var index int32 @@ -1064,7 +1255,7 @@ func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain index = transferTo } addToContract(addrContract, contractIndex, index, btxContract.contract, &bchain.TokenTransfer{ - Type: btxContract.transferType, + Standard: btxContract.transferStandard, Value: btxContract.value, MultiTokenValues: btxContract.idValues, }, false) @@ -1086,7 +1277,7 @@ func (d *RocksDB) disconnectAddress(btxID []byte, internal bool, addrDesc bchain return nil } -func (d *RocksDB) disconnectInternalData(btxID []byte, addresses map[string]map[string]struct{}, contracts map[string]*AddrContracts) error { +func (d *RocksDB) disconnectInternalData(btxID []byte, addresses map[string]map[string]struct{}, contracts map[string]*unpackedAddrContracts) error { internalData, err := d.getEthereumInternalData(btxID) if err != nil { return err @@ -1125,7 +1316,7 @@ func (d *RocksDB) disconnectInternalData(btxID []byte, addresses map[string]map[ return nil } -func (d *RocksDB) disconnectBlockTxsEthereumType(wb *grocksdb.WriteBatch, height uint32, blockTxs []ethBlockTx, contracts map[string]*AddrContracts) error { +func (d *RocksDB) disconnectBlockTxsEthereumType(wb *grocksdb.WriteBatch, height uint32, blockTxs []ethBlockTx, contracts map[string]*unpackedAddrContracts) error { glog.Info("Disconnecting block ", height, " containing ", len(blockTxs), " transactions") addresses := make(map[string]map[string]struct{}) for i := range blockTxs { @@ -1167,9 +1358,14 @@ func (d *RocksDB) disconnectBlockTxsEthereumType(wb *grocksdb.WriteBatch, height return nil } -// DisconnectBlockRangeEthereumType removes all data belonging to blocks in range lower-higher -// it is able to disconnect only blocks for which there are data in the blockTxs column +// DisconnectBlockRangeEthereumType removes all data for blocks in [lower,higher]. +// Requires blockTxs data for the range. Holds connectBlockMux to serialize the +// protocol scan + flush against SetErcProtocol writers; sync calls +// connect/disconnect serially so this can't deadlock against ConnectBlock. func (d *RocksDB) DisconnectBlockRangeEthereumType(lower uint32, higher uint32) error { + d.connectBlockMux.Lock() + defer d.connectBlockMux.Unlock() + blocks := make([][]ethBlockTx, higher-lower+1) for height := lower; height <= higher; height++ { blockTxs, err := d.getBlockTxsEthereumType(height) @@ -1184,7 +1380,7 @@ func (d *RocksDB) DisconnectBlockRangeEthereumType(lower uint32, higher uint32) } wb := grocksdb.NewWriteBatch() defer wb.Destroy() - contracts := make(map[string]*AddrContracts) + contracts := make(map[string]*unpackedAddrContracts) for height := higher; height >= lower; height-- { if err := d.disconnectBlockTxsEthereumType(wb, height, blocks[height-lower], contracts); err != nil { return err @@ -1194,11 +1390,532 @@ func (d *RocksDB) DisconnectBlockRangeEthereumType(lower uint32, higher uint32) wb.DeleteCF(d.cfh[cfHeight], key) wb.DeleteCF(d.cfh[cfBlockInternalDataErrors], key) } - d.storeAddressContracts(wb, contracts) + d.storeUnpackedAddressContracts(wb, contracts) + // Revert protocol rows whose persistHeight fell into [lower,higher]. + if err := d.disconnectErcProtocols(wb, lower, higher); err != nil { + return err + } err := d.WriteBatch(wb) if err == nil { d.is.RemoveLastBlockTimes(int(higher-lower) + 1) + d.reorgGen.Add(1) glog.Infof("rocksdb: blocks %d-%d disconnected", lower, higher) } return err } + +func (d *RocksDB) SortAddressContracts(stop chan os.Signal) error { + if d.chainParser.GetChainType() != bchain.ChainEthereumType { + glog.Info("SortAddressContracts: applicable only for ethereum type coins") + return nil + } + glog.Info("SortAddressContracts: starting") + // do not use cache + ro := grocksdb.NewDefaultReadOptions() + defer ro.Destroy() + ro.SetFillCache(false) + it := d.db.NewIteratorCF(ro, d.cfh[cfAddressContracts]) + defer it.Close() + var rowCount, idsSortedCount, multiTokenValuesSortedCount int + for it.SeekToFirst(); it.Valid(); it.Next() { + select { + case <-stop: + return errors.New("SortAddressContracts: interrupted") + default: + } + rowCount++ + addrDesc := it.Key().Data() + buf := it.Value().Data() + if len(buf) > 0 { + ca, err := unpackAddrContracts(buf, addrDesc) + if err != nil { + glog.Error("failed to unpack AddrContracts for: ", hex.EncodeToString(addrDesc)) + } + update := false + for i := range ca.Contracts { + c := &ca.Contracts[i] + if sorted := c.Ids.sort(); sorted { + idsSortedCount++ + update = true + } + if sorted := c.MultiTokenValues.sort(); sorted { + multiTokenValuesSortedCount++ + update = true + } + } + if update { + if err := func() error { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + buf := packAddrContracts(ca) + wb.PutCF(d.cfh[cfAddressContracts], addrDesc, buf) + return d.WriteBatch(wb) + }(); err != nil { + return errors.Errorf("failed to write cfAddressContracts for: %v: %v", addrDesc, err) + } + } + } + if rowCount%5000000 == 0 { + glog.Infof("SortAddressContracts: progress - scanned %d rows, sorted %d ids and %d multi token values", rowCount, idsSortedCount, multiTokenValuesSortedCount) + } + } + glog.Infof("SortAddressContracts: finished - scanned %d rows, sorted %d ids and %d multi token value", rowCount, idsSortedCount, multiTokenValuesSortedCount) + return nil +} + +type unpackedBigInt struct { + Slice []byte + Value *big.Int +} +type unpackedIds []unpackedBigInt + +type unpackedAddrContract struct { + Standard bchain.TokenStandard + Contract bchain.AddressDescriptor + Txs uint + Value unpackedBigInt // single value of ERC20 + Ids unpackedIds // multiple ERC721 tokens + MultiTokenValues unpackedMultiTokenValues // multiple ERC1155 tokens +} + +func (b *unpackedBigInt) get() *big.Int { + if b.Value == nil { + if len(b.Slice) == 0 { + b.Value = big.NewInt(0) + } else { + bi, _ := unpackBigint(b.Slice) + b.Value = &bi + } + } + return b.Value +} + +type unpackedAddrContracts struct { + Packed []byte + TotalTxs uint + NonContractTxs uint + InternalTxs uint + Contracts []unpackedAddrContract + // contractIndex lazily maps contract address -> index for large contract lists. + contractIndex map[contractIndexKey]int + contractIndexDirty bool +} + +type contractIndexKey [eth.EthereumTypeAddressDescriptorLen]byte + +func contractIndexKeyFromDesc(addr bchain.AddressDescriptor) (contractIndexKey, bool) { + var key contractIndexKey + if len(addr) != len(key) { + return key, false + } + copy(key[:], addr) + return key, true +} + +func (acs *unpackedAddrContracts) rebuildContractIndex() { + m := make(map[contractIndexKey]int, len(acs.Contracts)) + for i := range acs.Contracts { + if key, ok := contractIndexKeyFromDesc(acs.Contracts[i].Contract); ok { + m[key] = i + } + } + acs.contractIndex = m + acs.contractIndexDirty = false +} + +func (acs *unpackedAddrContracts) dropContractIndex() { + acs.contractIndex = nil + acs.contractIndexDirty = false +} + +func (d *RocksDB) dropAddrContractsContractIndex(addrKey addressHotnessKey) { + d.addrContractsCacheMux.Lock() + if acs := d.addrContractsCache[string(addrKey[:])]; acs != nil { + acs.dropContractIndex() + } + d.addrContractsCacheMux.Unlock() +} + +func (acs *unpackedAddrContracts) findContractIndex(addrDesc, contract bchain.AddressDescriptor, hot *addressHotness) (int, bool) { + useIndex := false + if hot != nil && len(acs.Contracts) >= hot.minContracts { + // Rule B: use the index only for addresses that are "hot" in this block, + // so mid-size lists stay on a cheap linear scan unless we see repeated lookups. + if addrKey, ok := addressHotnessKeyFromDesc(addrDesc); ok { + useIndex = hot.ShouldUseIndex(addrKey, len(acs.Contracts)) + } + } + if useIndex { + if acs.contractIndex == nil || acs.contractIndexDirty { + acs.rebuildContractIndex() + } + if acs.contractIndex != nil { + if key, ok := contractIndexKeyFromDesc(contract); ok { + if idx, found := acs.contractIndex[key]; found { + return idx, true + } + return 0, false + } + } + } + return findContractInAddressContracts(contract, acs.Contracts) +} + +func (acs *unpackedAddrContracts) addContractIndex(contract bchain.AddressDescriptor, idx int) { + if acs.contractIndex == nil || acs.contractIndexDirty { + return + } + key, ok := contractIndexKeyFromDesc(contract) + if !ok { + acs.contractIndexDirty = true + return + } + acs.contractIndex[key] = idx +} + +func (acs *unpackedAddrContracts) markContractIndexDirty() { + if acs.contractIndex != nil { + acs.contractIndexDirty = true + } +} + +func (s *unpackedIds) search(id big.Int) int { + // attempt to find id using a binary search + return sort.Search(len(*s), func(i int) bool { + return (*s)[i].get().CmpAbs(&id) >= 0 + }) +} + +// insert id in ascending order +func (s *unpackedIds) insert(id big.Int) { + i := s.search(id) + if i == len(*s) { + *s = append(*s, unpackedBigInt{Value: &id}) + } else { + *s = append((*s)[:i+1], (*s)[i:]...) + (*s)[i] = unpackedBigInt{Value: &id} + } +} + +func (s *unpackedIds) remove(id big.Int) { + i := s.search(id) + // remove id if found + if i < len(*s) && (*s)[i].get().CmpAbs(&id) == 0 { + *s = append((*s)[:i], (*s)[i+1:]...) + } +} + +type unpackedMultiTokenValue struct { + Id unpackedBigInt + Value unpackedBigInt +} + +type unpackedMultiTokenValues []unpackedMultiTokenValue + +// search for multi token value using a binary seach on id +func (s *unpackedMultiTokenValues) search(m bchain.MultiTokenValue) int { + return sort.Search(len(*s), func(i int) bool { + return (*s)[i].Id.get().CmpAbs(&m.Id) >= 0 + }) +} + +func (s *unpackedMultiTokenValues) upsert(m bchain.MultiTokenValue, index int32, aggregate AggregateFn) { + i := s.search(m) + if i < len(*s) && (*s)[i].Id.get().CmpAbs(&m.Id) == 0 { + aggregate((*s)[i].Value.get(), &m.Value) + // if transfer from, remove if the value is zero + if index < 0 && len((*s)[i].Value.get().Bits()) == 0 { + *s = append((*s)[:i], (*s)[i+1:]...) + } + return + } + if index >= 0 { + elem := unpackedMultiTokenValue{ + Id: unpackedBigInt{Value: &m.Id}, + Value: unpackedBigInt{Value: new(big.Int).Set(&m.Value)}, + } + if i == len(*s) { + *s = append(*s, elem) + } else { + *s = append((*s)[:i+1], (*s)[i:]...) + (*s)[i] = elem + } + } +} + +// getUnpackedAddrDescContracts returns partially unpacked AddrContracts for given addrDesc +func (d *RocksDB) getUnpackedAddrDescContracts(addrDesc bchain.AddressDescriptor) (*unpackedAddrContracts, error) { + d.addrContractsCacheMux.Lock() + rv, found := d.addrContractsCache[string(addrDesc)] + d.addrContractsCacheMux.Unlock() + if found && rv != nil { + if d.metrics != nil { + d.metrics.AddrContractsCacheHits.Inc() + } + return rv, nil + } + if d.metrics != nil { + d.metrics.AddrContractsCacheMisses.Inc() + } + val, err := d.db.GetCF(d.ro, d.cfh[cfAddressContracts], addrDesc) + if err != nil { + return nil, err + } + defer val.Free() + buf := val.Data() + if len(buf) == 0 { + return nil, nil + } + rv, err = partiallyUnpackAddrContracts(buf) + minSize := d.addrContractsCacheMinSize + if minSize <= 0 { + minSize = addrContractsCacheMinSize + } + if err == nil && rv != nil && len(buf) > minSize { + var cacheEntries int + var cacheBytes int64 + shouldFlush := false + d.addrContractsCacheMux.Lock() + key := string(addrDesc) + if _, exists := d.addrContractsCache[key]; !exists { + d.addrContractsCache[key] = rv + // Track bytes based on the packed size at insertion time; later growth isn't accounted for. + d.addrContractsCacheBytes += int64(len(buf)) + if d.addrContractsCacheMaxBytes > 0 && d.addrContractsCacheBytes > d.addrContractsCacheMaxBytes { + shouldFlush = true + } + } + cacheEntries = len(d.addrContractsCache) + cacheBytes = d.addrContractsCacheBytes + d.addrContractsCacheMux.Unlock() + if d.metrics != nil { + d.metrics.AddrContractsCacheEntries.Set(float64(cacheEntries)) + d.metrics.AddrContractsCacheBytes.Set(float64(cacheBytes)) + } + if shouldFlush { + // Flush early when we exceed the cap to avoid unbounded memory growth. + d.flushAddrContractsCache() + } + } + return rv, err +} + +// to speed up import of blocks, the unpacking of big ints is deferred to time when they are needed +func partiallyUnpackAddrContracts(buf []byte) (acs *unpackedAddrContracts, err error) { + // make copy of the slice to avoid subsequent allocation of smaller slices + buf = append([]byte{}, buf...) + index := 0 + tt, l := unpackVaruint(buf) + index += l + nct, l := unpackVaruint(buf[index:]) + index += l + ict, l := unpackVaruint(buf[index:]) + index += l + cl, l := unpackVaruint(buf[index:]) + index += l + c := make([]unpackedAddrContract, 0, cl) + for index < len(buf) { + contract := buf[index : index+eth.EthereumTypeAddressDescriptorLen] + index += eth.EthereumTypeAddressDescriptorLen + txs, l := unpackVaruint(buf[index:]) + index += l + standard := bchain.TokenStandard(txs & 3) + txs >>= 2 + ac := unpackedAddrContract{ + Standard: standard, + Contract: contract, + Txs: txs, + } + if standard == bchain.FungibleToken { + l := packedBigintLen(buf[index:]) + ac.Value = unpackedBigInt{Slice: buf[index : index+l]} + index += l + } else { + len, ll := unpackVaruint(buf[index:]) + index += ll + if standard == bchain.NonFungibleToken { + ac.Ids = make(unpackedIds, len) + for i := uint(0); i < len; i++ { + ll := packedBigintLen(buf[index:]) + ac.Ids[i] = unpackedBigInt{Slice: buf[index : index+ll]} + index += ll + } + } else { + ac.MultiTokenValues = make(unpackedMultiTokenValues, len) + for i := uint(0); i < len; i++ { + ll := packedBigintLen(buf[index:]) + ac.MultiTokenValues[i].Id = unpackedBigInt{Slice: buf[index : index+ll]} + index += ll + ll = packedBigintLen(buf[index:]) + ac.MultiTokenValues[i].Value = unpackedBigInt{Slice: buf[index : index+ll]} + index += ll + } + } + } + c = append(c, ac) + } + return &unpackedAddrContracts{ + Packed: buf, + TotalTxs: tt, + NonContractTxs: nct, + InternalTxs: ict, + Contracts: c, + }, nil +} + +// packUnpackedAddrContracts packs unpackedAddrContracts into a byte buffer +func packUnpackedAddrContracts(acs *unpackedAddrContracts) []byte { + buf := make([]byte, 0, len(acs.Packed)+eth.EthereumTypeAddressDescriptorLen+12) + varBuf := make([]byte, maxPackedBigintBytes) + l := packVaruint(acs.TotalTxs, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(acs.NonContractTxs, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(acs.InternalTxs, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(len(acs.Contracts)), varBuf) + buf = append(buf, varBuf[:l]...) + for _, ac := range acs.Contracts { + buf = append(buf, ac.Contract...) + l = packVaruint(uint(ac.Standard)+ac.Txs<<2, varBuf) + buf = append(buf, varBuf[:l]...) + if ac.Standard == bchain.FungibleToken { + if ac.Value.Value != nil { + l = packBigint(ac.Value.Value, varBuf) + buf = append(buf, varBuf[:l]...) + } else { + buf = append(buf, ac.Value.Slice...) + } + } else if ac.Standard == bchain.NonFungibleToken { + l = packVaruint(uint(len(ac.Ids)), varBuf) + buf = append(buf, varBuf[:l]...) + for i := range ac.Ids { + if ac.Ids[i].Value != nil { + l = packBigint(ac.Ids[i].Value, varBuf) + buf = append(buf, varBuf[:l]...) + } else { + buf = append(buf, ac.Ids[i].Slice...) + } + } + } else { // bchain.ERC1155 + l = packVaruint(uint(len(ac.MultiTokenValues)), varBuf) + buf = append(buf, varBuf[:l]...) + for i := range ac.MultiTokenValues { + if ac.MultiTokenValues[i].Id.Value != nil { + l = packBigint(ac.MultiTokenValues[i].Id.Value, varBuf) + buf = append(buf, varBuf[:l]...) + } else { + buf = append(buf, ac.MultiTokenValues[i].Id.Slice...) + } + if ac.MultiTokenValues[i].Value.Value != nil { + l = packBigint(ac.MultiTokenValues[i].Value.Value, varBuf) + buf = append(buf, varBuf[:l]...) + } else { + buf = append(buf, ac.MultiTokenValues[i].Value.Slice...) + } + } + } + } + return buf +} + +func (d *RocksDB) storeUnpackedAddressContracts(wb *grocksdb.WriteBatch, acm map[string]*unpackedAddrContracts) error { + for addrDesc, acs := range acm { + // address with 0 contracts is removed from db - happens on disconnect + if acs == nil || (acs.NonContractTxs == 0 && acs.InternalTxs == 0 && len(acs.Contracts) == 0) { + wb.DeleteCF(d.cfh[cfAddressContracts], bchain.AddressDescriptor(addrDesc)) + } else { + // do not store large address contracts found in cache + d.addrContractsCacheMux.Lock() + _, found := d.addrContractsCache[addrDesc] + d.addrContractsCacheMux.Unlock() + if !found { + buf := packUnpackedAddrContracts(acs) + wb.PutCF(d.cfh[cfAddressContracts], bchain.AddressDescriptor(addrDesc), buf) + } + } + } + return nil +} + +func (d *RocksDB) writeContractsCache() { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + d.addrContractsCacheMux.Lock() + for addrDesc, acs := range d.addrContractsCache { + buf := packUnpackedAddrContracts(acs) + wb.PutCF(d.cfh[cfAddressContracts], bchain.AddressDescriptor(addrDesc), buf) + } + d.addrContractsCacheMux.Unlock() + if err := d.WriteBatch(wb); err != nil { + glog.Error("writeContractsCache: failed to store addrContractsCache: ", err) + } +} + +func (d *RocksDB) writeContractsCacheSnapshot(cache map[string]*unpackedAddrContracts) { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + for addrDesc, acs := range cache { + buf := packUnpackedAddrContracts(acs) + wb.PutCF(d.cfh[cfAddressContracts], bchain.AddressDescriptor(addrDesc), buf) + } + if err := d.WriteBatch(wb); err != nil { + glog.Error("writeContractsCache: failed to store addrContractsCache: ", err) + } +} + +func (d *RocksDB) flushAddrContractsCache() { + start := time.Now() + d.addrContractsCacheMux.Lock() + cache := d.addrContractsCache + count := len(cache) + d.addrContractsCache = make(map[string]*unpackedAddrContracts) + d.addrContractsCacheBytes = 0 + d.addrContractsCacheMux.Unlock() + if d.metrics != nil { + d.metrics.AddrContractsCacheEntries.Set(0) + d.metrics.AddrContractsCacheBytes.Set(0) + if count > 0 { + d.metrics.AddrContractsCacheFlushes.With(common.Labels{"reason": "cap"}).Inc() + } + } + if count > 0 { + d.writeContractsCacheSnapshot(cache) + } + glog.Info("storeAddrContractsCache: store ", count, " entries in ", time.Since(start)) +} + +func (d *RocksDB) flushAddrContractsCacheIfOverCap() { + maxBytes := d.addrContractsCacheMaxBytes + if maxBytes <= 0 { + return + } + d.addrContractsCacheMux.Lock() + overCap := d.addrContractsCacheBytes > maxBytes + d.addrContractsCacheMux.Unlock() + if overCap { + d.flushAddrContractsCache() + } +} + +func (d *RocksDB) storeAddrContractsCache() { + start := time.Now() + count := len(d.addrContractsCache) + if count > 0 { + d.writeContractsCache() + } + if d.metrics != nil && count > 0 { + d.metrics.AddrContractsCacheFlushes.With(common.Labels{"reason": "timer"}).Inc() + } + glog.Info("storeAddrContractsCache: store ", len(d.addrContractsCache), " entries in ", time.Since(start)) +} + +func (d *RocksDB) periodicStoreAddrContractsCache() { + period := time.Duration(5) * time.Minute + timer := time.NewTimer(period) + for { + <-timer.C + timer.Reset(period) + d.storeAddrContractsCache() + } +} diff --git a/db/rocksdb_ethereumtype_test.go b/db/rocksdb_ethereumtype_test.go index 8083f7eba7..e27b858d8d 100644 --- a/db/rocksdb_ethereumtype_test.go +++ b/db/rocksdb_ethereumtype_test.go @@ -4,6 +4,7 @@ package db import ( "encoding/hex" + "fmt" "math/big" "reflect" "testing" @@ -30,6 +31,222 @@ func bigintFromStringToHex(s string) string { return bigintToHex(&b) } +func makeTestAddrDesc(seed int) bchain.AddressDescriptor { + b := make([]byte, eth.EthereumTypeAddressDescriptorLen) + b[0] = byte(seed >> 8) + if len(b) > 1 { + b[1] = byte(seed) + } + for i := 2; i < len(b); i++ { + b[i] = byte(seed) + } + return b +} + +func Test_unpackedAddrContracts_findContractIndex_LazyMap(t *testing.T) { + acs := &unpackedAddrContracts{} + minContracts := 192 + for i := 0; i < minContracts+2; i++ { + acs.Contracts = append(acs.Contracts, unpackedAddrContract{ + Contract: makeTestAddrDesc(i), + }) + } + addrDesc := makeTestAddrDesc(9999) + + target := acs.Contracts[minContracts].Contract + idx, found := acs.findContractIndex(addrDesc, target, nil) + if !found || idx != minContracts { + t.Fatalf("findContractIndex() = (%v, %v), want (%v, true)", idx, found, minContracts) + } + if acs.contractIndex != nil { + t.Fatal("did not expect contract index map to be built without hotness") + } + + missing := makeTestAddrDesc(minContracts + 1024) + if _, found := findContractInAddressContracts(missing, acs.Contracts); found { + missing = makeTestAddrDesc(minContracts + 2048) + if _, found := findContractInAddressContracts(missing, acs.Contracts); found { + t.Fatal("failed to generate a missing contract for test") + } + } + if _, found := acs.findContractIndex(addrDesc, missing, nil); found { + t.Fatal("expected missing contract to be not found") + } +} + +func Test_unpackedAddrContracts_findContractIndex_DirtyRebuild(t *testing.T) { + acs := &unpackedAddrContracts{} + minContracts := 192 + for i := 0; i < minContracts+1; i++ { + acs.Contracts = append(acs.Contracts, unpackedAddrContract{ + Contract: makeTestAddrDesc(i), + }) + } + addrDesc := makeTestAddrDesc(9998) + hot := newAddressHotness(minContracts, 4, 1) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + hot.BeginBlock() + + _, _ = acs.findContractIndex(addrDesc, acs.Contracts[0].Contract, hot) + if acs.contractIndex == nil { + t.Fatal("expected contract index map to be built") + } + + // Remove a contract and mark the index dirty to force rebuild. + removed := acs.Contracts[1].Contract + acs.Contracts = append(acs.Contracts[:1], acs.Contracts[2:]...) + acs.markContractIndexDirty() + + if _, found := acs.findContractIndex(addrDesc, removed, hot); found { + t.Fatal("expected removed contract to be not found after rebuild") + } + if idx, found := acs.findContractIndex(addrDesc, acs.Contracts[1].Contract, hot); !found || idx != 1 { + t.Fatalf("findContractIndex() = (%v, %v), want (1, true)", idx, found) + } +} + +func Test_unpackedAddrContracts_findContractIndex_InvalidLenFallback(t *testing.T) { + acs := &unpackedAddrContracts{} + minContracts := 192 + for i := 0; i < minContracts; i++ { + acs.Contracts = append(acs.Contracts, unpackedAddrContract{ + Contract: makeTestAddrDesc(i), + }) + } + addrDesc := makeTestAddrDesc(9997) + hot := newAddressHotness(minContracts, 4, 1) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + hot.BeginBlock() + invalid := bchain.AddressDescriptor([]byte{1, 2, 3}) + acs.Contracts = append(acs.Contracts, unpackedAddrContract{Contract: invalid}) + + // Build index, which will skip the invalid entry. + _, _ = acs.findContractIndex(addrDesc, acs.Contracts[0].Contract, hot) + if acs.contractIndex == nil { + t.Fatal("expected contract index map to be built") + } + + if idx, found := acs.findContractIndex(addrDesc, invalid, hot); !found || idx != len(acs.Contracts)-1 { + t.Fatalf("findContractIndex() = (%v, %v), want (%v, true)", idx, found, len(acs.Contracts)-1) + } +} + +func Test_unpackedAddrContracts_findContractIndex_HotnessTriggers(t *testing.T) { + hotMinContracts := 192 + hotMinHits := 3 + hot := newAddressHotness(hotMinContracts, 4, hotMinHits) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + hot.BeginBlock() + + acs := &unpackedAddrContracts{} + for i := 0; i < hotMinContracts; i++ { + acs.Contracts = append(acs.Contracts, unpackedAddrContract{ + Contract: makeTestAddrDesc(i), + }) + } + addrDesc := makeTestAddrDesc(777) + target := acs.Contracts[hotMinContracts/2].Contract + + for i := 0; i < hotMinHits-1; i++ { + _, _ = acs.findContractIndex(addrDesc, target, hot) + if acs.contractIndex != nil { + t.Fatalf("unexpected index build before min hits, hit %d", i+1) + } + } + _, _ = acs.findContractIndex(addrDesc, target, hot) + if acs.contractIndex == nil { + t.Fatal("expected index to be built after reaching min hits") + } +} + +func Test_unpackedAddrContracts_findContractIndex_DropsIndexOnHotnessEviction(t *testing.T) { + parser := ethereumTestnetParser() + parser.HotAddressMinContracts = 1 + parser.HotAddressLRUCacheSize = 1 + parser.HotAddressMinHits = 1 + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: parser, + }) + defer closeAndDestroyRocksDB(t, d) + + addr1 := makeTestAddrDesc(1100) + addr2 := makeTestAddrDesc(1101) + acs1 := &unpackedAddrContracts{Contracts: []unpackedAddrContract{{Contract: makeTestAddrDesc(1200)}}} + acs2 := &unpackedAddrContracts{Contracts: []unpackedAddrContract{{Contract: makeTestAddrDesc(1201)}}} + d.addrContractsCache[string(addr1)] = acs1 + d.addrContractsCache[string(addr2)] = acs2 + + if _, found := acs1.findContractIndex(addr1, acs1.Contracts[0].Contract, d.hotAddrTracker); !found { + t.Fatal("expected first contract to be found") + } + if acs1.contractIndex == nil { + t.Fatal("expected first contract index to be built") + } + if _, found := acs2.findContractIndex(addr2, acs2.Contracts[0].Contract, d.hotAddrTracker); !found { + t.Fatal("expected second contract to be found") + } + if acs1.contractIndex != nil { + t.Fatal("expected first contract index to be dropped after LRU eviction") + } + if acs2.contractIndex == nil { + t.Fatal("expected second contract index to remain hot") + } +} + +func Test_addrContractsCache_FlushOnCap(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + d.addrContractsCacheMinSize = 1 + d.addrContractsCacheMaxBytes = 10 + + addrDesc := makeTestAddrDesc(42) + acs := &unpackedAddrContracts{ + TotalTxs: 1, + Contracts: []unpackedAddrContract{ + { + Contract: makeTestAddrDesc(7), + Standard: bchain.FungibleToken, + Txs: 1, + Value: unpackedBigInt{Value: big.NewInt(0)}, + }, + }, + } + buf := packUnpackedAddrContracts(acs) + if int64(len(buf)) <= d.addrContractsCacheMaxBytes { + t.Fatalf("expected packed size to exceed cap, got %d", len(buf)) + } + wb := grocksdb.NewWriteBatch() + wb.PutCF(d.cfh[cfAddressContracts], addrDesc, buf) + if err := d.WriteBatch(wb); err != nil { + wb.Destroy() + t.Fatal(err) + } + wb.Destroy() + + got, err := d.getUnpackedAddrDescContracts(addrDesc) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("expected cached address contracts to be returned") + } + if len(d.addrContractsCache) != 0 { + t.Fatalf("expected cache to be flushed, got %d entries", len(d.addrContractsCache)) + } + if d.addrContractsCacheBytes != 0 { + t.Fatalf("expected cache bytes to be reset, got %d", d.addrContractsCacheBytes) + } +} + func verifyAfterEthereumTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool) { if err := checkColumn(d, cfHeight, []keyPair{ { @@ -55,17 +272,17 @@ func verifyAfterEthereumTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect boo } if err := checkColumn(d, cfAddressContracts, []keyPair{ - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser), "020102", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser), "02010200", nil}, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser), - "020100" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintFromStringToHex("10000000000000000000000"), nil, + "02010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser), - "010100" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, + "01010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, }, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr9f, d.chainParser), "010002", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), "010101", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr9f, d.chainParser), "01000200", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), "01010100", nil}, }); err != nil { { t.Fatal(err) @@ -177,51 +394,51 @@ func verifyAfterEthereumTypeBlock2(t *testing.T, d *RocksDB, wantBlockInternalDa if err := checkColumn(d, cfAddressContracts, []keyPair{ { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser), - "010100" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, + "01010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser), - "030202" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(1<<2+uint(bchain.MultiToken)) + varuintToHex(1) + bigintFromStringToHex("150") + bigintFromStringToHex("1"), nil, + "03020201" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(1<<2+uint(bchain.MultiToken)) + varuintToHex(1) + bigintFromStringToHex("150") + bigintFromStringToHex("1"), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser), - "010101" + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + varuintToHex(2<<2+uint(bchain.FungibleToken)) + bigintFromStringToHex("8086") + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(2<<2+uint(bchain.FungibleToken)) + bigintFromStringToHex("871180000950184"), nil, + "01010102" + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + varuintToHex(2<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)) + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(2<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser), - "050300" + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(2<<2+uint(bchain.FungibleToken)) + bigintFromStringToHex("10000000854307892726464") + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintFromStringToHex("0") + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintFromStringToHex("0"), nil, + "05030003" + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(2<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)) + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)) + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr5d, d.chainParser), - "010100" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(1<<2+uint(bchain.MultiToken)) + varuintToHex(2) + bigintFromStringToHex("1776") + bigintFromStringToHex("1") + bigintFromStringToHex("1898") + bigintFromStringToHex("10"), nil, + "01010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(1<<2+uint(bchain.MultiToken)) + varuintToHex(2) + bigintFromStringToHex("1776") + bigintFromStringToHex("1") + bigintFromStringToHex("1898") + bigintFromStringToHex("10"), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr7b, d.chainParser), - "020000" + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintFromStringToHex("0") + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintFromStringToHex("7674999999999991915") + + "02000003" + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)) + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContractCd, d.chainParser) + varuintToHex(1<<2+uint(bchain.NonFungibleToken)) + varuintToHex(1) + bigintFromStringToHex("1"), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr83, d.chainParser), - "010100" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContractCd, d.chainParser) + varuintToHex(1<<2+uint(bchain.NonFungibleToken)) + varuintToHex(0), nil, + "01010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContractCd, d.chainParser) + varuintToHex(1<<2+uint(bchain.NonFungibleToken)) + varuintToHex(0), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrA3, d.chainParser), - "010000" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(1<<2+uint(bchain.MultiToken)) + varuintToHex(0), nil, + "01000001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(1<<2+uint(bchain.MultiToken)) + varuintToHex(0), nil, }, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr92, d.chainParser), "010100", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr9f, d.chainParser), "030104", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser), "010001", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract47, d.chainParser), "010100", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), "020102", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser), "010100", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContractCd, d.chainParser), "010100", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr92, d.chainParser), "01010000", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr9f, d.chainParser), "03010400", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser), "01000100", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract47, d.chainParser), "01010000", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), "02010200", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser), "01010000", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContractCd, d.chainParser), "01010000", nil}, }); err != nil { { t.Fatal(err) @@ -409,8 +626,8 @@ func TestRocksDB_Index_EthereumType(t *testing.T) { } verifyAfterEthereumTypeBlock1(t, d, false) - if len(d.is.BlockTimes) != 1 { - t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes)) + if len(d.is.BlockTimes) != 4321001 { + t.Fatal("Expecting is.BlockTimes 4321001, got ", len(d.is.BlockTimes)) } // connect 2nd block, simulate InternalDataError and AddressAlias @@ -421,8 +638,8 @@ func TestRocksDB_Index_EthereumType(t *testing.T) { verifyAfterEthereumTypeBlock2(t, d, true) block2.CoinSpecificData = nil - if len(d.is.BlockTimes) != 2 { - t.Fatal("Expecting is.BlockTimes 2, got ", len(d.is.BlockTimes)) + if len(d.is.BlockTimes) != 4321002 { + t.Fatal("Expecting is.BlockTimes 4321002, got ", len(d.is.BlockTimes)) } // get transactions for various addresses / low-high ranges @@ -551,8 +768,8 @@ func TestRocksDB_Index_EthereumType(t *testing.T) { } } - if len(d.is.BlockTimes) != 1 { - t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes)) + if len(d.is.BlockTimes) != 4321001 { + t.Fatal("Expecting is.BlockTimes 4321001, got ", len(d.is.BlockTimes)) } // connect block again and verify the state of db @@ -561,8 +778,8 @@ func TestRocksDB_Index_EthereumType(t *testing.T) { } verifyAfterEthereumTypeBlock2(t, d, false) - if len(d.is.BlockTimes) != 2 { - t.Fatal("Expecting is.BlockTimes 2, got ", len(d.is.BlockTimes)) + if len(d.is.BlockTimes) != 4321002 { + t.Fatal("Expecting is.BlockTimes 4321002, got ", len(d.is.BlockTimes)) } } @@ -572,11 +789,16 @@ func Test_BulkConnect_EthereumType(t *testing.T) { EthereumParser: ethereumTestnetParser(), }) defer closeAndDestroyRocksDB(t, d) + tipMaxBytes := d.addrContractsCacheMaxBytes + bulkMaxBytes := d.bulkAddrContractsCacheMaxBytes bc, err := d.InitBulkConnect() if err != nil { t.Fatal(err) } + if got, want := d.addrContractsCacheMaxBytes, bulkMaxBytes; got != want { + t.Fatalf("InitBulkConnect() addrContractsCacheMaxBytes = %d, want %d", got, want) + } if d.is.DbState != common.DbStateInconsistent { t.Fatal("DB not in DbStateInconsistent") @@ -605,6 +827,10 @@ func Test_BulkConnect_EthereumType(t *testing.T) { if err := bc.Close(); err != nil { t.Fatal(err) } + assertBulkConnectReleased(t, bc) + if got := d.addrContractsCacheMaxBytes; got != tipMaxBytes { + t.Fatalf("Close() addrContractsCacheMaxBytes = %d, want %d", got, tipMaxBytes) + } if d.is.DbState != common.DbStateOpen { t.Fatal("DB not in DbStateOpen") @@ -617,6 +843,81 @@ func Test_BulkConnect_EthereumType(t *testing.T) { } } +func Test_BulkConnect_EthereumType_UsesConfiguredAddrContractsCacheMaxBytes(t *testing.T) { + parser := ethereumTestnetParser() + parser.AddrContractsCacheMaxBytes = 10 + parser.AddrContractsCacheBulkMaxBytes = 20 + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: parser, + }) + defer closeAndDestroyRocksDB(t, d) + tipMaxBytes := d.tipAddrContractsCacheMaxBytes + bulkMaxBytes := d.bulkAddrContractsCacheMaxBytes + + bc1, err := d.InitBulkConnect() + if err != nil { + t.Fatal(err) + } + if got := d.addrContractsCacheMaxBytes; got != bulkMaxBytes { + t.Fatalf("first InitBulkConnect() addrContractsCacheMaxBytes = %d, want %d", got, bulkMaxBytes) + } + + bc2, err := d.InitBulkConnect() + if err != nil { + t.Fatal(err) + } + if got := d.addrContractsCacheMaxBytes; got != bulkMaxBytes { + t.Fatalf("second InitBulkConnect() addrContractsCacheMaxBytes = %d, want %d", got, bulkMaxBytes) + } + + if err := bc2.Close(); err != nil { + t.Fatal(err) + } + if got := d.addrContractsCacheMaxBytes; got != tipMaxBytes { + t.Fatalf("second Close() addrContractsCacheMaxBytes = %d, want %d", got, tipMaxBytes) + } + + if err := bc1.Close(); err != nil { + t.Fatal(err) + } + if got := d.addrContractsCacheMaxBytes; got != tipMaxBytes { + t.Fatalf("first Close() addrContractsCacheMaxBytes = %d, want %d", got, tipMaxBytes) + } +} + +func Test_BulkConnect_EthereumType_CloseFlushesAddrContractsCacheOverTipCap(t *testing.T) { + parser := ethereumTestnetParser() + parser.AddrContractsCacheMaxBytes = 10 + parser.AddrContractsCacheBulkMaxBytes = 20 + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: parser, + }) + defer closeAndDestroyRocksDB(t, d) + + bc, err := d.InitBulkConnect() + if err != nil { + t.Fatal(err) + } + + d.addrContractsCacheMux.Lock() + d.addrContractsCache[string(makeTestAddrDesc(99))] = &unpackedAddrContracts{TotalTxs: 1} + d.addrContractsCacheBytes = d.tipAddrContractsCacheMaxBytes + 1 + d.addrContractsCacheMux.Unlock() + + if err := bc.Close(); err != nil { + t.Fatal(err) + } + if got := d.addrContractsCacheMaxBytes; got != d.tipAddrContractsCacheMaxBytes { + t.Fatalf("Close() addrContractsCacheMaxBytes = %d, want %d", got, d.tipAddrContractsCacheMaxBytes) + } + if got := len(d.addrContractsCache); got != 0 { + t.Fatalf("Close() addrContractsCache entries = %d, want 0", got) + } + if got := d.addrContractsCacheBytes; got != 0 { + t.Fatalf("Close() addrContractsCacheBytes = %d, want 0", got) + } +} + func Test_packUnpackEthInternalData(t *testing.T) { parser := ethereumTestnetParser() db := &RocksDB{chainParser: parser} @@ -729,6 +1030,108 @@ func Test_packUnpackEthInternalData(t *testing.T) { } } +func generateAddrContracts(f, nf, nfc, m, mc int) []AddrContract { + parser := ethereumTestnetParser() + rv := make([]AddrContract, f+nf+m) + i := 0 + for ; i < f; i++ { + rv[i] = AddrContract{ + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract0d, parser), + Txs: uint(i + 100000), + Value: *big.NewInt(793201132 + int64(i*1000)), + } + } + for ; i < f+nf; i++ { + ids := make(Ids, nfc) + for j := 0; j < nfc; j++ { + ids[j] = *big.NewInt(int64(i*100000) + int64(j*100)) + } + rv[i] = AddrContract{ + Standard: bchain.NonFungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Txs: uint(i + 100000), + Ids: ids, + } + } + for ; i < f+nf+m; i++ { + mtv := make(MultiTokenValues, mc) + for j := 0; j < nfc; j++ { + mtv[j] = bchain.MultiTokenValue{ + Id: *big.NewInt(int64(j)), + Value: *big.NewInt(4231521 + int64(i*1000000) + int64(j*1000)), + } + } + rv[i] = AddrContract{ + Standard: bchain.MultiToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract4a, parser), + Txs: uint(i + 100000), + MultiTokenValues: mtv, + } + } + return rv +} + +var fungibleContracts = AddrContracts{ + TotalTxs: 3333330, + NonContractTxs: 2222220, + InternalTxs: 1111110, + Contracts: generateAddrContracts(100_000, 1, 1, 1, 1), +} +var packedFungibleContracts = packAddrContracts(&fungibleContracts) +var unpackedFungibleContracts, _ = partiallyUnpackAddrContracts(packedFungibleContracts) + +var mixedContracts = AddrContracts{ + TotalTxs: 3333330, + NonContractTxs: 2222220, + InternalTxs: 1111110, + Contracts: generateAddrContracts(100_000, 1, 1_000_000, 1, 1_000_000), +} +var packedMixedContracts = packAddrContracts(&mixedContracts) +var unpackedMixedContracts, _ = partiallyUnpackAddrContracts(packedMixedContracts) + +func Benchmark_packUnpackAddrContractsV6_Fungible(b *testing.B) { + for i := 0; i < b.N; i++ { + packed := packAddrContractsV6(&fungibleContracts) + unpackAddrContractsV6(packed, nil) + } +} + +func Benchmark_packUnpackAddrContracts_Fungible(b *testing.B) { + for i := 0; i < b.N; i++ { + packed := packAddrContracts(&fungibleContracts) + unpackAddrContracts(packed, nil) + } +} + +func Benchmark_packUnpackUnpackedkAddrContracts_Fungible(b *testing.B) { + for i := 0; i < b.N; i++ { + packed := packUnpackedAddrContracts(unpackedFungibleContracts) + partiallyUnpackAddrContracts(packed) + } +} + +func Benchmark_packUnpackAddrContractsV6_Mixed(b *testing.B) { + for i := 0; i < b.N; i++ { + packed := packAddrContractsV6(&mixedContracts) + unpackAddrContractsV6(packed, nil) + } +} + +func Benchmark_packUnpackAddrContracts_Mixed(b *testing.B) { + for i := 0; i < b.N; i++ { + packed := packAddrContracts(&mixedContracts) + unpackAddrContracts(packed, nil) + } +} + +func Benchmark_packUnpackUnpackedkAddrContracts_Mixed(b *testing.B) { + for i := 0; i < b.N; i++ { + packed := packUnpackedAddrContracts(unpackedMixedContracts) + partiallyUnpackAddrContracts(packed) + } +} + func Test_packUnpackAddrContracts(t *testing.T) { parser := ethereumTestnetParser() type args struct { @@ -756,16 +1159,16 @@ func Test_packUnpackAddrContracts(t *testing.T) { InternalTxs: 8873, Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract0d, parser), Txs: 8, Value: *big.NewInt(793201132), }, { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), Txs: 41235, - Ids: []big.Int{ + Ids: Ids{ *big.NewInt(1), *big.NewInt(2), *big.NewInt(3), @@ -774,10 +1177,10 @@ func Test_packUnpackAddrContracts(t *testing.T) { }, }, { - Type: bchain.MultiToken, + Standard: bchain.MultiToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract4a, parser), Txs: 64, - MultiTokenValues: []bchain.MultiTokenValue{ + MultiTokenValues: MultiTokenValues{ { Id: *big.NewInt(1), Value: *big.NewInt(1412341234), @@ -791,6 +1194,24 @@ func Test_packUnpackAddrContracts(t *testing.T) { }, }, }, + { + name: "generated", + data: AddrContracts{ + TotalTxs: 3333330, + NonContractTxs: 2222220, + InternalTxs: 1111110, + Contracts: generateAddrContracts(10, 1, 1_000, 1, 1_000), + }, + }, + { + name: "huge", + data: AddrContracts{ + TotalTxs: 3333330, + NonContractTxs: 2222220, + InternalTxs: 1111110, + Contracts: generateAddrContracts(10000, 1, 1_000_000, 1, 1_000_000), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -830,8 +1251,8 @@ func Test_addToContracts(t *testing.T) { index: 1, contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), transfer: &bchain.TokenTransfer{ - Type: bchain.FungibleToken, - Value: *big.NewInt(123456), + Standard: bchain.FungibleToken, + Value: *big.NewInt(123456), }, addTxCount: true, }, @@ -839,10 +1260,10 @@ func Test_addToContracts(t *testing.T) { wantAddrContracts: &AddrContracts{ Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), Txs: 1, - Value: *big.NewInt(123456), + Value: *big.NewInt(0), }, }, }, @@ -853,8 +1274,8 @@ func Test_addToContracts(t *testing.T) { index: ^1, contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), transfer: &bchain.TokenTransfer{ - Type: bchain.FungibleToken, - Value: *big.NewInt(23456), + Standard: bchain.FungibleToken, + Value: *big.NewInt(23456), }, addTxCount: true, }, @@ -862,9 +1283,9 @@ func Test_addToContracts(t *testing.T) { wantAddrContracts: &AddrContracts{ Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), - Value: *big.NewInt(100000), + Value: *big.NewInt(0), Txs: 2, }, }, @@ -876,8 +1297,8 @@ func Test_addToContracts(t *testing.T) { index: 1, contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), transfer: &bchain.TokenTransfer{ - Type: bchain.NonFungibleToken, - Value: *big.NewInt(1), + Standard: bchain.NonFungibleToken, + Value: *big.NewInt(1), }, addTxCount: true, }, @@ -885,16 +1306,16 @@ func Test_addToContracts(t *testing.T) { wantAddrContracts: &AddrContracts{ Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), - Value: *big.NewInt(100000), + Value: *big.NewInt(0), Txs: 2, }, { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), Txs: 1, - Ids: []big.Int{*big.NewInt(1)}, + Ids: Ids{*big.NewInt(1)}, }, }, }, @@ -905,8 +1326,8 @@ func Test_addToContracts(t *testing.T) { index: 1, contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), transfer: &bchain.TokenTransfer{ - Type: bchain.NonFungibleToken, - Value: *big.NewInt(2), + Standard: bchain.NonFungibleToken, + Value: *big.NewInt(2), }, addTxCount: true, }, @@ -914,16 +1335,16 @@ func Test_addToContracts(t *testing.T) { wantAddrContracts: &AddrContracts{ Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), - Value: *big.NewInt(100000), + Value: *big.NewInt(0), Txs: 2, }, { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), Txs: 2, - Ids: []big.Int{*big.NewInt(1), *big.NewInt(2)}, + Ids: Ids{*big.NewInt(1), *big.NewInt(2)}, }, }, }, @@ -934,8 +1355,8 @@ func Test_addToContracts(t *testing.T) { index: ^1, contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), transfer: &bchain.TokenTransfer{ - Type: bchain.NonFungibleToken, - Value: *big.NewInt(1), + Standard: bchain.NonFungibleToken, + Value: *big.NewInt(1), }, addTxCount: false, }, @@ -943,16 +1364,16 @@ func Test_addToContracts(t *testing.T) { wantAddrContracts: &AddrContracts{ Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), - Value: *big.NewInt(100000), + Value: *big.NewInt(0), Txs: 2, }, { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), Txs: 2, - Ids: []big.Int{*big.NewInt(2)}, + Ids: Ids{*big.NewInt(2)}, }, }, }, @@ -963,7 +1384,7 @@ func Test_addToContracts(t *testing.T) { index: 1, contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), transfer: &bchain.TokenTransfer{ - Type: bchain.MultiToken, + Standard: bchain.MultiToken, MultiTokenValues: []bchain.MultiTokenValue{ { Id: *big.NewInt(11), @@ -977,22 +1398,22 @@ func Test_addToContracts(t *testing.T) { wantAddrContracts: &AddrContracts{ Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), - Value: *big.NewInt(100000), + Value: *big.NewInt(0), Txs: 2, }, { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), Txs: 2, - Ids: []big.Int{*big.NewInt(2)}, + Ids: Ids{*big.NewInt(2)}, }, { - Type: bchain.MultiToken, + Standard: bchain.MultiToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), Txs: 1, - MultiTokenValues: []bchain.MultiTokenValue{ + MultiTokenValues: MultiTokenValues{ { Id: *big.NewInt(11), Value: *big.NewInt(56789), @@ -1008,7 +1429,7 @@ func Test_addToContracts(t *testing.T) { index: 1, contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), transfer: &bchain.TokenTransfer{ - Type: bchain.MultiToken, + Standard: bchain.MultiToken, MultiTokenValues: []bchain.MultiTokenValue{ { Id: *big.NewInt(11), @@ -1026,22 +1447,22 @@ func Test_addToContracts(t *testing.T) { wantAddrContracts: &AddrContracts{ Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), - Value: *big.NewInt(100000), + Value: *big.NewInt(0), Txs: 2, }, { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), Txs: 2, - Ids: []big.Int{*big.NewInt(2)}, + Ids: Ids{*big.NewInt(2)}, }, { - Type: bchain.MultiToken, + Standard: bchain.MultiToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), Txs: 2, - MultiTokenValues: []bchain.MultiTokenValue{ + MultiTokenValues: MultiTokenValues{ { Id: *big.NewInt(11), Value: *big.NewInt(56900), @@ -1061,7 +1482,7 @@ func Test_addToContracts(t *testing.T) { index: ^1, contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), transfer: &bchain.TokenTransfer{ - Type: bchain.MultiToken, + Standard: bchain.MultiToken, MultiTokenValues: []bchain.MultiTokenValue{ { Id: *big.NewInt(11), @@ -1079,22 +1500,22 @@ func Test_addToContracts(t *testing.T) { wantAddrContracts: &AddrContracts{ Contracts: []AddrContract{ { - Type: bchain.FungibleToken, + Standard: bchain.FungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), - Value: *big.NewInt(100000), + Value: *big.NewInt(0), Txs: 2, }, { - Type: bchain.NonFungibleToken, + Standard: bchain.NonFungibleToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), Txs: 2, - Ids: []big.Int{*big.NewInt(2)}, + Ids: Ids{*big.NewInt(2)}, }, { - Type: bchain.MultiToken, + Standard: bchain.MultiToken, Contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), Txs: 3, - MultiTokenValues: []bchain.MultiTokenValue{ + MultiTokenValues: MultiTokenValues{ { Id: *big.NewInt(11), Value: *big.NewInt(56788), @@ -1107,17 +1528,24 @@ func Test_addToContracts(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - contractIndex, found := findContractInAddressContracts(tt.args.contract, addrContracts.Contracts) + // convert addrContracts to partially unpacked form which is used for block import + buf := packAddrContracts(addrContracts) + unpackedAddrContracts, _ := partiallyUnpackAddrContracts(buf) + // check logic + contractIndex, found := findContractInAddressContracts(tt.args.contract, unpackedAddrContracts.Contracts) if !found { - contractIndex = len(addrContracts.Contracts) - addrContracts.Contracts = append(addrContracts.Contracts, AddrContract{ + contractIndex = len(unpackedAddrContracts.Contracts) + unpackedAddrContracts.Contracts = append(unpackedAddrContracts.Contracts, unpackedAddrContract{ Contract: tt.args.contract, - Type: tt.args.transfer.Type, + Standard: tt.args.transfer.Standard, }) } - if got := addToContract(&addrContracts.Contracts[contractIndex], contractIndex, tt.args.index, tt.args.contract, tt.args.transfer, tt.args.addTxCount); got != tt.wantIndex { + if got := addToContract(&unpackedAddrContracts.Contracts[contractIndex], contractIndex, tt.args.index, tt.args.contract, tt.args.transfer, tt.args.addTxCount); got != tt.wantIndex { t.Errorf("addToContracts() = %v, want %v", got, tt.wantIndex) } + // convert from partially unpacked form to final form used by API + buf = packUnpackedAddrContracts(unpackedAddrContracts) + addrContracts, _ = unpackAddrContracts(buf, nil) if !reflect.DeepEqual(addrContracts, tt.wantAddrContracts) { t.Errorf("addToContracts() = %+v, want %+v", addrContracts, tt.wantAddrContracts) } @@ -1125,6 +1553,39 @@ func Test_addToContracts(t *testing.T) { } } +func Test_addToContract_ERC20ZeroesExistingValue(t *testing.T) { + transfer := &bchain.TokenTransfer{ + Standard: bchain.FungibleToken, + Value: *big.NewInt(1), + } + + c := &unpackedAddrContract{ + Standard: bchain.FungibleToken, + Contract: makeTestAddrDesc(123), + Value: unpackedBigInt{Value: big.NewInt(123456)}, + } + addToContract(c, 0, 1, c.Contract, transfer, false) + if c.Value.Value == nil || c.Value.Value.Sign() != 0 { + t.Fatalf("expected ERC20 value to be zeroed, got %v", c.Value.Value) + } + if len(c.Value.Slice) != 0 { + t.Fatalf("expected ERC20 packed slice to be cleared, got %d bytes", len(c.Value.Slice)) + } + + c = &unpackedAddrContract{ + Standard: bchain.FungibleToken, + Contract: makeTestAddrDesc(124), + Value: unpackedBigInt{Slice: []byte{0x1, 0x2}}, + } + addToContract(c, 0, 1, c.Contract, transfer, false) + if c.Value.Value == nil || c.Value.Value.Sign() != 0 { + t.Fatalf("expected ERC20 value to be zeroed after slice, got %v", c.Value.Value) + } + if len(c.Value.Slice) != 0 { + t.Fatalf("expected ERC20 packed slice to be cleared, got %d bytes", len(c.Value.Slice)) + } +} + func Test_packUnpackBlockTx(t *testing.T) { parser := ethereumTestnetParser() tests := []struct { @@ -1150,11 +1611,11 @@ func Test_packUnpackBlockTx(t *testing.T) { to: addressToAddrDesc(dbtestdata.EthAddr55, parser), contracts: []ethBlockTxContract{ { - from: addressToAddrDesc(dbtestdata.EthAddr20, parser), - to: addressToAddrDesc(dbtestdata.EthAddr5d, parser), - contract: addressToAddrDesc(dbtestdata.EthAddrContract4a, parser), - transferType: bchain.FungibleToken, - value: *big.NewInt(10000), + from: addressToAddrDesc(dbtestdata.EthAddr20, parser), + to: addressToAddrDesc(dbtestdata.EthAddr5d, parser), + contract: addressToAddrDesc(dbtestdata.EthAddrContract4a, parser), + transferStandard: bchain.FungibleToken, + value: *big.NewInt(10000), }, }, }, @@ -1168,24 +1629,24 @@ func Test_packUnpackBlockTx(t *testing.T) { to: addressToAddrDesc(dbtestdata.EthAddr55, parser), contracts: []ethBlockTxContract{ { - from: addressToAddrDesc(dbtestdata.EthAddr20, parser), - to: addressToAddrDesc(dbtestdata.EthAddr3e, parser), - contract: addressToAddrDesc(dbtestdata.EthAddrContract4a, parser), - transferType: bchain.FungibleToken, - value: *big.NewInt(987654321), + from: addressToAddrDesc(dbtestdata.EthAddr20, parser), + to: addressToAddrDesc(dbtestdata.EthAddr3e, parser), + contract: addressToAddrDesc(dbtestdata.EthAddrContract4a, parser), + transferStandard: bchain.FungibleToken, + value: *big.NewInt(987654321), }, { - from: addressToAddrDesc(dbtestdata.EthAddr4b, parser), - to: addressToAddrDesc(dbtestdata.EthAddr55, parser), - contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), - transferType: bchain.NonFungibleToken, - value: *big.NewInt(13), + from: addressToAddrDesc(dbtestdata.EthAddr4b, parser), + to: addressToAddrDesc(dbtestdata.EthAddr55, parser), + contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + transferStandard: bchain.NonFungibleToken, + value: *big.NewInt(13), }, { - from: addressToAddrDesc(dbtestdata.EthAddr5d, parser), - to: addressToAddrDesc(dbtestdata.EthAddr7b, parser), - contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), - transferType: bchain.MultiToken, + from: addressToAddrDesc(dbtestdata.EthAddr5d, parser), + to: addressToAddrDesc(dbtestdata.EthAddr7b, parser), + contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), + transferStandard: bchain.MultiToken, idValues: []bchain.MultiTokenValue{ { Id: *big.NewInt(1234), @@ -1257,43 +1718,47 @@ func Test_packUnpackFourByteSignature(t *testing.T) { } } -func Test_packUnpackContractInfo(t *testing.T) { - tests := []struct { - name string - contractInfo bchain.ContractInfo - }{ - { - name: "empty", - contractInfo: bchain.ContractInfo{}, - }, - { - name: "unknown", - contractInfo: bchain.ContractInfo{ - Type: bchain.UnknownTokenType, - Name: "Test contract", - Symbol: "TCT", - Decimals: 18, - CreatedInBlock: 1234567, - DestructedInBlock: 234567890, - }, - }, - { - name: "ERC20", - contractInfo: bchain.ContractInfo{ - Type: bchain.ERC20TokenType, - Name: "GreenContract🟢", - Symbol: "🟢", - Decimals: 0, - CreatedInBlock: 1, - DestructedInBlock: 2, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf := packContractInfo(&tt.contractInfo) - if got, err := unpackContractInfo(buf); !reflect.DeepEqual(*got, tt.contractInfo) || err != nil { - t.Errorf("packUnpackContractInfo() = %v, want %v, error %v", *got, tt.contractInfo, err) +func Benchmark_contractIndexLookup(b *testing.B) { + sizes := []int{192, 256} + for _, n := range sizes { + contracts := make([]unpackedAddrContract, n) + for i := 0; i < n; i++ { + contracts[i].Contract = makeTestAddrDesc(i) + } + addrDesc := makeTestAddrDesc(1234) + target := contracts[n/2].Contract + hot := newAddressHotness(192, 8, 1) + if hot != nil { + hot.BeginBlock() + } + + b.Run(fmt.Sprintf("ScanHit_%d", n), func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = findContractInAddressContracts(target, contracts) + } + }) + + b.Run(fmt.Sprintf("MapHit_%d", n), func(b *testing.B) { + acs := &unpackedAddrContracts{Contracts: contracts} + // Build once to isolate lookup cost. + _, _ = acs.findContractIndex(addrDesc, target, hot) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = acs.findContractIndex(addrDesc, target, hot) + } + }) + + b.Run(fmt.Sprintf("MapBuildHit_%d", n), func(b *testing.B) { + acs := &unpackedAddrContracts{Contracts: contracts} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + acs.contractIndex = nil + acs.contractIndexDirty = false + _, _ = acs.findContractIndex(addrDesc, target, hot) } }) } diff --git a/db/rocksdb_protocols.go b/db/rocksdb_protocols.go new file mode 100644 index 0000000000..615f2ea626 --- /dev/null +++ b/db/rocksdb_protocols.go @@ -0,0 +1,201 @@ +package db + +import ( + "bytes" + + vlq "github.com/bsm/go-vlq" + "github.com/golang/glog" + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/bchain" +) + +// Per-protocol contract metadata in cfErcProtocols, decoupled from the +// sync-owned cfContracts row. Two prefixes share the CF: +// +// 0x00 || protocolID(1B) || addrDesc → packVaruint(persistHeight) || payload +// 0x01 || protocolID(1B) || packUint32(persistHeight) || addrDesc → (empty) +// +// byContract is the read path; byHeight is the secondary index disconnect uses +// to revert rows whose persist-height fell into a reorged range. +const ( + ercProtocolKeyByContract byte = 0x00 + ercProtocolKeyByHeight byte = 0x01 + + // ErcProtocolErc4626 marks a confirmed ERC4626 vault. New protocols + // take the next free byte; 0x00 is reserved. + ErcProtocolErc4626 byte = 0x01 +) + +func protocolByContractKey(protocolID byte, addrDesc bchain.AddressDescriptor) []byte { + buf := make([]byte, 0, 2+len(addrDesc)) + buf = append(buf, ercProtocolKeyByContract, protocolID) + buf = append(buf, addrDesc...) + return buf +} + +func protocolByHeightKey(protocolID byte, height uint32, addrDesc bchain.AddressDescriptor) []byte { + buf := make([]byte, 0, 2+4+len(addrDesc)) + buf = append(buf, ercProtocolKeyByHeight, protocolID) + buf = append(buf, packUint(height)...) + buf = append(buf, addrDesc...) + return buf +} + +func packErcProtocolValue(persistHeight uint32, payload []byte) []byte { + varBuf := make([]byte, vlq.MaxLen64) + l := packVaruint(uint(persistHeight), varBuf) + out := make([]byte, 0, l+len(payload)) + out = append(out, varBuf[:l]...) + out = append(out, payload...) + return out +} + +func unpackErcProtocolValue(buf []byte) (persistHeight uint32, payload []byte, ok bool) { + h, l, ok := unpackVaruintSafe(buf) + if !ok { + return 0, nil, false + } + return uint32(h), buf[l:], true +} + +// SetErcProtocol persists a per-protocol detection record anchored to +// persistHeight (the API request's bestHeight, i.e. the multicall's pinned +// height — proxy upgrades make deploy-height an unreliable provenance). A +// future disconnect of that range deletes the row via the byHeight index. +// +// observedBlockHash and observedReorgGen are sampled before the multicall. +// Under connectBlockMux we refuse the write if either has shifted, closing +// the race where a reorg disconnects persistHeight while the multicall is in +// flight. False positives cost one re-probe. +// +// persistHeight==0 is refused defensively (no realistic disconnect range +// would clean it up). On payload conflict we refuse and warn rather than +// overwrite. +func (d *RocksDB) SetErcProtocol(addrDesc bchain.AddressDescriptor, protocolID byte, payload []byte, persistHeight uint32, observedBlockHash string, observedReorgGen uint64) error { + if len(addrDesc) == 0 || persistHeight == 0 { + return nil + } + + d.connectBlockMux.Lock() + defer d.connectBlockMux.Unlock() + + if d.reorgGen.Load() != observedReorgGen { + // Reorg ran during the request; drop, next request re-probes. + return nil + } + if observedBlockHash != "" { + currentHash, err := d.GetBlockHash(persistHeight) + if err != nil { + return err + } + if currentHash != observedBlockHash { + // Observed height replaced since the multicall; drop. + return nil + } + } + + byContract := protocolByContractKey(protocolID, addrDesc) + val, err := d.db.GetCF(d.ro, d.cfh[cfErcProtocols], byContract) + if err != nil { + return err + } + defer val.Free() + if buf := val.Data(); len(buf) > 0 { + _, existingPayload, ok := unpackErcProtocolValue(buf) + if ok { + // Drop any cachedContracts entry that may have been populated + // before the existing row landed and still carries + // IsErc4626=false. Applies on both the idempotent path and the + // conflict-refusal path — neither writes, but both must clear + // stale negatives so the next reader sees the persisted row. + cachedContracts.delete(string(addrDesc)) + if !bytes.Equal(existingPayload, payload) { + glog.Warningf("SetErcProtocol: refusing to overwrite protocol %d row for %x: stored payload differs", protocolID, addrDesc) + } + return nil + } + } + + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + wb.PutCF(d.cfh[cfErcProtocols], byContract, packErcProtocolValue(persistHeight, payload)) + wb.PutCF(d.cfh[cfErcProtocols], protocolByHeightKey(protocolID, persistHeight, addrDesc), nil) + if err := d.WriteBatch(wb); err != nil { + return err + } + // Bump before the cache delete: a concurrent GetContractInfo that already + // sampled the old protocolGen and is about to add a stale-negative entry + // will mismatch on the next read and miss, even if its add lands after our + // delete clears the slot. + d.protocolGen.Add(1) + cachedContracts.delete(string(addrDesc)) + return nil +} + +// disconnectErcProtocols deletes per-protocol rows whose persist-height +// falls into [lower, higher] via a byHeight range scan per protocolID. +func (d *RocksDB) disconnectErcProtocols(wb *grocksdb.WriteBatch, lower, higher uint32) error { + for _, protocolID := range []byte{ErcProtocolErc4626} { + if err := d.disconnectErcProtocolRange(wb, protocolID, lower, higher); err != nil { + return err + } + } + return nil +} + +func (d *RocksDB) disconnectErcProtocolRange(wb *grocksdb.WriteBatch, protocolID byte, lower, higher uint32) error { + startKey := []byte{ercProtocolKeyByHeight, protocolID} + startKey = append(startKey, packUint(lower)...) + endKey := []byte{ercProtocolKeyByHeight, protocolID} + endKey = append(endKey, packUint(higher+1)...) + + it := d.db.NewIteratorCF(d.ro, d.cfh[cfErcProtocols]) + defer it.Close() + for it.Seek(startKey); it.Valid(); it.Next() { + key := it.Key().Data() + if bytes.Compare(key, endKey) >= 0 { + it.Key().Free() + break + } + // key layout: 0x01 || protocolID(1B) || packUint32(height)(4B) || addrDesc + const headerLen = 2 + 4 + if len(key) <= headerLen { + it.Key().Free() + continue + } + addrDesc := bchain.AddressDescriptor(append([]byte(nil), key[headerLen:]...)) + byHeightKey := append([]byte(nil), key...) // iterator owns the buffer + wb.DeleteCF(d.cfh[cfErcProtocols], protocolByContractKey(protocolID, addrDesc)) + wb.DeleteCF(d.cfh[cfErcProtocols], byHeightKey) + cachedContracts.delete(string(addrDesc)) + it.Key().Free() + } + if err := it.Err(); err != nil { + return err + } + return nil +} + +// GetErcProtocol returns the persisted payload for (addrDesc, protocolID) +// if present. ok=false with err=nil means the row is absent. +func (d *RocksDB) GetErcProtocol(addrDesc bchain.AddressDescriptor, protocolID byte) (payload []byte, persistHeight uint32, ok bool, err error) { + if len(addrDesc) == 0 { + return nil, 0, false, nil + } + val, err := d.db.GetCF(d.ro, d.cfh[cfErcProtocols], protocolByContractKey(protocolID, addrDesc)) + if err != nil { + return nil, 0, false, err + } + defer val.Free() + buf := val.Data() + if len(buf) == 0 { + return nil, 0, false, nil + } + h, p, ok := unpackErcProtocolValue(buf) + if !ok { + return nil, 0, false, nil + } + out := make([]byte, len(p)) + copy(out, p) + return out, h, true, nil +} diff --git a/db/rocksdb_protocols_test.go b/db/rocksdb_protocols_test.go new file mode 100644 index 0000000000..cc15506d4d --- /dev/null +++ b/db/rocksdb_protocols_test.go @@ -0,0 +1,534 @@ +//go:build unittest + +package db + +import ( + "bytes" + "testing" + + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/bchain" +) + +// helper: drive the generic SetErcProtocol with a payload of bytes("asset") and +// fetch back via GetErcProtocol. Most tests below operate at this level so we +// exercise the generic path; one spot-checks the ERC4626 shim too. + +func newProtocolTestDB(t *testing.T) *RocksDB { + t.Helper() + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + return d +} + +func seedProtocolTestBlockHash(t *testing.T, d *RocksDB, height uint32, hash string) { + t.Helper() + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.writeHeight(wb, height, &BlockInfo{Hash: hash, Time: 1, Height: height}, opInsert); err != nil { + t.Fatalf("writeHeight: %v", err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatalf("seed block hash: %v", err) + } +} + +func TestSetErcProtocol_PersistsAndReadsBack(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + addr := makeTestAddrDesc(0x4001) + payload := []byte("asset") + + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, payload, 100, "", 0); err != nil { + t.Fatalf("SetErcProtocol: %v", err) + } + got, h, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626) + if err != nil || !ok { + t.Fatalf("expected row, ok=%v err=%v", ok, err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("payload mismatch: %x vs %x", got, payload) + } + if h != 100 { + t.Fatalf("persistHeight: got %d want 100", h) + } +} + +func TestSetErcProtocol_RefusesZeroPersistHeight(t *testing.T) { + // Direct chain.GetContractInfo can return metadata without a known + // CreatedInBlock, leaving persistHeight==0. A row keyed at height 0 + // would never be cleaned up by any realistic disconnect range, so the + // writer refuses these defensively. + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + addr := makeTestAddrDesc(0x4001) + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("asset"), 0, "", 0); err != nil { + t.Fatalf("SetErcProtocol: %v", err) + } + if _, _, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626); err != nil || ok { + t.Fatalf("expected no row for persistHeight==0, ok=%v err=%v", ok, err) + } +} + +func TestSetErcProtocol_RefusesConflictingOverwrite(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + addr := makeTestAddrDesc(0x4002) + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("first"), 100, "", 0); err != nil { + t.Fatalf("SetErcProtocol: %v", err) + } + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("different"), 100, "", 0); err != nil { + t.Fatalf("SetErcProtocol on conflict should not return error: %v", err) + } + got, _, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626) + if err != nil || !ok { + t.Fatalf("row missing after conflict refusal, ok=%v err=%v", ok, err) + } + if !bytes.Equal(got, []byte("first")) { + t.Fatalf("conflict overwrote payload: got %s want first", got) + } +} + +func TestSetErcProtocol_IdempotentOnSamePayload(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + addr := makeTestAddrDesc(0x4003) + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("asset"), 100, "", 0); err != nil { + t.Fatalf("first SetErcProtocol: %v", err) + } + // Second call with the same payload at a different persistHeight should be a no-op + // (the existing row already records what we'd write). + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("asset"), 200, "", 0); err != nil { + t.Fatalf("idempotent SetErcProtocol: %v", err) + } + _, h, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626) + if err != nil || !ok { + t.Fatalf("row missing, ok=%v err=%v", ok, err) + } + if h != 100 { + t.Fatalf("persistHeight changed: got %d want 100", h) + } +} + +func TestSetErcProtocol_DifferentProtocolsCoexist(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + // Two protocolIDs sharing the same contract address must not collide. + addr := makeTestAddrDesc(0x4004) + const otherProtocolID byte = 0x02 + + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("vaultAsset"), 100, "", 0); err != nil { + t.Fatalf("4626 set: %v", err) + } + if err := d.SetErcProtocol(addr, otherProtocolID, []byte("foreign"), 100, "", 0); err != nil { + t.Fatalf("foreign set: %v", err) + } + + got1, _, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626) + if err != nil || !ok || string(got1) != "vaultAsset" { + t.Fatalf("4626 readback: ok=%v err=%v payload=%s", ok, err, got1) + } + got2, _, ok, err := d.GetErcProtocol(addr, otherProtocolID) + if err != nil || !ok || string(got2) != "foreign" { + t.Fatalf("foreign readback: ok=%v err=%v payload=%s", ok, err, got2) + } +} + +func TestDisconnectErcProtocols_RemovesInRange(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + in := makeTestAddrDesc(0x5001) + out := makeTestAddrDesc(0x5002) + + if err := d.SetErcProtocol(in, ErcProtocolErc4626, []byte("a"), 105, "", 0); err != nil { + t.Fatalf("set in-range: %v", err) + } + if err := d.SetErcProtocol(out, ErcProtocolErc4626, []byte("b"), 90, "", 0); err != nil { + t.Fatalf("set out-of-range: %v", err) + } + + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.disconnectErcProtocols(wb, 100, 110); err != nil { + t.Fatalf("disconnect: %v", err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatalf("flush: %v", err) + } + + if _, _, ok, err := d.GetErcProtocol(in, ErcProtocolErc4626); err != nil || ok { + t.Fatalf("expected in-range row removed, ok=%v err=%v", ok, err) + } + got, _, ok, err := d.GetErcProtocol(out, ErcProtocolErc4626) + if err != nil || !ok || string(got) != "b" { + t.Fatalf("out-of-range row should survive, ok=%v err=%v payload=%s", ok, err, got) + } +} + +func TestDisconnectBlockRangeEthereumType_BumpsReorgGenAndRevertsProtocols(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + addr := makeTestAddrDesc(0x6001) + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("a"), 50, "", 0); err != nil { + t.Fatalf("set: %v", err) + } + + // Seed the height column so DisconnectBlockRangeEthereumType is willing to act. + wb := grocksdb.NewWriteBatch() + for h := uint32(50); h <= 51; h++ { + wb.PutCF(d.cfh[cfHeight], packUint(h), []byte{}) + // A non-nil blockTxs row is required by the disconnect helper. + wb.PutCF(d.cfh[cfBlockTxs], packUint(h), []byte{}) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatalf("seed: %v", err) + } + wb.Destroy() + + prevGen := d.ReorgGeneration() + if err := d.DisconnectBlockRangeEthereumType(50, 51); err != nil { + t.Fatalf("DisconnectBlockRangeEthereumType: %v", err) + } + if d.ReorgGeneration() != prevGen+1 { + t.Fatalf("reorg generation not bumped: was %d now %d", prevGen, d.ReorgGeneration()) + } + if _, _, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626); err != nil || ok { + t.Fatalf("expected protocol row to be reverted by disconnect, ok=%v err=%v", ok, err) + } +} + +func TestErc4626VaultShim_RoundTrip(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + address := "0x000000000000000000000000000000000000a17e" + asset := "0x000000000000000000000000000000000000a55e7" + + if err := d.SetContractInfoErc4626Vault(address, asset, 50, "", 0); err != nil { + t.Fatalf("set: %v", err) + } + addrDesc, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil { + t.Fatalf("addr desc: %v", err) + } + got, ok, err := d.GetContractInfoErc4626Vault(addrDesc) + if err != nil || !ok { + t.Fatalf("readback: ok=%v err=%v", ok, err) + } + if got != asset { + t.Fatalf("asset mismatch: got %q want %q", got, asset) + } +} + +// Simulates the reviewer's race: API request observes the chain at gen G, +// issues a multicall pinned to height H. Before the API write lands, a +// disconnect runs and bumps reorgGen. The writer must refuse the now-stale +// observation rather than persist a row at H that no future disconnect would +// catch. +func TestSetErcProtocol_RefusesStaleReorgGen(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + addr := makeTestAddrDesc(0x7001) + observedGen := d.ReorgGeneration() + + // Disconnect happens between observation and write — bump reorgGen. + d.reorgGen.Add(1) + + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("asset"), 100, "", observedGen); err != nil { + t.Fatalf("SetErcProtocol: %v", err) + } + if _, _, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626); err != nil || ok { + t.Fatalf("expected stale observation to be refused, ok=%v err=%v", ok, err) + } + + // A fresh observation under the new gen must succeed. + freshGen := d.ReorgGeneration() + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("asset"), 100, "", freshGen); err != nil { + t.Fatalf("SetErcProtocol after re-observation: %v", err) + } + if _, _, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626); err != nil || !ok { + t.Fatalf("expected fresh-gen write to land, ok=%v err=%v", ok, err) + } +} + +func TestSetErcProtocol_RefusesStaleObservedHash(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + addr := makeTestAddrDesc(0x7002) + const observedHash = "0x1111111111111111111111111111111111111111111111111111111111111111" + const currentHash = "0x2222222222222222222222222222222222222222222222222222222222222222" + seedProtocolTestBlockHash(t, d, 100, currentHash) + gen := d.ReorgGeneration() + + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("asset"), 100, observedHash, gen); err != nil { + t.Fatalf("SetErcProtocol stale hash: %v", err) + } + if _, _, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626); err != nil || ok { + t.Fatalf("expected stale observed hash to be refused, ok=%v err=%v", ok, err) + } + + if err := d.SetErcProtocol(addr, ErcProtocolErc4626, []byte("asset"), 100, currentHash, gen); err != nil { + t.Fatalf("SetErcProtocol current hash: %v", err) + } + if _, _, ok, err := d.GetErcProtocol(addr, ErcProtocolErc4626); err != nil || !ok { + t.Fatalf("expected current observed hash to land, ok=%v err=%v", ok, err) + } +} + +// Test that the API and sync paths can run their writes concurrently without +// either side dropping the other's data. The two writes target different column +// families and the API writer holds connectBlockMux, so this should pass even +// with -race. +func TestSetErcProtocol_DoesNotRaceWithStoreContractInfo(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + address := "0x0000000000000000000000000000000000abcdef" + addrDesc, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil { + t.Fatalf("addr desc: %v", err) + } + + done := make(chan struct{}, 2) + go func() { + ci := &bchain.ContractInfo{ + Contract: address, + Standard: bchain.ERC20TokenStandard, + Type: bchain.ERC20TokenStandard, + Name: "T", + Symbol: "T", + Decimals: 18, + CreatedInBlock: 50, + } + for i := 0; i < 100; i++ { + if err := d.StoreContractInfo(ci); err != nil { + t.Errorf("StoreContractInfo: %v", err) + break + } + } + done <- struct{}{} + }() + go func() { + for i := 0; i < 100; i++ { + if err := d.SetContractInfoErc4626Vault(address, "0x000000000000000000000000000000000000beef", 50, "", 0); err != nil { + t.Errorf("SetContractInfoErc4626Vault: %v", err) + break + } + } + done <- struct{}{} + }() + <-done + <-done + + // Both records must be intact. + ci, err := d.GetContractInfo(addrDesc, "") + if err != nil || ci == nil { + t.Fatalf("GetContractInfo: ci=%v err=%v", ci, err) + } + if ci.Name != "T" || ci.Symbol != "T" || ci.Decimals != 18 || ci.CreatedInBlock != 50 { + t.Fatalf("sync metadata clobbered: %+v", ci) + } + if !ci.IsErc4626 || ci.Erc4626AssetContract != "0x000000000000000000000000000000000000bEEF" { + // The asset address comparison is case-sensitive; the writer stores whatever the + // caller passes, so just check it's non-empty and IsErc4626 is set. + if !ci.IsErc4626 || ci.Erc4626AssetContract == "" { + t.Fatalf("erc4626 record missing: %+v", ci) + } + } +} + +// Reproduces the cache populate-after-write race: a reader caches IsErc4626=false +// just after a concurrent SetErcProtocol wrote the row. A subsequent +// SetErcProtocol with the same payload (idempotent path) must invalidate the +// stale entry so it doesn't drive a re-probe loop. +func TestSetErcProtocol_IdempotentInvalidatesStaleCache(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + address := "0x000000000000000000000000000000000000c0de" + addrDesc, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil { + t.Fatalf("addr desc: %v", err) + } + if err := d.StoreContractInfo(&bchain.ContractInfo{ + Contract: address, Standard: bchain.ERC20TokenStandard, Type: bchain.ERC20TokenStandard, + Name: "T", Symbol: "T", Decimals: 18, CreatedInBlock: 50, + }); err != nil { + t.Fatalf("StoreContractInfo: %v", err) + } + + if err := d.SetContractInfoErc4626Vault(address, "0x00000000000000000000000000000000000000a5", 100, "", 0); err != nil { + t.Fatalf("SetContractInfoErc4626Vault: %v", err) + } + // Simulate the race: reader's CF read pre-dated the write, populates stale + // entry under the post-write protocolGen (so the protocolGen-mismatch path + // can't help — only the writer's cache delete can). + stale := &bchain.ContractInfo{ + Contract: address, Standard: bchain.ERC20TokenStandard, Type: bchain.ERC20TokenStandard, + Name: "T", Symbol: "T", Decimals: 18, CreatedInBlock: 50, + IsErc4626: false, Erc4626AssetContract: "", + } + cachedContracts.add(string(addrDesc), stale, d.ReorgGeneration(), d.protocolGen.Load()) + + // Idempotent re-write must clear the stale cache entry. + if err := d.SetContractInfoErc4626Vault(address, "0x00000000000000000000000000000000000000a5", 100, "", 0); err != nil { + t.Fatalf("idempotent SetContractInfoErc4626Vault: %v", err) + } + ci, err := d.GetContractInfo(addrDesc, "") + if err != nil || ci == nil { + t.Fatalf("GetContractInfo: ci=%v err=%v", ci, err) + } + if !ci.IsErc4626 || ci.Erc4626AssetContract == "" { + t.Fatalf("expected fresh ERC4626 fields after idempotent re-write, got %+v", ci) + } +} + +// Same shape as the idempotent test, but exercises the conflict-refusal path +// (existing row, different payload). The write is refused but any stale cache +// entry must still be invalidated; otherwise stale negatives survive past a +// conflict and keep driving re-probes. +func TestSetErcProtocol_ConflictRefusalInvalidatesStaleCache(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + address := "0x000000000000000000000000000000000000c1ff" + addrDesc, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil { + t.Fatalf("addr desc: %v", err) + } + if err := d.StoreContractInfo(&bchain.ContractInfo{ + Contract: address, Standard: bchain.ERC20TokenStandard, Type: bchain.ERC20TokenStandard, + Name: "T", Symbol: "T", Decimals: 18, CreatedInBlock: 50, + }); err != nil { + t.Fatalf("StoreContractInfo: %v", err) + } + + const original = "0x00000000000000000000000000000000000000a5" + if err := d.SetContractInfoErc4626Vault(address, original, 100, "", 0); err != nil { + t.Fatalf("initial SetContractInfoErc4626Vault: %v", err) + } + stale := &bchain.ContractInfo{ + Contract: address, Standard: bchain.ERC20TokenStandard, Type: bchain.ERC20TokenStandard, + Name: "T", Symbol: "T", Decimals: 18, CreatedInBlock: 50, + IsErc4626: false, Erc4626AssetContract: "", + } + cachedContracts.add(string(addrDesc), stale, d.ReorgGeneration(), d.protocolGen.Load()) + + // Write with a *different* asset; conflict path refuses and warns. + if err := d.SetContractInfoErc4626Vault(address, "0x00000000000000000000000000000000000000ff", 100, "", 0); err != nil { + t.Fatalf("conflict SetContractInfoErc4626Vault: %v", err) + } + ci, err := d.GetContractInfo(addrDesc, "") + if err != nil || ci == nil { + t.Fatalf("GetContractInfo: ci=%v err=%v", ci, err) + } + if !ci.IsErc4626 || ci.Erc4626AssetContract != original { + t.Fatalf("expected fresh read of original asset after conflict refusal, got %+v", ci) + } +} + +// Reproduces the reorg populate-after-delete race: a reader populates the +// cache stamped at the old reorgGen; a later disconnect bumps the counter. +// The next reader sees the stamped entry mismatch and re-reads the post-disconnect +// CF state (IsErc4626=false) instead of the stale true. +func TestGetContractInfo_RejectsCacheEntryStampedAtOldReorgGen(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + address := "0x000000000000000000000000000000000000beef" + addrDesc, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil { + t.Fatalf("addr desc: %v", err) + } + if err := d.StoreContractInfo(&bchain.ContractInfo{ + Contract: address, Standard: bchain.ERC20TokenStandard, Type: bchain.ERC20TokenStandard, + Name: "T", Symbol: "T", Decimals: 18, CreatedInBlock: 100, + }); err != nil { + t.Fatalf("StoreContractInfo: %v", err) + } + + // Plant a stale-true entry stamped at the current generation, mimicking a + // reader who saw the old-fork protocol row before it was deleted. + staleGen := d.ReorgGeneration() + staleProtocolGen := d.protocolGen.Load() + stale := &bchain.ContractInfo{ + Contract: address, Standard: bchain.ERC20TokenStandard, Type: bchain.ERC20TokenStandard, + Name: "T", Symbol: "T", Decimals: 18, CreatedInBlock: 100, + IsErc4626: true, Erc4626AssetContract: "0x00000000000000000000000000000000000000a5", + } + cachedContracts.add(string(addrDesc), stale, staleGen, staleProtocolGen) + + // Disconnect bumps the generation; cfErcProtocols is empty (row never persisted). + d.reorgGen.Add(1) + + ci, err := d.GetContractInfo(addrDesc, "") + if err != nil || ci == nil { + t.Fatalf("GetContractInfo: ci=%v err=%v", ci, err) + } + if ci.IsErc4626 || ci.Erc4626AssetContract != "" { + t.Fatalf("expected stale cache entry to be rejected after reorgGen bump, got %+v", ci) + } +} + +// Reproduces the populate-after-write race that the conflict/idempotent cache +// deletes alone don't cover: reader misses, samples (reorgGen, protocolGen), +// reads cfErcProtocols (row absent), then a writer lands the row and bumps +// protocolGen. The reader's add lands AFTER the writer's cache delete, leaving +// a stale IsErc4626=false stamped at the pre-write protocolGen. The next +// GetContractInfo samples the bumped protocolGen and must miss. +// +// Without the protocolGen counter this stale entry would survive until LRU +// eviction, even though the protocol row exists on disk and no further +// SetErcProtocol call is guaranteed to clear it. +func TestGetContractInfo_RejectsCacheEntryStampedAtOldProtocolGen(t *testing.T) { + d := newProtocolTestDB(t) + defer closeAndDestroyRocksDB(t, d) + + address := "0x000000000000000000000000000000000000abba" + addrDesc, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil { + t.Fatalf("addr desc: %v", err) + } + if err := d.StoreContractInfo(&bchain.ContractInfo{ + Contract: address, Standard: bchain.ERC20TokenStandard, Type: bchain.ERC20TokenStandard, + Name: "T", Symbol: "T", Decimals: 18, CreatedInBlock: 50, + }); err != nil { + t.Fatalf("StoreContractInfo: %v", err) + } + + // Snapshot the pre-write protocolGen (the racing reader's view). + staleReorgGen := d.ReorgGeneration() + staleProtocolGen := d.protocolGen.Load() + + // Writer lands the protocol row (bumps protocolGen). + if err := d.SetContractInfoErc4626Vault(address, "0x00000000000000000000000000000000000000a5", 100, "", 0); err != nil { + t.Fatalf("SetContractInfoErc4626Vault: %v", err) + } + + // Racing reader's stale-false entry lands AFTER the writer's cache delete, + // stamped at the old protocolGen. + stale := &bchain.ContractInfo{ + Contract: address, Standard: bchain.ERC20TokenStandard, Type: bchain.ERC20TokenStandard, + Name: "T", Symbol: "T", Decimals: 18, CreatedInBlock: 50, + IsErc4626: false, Erc4626AssetContract: "", + } + cachedContracts.add(string(addrDesc), stale, staleReorgGen, staleProtocolGen) + + ci, err := d.GetContractInfo(addrDesc, "") + if err != nil || ci == nil { + t.Fatalf("GetContractInfo: ci=%v err=%v", ci, err) + } + if !ci.IsErc4626 || ci.Erc4626AssetContract == "" { + t.Fatalf("expected fresh ERC4626 fields after protocolGen bump, got %+v", ci) + } +} diff --git a/db/rocksdb_test.go b/db/rocksdb_test.go index 8a287403fd..16f2dffd43 100644 --- a/db/rocksdb_test.go +++ b/db/rocksdb_test.go @@ -5,7 +5,6 @@ package db import ( "encoding/binary" "encoding/hex" - "io/ioutil" "math/big" "os" "reflect" @@ -15,6 +14,7 @@ import ( vlq "github.com/bsm/go-vlq" "github.com/juju/errors" + "github.com/linxGnu/grocksdb" "github.com/martinboehm/btcutil/chaincfg" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/btc" @@ -43,15 +43,15 @@ func bitcoinTestnetParser() *btc.BitcoinParser { } func setupRocksDB(t *testing.T, p bchain.BlockChainParser) *RocksDB { - tmp, err := ioutil.TempDir("", "testdb") + tmp, err := os.MkdirTemp("", "testdb") if err != nil { t.Fatal(err) } - d, err := NewRocksDB(tmp, 100000, -1, p, nil) + d, err := NewRocksDB(tmp, 100000, -1, p, nil, false) if err != nil { t.Fatal(err) } - is, err := d.LoadInternalState("coin-unittest") + is, err := d.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) if err != nil { t.Fatal(err) } @@ -66,6 +66,40 @@ func closeAndDestroyRocksDB(t *testing.T, d *RocksDB) { os.RemoveAll(d.path) } +func assertBulkConnectReleased(t *testing.T, bc *BulkConnect) { + t.Helper() + if bc.d != nil { + t.Fatal("expected BulkConnect RocksDB handle to be released") + } + if bc.bulkAddresses != nil { + t.Fatal("expected bulkAddresses cache to be released") + } + if bc.bulkAddressesCount != 0 { + t.Fatal("expected bulkAddressesCount to be reset") + } + if bc.ethBlockTxs != nil { + t.Fatal("expected ethBlockTxs cache to be released") + } + if bc.txAddressesMap != nil { + t.Fatal("expected txAddressesMap cache to be released") + } + if bc.blockFilters != nil { + t.Fatal("expected blockFilters cache to be released") + } + if bc.balances != nil { + t.Fatal("expected balances cache to be released") + } + if bc.addressContracts != nil { + t.Fatal("expected addressContracts cache to be released") + } + if bc.bulkStats != (bulkConnectStats{}) { + t.Fatal("expected bulkStats to be reset") + } + if bc.bulkHotness != (bulkHotnessStats{}) { + t.Fatal("expected bulkHotness to be reset") + } +} + func inputAddressToPubKeyHexWithLength(addr string, t *testing.T, d *RocksDB) string { h := dbtestdata.AddressToPubKeyHex(addr, d.chainParser) return hex.EncodeToString([]byte{byte(len(h) / 2)}) + h @@ -547,8 +581,8 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) { } verifyAfterBitcoinTypeBlock1(t, d, false) - if len(d.is.BlockTimes) != 1 { - t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes)) + if len(d.is.BlockTimes) != 225494 { + t.Fatal("Expecting is.BlockTimes 225494, got ", len(d.is.BlockTimes)) } // connect 2nd block - use some outputs from the 1st block as the inputs and 1 input uses tx from the same block @@ -558,8 +592,8 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) { } verifyAfterBitcoinTypeBlock2(t, d) - if len(d.is.BlockTimes) != 2 { - t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes)) + if len(d.is.BlockTimes) != 225495 { + t.Fatal("Expecting is.BlockTimes 225495, got ", len(d.is.BlockTimes)) } // get transactions for various addresses / low-high ranges @@ -667,8 +701,8 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) { } } - if len(d.is.BlockTimes) != 1 { - t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes)) + if len(d.is.BlockTimes) != 225494 { + t.Fatal("Expecting is.BlockTimes 225494, got ", len(d.is.BlockTimes)) } // connect block again and verify the state of db @@ -677,8 +711,8 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) { } verifyAfterBitcoinTypeBlock2(t, d) - if len(d.is.BlockTimes) != 2 { - t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes)) + if len(d.is.BlockTimes) != 225495 { + t.Fatal("Expecting is.BlockTimes 225495, got ", len(d.is.BlockTimes)) } // test public methods for address balance and tx addresses @@ -790,6 +824,7 @@ func Test_BulkConnect_BitcoinType(t *testing.T) { if err := bc.Close(); err != nil { t.Fatal(err) } + assertBulkConnectReleased(t, bc) if d.is.DbState != common.DbStateOpen { t.Fatal("DB not in DbStateOpen") @@ -802,6 +837,46 @@ func Test_BulkConnect_BitcoinType(t *testing.T) { } } +func Test_BlockFilter_GetAndStore(t *testing.T) { + d := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + blockHash := "0000000000000003d0c9722718f8ee86c2cf394f9cd458edb1c854de2a7b1a91" + blockFilter := "042c6340895e413d8a811fa0" + blockFilterBytes, _ := hex.DecodeString(blockFilter) + + // Empty at the beginning + got, err := d.GetBlockFilter(blockHash) + if err != nil { + t.Fatal(err) + } + want := "" + if got != want { + t.Fatalf("GetBlockFilter(%s) = %s, want %s", blockHash, got, want) + } + + // Store the filter + wb := grocksdb.NewWriteBatch() + if err := d.storeBlockFilter(wb, blockHash, blockFilterBytes); err != nil { + t.Fatal(err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatal(err) + } + + // Get the filter + got, err = d.GetBlockFilter(blockHash) + if err != nil { + t.Fatal(err) + } + want = blockFilter + if got != want { + t.Fatalf("GetBlockFilter(%s) = %s, want %s", blockHash, got, want) + } +} + func Test_packBigint_unpackBigint(t *testing.T) { bigbig1, _ := big.NewInt(0).SetString("123456789123456789012345", 10) bigbig2, _ := big.NewInt(0).SetString("12345678912345678901234512389012345123456789123456789012345123456789123456789012345", 10) @@ -903,9 +978,10 @@ func addressToAddrDesc(addr string, parser bchain.BlockChainParser) []byte { func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { parser := bitcoinTestnetParser() tests := []struct { - name string - hex string - data *TxAddresses + name string + hex string + data *TxAddresses + rocksDB *RocksDB }{ { name: "1", @@ -930,6 +1006,7 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { }, }, }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: false}, }, { name: "2", @@ -976,6 +1053,7 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { }, }, }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: false}, }, { name: "empty address", @@ -1000,6 +1078,7 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { }, }, }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: false}, }, { name: "empty", @@ -1008,18 +1087,111 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { Inputs: []TxInput{}, Outputs: []TxOutput{}, }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: false}, + }, + { + name: "extendedIndex 1", + hex: "e0398241032ea9149eb21980dc9d413d8eac27314938b9da920ee53e8705021918f2c0c50c7ce2f5670fd52de738288299bd854a85ef1bb304f62f35ced1bd49a8a810002ea91409f70b896169c37981d2b54b371df0d81a136a2c870501dd7e28c0e96672c7fcc8da131427fcea7e841028614813496a56c11e8a6185c16861c495012ea914e371782582a4addb541362c55565d2cdf56f6498870501a1e35ec0ed308c72f9804dfeefdbb483ef8fd1e638180ad81d6b33f4b58d36d19162fa6d8106052fa9141d9ca71efa36d814424ea6ca1437e67287aebe348705012aadcac000b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa38400081ce8685592ea91424fbc77cdc62702ade74dcf989c15e5d3f9240bc870501664894c02fa914afbfb74ee994c7d45f6698738bc4226d065266f7870501a1e35ec0effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75ef17a1f4233276a914d2a37ce20ac9ec4f15dd05a7c6e8e9fbdb99850e88ac043b9943603376a9146b2044146a4438e6e5bfbc65f147afeb64d14fbb88ac05012a05f2007c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25a9956d8396f32a", + data: &TxAddresses{ + Height: 12345, + VSize: 321, + Inputs: []TxInput{ + { + AddrDesc: addressToAddrDesc("2N7iL7AvS4LViugwsdjTB13uN4T7XhV1bCP", parser), + ValueSat: *big.NewInt(9011000000), + Txid: "c50c7ce2f5670fd52de738288299bd854a85ef1bb304f62f35ced1bd49a8a810", + Vout: 0, + }, + { + AddrDesc: addressToAddrDesc("2Mt9v216YiNBAzobeNEzd4FQweHrGyuRHze", parser), + ValueSat: *big.NewInt(8011000000), + Txid: "e96672c7fcc8da131427fcea7e841028614813496a56c11e8a6185c16861c495", + Vout: 1, + }, + { + AddrDesc: addressToAddrDesc("2NDyqJpHvHnqNtL1F9xAeCWMAW8WLJmEMyD", parser), + ValueSat: *big.NewInt(7011000000), + Txid: "ed308c72f9804dfeefdbb483ef8fd1e638180ad81d6b33f4b58d36d19162fa6d", + Vout: 134, + }, + }, + Outputs: []TxOutput{ + { + AddrDesc: addressToAddrDesc("2MuwoFGwABMakU7DCpdGDAKzyj2nTyRagDP", parser), + ValueSat: *big.NewInt(5011000000), + Spent: true, + SpentTxid: dbtestdata.TxidB1T1, + SpentIndex: 0, + SpentHeight: 432112345, + }, + { + AddrDesc: addressToAddrDesc("2Mvcmw7qkGXNWzkfH1EjvxDcNRGL1Kf2tEM", parser), + ValueSat: *big.NewInt(6011000000), + }, + { + AddrDesc: addressToAddrDesc("2N9GVuX3XJGHS5MCdgn97gVezc6EgvzikTB", parser), + ValueSat: *big.NewInt(7011000000), + Spent: true, + SpentTxid: dbtestdata.TxidB1T2, + SpentIndex: 14231, + SpentHeight: 555555, + }, + { + AddrDesc: addressToAddrDesc("mzii3fuRSpExMLJEHdHveW8NmiX8MPgavk", parser), + ValueSat: *big.NewInt(999900000), + }, + { + AddrDesc: addressToAddrDesc("mqHPFTRk23JZm9W1ANuEFtwTYwxjESSgKs", parser), + ValueSat: *big.NewInt(5000000000), + Spent: true, + SpentTxid: dbtestdata.TxidB2T1, + SpentIndex: 674541, + SpentHeight: 6666666, + }, + }, + }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: true}, + }, + { + name: "extendedIndex empty address", + hex: "baef9a152d01010204d2020002162e010162fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db03e039", + data: &TxAddresses{ + Height: 123456789, + VSize: 45, + Inputs: []TxInput{ + { + AddrDesc: []byte(nil), + ValueSat: *big.NewInt(1234), + }, + }, + Outputs: []TxOutput{ + { + AddrDesc: []byte(nil), + ValueSat: *big.NewInt(5678), + }, + { + AddrDesc: []byte(nil), + ValueSat: *big.NewInt(98), + Spent: true, + SpentTxid: dbtestdata.TxidB2T4, + SpentIndex: 3, + SpentHeight: 12345, + }, + }, + }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: true}, }, } varBuf := make([]byte, maxPackedBigintBytes) buf := make([]byte, 1024) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := packTxAddresses(tt.data, buf, varBuf) + b := tt.rocksDB.packTxAddresses(tt.data, buf, varBuf) hex := hex.EncodeToString(b) if !reflect.DeepEqual(hex, tt.hex) { t.Errorf("packTxAddresses() = %v, want %v", hex, tt.hex) } - got1, err := unpackTxAddresses(b) + got1, err := tt.rocksDB.unpackTxAddresses(b) if err != nil { t.Errorf("unpackTxAddresses() error = %v", err) return @@ -1499,3 +1671,79 @@ func Test_packUnpackString(t *testing.T) { }) } } + +func TestRocksDB_packTxIndexes_unpackTxIndexes(t *testing.T) { + type args struct { + txi []txIndexes + } + tests := []struct { + name string + data []txIndexes + hex string + }{ + { + name: "1", + data: []txIndexes{ + { + btxID: hexToBytes(dbtestdata.TxidB1T1), + indexes: []int32{1}, + }, + }, + hex: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa384006", + }, + { + name: "2", + data: []txIndexes{ + { + btxID: hexToBytes(dbtestdata.TxidB1T1), + indexes: []int32{-2, 1, 3, 1234, -53241}, + }, + { + btxID: hexToBytes(dbtestdata.TxidB1T2), + indexes: []int32{-2, -1, 0, 1, 2, 3}, + }, + }, + hex: "effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac7507030004080e00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa384007040ca6488cff61", + }, + { + name: "3", + data: []txIndexes{ + { + btxID: hexToBytes(dbtestdata.TxidB2T1), + indexes: []int32{-2, 1, 3}, + }, + { + btxID: hexToBytes(dbtestdata.TxidB1T1), + indexes: []int32{-2, -1, 0, 1, 2, 3}, + }, + { + btxID: hexToBytes(dbtestdata.TxidB1T2), + indexes: []int32{-2}, + }, + }, + hex: "effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac750500b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa384007030004080e7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d2507040e", + }, + } + d := &RocksDB{ + chainParser: &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := d.packTxIndexes(tt.data) + hex := hex.EncodeToString(b) + if !reflect.DeepEqual(hex, tt.hex) { + t.Errorf("packTxIndexes() = %v, want %v", hex, tt.hex) + } + got, err := d.unpackTxIndexes(b) + if err != nil { + t.Errorf("unpackTxIndexes() error = %v", err) + return + } + if !reflect.DeepEqual(got, tt.data) { + t.Errorf("unpackTxIndexes() = %+v, want %+v", got, tt.data) + } + }) + } +} diff --git a/db/sync.go b/db/sync.go index f04512ec10..bfb43028ee 100644 --- a/db/sync.go +++ b/db/sync.go @@ -1,9 +1,15 @@ package db import ( + "context" + stdErrors "errors" + "io" + "net" "os" + "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/golang/glog" @@ -21,31 +27,73 @@ type SyncWorker struct { startHeight uint32 startHash string chanOsSignal chan os.Signal + missingBlockRetry MissingBlockRetryConfig metrics *common.Metrics is *common.InternalState } +// MissingBlockRetryConfig controls how long we retry a missing block before re-checking chain state. +type MissingBlockRetryConfig struct { + // RecheckThreshold is the number of consecutive ErrBlockNotFound retries + // before re-checking the tip/hash for a reorg or rollback. + RecheckThreshold int + // TipRecheckThreshold is a lower threshold used once the hash queue is + // closed (we are at the tail of the requested range). + TipRecheckThreshold int + // RetryDelay keeps retry pressure low while still reacting quickly to transient backend gaps. + RetryDelay time.Duration +} + +// SyncWorkerConfig bundles optional tuning knobs for SyncWorker. +type SyncWorkerConfig struct { + MissingBlockRetry MissingBlockRetryConfig +} + +func defaultSyncWorkerConfig() SyncWorkerConfig { + return SyncWorkerConfig{ + MissingBlockRetry: MissingBlockRetryConfig{ + RecheckThreshold: 10, // - RecheckThreshold >= 1 + RetryDelay: 1 * time.Second, // - TipRecheckThreshold >= 1 && TipRecheckThreshold <= RecheckThreshold + TipRecheckThreshold: 3, // - RetryDelay > 0 + }, + } +} + // NewSyncWorker creates new SyncWorker and returns its handle func NewSyncWorker(db *RocksDB, chain bchain.BlockChain, syncWorkers, syncChunk int, minStartHeight int, dryRun bool, chanOsSignal chan os.Signal, metrics *common.Metrics, is *common.InternalState) (*SyncWorker, error) { + return NewSyncWorkerWithConfig(db, chain, syncWorkers, syncChunk, minStartHeight, dryRun, chanOsSignal, metrics, is, nil) +} + +// NewSyncWorkerWithConfig allows tests or callers to override SyncWorker defaults. +func NewSyncWorkerWithConfig(db *RocksDB, chain bchain.BlockChain, syncWorkers, syncChunk int, minStartHeight int, dryRun bool, chanOsSignal chan os.Signal, metrics *common.Metrics, is *common.InternalState, cfg *SyncWorkerConfig) (*SyncWorker, error) { if minStartHeight < 0 { minStartHeight = 0 } + effectiveCfg := defaultSyncWorkerConfig() + if cfg != nil { + effectiveCfg = *cfg + } return &SyncWorker{ - db: db, - chain: chain, - syncWorkers: syncWorkers, - syncChunk: syncChunk, - dryRun: dryRun, - startHeight: uint32(minStartHeight), - chanOsSignal: chanOsSignal, - metrics: metrics, - is: is, + db: db, + chain: chain, + syncWorkers: syncWorkers, + syncChunk: syncChunk, + dryRun: dryRun, + startHeight: uint32(minStartHeight), + chanOsSignal: chanOsSignal, + missingBlockRetry: effectiveCfg.MissingBlockRetry, + metrics: metrics, + is: is, }, nil } var errSynced = errors.New("synced") var errFork = errors.New("fork") +// errResync signals that the parallel/bulk sync should restart because the +// target block hash no longer matches the chain (likely reorg/rollback). +var errResync = errors.New("resync") + // ErrOperationInterrupted is returned when operation is interrupted by OS signal var ErrOperationInterrupted = errors.New("ErrOperationInterrupted") @@ -132,7 +180,7 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b if localBestHash != "" { remoteHash, err := w.chain.GetBlockHash(localBestHeight) // for some coins (eth) remote can be at lower best height after rollback - if err != nil && err != bchain.ErrBlockNotFound { + if err != nil && !stdErrors.Is(err, bchain.ErrBlockNotFound) { return err } if remoteHash != localBestHash { @@ -153,7 +201,8 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b // if parallel operation is enabled and the number of blocks to be connected is large, // use parallel routine to load majority of blocks // use parallel sync only in case of initial sync because it puts the db to inconsistent state - if w.syncWorkers > 1 && initialSync { + // or in case of ChainEthereumType if the tip is farther + if w.syncWorkers > 1 && (initialSync || w.chain.GetChainParser().GetChainType() == bchain.ChainEthereumType) { remoteBestHeight, err := w.chain.GetBestBlockHeight() if err != nil { return err @@ -162,19 +211,46 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b glog.Error("resync: error - remote best height ", remoteBestHeight, " less than sync start height ", w.startHeight) return errors.New("resync: remote best height error") } - if remoteBestHeight-w.startHeight > uint32(w.syncChunk) { - glog.Infof("resync: parallel sync of blocks %d-%d, using %d workers", w.startHeight, remoteBestHeight, w.syncWorkers) - err = w.ConnectBlocksParallel(w.startHeight, remoteBestHeight) - if err != nil { - return err + if initialSync { + if remoteBestHeight-w.startHeight > uint32(w.syncChunk) { + glog.Infof("resync: bulk sync of blocks %d-%d, using %d workers", w.startHeight, remoteBestHeight, w.syncWorkers) + // Bulk sync can encounter a disappearing block hash during reorgs. + // When that happens, it returns errResync to trigger a full restart. + err = w.BulkConnectBlocks(w.startHeight, remoteBestHeight) + if err != nil { + if stdErrors.Is(err, errResync) { + // block hash changed during parallel sync, restart the full resync + return w.resyncIndex(onNewBlock, initialSync) + } + return err + } + // after parallel load finish the sync using standard way, + // new blocks may have been created in the meantime + return w.resyncIndex(onNewBlock, initialSync) + } + } + if w.chain.GetChainParser().GetChainType() == bchain.ChainEthereumType { + syncWorkers := uint32(4) + if remoteBestHeight-w.startHeight >= syncWorkers { + glog.Infof("resync: parallel sync of blocks %d-%d, using %d workers", w.startHeight, remoteBestHeight, syncWorkers) + // Parallel sync also returns errResync when a requested hash no longer + // exists at its height; restart to realign with the canonical chain. + err = w.ParallelConnectBlocks(onNewBlock, w.startHeight, remoteBestHeight, syncWorkers) + if err != nil { + if stdErrors.Is(err, errResync) { + // block hash changed during parallel sync, restart the full resync + return w.resyncIndex(onNewBlock, initialSync) + } + return err + } + // after parallel load finish the sync using standard way, + // new blocks may have been created in the meantime + return w.resyncIndex(onNewBlock, initialSync) } - // after parallel load finish the sync using standard way, - // new blocks may have been created in the meantime - return w.resyncIndex(onNewBlock, initialSync) } } err = w.connectBlocks(onNewBlock, initialSync) - if err == errFork { + if stdErrors.Is(err, errFork) || stdErrors.Is(err, errResync) { return w.resyncIndex(onNewBlock, initialSync) } return err @@ -184,7 +260,7 @@ func (w *SyncWorker) handleFork(localBestHeight uint32, localBestHash string, on // find forked blocks, disconnect them and then synchronize again var height uint32 hashes := []string{localBestHash} - for height = localBestHeight - 1; height >= 0; height-- { + for height = localBestHeight - 1; ; height-- { local, err := w.db.GetBlockHash(height) if err != nil { return err @@ -194,7 +270,7 @@ func (w *SyncWorker) handleFork(localBestHeight uint32, localBestHash string, on } remote, err := w.chain.GetBlockHash(height) // for some coins (eth) remote can be at lower best height after rollback - if err != nil && err != bchain.ErrBlockNotFound { + if err != nil && !stdErrors.Is(err, bchain.ErrBlockNotFound) { return err } if local == remote { @@ -227,7 +303,7 @@ func (w *SyncWorker) connectBlocks(onNewBlock bchain.OnNewBlockFunc, initialSync return err } if onNewBlock != nil { - onNewBlock(res.block.Hash, res.block.Height) + onNewBlock(res.block) } w.metrics.BlockbookBestHeight.Set(float64(res.block.Height)) if res.block.Height > 0 && res.block.Height%1000 == 0 { @@ -237,26 +313,26 @@ func (w *SyncWorker) connectBlocks(onNewBlock bchain.OnNewBlockFunc, initialSync return nil } - if initialSync { - ConnectLoop: - for { - select { - case <-w.chanOsSignal: - glog.Info("connectBlocks interrupted at height ", lastRes.block.Height) - return ErrOperationInterrupted - case res := <-bch: - if res == empty { - break ConnectLoop - } - err := connect(res) - if err != nil { - return err - } - } + logInterrupted := func() { + if lastRes.block != nil { + glog.Info("connectBlocks interrupted at height ", lastRes.block.Height) + } else { + glog.Info("connectBlocks interrupted") } - } else { - // while regular sync, OS sig is handled by waitForSignalAndShutdown - for res := range bch { + } + // During regular sync, shutdown is now signaled by closing chanOsSignal, + // so we honor it here to avoid leaving RocksDB in an open state. + // Initial sync uses the same shutdown-aware loop. +ConnectLoop: + for { + select { + case <-w.chanOsSignal: + logInterrupted() + return ErrOperationInterrupted + case res := <-bch: + if res == empty { + break ConnectLoop + } err := connect(res) if err != nil { return err @@ -271,35 +347,106 @@ func (w *SyncWorker) connectBlocks(onNewBlock bchain.OnNewBlockFunc, initialSync return nil } -// ConnectBlocksParallel uses parallel goroutines to get data from blockchain daemon -func (w *SyncWorker) ConnectBlocksParallel(lower, higher uint32) error { - type hashHeight struct { - hash string - height uint32 +type hashHeight struct { + hash string + height uint32 +} + +func (w *SyncWorker) shouldRestartSyncOnMissingBlock(height uint32, expectedHash string) (bool, error) { + // When a block hash disappears at a given height, it usually indicates a + // reorg/rollback. Confirm by checking the current tip and block hash. + bestHeight, err := w.chain.GetBestBlockHeight() + if err != nil { + return false, err } + if bestHeight < height { + // The tip moved below the requested height, so this block is no longer valid. + return true, nil + } + currentHash, err := w.chain.GetBlockHash(height) + if err != nil { + if stdErrors.Is(err, bchain.ErrBlockNotFound) { + return true, nil + } + return false, err + } + return currentHash != expectedHash, nil +} + +func isRetryableGetBlockError(err error) bool { + if err == nil { + return false + } + isRetryable := func(e error) bool { + if stdErrors.Is(e, bchain.ErrBlockNotFound) || + stdErrors.Is(e, context.DeadlineExceeded) || + stdErrors.Is(e, io.ErrUnexpectedEOF) || + stdErrors.Is(e, io.EOF) || + stdErrors.Is(e, net.ErrClosed) || + stdErrors.Is(e, syscall.ECONNRESET) || + stdErrors.Is(e, syscall.ECONNREFUSED) || + stdErrors.Is(e, syscall.ECONNABORTED) || + stdErrors.Is(e, syscall.EPIPE) || + stdErrors.Is(e, syscall.ETIMEDOUT) { + return true + } + + var netErr net.Error + if stdErrors.As(e, &netErr) && netErr.Timeout() { + return true + } + + msg := strings.ToLower(e.Error()) + switch { + case strings.Contains(msg, "connection reset by peer"), + strings.Contains(msg, "connection refused"), + strings.Contains(msg, "broken pipe"), + strings.Contains(msg, "connection lost"), + strings.Contains(msg, "client is closed"), + strings.Contains(msg, "i/o timeout"), + strings.Contains(msg, "request timed out"), + strings.Contains(msg, "429 too many requests"), + strings.Contains(msg, "502 bad gateway"), + strings.Contains(msg, "503 service unavailable"), + strings.Contains(msg, "504 gateway timeout"), + strings.Contains(msg, "header not found"), + strings.Contains(msg, "block not found"): + return true + default: + return false + } + } + if isRetryable(err) { + return true + } + cause := errors.Cause(err) + return cause != nil && isRetryable(cause) +} + +// ParallelConnectBlocks uses parallel goroutines to get data from blockchain daemon but keeps Blockbook in +func (w *SyncWorker) ParallelConnectBlocks(onNewBlock bchain.OnNewBlockFunc, lower, higher uint32, syncWorkers uint32) error { var err error var wg sync.WaitGroup - bch := make([]chan *bchain.Block, w.syncWorkers) - for i := 0; i < w.syncWorkers; i++ { + bch := make([]chan *bchain.Block, syncWorkers) + for i := 0; i < int(syncWorkers); i++ { bch[i] = make(chan *bchain.Block) } - hch := make(chan hashHeight, w.syncWorkers) + hch := make(chan hashHeight, syncWorkers) hchClosed := atomic.Value{} hchClosed.Store(false) writeBlockDone := make(chan struct{}) terminating := make(chan struct{}) + // abortCh is used by workers to signal a resync-worthy reorg or a terminal worker error. + // Keep it buffered so the first worker can report without blocking while the + // coordinator is closing channels/terminating. + abortCh := make(chan error, 1) writeBlockWorker := func() { defer close(writeBlockDone) - bc, err := w.db.InitBulkConnect() - if err != nil { - glog.Error("sync: InitBulkConnect error ", err) - } lastBlock := lower - 1 - keep := uint32(w.chain.GetChainParser().KeepBlockAddresses()) WriteBlockLoop: for { select { - case b := <-bch[(lastBlock+1)%uint32(w.syncWorkers)]: + case b := <-bch[(lastBlock+1)%syncWorkers]: if b == nil { // channel is closed and empty - work is done break WriteBlockLoop @@ -307,56 +454,225 @@ func (w *SyncWorker) ConnectBlocksParallel(lower, higher uint32) error { if b.Height != lastBlock+1 { glog.Fatal("writeBlockWorker skipped block, expected block ", lastBlock+1, ", new block ", b.Height) } - err := bc.ConnectBlock(b, b.Height+keep > higher) + err := w.db.ConnectBlock(b) if err != nil { glog.Fatal("writeBlockWorker ", b.Height, " ", b.Hash, " error ", err) } + + if onNewBlock != nil { + onNewBlock(b) + } + w.metrics.BlockbookBestHeight.Set(float64(b.Height)) + + if b.Height > 0 && b.Height%1000 == 0 { + glog.Info("connected block ", b.Height, " ", b.Hash) + } + lastBlock = b.Height case <-terminating: break WriteBlockLoop } } - err = bc.Close() if err != nil { - glog.Error("sync: bulkconnect.Close error ", err) + glog.Error("sync: ParallelConnectBlocks.Close error ", err) } glog.Info("WriteBlock exiting...") } - getBlockWorker := func(i int) { - defer wg.Done() - var err error - var block *bchain.Block - GetBlockLoop: - for hh := range hch { - for { - block, err = w.chain.GetBlock(hh.hash, hh.height) - if err != nil { - // signal came while looping in the error loop + for i := 0; i < int(syncWorkers); i++ { + wg.Add(1) + go w.getBlockWorker(i, syncWorkers, &wg, hch, bch, &hchClosed, terminating, abortCh) + } + go writeBlockWorker() + var hash string +ConnectLoop: + for h := lower; h <= higher; { + select { + case abortErr := <-abortCh: + if stdErrors.Is(abortErr, errResync) { + glog.Warning("sync: parallel connect aborted, restarting sync") + } else { + glog.Error("sync: parallel connect aborted, worker error ", abortErr) + } + err = abortErr + close(terminating) + break ConnectLoop + case <-w.chanOsSignal: + glog.Info("connectBlocksParallel interrupted at height ", h) + err = ErrOperationInterrupted + // signal all workers to terminate their loops (error loops are interrupted below) + close(terminating) + break ConnectLoop + default: + hash, err = w.chain.GetBlockHash(h) + if err != nil { + glog.Error("GetBlockHash error ", err) + w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + time.Sleep(time.Millisecond * 500) + continue + } + hch <- hashHeight{hash, h} + h++ + } + } + close(hch) + // signal stop to workers that are in a error loop + hchClosed.Store(true) + // wait for workers and close bch that will stop writer loop + wg.Wait() + // Hardening: a worker can report a terminal tail error after ConnectLoop has + // already ended (for example once hchClosed=true). Drain once so we return + // that error instead of silently succeeding. + select { + case abortErr := <-abortCh: + if err == nil { + err = abortErr + } + default: + } + for i := 0; i < int(syncWorkers); i++ { + close(bch[i]) + } + <-writeBlockDone + return err +} + +func (w *SyncWorker) getBlockWorker(i int, syncWorkers uint32, wg *sync.WaitGroup, hch chan hashHeight, bch []chan *bchain.Block, hchClosed *atomic.Value, terminating chan struct{}, abortCh chan error) { + defer wg.Done() + var err error + var block *bchain.Block + cfg := w.missingBlockRetry +GetBlockLoop: + for hh := range hch { + // Track consecutive not-found errors per block so we only re-check the + // chain once the backend has had a chance to catch up. + notFoundRetries := 0 + for { + // Allow global shutdown or an abort to stop the retry loop promptly. + select { + case <-terminating: + return + case <-w.chanOsSignal: + return + default: + } + block, err = w.chain.GetBlock(hh.hash, hh.height) + if err != nil { + if isRetryableGetBlockError(err) { + notFoundRetries++ + glog.Error("getBlockWorker ", i, " connect block ", hh.height, " ", hh.hash, " error ", err, ". Retrying...") + threshold := cfg.RecheckThreshold + // Once the hash queue is closed we are at the tail of the range; use + // a smaller threshold to avoid stalling on a missing tip block. + if hchClosed.Load() == true { + threshold = cfg.TipRecheckThreshold + } + if notFoundRetries >= threshold { + restart, checkErr := w.shouldRestartSyncOnMissingBlock(hh.height, hh.hash) + if checkErr != nil { + glog.Error("getBlockWorker ", i, " missing block check error ", checkErr) + } else if restart { + // The block hash at this height no longer exists; restart sync to realign. + glog.Warning("sync: block ", hh.height, " ", hh.hash, " no longer on chain, restarting sync") + select { + case abortCh <- errResync: + default: + } + return + } + } + } else { + // When the hash queue is closed, stop retrying non-retryable errors. if hchClosed.Load() == true { glog.Error("getBlockWorker ", i, " connect block error ", err, ". Exiting...") + // Hardening: without surfacing this tail failure, the worker could + // exit and leave the sync loop stuck until manual restart. + select { + case abortCh <- err: + default: + } return } + notFoundRetries = 0 glog.Error("getBlockWorker ", i, " connect block error ", err, ". Retrying...") - w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() - time.Sleep(time.Millisecond * 500) - } else { - break } + w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + select { + case <-terminating: + return + case <-w.chanOsSignal: + return + case <-time.After(cfg.RetryDelay): + } + } else { + break } - if w.dryRun { - continue - } + } + if w.dryRun { + continue + } + select { + case bch[hh.height%syncWorkers] <- block: + case <-terminating: + break GetBlockLoop + } + } + glog.Info("getBlockWorker ", i, " exiting...") +} + +// BulkConnectBlocks uses parallel goroutines to get data from blockchain daemon +func (w *SyncWorker) BulkConnectBlocks(lower, higher uint32) error { + var err error + var wg sync.WaitGroup + bch := make([]chan *bchain.Block, w.syncWorkers) + for i := 0; i < w.syncWorkers; i++ { + bch[i] = make(chan *bchain.Block) + } + hch := make(chan hashHeight, w.syncWorkers) + hchClosed := atomic.Value{} + hchClosed.Store(false) + writeBlockDone := make(chan struct{}) + terminating := make(chan struct{}) + // abortCh is used by workers to signal a resync-worthy reorg or a terminal worker error. + // Keep it buffered so the first worker can report without blocking while the + // coordinator is closing channels/terminating. + abortCh := make(chan error, 1) + writeBlockWorker := func() { + defer close(writeBlockDone) + bc, err := w.db.InitBulkConnect() + if err != nil { + glog.Error("sync: InitBulkConnect error ", err) + } + lastBlock := lower - 1 + keep := uint32(w.chain.GetChainParser().KeepBlockAddresses()) + WriteBlockLoop: + for { select { - case bch[hh.height%uint32(w.syncWorkers)] <- block: + case b := <-bch[(lastBlock+1)%uint32(w.syncWorkers)]: + if b == nil { + // channel is closed and empty - work is done + break WriteBlockLoop + } + if b.Height != lastBlock+1 { + glog.Fatal("writeBlockWorker skipped block, expected block ", lastBlock+1, ", new block ", b.Height) + } + err := bc.ConnectBlock(b, b.Height+keep > higher) + if err != nil { + glog.Fatal("writeBlockWorker ", b.Height, " ", b.Hash, " error ", err) + } + lastBlock = b.Height case <-terminating: - break GetBlockLoop + break WriteBlockLoop } } - glog.Info("getBlockWorker ", i, " exiting...") + err = bc.Close() + if err != nil { + glog.Error("sync: bulkconnect.Close error ", err) + } + glog.Info("WriteBlock exiting...") } for i := 0; i < w.syncWorkers; i++ { wg.Add(1) - go getBlockWorker(i) + go w.getBlockWorker(i, uint32(w.syncWorkers), &wg, hch, bch, &hchClosed, terminating, abortCh) } go writeBlockWorker() var hash string @@ -365,6 +681,16 @@ func (w *SyncWorker) ConnectBlocksParallel(lower, higher uint32) error { ConnectLoop: for h := lower; h <= higher; { select { + case abortErr := <-abortCh: + if stdErrors.Is(abortErr, errResync) { + // Another worker observed a missing block that no longer matches the chain. + glog.Warning("sync: bulk connect aborted, restarting sync") + } else { + glog.Error("sync: bulk connect aborted, worker error ", abortErr) + } + err = abortErr + close(terminating) + break ConnectLoop case <-w.chanOsSignal: glog.Info("connectBlocksParallel interrupted at height ", h) err = ErrOperationInterrupted @@ -400,6 +726,15 @@ ConnectLoop: hchClosed.Store(true) // wait for workers and close bch that will stop writer loop wg.Wait() + // Hardening: capture a late worker error reported after the connect loop + // exits so the caller can retry instead of treating sync as successful. + select { + case abortErr := <-abortCh: + if err == nil { + err = abortErr + } + default: + } for i := 0; i < w.syncWorkers; i++ { close(bch[i]) } @@ -418,7 +753,6 @@ func (w *SyncWorker) getBlockChain(out chan blockResult, done chan struct{}) { hash := w.startHash height := w.startHeight prevHash := "" - // loop until error ErrBlockNotFound for { select { @@ -428,7 +762,7 @@ func (w *SyncWorker) getBlockChain(out chan blockResult, done chan struct{}) { } block, err := w.chain.GetBlock(hash, height) if err != nil { - if err == bchain.ErrBlockNotFound { + if stdErrors.Is(err, bchain.ErrBlockNotFound) { break } out <- blockResult{err: err} diff --git a/db/sync_test.go b/db/sync_test.go new file mode 100644 index 0000000000..5c115f806b --- /dev/null +++ b/db/sync_test.go @@ -0,0 +1,121 @@ +//go:build unittest + +package db + +import ( + "context" + stdErrors "errors" + "io" + "net" + "net/url" + "syscall" + "testing" + + jujuErrors "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" +) + +func TestIsRetryableGetBlockError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil", + err: nil, + want: false, + }, + { + name: "block not found", + err: bchain.ErrBlockNotFound, + want: true, + }, + { + name: "deadline exceeded", + err: context.DeadlineExceeded, + want: true, + }, + { + name: "unexpected EOF", + err: io.ErrUnexpectedEOF, + want: true, + }, + { + name: "EOF", + err: io.EOF, + want: true, + }, + { + name: "annotated deadline exceeded", + err: jujuErrors.Annotatef(context.DeadlineExceeded, "eth_getLogs blockNumber %v", "0x1"), + want: true, + }, + { + name: "annotated unexpected EOF", + err: jujuErrors.Annotatef(io.ErrUnexpectedEOF, "eth_getLogs blockNumber %v", "0x1"), + want: true, + }, + { + name: "network timeout", + err: &net.DNSError{ + Err: "i/o timeout", + Name: "example.org", + IsTimeout: true, + }, + want: true, + }, + { + name: "connection reset by peer", + err: &url.Error{ + Op: "Post", + URL: "http://127.0.0.1:8545", + Err: syscall.ECONNRESET, + }, + want: true, + }, + { + name: "connection refused", + err: &url.Error{ + Op: "Post", + URL: "http://127.0.0.1:8545", + Err: syscall.ECONNREFUSED, + }, + want: true, + }, + { + name: "rpc 503", + err: stdErrors.New("503 Service Unavailable: backend overloaded"), + want: true, + }, + { + name: "rpc 429", + err: stdErrors.New("429 Too Many Requests"), + want: true, + }, + { + name: "header not found", + err: stdErrors.New("header not found"), + want: true, + }, + { + name: "other error", + err: stdErrors.New("boom"), + want: false, + }, + { + name: "annotated other error", + err: jujuErrors.Annotatef(stdErrors.New("boom"), "eth_getLogs blockNumber %v", "0x1"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isRetryableGetBlockError(tt.err) + if got != tt.want { + t.Fatalf("isRetryableGetBlockError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/db/test_helper.go b/db/test_helper.go index 9e6a26375e..2779a58398 100644 --- a/db/test_helper.go +++ b/db/test_helper.go @@ -17,3 +17,12 @@ func ConnectBlocks(w *SyncWorker, onNewBlock bchain.OnNewBlockFunc, initialSync func HandleFork(w *SyncWorker, localBestHeight uint32, localBestHash string, onNewBlock bchain.OnNewBlockFunc, initialSync bool) error { return w.handleFork(localBestHeight, localBestHash, onNewBlock, initialSync) } + +// ConnectBlocksParallel keeps legacy integration tests compiling against the new API. +func (w *SyncWorker) ConnectBlocksParallel(lower, higher uint32) error { + workers := w.syncWorkers + if workers < 1 { + workers = 1 + } + return w.ParallelConnectBlocks(nil, lower, higher, uint32(workers)) +} diff --git a/db/txcache.go b/db/txcache.go index 27012849fd..bcc89bc254 100644 --- a/db/txcache.go +++ b/db/txcache.go @@ -46,7 +46,7 @@ func (c *TxCache) GetTransaction(txid string) (*bchain.Tx, int, error) { } if tx != nil { // number of confirmations is not stored in cache, they change all the time - _, bestheight, _ := c.is.GetSyncState() + _, bestheight, _, _ := c.is.GetSyncState() tx.Confirmations = bestheight - h + 1 c.metrics.TxCacheEfficiency.With(common.Labels{"status": "hit"}).Inc() return tx, int(h), nil diff --git a/docs/README.md b/docs/README.md index b3c11f4e08..ba49648a2f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,11 @@ * [Contributing](/CONTRIBUTING.md) – Blockbook contributor guide * [Build](/docs/build.md) – Blockbook build guide +* [CI/CD](/docs/ci_cd.md) – GitHub Actions build, deploy, and test workflow guide * [Config](/docs/config.md) – Description of Blockbook and back-end configuration and package definitions +* [Tron Config](/docs/tron-config.md) – Tron-specific backend endpoint configuration and fallback rules * [Ports](/docs/ports.md) – Automatically generated registry of ports * [RocksDB](/docs/rocksdb.md) – Description of RocksDB structures used by Blockbook * [API](/docs/api.md) – Description of Blockbook API +* [API (Tron specifics)](/docs/api-tron.md) – Tron-specific behavior and data extensions for API V2 * [Testing](/docs/testing.md) – Description of tests used during Blockbook development diff --git a/docs/api-tron.md b/docs/api-tron.md new file mode 100644 index 0000000000..1b62a94f5a --- /dev/null +++ b/docs/api-tron.md @@ -0,0 +1,71 @@ +# Blockbook API V2 - Tron specifics + +This document describes Tron-specific behavior in API V2 on top of the generic API documented in [`docs/api.md`](./api.md). + +## ID/hash format + +For Tron, API V2 returns transaction and block identifiers **without** `0x` prefix: + +- `txid` +- `blockHash` +- `previousBlockHash` +- `nextBlockHash` +- status fields like `backend.bestBlockHash` / websocket `bestHash` + +Input IDs are accepted in both formats (`` and `0x`), but responses are normalized to no-prefix format. + +### Important note about hex-encoded fields + +Hex-encoded EVM-like fields inside `coinSpecificData` still use `0x` where applicable (for example `input`, `topics`, `data`, `gasPrice`, `blockNumber`, `status`). + +## Tron-specific transaction data (`chainExtraData`) + +On Tron, `Tx.chainExtraData` is populated with normalized transaction metadata derived from Tron HTTP APIs (`wallet/gettransactionbyid` + `wallet/gettransactioninfobyid` / `wallet/gettransactioninfobyblocknum`). + +The object is omitted when no Tron-specific fields are available. + +Schema: + +- `contractType` (`string`): raw Tron contract type, e.g. `TriggerSmartContract`, `VoteWitnessContract`, `FreezeBalanceV2Contract` +- `operation` (`string`): normalized operation + - `vote` + - `freeze` + - `unfreeze` + - `delegate` + - `undelegate` + - `transfer` + - `trc10Transfer` + - `contractCall` +- `resource` (`string`): `energy` or `bandwidth` (if present on transaction) +- `stakeAmount` (`string`): staked amount (sun), for freeze operations +- `unstakeAmount` (`string`): unstaked amount (sun), for unfreeze operations +- `delegateAmount` (`string`): delegated / undelegated amount (sun) +- `delegateTo` (`string`): destination address for delegate/undelegate operations (base58) +- `assetIssueID` (`string`): TRC10 token ID (when provided by backend) +- `totalFee` (`string`): total transaction fee (sun) +- `energyUsage` (`string`): energy usage from receipt +- `energyUsageTotal` (`string`): total energy usage from receipt +- `energyFee` (`string`): fee paid for energy (sun) +- `bandwidthUsage` (`string`): net/bandwidth usage from receipt +- `bandwidthFee` (`string`): fee paid for bandwidth (sun) +- `result` (`string`): execution result (`SUCCESS`, `FAILED`, etc.) +- `votes` (`array`): only for vote transactions + - `address` (`string`): voted witness address (base58) + - `count` (`string`): vote count + +## Example (`GET /api/v2/tx/`) + +```json +{ + "txid": "a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302", + "blockHash": "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff", + "chainExtraData": { + "contractType": "TriggerSmartContract", + "operation": "contractCall", + "totalFee": "3076500", + "energyUsageTotal": "14650", + "bandwidthUsage": "345", + "result": "SUCCESS" + } +} +``` diff --git a/docs/api.md b/docs/api.md index 4b0cf795ad..713ddae10b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,68 +8,80 @@ API V2 is the current version of API. It can be used with all coin types that Bl Common principles used in API V2: -- all crypto amounts are transferred as strings, in the lowest denomination (satoshis, wei, ...), without decimal point -- empty fields are omitted. Empty field is a string of value _null_ or _""_, a number of value _0_, an object of value _null_ or an array without elements. The reason for this is that the interface serves many different coins which use only subset of the fields. Sometimes this principle can lead to slightly confusing results, for example when transaction version is 0, the field _version_ is omitted. +- all crypto amounts are transferred as strings, in the lowest denomination (satoshis, wei, ...), without decimal point +- empty fields are omitted. Empty field is a string of value _null_ or _""_, a number of value _0_, an object of value _null_ or an array without elements. The reason for this is that the interface serves many different coins which use only subset of the fields. Sometimes this principle can lead to slightly confusing results, for example when transaction version is 0, the field _version_ is omitted. + +See all the referred types (`typescript` interfaces) in the [blockbook-api.ts](../blockbook-api.ts) file. ### REST API The following methods are supported: -- [Status](#status) -- [Get block hash](#get-block-hash) -- [Get transaction](#get-transaction) -- [Get transaction specific](#get-transaction-specific) -- [Get address](#get-address) -- [Get xpub](#get-xpub) -- [Get utxo](#get-utxo) -- [Get block](#get-block) -- [Send transaction](#send-transaction) -- [Tickers list](#tickers-list) -- [Tickers](#tickers) -- [Balance history](#balance-history) +- [Blockbook API](#blockbook-api) + - [API V2](#api-v2) + - [REST API](#rest-api) + - [Status page](#status-page) + - [Get block hash](#get-block-hash) + - [Get transaction](#get-transaction) + - [Get transaction specific](#get-transaction-specific) + - [Get address](#get-address) + - [Get xpub](#get-xpub) + - [Get utxo](#get-utxo) + - [Get block](#get-block) + - [Send transaction](#send-transaction) + - [Tickers list](#tickers-list) + - [Tickers](#tickers) + - [Balance history](#balance-history) + - [Websocket API](#websocket-api) + - [Legacy API V1](#legacy-api-v1) + - [REST API](#rest-api-1) #### Status page Status page returns current status of Blockbook and connected backend. ``` -GET /api +GET /api/status ``` -Response: +Response (`SystemInfo` type): + + ```javascript { "blockbook": { "coin": "Bitcoin", - "host": "blockbook", - "version": "0.4.0", - "gitCommit": "3d9ad91", - "buildTime": "2019-05-17T14:34:00+00:00", + "network": "BTC", + "host": "backend5", + "version": "0.5.1", + "gitCommit": "a0960c8e", + "buildTime": "2024-08-08T12:32:50+00:00", "syncMode": true, "initialSync": false, "inSync": true, - "bestHeight": 577261, - "lastBlockTime": "2019-05-22T18:03:33.547762973+02:00", + "bestHeight": 860730, + "lastBlockTime": "2024-09-10T08:19:04.471017534Z", "inSyncMempool": true, - "lastMempoolTime": "2019-05-22T18:10:10.27929383+02:00", - "mempoolSize": 17348, + "lastMempoolTime": "2024-09-10T08:42:39.38871351Z", + "mempoolSize": 232021, "decimals": 8, - "dbSize": 191887866502, - "about": "Blockbook - blockchain indexer for Trezor wallet https://trezor.io/. Do not use for any other purpose." + "dbSize": 761283489075, + "hasFiatRates": true, + "currentFiatRatesTime": "2024-09-10T08:42:00.898792419Z", + "historicalFiatRatesTime": "2024-09-10T00:00:00Z", + "about": "Blockbook - blockchain indexer for Trezor Suite https://trezor.io/trezor-suite. Do not use for any other purpose." }, "backend": { "chain": "main", - "blocks": 577261, - "headers": 577261, - "bestBlockHash": "0000000000000000000ca8c902aa58b3118a7f35d093e25a07f17bcacd91cabf", - "difficulty": "6704632680587.417", - "sizeOnDisk": 250504188580, - "version": "180000", - "subversion": "/Satoshi:0.18.0/", - "protocolVersion": "70015", - "timeOffset": 0, - "warnings": "" + "blocks": 860730, + "headers": 860730, + "bestBlockHash": "00000000000000000000effeb0c4460480e6a347deab95332c63007a68646ee5", + "difficulty": "89471664776970.77", + "sizeOnDisk": 681584532221, + "version": "270100", + "subversion": "/Satoshi:27.1.0/", + "protocolVersion": "70016" } } ``` @@ -82,9 +94,11 @@ GET /api/v2/block-index/ Response: + + ```javascript { - "blockHash": "ed8f3af8c10ca70a136901c6dd3adf037f0aea8a93fbe9e80939214034300f1e" + "blockHash": "0000000000000000000b7b8574bc6fd285825ec2dbcbeca149121fc05b0c828c" } ``` @@ -98,117 +112,183 @@ Get transaction returns "normalized" data about transaction, which has the same GET /api/v2/tx/ ``` -Response for Bitcoin-type coins, confirmed transaction: +Response for Bitcoin-type coins, confirmed transaction (`Tx` type): + + ```javascript { - "txid": "9e2bc8fbd40af17a6564831f84aef0cab2046d4bad19e91c09d21bff2c851851", - "version": 1, + "txid": "8c1e3dec662d1f2a5e322ccef5eca263f98eb16723c6f990be0c88c1db113fb1", + "version": 2, + "lockTime": 860729, "vin": [ { - "txid": "f124e6999bf67e710b9e8a8ac4dbb08a64aa9c264120cf98793455e36a531615", - "vout": 2, - "sequence": 4294967295, + "txid": "0eb7b574373de2c88d0dc1444f49947c681d0437d21361f9ebb4dd09c62f2a66", + "vout": 1, + "sequence": 4294967293, "n": 0, "addresses": [ - "DDhUv8JZGmSxKYV95NLnbRTUKni9cDZD3S" + "bc1qmgwnfjlda4ns3g6g3yz74w6scnn9yu2ts82yyc" ], "isAddress": true, - "value": "55795108999999", - "hex": "473...2c7ec77bb982" + "value": "10106300" } ], "vout": [ { - "value": "55585679000000", + "value": "175000", "n": 0, - "hex": "76a914feaca9d9fa7120c7c587c00c639bb18d40faadd388ac", + "hex": "76a914ecc999d554eaa3efa5e871c28f58b549c36ec51788ac", "addresses": [ - "DUMh1rPrXTrCN2Z9EHsLPg7b78rACHB2h7" + "1Nb1ykSD7J5k4RFjJQGsrD9gxBE6jzfNa9" ], "isAddress": true }, { - "value": "209329999999", + "value": "9888100", "n": 1, - "hex": "76a914ea8984be785868391d92f49c14933f47c152ea0a88ac", + "hex": "001496f152a0919487624bf4f13f46f0d20fa10d9acc", "addresses": [ - "DSXDQ6rnwLX47WFRnemctoXPHA9pLMxqXn" + "bc1qjmc49gy3jjrkyjl57yl5duxjp7ssmxkvh5t2q5" ], "isAddress": true } ], - "blockHash": "78d1f3de899a10dd2e580704226ebf9508e95e1706f177fc9c31c47f245d2502", - "blockHeight": 2647927, + "blockHash": "00000000000000000000effeb0c4460480e6a347deab95332c63007a68646ee5", + "blockHeight": 860730, "confirmations": 1, - "blockTime": 1553088212, - "size": 234, - "vsize": 153, - "value": "55795008999999", - "valueIn": "55795108999999", - "fees": "100000000", - "hex": "0100000...0011000" + "blockTime": 1725956288, + "size": 225, + "vsize": 144, + "value": "10063100", + "valueIn": "10106300", + "fees": "43200", + "hex": "02000000000101662a2fc609ddb4ebf96113d237041d687c94494f44c10d8dc8e23d3774b5b70e0100000000fdffffff0298ab0200000000001976a914ecc999d554eaa3efa5e871c28f58b549c36ec51788ac64e196000000000016001496f152a0919487624bf4f13f46f0d20fa10d9acc0247304402202bb0591180cdbbe0f639af6eb21abdb993fc5a667b09e6392d5c11b025a9187102201ef2e84fc91a5d2c6fbbc9f943482d230256a3640f8ecb83c1f3f17242cf011001210314f03889e1667feb696ee280625943195189cfabe46d54204d987f631fe6892739220d00" } ``` -Response for Bitcoin-type coins, unconfirmed transaction (_blockHeight_: -1, _confirmations_: 0, mining estimates _confirmationETABlocks_ and _confirmationETASeconds_): +Response for Bitcoin-type coins, unconfirmed transaction: + +Special fields: + +- _blockHeight_: -1 +- _confirmations_: 0 +- _confirmationETABlocks_: number +- _confirmationETASeconds_: number + + ```javascript { - "txid": "cd8ec77174e426070d0a50779232bba7312b712e2c6843d82d963d7076c61366", + "txid": "73b1ad97194e426031e5c692869de2d83dc2ff6033fc6f0ab5514345f92eaf0d", "version": 2, "vin": [ { - "txid": "47687cc4abb58d815168686465a38113a0608b2568a6d6480129d197e653f6dc", - "sequence": 4294967295, + "txid": "bccbebb64b1613ada74eefa96753088a80fefa53a10e42c66eef1899371bc096", "n": 0, - "addresses": ["bc1qka0gpenex558g8gpxmpx247mwhw695k6a7yhs4"], + "addresses": [ + "bc1q9lh77es6m8ztr7muwcec00ewn8fxakpl9jwv8y" + ], "isAddress": true, - "value": "1983687" + "value": "371042" } ], "vout": [ { - "value": "3106", + "value": "293135", "n": 0, - "hex": "0020d7da4868055fde790a8581637ab81c216e17a3f8a099283da6c4a27419ffa539", + "hex": "0014aafd7386f99f4b508ec05ee8f7edc2e07126620a", "addresses": [ - "bc1q6ldys6q9tl08jz59s93h4wquy9hp0glc5zvjs0dxcj38gx0l55uspu8x86" + "bc1q4t7h8phena94prkqtm500mwzupcjvcs2akcdy9" ], "isAddress": true }, { - "value": "1979101", + "value": "74022", "n": 1, - "hex": "0014381be30ca46ddf378ef69ebc4a601bd6ff30b754", - "addresses": ["bc1q8qd7xr9ydh0n0rhkn67y5cqm6mlnpd65dcyeeg"], + "hex": "0014a3de0fbba89c17d43093164ea955bad65bc260bf", + "addresses": [ + "bc1q500qlwagnstagvynze82j4d66eduyc9lf64ksh" + ], "isAddress": true } ], "blockHeight": -1, "confirmations": 0, - "confirmationETABlocks": 3, - "confirmationETASeconds": 2055, - "blockTime": 1675270935, - "size": 234, - "vsize": 153, - "value": "1982207", - "valueIn": "1983687", - "fees": "1480", - "hex": "020000000001...b18f00000000" + "confirmationETABlocks": 1, + "confirmationETASeconds": 619, + "blockTime": 1725959035, + "size": 222, + "vsize": 141, + "value": "367157", + "valueIn": "371042", + "fees": "3885", + "hex": "0200000000010196c01b379918ef6ec6420ea153fafe808a085367a9ef4ea7ad13164bb6ebcbbc000000000000000000020f79040000000000160014aafd7386f99f4b508ec05ee8f7edc2e07126620a2621010000000000160014a3de0fbba89c17d43093164ea955bad65bc260bf0247304402204a5bdf8a8d19b0a19044b0c0de3ced92b92e8d0c629ffca83178c85a608f719e02203841d40dd92db48715f9f41a732e139ac3cc7696a23adc87136bd8037a594e9f012102824a5e7b878f8d63887bdcb1b0982cdb0b375068b3798c4c96799476a19a389e00000000", + "rbf": true, + "coinSpecificData": { + "txid": "73b1ad97194e426031e5c692869de2d83dc2ff6033fc6f0ab5514345f92eaf0d", + "hash": "91deb6a9d0f5a37e2e83d1e602ba14cd9811fd3605f582154c9bd1337f7f4c8a", + "version": 2, + "size": 222, + "vsize": 141, + "weight": 561, + "locktime": 0, + "vin": [ + { + "txid": "bccbebb64b1613ada74eefa96753088a80fefa53a10e42c66eef1899371bc096", + "vout": 0, + "scriptSig": { + "asm": "", + "hex": "" + }, + "txinwitness": [ + "304402204a5bdf8a8d19b0a19044b0c0de3ced92b92e8d0c629ffca83178c85a608f719e02203841d40dd92db48715f9f41a732e139ac3cc7696a23adc87136bd8037a594e9f01", + "02824a5e7b878f8d63887bdcb1b0982cdb0b375068b3798c4c96799476a19a389e" + ], + "sequence": 0 + } + ], + "vout": [ + { + "value": 0.00293135, + "n": 0, + "scriptPubKey": { + "asm": "0 aafd7386f99f4b508ec05ee8f7edc2e07126620a", + "desc": "addr(bc1q4t7h8phena94prkqtm500mwzupcjvcs2akcdy9)#qmxeweuu", + "hex": "0014aafd7386f99f4b508ec05ee8f7edc2e07126620a", + "address": "bc1q4t7h8phena94prkqtm500mwzupcjvcs2akcdy9", + "type": "witness_v0_keyhash" + } + }, + { + "value": 0.00074022, + "n": 1, + "scriptPubKey": { + "asm": "0 a3de0fbba89c17d43093164ea955bad65bc260bf", + "desc": "addr(bc1q500qlwagnstagvynze82j4d66eduyc9lf64ksh)#mynfp6xy", + "hex": "0014a3de0fbba89c17d43093164ea955bad65bc260bf", + "address": "bc1q500qlwagnstagvynze82j4d66eduyc9lf64ksh", + "type": "witness_v0_keyhash" + } + } + ], + "hex": "0200000000010196c01b379918ef6ec6420ea153fafe808a085367a9ef4ea7ad13164bb6ebcbbc000000000000000000020f79040000000000160014aafd7386f99f4b508ec05ee8f7edc2e07126620a2621010000000000160014a3de0fbba89c17d43093164ea955bad65bc260bf0247304402204a5bdf8a8d19b0a19044b0c0de3ced92b92e8d0c629ffca83178c85a608f719e02203841d40dd92db48715f9f41a732e139ac3cc7696a23adc87136bd8037a594e9f012102824a5e7b878f8d63887bdcb1b0982cdb0b375068b3798c4c96799476a19a389e00000000" + } } ``` Response for Ethereum-type coins. Data of the transaction consist of: -- always only one _vin_, only one _vout_ -- an array of _tokenTransfers_ (ERC20, ERC721 or ERC1155) -- _ethereumSpecific_ data - - _type_ (returned only for contract creation - value `1` and destruction value `2`) - - _status_ (`1` OK, `0` Failure, `-1` pending), potential _error_ message, _gasLimit_, _gasUsed_, _gasPrice_, _nonce_, input _data_ - - parsed input data in the field _parsedData_, if a match with the 4byte directory was found - - internal transfers (type `0` transfer, type `1` contract creation, type `2` contract destruction) -- _addressAliases_ - maps addresses in the transaction to names from contract or ENS. Only addresses with known names are returned. +- always only one _vin_, only one _vout_ +- an array of _tokenTransfers_ (ERC20, ERC721 or ERC1155) +- _ethereumSpecific_ data + - _type_ (returned only for contract creation - value `1` and destruction value `2`) + - _status_ (`1` OK, `0` Failure, `-1` pending), potential _error_ message, _gasLimit_, _gasUsed_, _gasPrice_, _nonce_, input _data_ + - parsed input data in the field _parsedData_, if a match with the 4byte directory was found + - internal transfers (type `0` transfer, type `1` contract creation, type `2` contract destruction) +- _addressAliases_ - maps addresses in the transaction to names from contract or ENS. Only addresses with known names are returned. + + ```javascript { @@ -272,6 +352,9 @@ Response for Ethereum-type coins. Data of the transaction consist of: "gasLimit": 550941, "gasUsed": 434686, "gasPrice": "44035608242", + "maxPriorityFeePerGas": "44035608243", + "maxFeePerGas": "44035608244", + "baseFeePerGas": "2035608244", "data": "0xac9650d800000000000000000000", "parsedData": { "methodId": "0xfa2b068f", @@ -311,8 +394,8 @@ Response for Ethereum-type coins. Data of the transaction consist of: A note about the `blockTime` field: -- for already mined transaction (`confirmations > 0`), the field `blockTime` contains time of the block -- for transactions in mempool (`confirmations == 0`), the field contains time when the running instance of Blockbook was first time notified about the transaction. This time may be different in different instances of Blockbook. +- for already mined transaction (`confirmations > 0`), the field `blockTime` contains time of the block +- for transactions in mempool (`confirmations == 0`), the field contains time when the running instance of Blockbook was first time notified about the transaction. This time may be different in different instances of Blockbook. #### Get transaction specific @@ -324,10 +407,14 @@ GET /api/v2/tx-specific/ Example response: + + ```javascript { "hex": "040000808...8e6e73cb009", "txid": "7a0a0ff6f67bac2a856c7296382b69151949878de6fb0d01a8efa197182b2913", + "authdigest": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "size": 1809, "overwintered": true, "version": 4, "versiongroupid": "892f2085", @@ -337,6 +424,7 @@ Example response: "vout": [], "vjoinsplit": [], "valueBalance": 0, + "valueBalanceZat": 0, "vShieldedSpend": [ { "cv": "50258bfa65caa9f42f4448b9194840c7da73afc8159faf7358140bfd0f237962", @@ -367,7 +455,8 @@ Example response: ], "bindingSig": "bc018af8808387...5130bb382ad8e6e73cb009", "blockhash": "0000000001c4aa394e796dd1b82e358f114535204f6f5b6cf4ad58dc439c47af", - "confirmations": 5222, + "height": 495665, + "confirmations": 2145803, "time": 1552301566, "blocktime": 1552301566 } @@ -378,48 +467,52 @@ Example response: Returns balances and transactions of an address. The returned transactions are sorted by block height, newest blocks first. ``` -GET /api/v2/address/
[?page=&pageSize=&from=&to=&details=&contract=&secondary=usd] +GET /api/v2/address/
[?page=&pageSize=&from=&to=&details=&contract=&protocols=&secondary=usd] ``` The optional query parameters: -- _page_: specifies page of returned transactions, starting from 1. If out of range, Blockbook returns the closest possible page. -- _pageSize_: number of transactions returned by call (default and maximum 1000) -- _from_, _to_: filter of the returned transactions _from_ block height _to_ block height (default no filter) -- _details_: specifies level of details returned by request (default _txids_) - - _basic_: return only address balances, without any transactions - - _tokens_: _basic_ + tokens belonging to the address (applicable only to some coins) - - _tokenBalances_: _basic_ + tokens with balances + belonging to the address (applicable only to some coins) - - _txids_: _tokenBalances_ + list of txids, subject to _from_, _to_ filter and paging - - _txslight_: _tokenBalances_ + list of transaction with limited details (only data from index), subject to _from_, _to_ filter and paging - - _txs_: _tokenBalances_ + list of transaction with details, subject to _from_, _to_ filter and paging -- _contract_: return only transactions which affect specified contract (applicable only to coins which support contracts) -- _secondary_: specifies secondary (fiat) currency in which the token and total balances are returned in addition to crypto values - -Example response for bitcoin type coin, _details_ set to _txids_: +- _page_: specifies page of returned transactions, starting from 1. If out of range, Blockbook returns the closest possible page. +- _pageSize_: number of transactions returned by call (default and maximum 1000) +- _from_, _to_: filter of the returned transactions _from_ block height _to_ block height (default no filter) +- _details_: specifies level of details returned by request (default _txids_) + - _basic_: return only address balances, without any transactions + - _tokens_: _basic_ + tokens belonging to the address (applicable only to some coins) + - _tokenBalances_: _basic_ + tokens with balances + belonging to the address (applicable only to some coins) + - _txids_: _tokenBalances_ + list of txids, subject to _from_, _to_ filter and paging + - _txslight_: _tokenBalances_ + list of transaction with limited details (only data from index), subject to _from_, _to_ filter and paging + - _txs_: _tokenBalances_ + list of transaction with details, subject to _from_, _to_ filter and paging +- _contract_: return only transactions which affect specified contract (applicable only to coins which support contracts) +- _protocols_: optional comma-separated list of protocol enrichments to include. Currently supported value: `erc4626`. Unknown values are rejected with an error. In account responses, protocol payloads are returned under `tokens[].protocols`. +- _secondary_: specifies secondary (fiat) currency in which the token and total balances are returned in addition to crypto values + +Example response for bitcoin type coin, _details_ set to _txids_ (`Address` type): + + ```javascript { "page": 1, "totalPages": 1, "itemsOnPage": 1000, - "address": "D5Z7XrtJNg7hAtznSDMXvfiFmMYphwuWz7", - "balance": "2432468097999991", - "totalReceived": "3992283916999979", - "totalSent": "1559815818999988", + "address": "bc1q0wd209cv5k9pd9mhk7nspacywcj038xxdhnt5u", + "balance": "4225100", + "totalReceived": "4225100", + "totalSent": "0", "unconfirmedBalance": "0", "unconfirmedTxs": 0, - "txs": 3, + "txs": 2, "txids": [ - "461dd46d5d6f56d765f82e60e6bf0727a3a1d1cb8c4144373d805b152a21d308", - "bdb5b47603c5d174eae3384c368068c8e9d2183b398ed0e31d125defa4447a10", - "5c1d2686d70d82bd8e84b5d3dc4bd0e8485e28cdc865336db6a5e40b2098277d" + "0db6010dc0815a4bdaa505bd1ccc851056b0d53c7e4ea7af39c4d648a2c0c019", + "7532920ddc506218337cceac978cce9c7f98e27ad3226dee55f3e934e0b32e80" ] } ``` Example response for ethereum type coin, _details_ set to _tokenBalances_ and _secondary_ set to _usd_. The _baseValue_ is value of the token in the base currency (ETH), _secondaryValue_ is value of the token in specified _secondary_ currency: + + ```javascript { "address": "0x2df3951b2037bA620C20Ed0B73CCF45Ea473e83B", @@ -451,31 +544,93 @@ Example response for ethereum type coin, _details_ set to _tokenBalances_ and _s ``` +#### Get contract info + +Returns metadata for a single contract together with optional enrichments requested by the caller. + +This endpoint exists in part because `erc4626` data returned from `getAccountInfo` or `/api/v2/address` is only a snapshot taken when that broader account response was fetched. Suite can fetch current contract-level metadata for the token the user is actively interacting with without reloading full account data. + +``` +GET /api/v2/contract/[?currency=&protocols=] +``` + +Parameters: + +- _currency_: optional secondary currency code (for example `usd`). When present, the response may include `rates.secondaryRate` in that currency. +- _protocols_: optional comma-separated list of protocol enrichments to include. Currently supported value: `erc4626`. Unknown values are rejected with an error. + +`blockHeight` reflects the indexer's best block at request time. ERC-4626 fields inside `protocols.erc4626` are fetched via JSON-RPC `eth_call` (batched through Multicall3) pinned to that exact `blockHeight`, so all values inside `protocols.erc4626` are a consistent snapshot at that height. + +For ERC-4626, `asset` is returned only when Blockbook can resolve underlying +asset metadata including `decimals`. If a vault is detected but asset metadata +cannot be resolved, Blockbook returns `protocols.erc4626` with `error` and +without `asset`; callers must not derive fiat rates or human-unit exchange rates +from such a partial response. + +Response (`ContractInfoResult` type): + +```javascript +{ + "contract": "0x...", + "standard": "ERC20", + "name": "Vault Share", + "symbol": "vETH", + "decimals": 18, + "rates": { + "baseRate": 0.000523, + "currency": "usd", + "secondaryRate": 1.24 + }, + "protocols": { + "erc4626": { + "asset": { + "contract": "0x...", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18 + }, + "share": { + "contract": "0x...", + "name": "Vault Share", + "symbol": "vETH", + "decimals": 18 + }, + "totalAssets": "123456789", + "convertToAssets1Share": "1000000000000000000", + "convertToShares1Asset": "1000000000000000000", + "previewDeposit1Asset": "999999999999999999", + "previewRedeem1Share": "1000000000000000000" + } + }, + "blockHeight": 12345678 +} +``` + #### Get xpub Returns balances and transactions of an xpub or output descriptor, applicable only for Bitcoin-type coins. Blockbook supports BIP44, BIP49, BIP84 and BIP86 (Taproot) derivation schemes, using either xpubs or output descriptors (see https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md) -- Xpubs +- Xpubs - Blockbook expects xpub at level 3 derivation path, i.e. _m/purpose'/coin_type'/account'/_. Blockbook completes the _change/address_index_ part of the path when deriving addresses. - The BIP version is determined by the prefix of the xpub. The prefixes for each coin are defined by fields `xpub_magic`, `xpub_magic_segwit_p2sh`, `xpub_magic_segwit_native` in the [trezor-common](https://github.com/trezor/trezor-common/tree/master/defs/bitcoin) library. If the prefix is not recognized, Blockbook defaults to BIP44 derivation scheme. + Blockbook expects xpub at level 3 derivation path, i.e. _m/purpose'/coin_type'/account'/_. Blockbook completes the _change/address_index_ part of the path when deriving addresses. + The BIP version is determined by the prefix of the xpub. The prefixes for each coin are defined by fields `xpub_magic`, `xpub_magic_segwit_p2sh`, `xpub_magic_segwit_native` in the [trezor-common](https://github.com/trezor/trezor-common/tree/master/defs/bitcoin) library. If the prefix is not recognized, Blockbook defaults to BIP44 derivation scheme. -- Output descriptors +- Output descriptors - Output descriptors are in the form `([][//*])[#checkum]`, for example `pkh([5c9e228d/44'/0'/0']xpub6BgBgses...Mj92pReUsQ/<0;1>/*)#abcd` + Output descriptors are in the form `([][//*])[#checksum]`, for example `pkh([5c9e228d/44'/0'/0']xpub6BgBgses...Mj92pReUsQ/<0;1>/*)#abcd` - Parameters `type` and `xpub` are mandatory, the rest is optional + Parameters `type` and `xpub` are mandatory, the rest is optional - Blockbook supports a limited set of `type`s: + Blockbook supports a limited set of `type`s: - - BIP44: `pkh(xpub)` - - BIP49: `sh(wpkh(xpub))` - - BIP84: `wpkh(xpub)` - - BIP86 (Taproot single key): `tr(xpub)` + - BIP44: `pkh(xpub)` + - BIP49: `sh(wpkh(xpub))` + - BIP84: `wpkh(xpub)` + - BIP86 (Taproot single key): `tr(xpub)` - Parameter `change` can be a single number or a list of change indexes, specified either in the format `` or `{index1,index2,...}`. If the parameter `change` is not specified, Blockbook defaults to `<0;1>`. + Parameter `change` can be a single number or a list of change indexes, specified either in the format `` or `{index1,index2,...}`. If the parameter `change` is not specified, Blockbook defaults to `<0;1>`. The returned transactions are sorted by block height, newest blocks first. @@ -485,22 +640,22 @@ GET /api/v2/xpub/[?page=&pageSize=&from=[?confirmed=true] ``` -Response: +Response (`Utxo[]` type): ```javascript [ - { - txid: "13d26cd939bf5d155b1c60054e02d9c9b832a85e6ec4f2411be44b6b5a2842e9", - vout: 0, - value: "1422303206539", - confirmations: 0, - lockTime: 2648100, - }, - { - txid: "a79e396a32e10856c97b95f43da7e9d2b9a11d446f7638dbd75e5e7603128cac", - vout: 1, - value: "39748685", - height: 2648043, - confirmations: 47, - coinbase: true, - }, - { - txid: "de4f379fdc3ea9be063e60340461a014f372a018d70c3db35701654e7066b3ef", - vout: 0, - value: "122492339065", - height: 2646043, - confirmations: 2047, - }, - { - txid: "9e8eb9b3d2e8e4b5d6af4c43a9196dfc55a05945c8675904d8c61f404ea7b1e9", - vout: 0, - value: "142771322208", - height: 2644885, - confirmations: 3205, - }, + { + txid: '13d26cd939bf5d155b1c60054e02d9c9b832a85e6ec4f2411be44b6b5a2842e9', + vout: 0, + value: '1422303206539', + confirmations: 0, + lockTime: 2648100, + }, + { + txid: 'a79e396a32e10856c97b95f43da7e9d2b9a11d446f7638dbd75e5e7603128cac', + vout: 1, + value: '39748685', + height: 2648043, + confirmations: 47, + coinbase: true, + }, + { + txid: 'de4f379fdc3ea9be063e60340461a014f372a018d70c3db35701654e7066b3ef', + vout: 0, + value: '122492339065', + height: 2646043, + confirmations: 2047, + }, + { + txid: '9e8eb9b3d2e8e4b5d6af4c43a9196dfc55a05945c8675904d8c61f404ea7b1e9', + vout: 0, + value: '142771322208', + height: 2644885, + confirmations: 3205, + }, ]; ``` @@ -606,7 +761,7 @@ Returns information about block with transactions, subject to paging. GET /api/v2/block/ ``` -Response: +Response (`Block` type): ```javascript { @@ -707,6 +862,8 @@ GET /api/v2/sendtx/ POST /api/v2/sendtx/ (hex tx data in request body) NB: the '/' symbol at the end is mandatory. ``` +POST request body is limited to 8 MiB. + Response: ```javascript @@ -735,9 +892,9 @@ GET /api/v2/tickers-list/?timestamp= The query parameters: -- _timestamp_: specifies a Unix timestamp to return available tickers for. +- _timestamp_: specifies a Unix timestamp to return available tickers for. -Example response: +Example response (`AvailableVsCurrencies` type): ```javascript { @@ -760,10 +917,10 @@ GET /api/v2/tickers/[?currency=×tamp=] The optional query parameters: -- _currency_: specifies a currency of returned rate ("usd", "eur", "eth"...). If not specified, all available currencies will be returned. -- _timestamp_: a Unix timestamp that specifies a date to return currency rates for. If not specified, the last available rate will be returned. +- _currency_: specifies a currency of returned rate ("usd", "eur", "eth"...). If not specified, all available currencies will be returned. +- _timestamp_: a Unix timestamp that specifies a date to return currency rates for. If not specified, the last available rate will be returned. -Example response (no parameters): +Example response (no parameters, `FiatTicker` type): ```javascript { @@ -807,15 +964,15 @@ GET /api/v2/balancehistory/?from=&to=[&fiatcur Query parameters: -- _from_: specifies a start date as a Unix timestamp -- _to_: specifies an end date as a Unix timestamp +- _from_: specifies a start date as a Unix timestamp +- _to_: specifies an end date as a Unix timestamp The optional query parameters: -- _fiatcurrency_: if specified, the response will contain secondary (fiat) rate at the time of transaction. If not, all available currencies will be returned. -- _groupBy_: an interval in seconds, to group results by. Default is 3600 seconds. +- _fiatcurrency_: if specified, the response will contain secondary (fiat) rate at the time of transaction. If not, all available currencies will be returned. +- _groupBy_: an interval in seconds, to group results by. Default is 3600 seconds. -Example response (_fiatcurrency_ not specified): +Example response (_fiatcurrency_ not specified, `BalanceHistory[]` type): ```javascript [ @@ -850,26 +1007,26 @@ Example response (fiatcurrency=usd): ```javascript [ - { - time: 1578391200, - txs: 5, - received: "5000000", - sent: "0", - sentToSelf: "0", - rates: { - usd: 7855.9, + { + time: 1578391200, + txs: 5, + received: '5000000', + sent: '0', + sentToSelf: '0', + rates: { + usd: 7855.9, + }, }, - }, - { - time: 1578488400, - txs: 1, - received: "0", - sent: "5000000", - sentToSelf: "0", - rates: { - usd: 8283.11, + { + time: 1578488400, + txs: 1, + received: '0', + sent: '5000000', + sentToSelf: '0', + rates: { + usd: 8283.11, + }, }, - }, ]; ``` @@ -877,16 +1034,16 @@ Example response (fiatcurrency=usd&groupBy=172800): ```javascript [ - { - time: 1578355200, - txs: 6, - received: "5000000", - sent: "5000000", - sentToSelf: "0", - rates: { - usd: 7734.45, + { + time: 1578355200, + txs: 6, + received: '5000000', + sent: '5000000', + sentToSelf: '0', + rates: { + usd: 7734.45, + }, }, - }, ]; ``` @@ -898,26 +1055,30 @@ Websocket interface is provided at `/websocket/`. The interface can be explored The websocket interface provides the following requests: -- getInfo -- getBlockHash -- getAccountInfo -- getAccountUtxo -- getTransaction -- getTransactionSpecific -- getBalanceHistory -- getCurrentFiatRates -- getFiatRatesTickersList -- getFiatRatesForTimestamps -- estimateFee -- sendTransaction -- ping +- getInfo +- getBlockHash +- getBlock +- getAccountInfo +- getContractInfo +- getAccountUtxo +- getTransaction +- getTransactionSpecific +- getBalanceHistory +- getCurrentFiatRates +- getFiatRatesTickersList +- getFiatRatesForTimestamps +- getMempoolFilters +- getBlockFilter +- estimateFee +- sendTransaction +- ping The client can subscribe to the following events: -- `subscribeNewBlock` - new block added to blockchain -- `subscribeNewTransaction` - new transaction added to blockchain (all addresses) -- `subscribeAddresses` - new transaction for a given address (list of addresses) added to mempool -- `subscribeFiatRates` - new currency rate ticker +- `subscribeNewBlock` - new block added to blockchain +- `subscribeNewTransaction` - new transaction added to blockchain (all addresses) +- `subscribeAddresses` - new transaction for a given address (list of addresses) added to mempool (and optionally confirmed in a new block) +- `subscribeFiatRates` - new currency rate ticker There can be always only one subscription of given event per connection, i.e. new list of addresses replaces previous list of addresses. @@ -925,7 +1086,7 @@ The subscribeNewTransaction event is not enabled by default. To enable support, _Note: If there is reorg on the backend (blockchain), you will get a new block hash with the same or even smaller height if the reorg is deeper_ -Websocket communication format +Websocket communication format (`WsReq` type) ```javascript { @@ -947,9 +1108,57 @@ Example for subscribing to an address (or multiple addresses) } ``` +Example for subscribing to an address (or multiple addresses) including new block (confirmed) transactions + +```javascript +{ + "id":"1", + "method":"subscribeAddresses", + "params":{ + "addresses":["mnYYiDCb2JZXnqEeXta1nkt5oCVe2RVhJj", "tb1qp0we5epypgj4acd2c4au58045ruud2pd6heuee"], + "newBlockTxs": true, + } +} +``` + +Example for getting current contract info including ERC4626 enrichment + +```javascript +{ + "id":"1", + "method":"getContractInfo", + "params":{ + "contract":"0x...", + "currency":"usd", + "protocols":["erc4626"] + } +} +``` + +Example for getting a block with paged transactions + +```javascript +{ + "id":"1", + "method":"getBlock", + "params":{ + "id":"760f8ed32894ccce9c1ea11c8a019cadaa82bcb434b25c30102dd7e43f326217", + "page":1, + "pageSize":1000 + } +} +``` + +Notes for `getBlock`: + +- available only when Blockbook runs with extended index enabled +- response format matches REST `GET /api/v2/block/` +- _pageSize_ defaults to `1000` and is capped at `10000` +- _page_ is sanitized to stay within safe internal limits + ## Legacy API V1 -The legacy API is a compatible subset of API provided by **Bitcore Insight**. It is supported only Bitcoin-type coins. The details of the REST/socket.io requests can be found in the Insight's documentation. +The legacy API is a compatible subset of API provided by **Bitcore Insight**. It is supported only for Bitcoin-type coins. The details of the REST requests can be found in the Insight's documentation. ### REST API @@ -964,10 +1173,6 @@ GET /api/v1/sendtx/ POST /api/v1/sendtx/ (hex tx data in request body) ``` -### Socket.io API - -Socket.io interface is provided at `/socket.io/`. The interface also can be explored using Blockbook Socket.io Test Page found at `/test-socketio.html`. - The legacy API is provided as is and will not be further developed. -The legacy API is currently (as of Blockbook v0.4.0) also accessible without the _/v1/_ prefix, however in the future versions the version less access will be removed. +The legacy API is currently (as of Blockbook v0.5.0) also accessible without the _/v1/_ prefix, however in the future versions the version-less access will be removed. diff --git a/docs/build.md b/docs/build.md index 3623d50f26..72d1b5bdc2 100644 --- a/docs/build.md +++ b/docs/build.md @@ -11,7 +11,7 @@ Manual build require additional dependencies that are described in appropriate s ## Build in Docker environment All build operations run in Docker container in order to keep build environment isolated. Makefile in root of repository -define few targets used for building, testing and packaging of Blockbook. With Docker image definitions and Debian +defines few targets used for building, testing and packaging of Blockbook. With Docker image definitions and Debian package templates in *build/docker* and *build/templates* respectively, they are only inputs that make build process. Docker build images are created at first execution of Makefile and that information is persisted. (Actually there are @@ -88,6 +88,34 @@ command: `make NO_CACHE=true all-bitcoin`. `PORTABLE`: By default, the RocksDB binaries shipped with Blockbook are optimized for the platform you're compiling on (-march=native or the equivalent). If you want to build a portable binary, use `make PORTABLE=1 all-bitcoin`. +`BB_BUILD_ENV`: Selects which RPC URL override family is active during package/config generation. Defaults to `dev`. +Accepted values are `dev` and `prod`. + +`BB_DEV_RPC_URL_HTTP_` / `BB_PROD_RPC_URL_HTTP_`: Override `ipc.rpc_url_template` while generating +package definitions so you can target hosted HTTP RPC endpoints without editing coin JSON. The root `Makefile` forwards +`BB_BUILD_ENV` and any `BB_DEV_RPC_URL_*` / `BB_PROD_RPC_URL_*` variables into the Docker build/test containers. +Resolution prefers the exact alias and also accepts archive variants such as `_archive` and, for names like Polygon, +`_archive_`. + +`BB_DEV_RPC_URL_WS_` / `BB_PROD_RPC_URL_WS_`: Override `ipc.rpc_url_ws_template` for WebSocket +subscriptions. The selected value should point to the same host as the selected HTTP RPC override and follows the same +fallback resolution. + +`BB_DEV_MQ_URL_` / `BB_PROD_MQ_URL_`: Override `ipc.message_queue_binding_template` during +package/config generation. The value is used as-is and should be a full MQ endpoint such as +`tcp://backend_hostname:28332`. The root `Makefile` forwards these variables into the Docker build/test containers and +the same alias/archive fallback resolution applies. + +Example: +`BB_BUILD_ENV=prod BB_PROD_RPC_URL_HTTP_ethereum=http://backend_hostname:1234 BB_PROD_RPC_URL_WS_ethereum_archive=ws://backend_hostname:1234 make deb-ethereum_archive`. + +`BB_RPC_BIND_HOST_`: Overrides backend RPC bind host during package generation. Defaults to `127.0.0.1` +to avoid unintended exposure. Example: `BB_RPC_BIND_HOST_ethereum=0.0.0.0 make deb-ethereum`. + +`BB_RPC_ALLOW_IP_`: Overrides backend RPC allow list for UTXO configs (e.g. `rpcallowip`). Defaults to +`127.0.0.1` so binding to `0.0.0.0` does not implicitly open access. Example: +`BB_RPC_ALLOW_IP_bitcoin=10.0.0.0/24 make deb-bitcoin`. + ### Naming conventions and versioning All configuration keys described below are in coin definition file in *configs/coins*. @@ -137,7 +165,7 @@ Blockbook versioning is much simpler. There is only one version defined in *conf ### Back-end building -Because we don't keep back-end archives inside out repository we download them during build process. Build steps +Because we don't keep back-end archives inside our repository we download them during build process. Build steps are these: download, verify and extract archive, prepare distribution and make package. All configuration keys described below are in coin definition file in *configs/coins*. @@ -153,7 +181,7 @@ have signed sha256 sums and some don't care about verification at all. So there could be *gpg*, *gpg-sha256* or *sha256* and chooses particular method. *gpg* type require file with digital sign and maintainer's public key imported in Docker build image (see below). Sign -file is downloaded from URL defined in *backend.verification_source*. Than is passed to gpg in order to verify archvie. +file is downloaded from URL defined in *backend.verification_source*. Than is passed to gpg in order to verify archive. *gpg-sha256* type require signed checksum file and maintainer's public key imported in Docker build image (see below). Checksum file is downloaded from URL defined in *backend.verification_source*. Then is verified by gpg and passed to @@ -191,7 +219,7 @@ like macOS or Windows, please adapt the instructions to your target system. Setup go environment (use newer version of go as available) ``` -wget https://golang.org/dl/go1.19.linux-amd64.tar.gz && tar xf go1.19.linux-amd64.tar.gz +wget https://golang.org/dl/go1.22.8.linux-amd64.tar.gz && tar xf go1.22.8.linux-amd64.tar.gz sudo mv go /opt/go sudo ln -s /opt/go/bin/go /usr/bin/go # see `go help gopath` for details @@ -201,16 +229,16 @@ export PATH=$PATH:$GOPATH/bin ``` Install RocksDB: https://github.com/facebook/rocksdb/blob/master/INSTALL.md -and compile the static_lib and tools. Optionally, consider adding `PORTABLE=1` before the +and compile the static_lib and tools. Optionally, consider adding `PORTABLE=1` before the make command to create a portable binary. ``` sudo apt-get update && sudo apt-get install -y \ - build-essential git wget pkg-config libzmq3-dev libgflags-dev libsnappy-dev zlib1g-dev libzstd-dev libbz2-dev liblz4-dev + build-essential git wget pkg-config libzmq3-dev libgflags-dev libsnappy-dev zlib1g-dev libzstd-dev libbz2-dev liblz4-dev git clone https://github.com/facebook/rocksdb.git cd rocksdb -git checkout v7.5.3 -CFLAGS=-fPIC CXXFLAGS=-fPIC make release +git checkout v9.10.0 +CFLAGS=-fPIC CXXFLAGS="-fPIC -Wno-error=array-bounds" make release ``` Setup variables for grocksdb @@ -258,7 +286,7 @@ Example for Bitcoin: ./blockbook -sync -blockchaincfg=build/blockchaincfg.json -internal=:9030 -public=:9130 -certfile=server/testcert -logtostderr ``` -This command starts Blockbook with parallel synchronization and providing HTTP and Socket.IO interface, with database +This command starts Blockbook with parallel synchronization and providing HTTP API and WebSocket interfaces, with database in local directory *data* and established ZeroMQ and RPC connections to back-end daemon specified in configuration file passed to *-blockchaincfg* option. diff --git a/docs/ci_cd.md b/docs/ci_cd.md new file mode 100644 index 0000000000..bad0b516ea --- /dev/null +++ b/docs/ci_cd.md @@ -0,0 +1,233 @@ +# CI/CD + +## GitHub Actions Workflows + +The repository currently uses two main workflows: + +- `testing.yml` for automated test checks on pushes and pull requests +- `deploy.yml` for manual self-hosted build/deploy runs (shown in GitHub Actions as `Build / Deploy`) + +## Testing Workflow + +Workflow: `.github/workflows/testing.yml` + +Trigger: + +- `push` to `master` and `develop` +- `pull_request` to any branch + +Jobs: + +1. `unit-tests` +2. `connectivity-tests` test everything is reachable on the network +3. `integration-tests` + +Security gate for self-hosted test jobs: + +- self-hosted jobs run only for non-PR events or same-repository PRs +- condition: + `github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository` + +## Deploy Workflow + +Workflow: `.github/workflows/deploy.yml` (`Build / Deploy` in the Actions UI) + +Trigger: + +- manual `workflow_dispatch` + +Inputs: + +- `mode`: + - `build` when you want to build Blockbook Debian packages only. + - `deploy` when you want the full flow: + 1. build package + 2. install and restart service + 3. wait for Blockbook sync + 4. run post-deploy e2e tests +- `env`: + - `dev` keeps the current per-coin dev runner mapping + - `prod` builds selected coins on the `production-builder` runner regardless of `BB_RUNNER_*` + - default is `dev` + - selected value is exported downstream as `BB_BUILD_ENV` + - ignored when `mode=deploy` +- `backend_mode`: + - `auto` derives backend builds per coin from the selected `BB_{DEV|PROD}_RPC_URL_HTTP_` value + - in `auto`, backend is built when that env var is unset, empty, or resolves to `localhost`, `127.0.0.1`, or `::1` + - in `auto`, backend is skipped only when the env var is present and points to a non-loopback target + - `always` forces backend builds for all selected coins + - `never` skips backend builds for coins that also produce a blockbook package; backend-only coins still build their backend package + - ignored when `mode=deploy` +- `coins`: comma-separated aliases from `configs/coins`; `ALL` is supported only in `mode=build` +- `branch_or_tag`: optional branch or tag to check out and deploy; leave empty to use the workflow run ref name + - the selected value is validated before checkout and must exist in the target repository as a branch or tag + +In `mode=build`, selected coins are grouped by runner so one build job can build multiple +`deb-blockbook-` targets in a single invocation on the same self-hosted machine. +Deploy and test-related workflow steps use `BB_BUILD_ENV=dev`. + +Env vars : + +See also [CI/CD workflow variables](env.md#cicd-workflow-variables). + +- `BB_PACKAGE_ROOT=/opt/blockbook-builds` + - When absolute path set, build jobs copy packages to: + - `/opt/blockbook-builds/{branch_or_tag}/{coin}/blockbook-*.deb` + - `/opt/blockbook-builds/{branch_or_tag}/{coin}/backend-*.deb` + - `{coin}` here is the workflow/config name from `configs/coins/.json`, not `coin.alias` + +Special cases: + +- `mode=build` + `env=dev` skips prod-only coins when `coins=ALL` +- `mode=build` + `env=prod` + `coins=ALL` builds all configured coins with `BB_RUNNER_*` mappings on the `production-builder` runner +- `mode=build` + `env=dev` fails if you explicitly request a coin whose `BB_RUNNER_*` is `production_builder` +- `mode=deploy` is dev-only and fails fast if any selected coin is mapped to `production_builder` + +## Naming Matrix + +```text ++-------------------------------+----------------------------------------+--------------------------------------+ +| Concern | Example source | Name used | ++-------------------------------+----------------------------------------+--------------------------------------+ +| Workflow/build/deploy identity| configs/coins/.json filename | polygon_archive | +| Runner mapping | BB_RUNNER_ | BB_RUNNER_POLYGON_ARCHIVE | +| Build env selector | BB_BUILD_ENV | dev | +| Backend RPC env identity | coin.alias | BB_DEV_RPC_URL_HTTP_polygon_archive_bor | +| Backend MQ env identity | coin.alias | BB_DEV_MQ_URL_polygon_archive_bor | +| Blockbook package name | blockbook.package_name | blockbook-polygon | +| Backend package name | backend.package_name | backend-polygon | +| Build target identity | workflow/config coin name | deb-blockbook-polygon_archive | +| Built Blockbook .deb filename | build/_*.deb | build/blockbook-polygon_*.deb | +| Built backend .deb filename | build/_*.deb | build/backend-polygon_*.deb | +| Staged artifact path identity | workflow/config coin name | {branch_or_tag}/polygon_archive/... | +| API/e2e test identity | coin.test_name or config filename | polygon | +| API test env identity | BB_DEV_API_URL_* from test identity | BB_DEV_API_URL_HTTP_polygon | ++-------------------------------+----------------------------------------+--------------------------------------+ +``` + +For `polygon_archive` specifically: + +- workflow coin: `polygon_archive` +- alias: `polygon_archive_bor` +- blockbook package name: `blockbook-polygon` +- backend package name: `backend-polygon` +- test name: `polygon` + +## CLI examples + +Wrapper entrypoint: + +```bash +./.github/bin/bbcli +``` + +Without `--run`, `build` and `deploy` print the underlying `gh workflow run ...` +command. `list` prints coins, not commands. + +Current branch example output was captured on `new-test-name-config`, so the printed +`--ref` and `branch_or_tag` values will differ on other branches. +The output below assumes `BB_RUNNER_*` repository variables are valid for the current checkout. + +List coins buildable on dev runners: + +```bash +./.github/bin/bbcli list --env dev +``` + +```text +avalanche_archive +base_archive +bcash +bitcoin +bitcoin_regtest +bitcoin_testnet +bitcoin_testnet4 +bsc_archive +dash +dogecoin +ethereum_archive +ethereum_testnet_hoodi_archive +ethereum_testnet_sepolia_archive +litecoin +zcash +``` + +List all configured runner-mapped coins in CSV form: + +```bash +./.github/bin/bbcli list --env prod --format csv +``` + +```text +arbitrum_archive,avalanche_archive,base_archive,bcash,bitcoin,bitcoin_regtest,bitcoin_testnet,bitcoin_testnet4,bsc_archive,dash,dogecoin,ethereum_archive,ethereum_testnet_hoodi_archive,ethereum_testnet_sepolia_archive,litecoin,optimism_archive,polygon_archive,zcash +``` + +Print the default dev build command for selected coins: + +```bash +./.github/bin/bbcli build --coins bitcoin,dogecoin +``` + +```text +gh workflow run deploy.yml -R trezor/blockbook --ref new-test-name-config -f mode=build -f env=dev -f coins=bitcoin,dogecoin -f branch_or_tag=new-test-name-config +``` + +Print the prod build command for selected coins: + +```bash +./.github/bin/bbcli build --env prod --coins bitcoin,bsc_archive +``` + +```text +gh workflow run deploy.yml -R trezor/blockbook --ref new-test-name-config -f mode=build -f env=prod -f coins=bitcoin,bsc_archive -f branch_or_tag=new-test-name-config +``` + +Print a build command that skips backend packages entirely: + +```bash +./.github/bin/bbcli build --coins bitcoin,dogecoin --backend-mode never +``` + +```text +gh workflow run deploy.yml -R trezor/blockbook --ref new-test-name-config -f mode=build -f env=dev -f coins=bitcoin,dogecoin -f backend_mode=never -f branch_or_tag=new-test-name-config +``` + +Print the dev build command for all selectable coins: + +```bash +./.github/bin/bbcli build --coins ALL +``` + +```text +gh workflow run deploy.yml -R trezor/blockbook --ref new-test-name-config -f mode=build -f env=dev -f coins=ALL -f branch_or_tag=new-test-name-config +``` + +Print the prod build command for all selectable coins: + +```bash +./.github/bin/bbcli build --env prod --coins ALL +``` + +```text +gh workflow run deploy.yml -R trezor/blockbook --ref new-test-name-config -f mode=build -f env=prod -f coins=ALL -f branch_or_tag=new-test-name-config +``` + +Print the deploy command for selected coins: + +```bash +./.github/bin/bbcli deploy --coins bitcoin,dogecoin +``` + +```text +gh workflow run deploy.yml -R trezor/blockbook --ref new-test-name-config -f mode=deploy -f env=dev -f coins=bitcoin,dogecoin -f branch_or_tag=new-test-name-config +``` + +Print the deploy command with an explicit branch or tag: + +```bash +./.github/bin/bbcli deploy --coins bitcoin --branch-or-tag master +``` + +```text +gh workflow run deploy.yml -R trezor/blockbook --ref new-test-name-config -f mode=deploy -f env=dev -f coins=bitcoin -f branch_or_tag=master +``` diff --git a/docs/config.md b/docs/config.md index b4e14296c8..9a8fe89717 100644 --- a/docs/config.md +++ b/docs/config.md @@ -32,15 +32,25 @@ Good examples of coin configuration are * `backend_*` – Additional back-end ports can be documented here. Actually the only purpose is to get them to port table (prefix is removed and rest of string is used as note). * `blockbook_internal` – Blockbook's internal port that is used for metric collecting, debugging etc. - * `blockbook_public` – Blockbook's public port that is used to comunicate with Trezor wallet (via Socket.IO). + * `blockbook_public` – Blockbook's public HTTP/API/WebSocket port. * `ipc` – Defines how Blockbook connects its back-end service. - * `rpc_url_template` – Template that defines URL of back-end RPC service. See note on templates below. + * `rpc_url_template` – Template that defines URL of back-end RPC service. See note on templates below. You can + override it at build time by setting the selected `BB_DEV_RPC_URL_HTTP_` or + `BB_PROD_RPC_URL_HTTP_` variable (for example, + `BB_BUILD_ENV=dev BB_DEV_RPC_URL_HTTP_ethereum=http://backend_hostname:1234`), which is used as-is during + template generation. `BB_BUILD_ENV` defaults to `dev`. + * `rpc_url_ws_template` – Template that defines URL of back-end WebSocket RPC service for subscriptions. You can + override it at build time by setting the selected `BB_DEV_RPC_URL_WS_` or + `BB_PROD_RPC_URL_WS_` variable and it should point to the same host as `rpc_url_template`. * `rpc_user` – User name of back-end RPC service, used by both Blockbook and back-end configuration templates. * `rpc_pass` – Password of back-end RPC service, used by both Blockbook and back-end configuration templates. * `rpc_timeout` – RPC timeout used by Blockbook. * `message_queue_binding_template` – Template that defines URL of back-end's message queue (ZMQ), used by both - Blockbook and back-end configuration template. See note on templates below. + Blockbook and back-end configuration template. You can override it at build time by setting the selected + `BB_DEV_MQ_URL_` or `BB_PROD_MQ_URL_` variable (for example, + `BB_BUILD_ENV=dev BB_DEV_MQ_URL_bitcoin=tcp://backend_hostname:28332`), which is used as-is during template + generation. See note on templates below. * `backend` – Definition of back-end package, configuration and service. * `package_name` – Name of package. See convention note in [build guide](/docs/build.md#on-naming-conventions-and-versioning). @@ -82,7 +92,7 @@ Good examples of coin configuration are * `explorer_url` – URL of blockchain explorer. Leave empty for internal explorer. * `additional_params` – Additional params of exec command (see [Dogecoin definition](/configs/coins/dogecoin.json)). * `block_chain` – Configuration of BlockChain type that ensures communication with back-end service. All options - must be tweaked for each individual coin separely. + must be tweaked for each individual coin separately. * `parse` – Use binary parser for block decoding if *true* else call verbose back-end RPC method that returns JSON. Note that verbose method is slow and not every coin support it. However there are coin implementations that don't support binary parsing (e.g. ZCash). @@ -90,6 +100,25 @@ Good examples of coin configuration are * `mempool_sub_workers` – Number of subworkers for BitcoinType mempool. * `block_addresses_to_keep` – Number of blocks that are to be kept in blockaddresses column. * `additional_params` – Object of coin-specific params. + * Tron-specific endpoint configuration is documented in [Tron Config](/docs/tron-config.md). + * Infura alternative EIP-1559 fee provider configuration: + * `alternative_estimate_fee` – Set to `infura` to use Infura Gas API fee suggestions instead of native node fee estimation. + * `alternative_estimate_fee_params` – JSON string with `url` and `periodSeconds`. `periodSeconds` controls how often Blockbook polls Infura. + Cached Infura fees remain usable for 30 failed polling periods, so `periodSeconds: 60` keeps the last successful fees for up to 30 minutes before native fallback. + * Ethereum mempool timeout configuration: + * `mempoolTxTimeoutHours` – Legacy Blockbook-side EVM mempool retention in whole hours. It is used when `mempoolTxTimeout` is not set and no alternative send transaction provider is enabled. + * `mempoolTxTimeout` – Optional Blockbook-side EVM mempool retention as a Go duration string such as `"10m"`; `"0s"` preserves the legacy zero-retention setting. If omitted and an alternative send transaction provider is enabled, Blockbook uses **10 minutes** instead of the legacy hour-based value. + * `alternativeMempoolTxTimeout` – Optional alternative-provider transaction cache retention as a positive Go duration string such as `"5m"`. Defaults to **5 minutes** when the alternative send transaction provider is enabled. + * Hot-address configuration (Blockbook, Ethereum-type indexing): + * `hot_address_min_contracts` – Minimum number of contracts before hotness tracking applies (default **192**). + * `hot_address_min_hits` – Lookups within the current block required to mark an address hot (default **3**, clamped to **10**). + * `hot_address_lru_cache_size` – Max hot addresses kept in the LRU (default **20000**, clamped to **100,000**). + * Ethereum trace configuration (Blockbook, Ethereum-type indexing): + * `trace_timeout` – Optional per-request timeout passed to `debug_traceBlockByHash` as tracer config, formatted as a Go duration string such as `"20s"`. + * Address-contracts cache configuration (Blockbook, Ethereum-type indexing): + * `address_contracts_cache_min_size` – Minimum packed size (bytes) before an addressContracts entry is cached (default **300000**). + * `address_contracts_cache_max_bytes` – Cache size cap in bytes used while syncing near chain tip; when exceeded, cached entries are flushed early (default **2000000000**). + * `address_contracts_cache_bulk_max_bytes` – Cache size cap in bytes used during bulk connect; when exceeded, cached entries are flushed early (default **4000000000**). * `meta` – Common package metadata. * `package_maintainer` – Full name of package maintainer. @@ -103,6 +132,9 @@ where *.path* can be for example *.Blockbook.BlockChain.Parse*. Go uses CamelCas as well. Note that dot at the beginning is mandatory. Go template syntax is fully documented [here](https://godoc.org/text/template). +Backend templates may also reference `.Env.RPCBindHost` and `.Env.RPCAllowIP`, which are derived at build time from +`BB_RPC_BIND_HOST_` and `BB_RPC_ALLOW_IP_` to keep RPC exposure explicit and controlled. + ## Built-in text Since Blockbook is an open-source project and we don't prevent anybody from running independent instances, it is possible @@ -112,4 +144,4 @@ to alter built-in text that is specific for Trezor. Text fields that could be up * [tos_link](/build/text/tos_link) – A link to Terms of service shown as the footer on the Explorer pages. Text data are stored as plain text files in *build/text* directory and are embedded to binary during build. A change of -theese files is mean for a private purpose and PRs that would update them won't be accepted. +these files is meant for a private purpose and PRs that would update them won't be accepted. diff --git a/docs/env.md b/docs/env.md new file mode 100644 index 0000000000..928330d182 --- /dev/null +++ b/docs/env.md @@ -0,0 +1,74 @@ +# Environment variables + +Some behavior of Blockbook can be modified by environment variables. The variables usually start with a coin shortcut to allow to run multiple Blockbooks on a single server. + +- `_WS_GETACCOUNTINFO_LIMIT` - Limits the number of `getAccountInfo` requests per websocket connection to reduce server abuse. Accepts number as input. + +- `_WS_ALLOWED_ORIGINS` - Comma-separated list of allowed WebSocket origins (e.g. `https://example.com`, `http://localhost:3000`). If omitted, all origins are allowed and it is the operator's responsibility to enforce origin access (for example via proxy). + +- `_WS_TRUSTED_PROXIES` - Comma-separated list of trusted proxy CIDRs whose `X-Real-Ip` header should be used as the WebSocket client IP. This IP is used by per-IP WebSocket connection and connection-attempt limits. + Blockbook always trusts `X-Real-Ip` from loopback, RFC1918/private, and link-local peers, so this variable is only needed for additional non-local proxies. + + If this variable is unset, Blockbook keeps the default Cloudflare behavior and uses `CF-Connecting-IPv6` first, then `CF-Connecting-IP`, when either header contains a valid IP address. This is intended for deployments where the origin only accepts traffic from Cloudflare IP ranges, for example enforced by nginx or a firewall. Blockbook does not validate the TCP peer against Cloudflare ranges itself. + + If this variable is set, Blockbook switches to generic trusted-proxy mode: `CF-Connecting-IP` and `CF-Connecting-IPv6` are ignored, and `X-Real-Ip` is used only when the TCP peer is a built-in trusted proxy or matches one of the configured CIDRs. In this mode the proxy must overwrite or strip any client-supplied `X-Real-Ip` header before forwarding requests to Blockbook. + + Do not set this variable for a normal Cloudflare-only deployment unless the proxy in front of Blockbook sets `X-Real-Ip` to the real visitor IP. Otherwise all clients may collapse to the proxy or Cloudflare address for rate limiting. + + To avoid unsafe configuration, Blockbook fails startup if a configured prefix is too broad (`/<8` for IPv4, `/<16` for IPv6), malformed, or uses IPv4-mapped IPv6 notation. Use regular IPv4 CIDR notation instead, for example `198.51.100.0/24` rather than `::ffff:198.51.100.0/120`. + +- `_STAKING_POOL_CONTRACT` - The pool name and contract used for Ethereum staking. The format of the variable is `/`. If missing, staking support is disabled. + +- `INFURA_API_KEY` - API key for the Infura alternative EIP-1559 fee provider. Archive EVM configs using Infura poll once per minute and keep serving the last successful fee data for up to 30 failed polls before falling back to native fee estimation. + +- `COINGECKO_API_KEY`, `_COINGECKO_API_KEY`, or `_COINGECKO_API_KEY` - API key for making requests to CoinGecko in the paid tier. + If any of these variables is set, it must be non-empty (empty value is treated as a configuration error and Blockbook fails on startup). + Lookup priority is: + 1. `_COINGECKO_API_KEY` + 2. `_COINGECKO_API_KEY` + 3. `COINGECKO_API_KEY` + Example: for Optimism, `network=OP` and `coin shortcut=ETH`, so `OP_COINGECKO_API_KEY` is preferred over `ETH_COINGECKO_API_KEY`. + +- `_ALLOWED_RPC_CALL_TO` - Addresses to which `rpcCall` websocket requests can be made, as a comma-separated list. If omitted, `rpcCall` is enabled for all addresses. + +- `_ALTERNATIVE_SENDTX_URLS` - Comma-separated list of alternative EVM `eth_sendRawTransaction` providers, used for private/MEV-protected transaction submission. The prefix is the configured `network` value when present (for example `OP`, `BASE`, `POL`, `BSC`, `ARB`, `AVAX`), otherwise the coin shortcut (for example `ETH`). If omitted, Blockbook sends transactions through the normal backend RPC. + +- `_ALTERNATIVE_SENDTX_ONLY` - Set to `TRUE` to use only the alternative send transaction provider and avoid fallback to the normal backend RPC if alternative submission fails. + +- `_ALTERNATIVE_FETCH_MEMPOOL_TX` - Set to `TRUE` to fetch and cache transactions submitted through the alternative provider, so Blockbook can expose them as pending even if they are not visible in the public backend mempool. When the alternative provider is enabled, the default alternative cache timeout is 5 minutes and the default Blockbook EVM mempool timeout is 10 minutes; both can be overridden in coin config with `alternativeMempoolTxTimeout` and `mempoolTxTimeout`. + + WebSocket `sendTransaction` can bypass the alternative provider for a single request by setting `disableAlternativeRPC` to `true`. + +## Build-time variables + +- `BB_BUILD_ENV` - Selects the active RPC URL override family during package/config generation. Defaults to `dev`. + Accepted values are `dev` and `prod`. +- `BB_DEV_RPC_URL_HTTP_` / `BB_PROD_RPC_URL_HTTP_` - Override `ipc.rpc_url_template` during + package/config generation so build and integration-test tooling can target hosted HTTP RPC endpoints without editing + coin JSON. Lookup prefers the exact alias and also accepts archive variants like `_archive` and + `_archive_` within the selected env family. +- `BB_DEV_RPC_URL_WS_` / `BB_PROD_RPC_URL_WS_` - Override `ipc.rpc_url_ws_template` for + WebSocket subscriptions; should point to the same host as the selected HTTP RPC override and follows the same + fallback resolution. +- `BB_DEV_MQ_URL_` / `BB_PROD_MQ_URL_` - Override `ipc.message_queue_binding_template` + during package/config generation. The value is used as-is, so it should include the full MQ transport URL + (for example `tcp://backend_hostname:28332`). This follows the same alias/archive fallback resolution as the + RPC URL overrides. +- `BB_RPC_BIND_HOST_` - Overrides backend RPC bind host during package/config generation; when set to + `0.0.0.0`, RPC stays restricted unless `BB_RPC_ALLOW_IP_` is set. +- `BB_RPC_ALLOW_IP_` - Overrides backend RPC allow list for UTXO configs (e.g. `rpcallowip`), defaulting + to `127.0.0.1`. + +## CI/CD workflow variables + +- `BB_RUNNER_` - Maps a workflow/config coin name from `configs/coins/.json` to the self-hosted runner label + used by the `Build / Deploy` workflow. `production_builder` marks coins that are buildable only in `env=prod`; those builds run on the `production-builder` self-hosted runner label. + +- `BB_PACKAGE_ROOT` - Absolute filesystem path where workflow build jobs stage copied `.deb` packages after build. + Defaults to `/opt/blockbook-builds` in the workflow. + +- `BB_DEV_API_URL_HTTP_` - Overrides the HTTP Blockbook API endpoint used by API/e2e tests and the + post-deploy sync wait step. Uses the test identity (`coin.test_name`, or config filename fallback), not `coin.alias`. + +- `BB_DEV_API_URL_WS_` - Overrides the WebSocket Blockbook API endpoint used by API/e2e tests. Uses the + same test identity as `BB_DEV_API_URL_HTTP_`. diff --git a/docs/ports.md b/docs/ports.md index 9b010b39cf..48519ea88f 100644 --- a/docs/ports.md +++ b/docs/ports.md @@ -1,76 +1,95 @@ # Registry of ports -| coin | blockbook internal port | blockbook public port | backend rpc port | backend service ports (zmq) | -|------------------------|-------------------------|-----------------------|------------------|-----------------------------| -| Bitcoin | 9030 | 9130 | 8030 | 38330 | -| Bitcoin Cash | 9031 | 9131 | 8031 | 38331 | -| Zcash | 9032 | 9132 | 8032 | 38332 | -| Dash | 9033 | 9133 | 8033 | 38333 | -| Litecoin | 9034 | 9134 | 8034 | 38334 | -| Bitcoin Gold | 9035 | 9135 | 8035 | 38335 | -| Ethereum | 9036 | 9136 | 8036 | 38336 p2p, 8136 http | -| Ethereum Classic | 9037 | 9137 | 8037 | | -| Dogecoin | 9038 | 9138 | 8038 | 38338 | -| Namecoin | 9039 | 9139 | 8039 | 38339 | -| Vertcoin | 9040 | 9140 | 8040 | 38340 | -| Monacoin | 9041 | 9141 | 8041 | 38341 | -| DigiByte | 9042 | 9142 | 8042 | 38342 | -| Myriad | 9043 | 9143 | 8043 | 38343 | -| GameCredits | 9044 | 9144 | 8044 | 38344 | -| Groestlcoin | 9045 | 9145 | 8045 | 38345 | -| Bitcoin Cash SV | 9046 | 9146 | 8046 | 38346 | -| Liquid | 9047 | 9147 | 8047 | 38347 | -| Fujicoin | 9048 | 9148 | 8048 | 38348 | -| PIVX | 9049 | 9149 | 8049 | 38349 | -| Firo | 9050 | 9150 | 8050 | 38350 | -| Koto | 9051 | 9151 | 8051 | 38351 | -| Bellcoin | 9052 | 9152 | 8052 | 38352 | -| NULS | 9053 | 9153 | 8053 | 38353 | -| Bitcore | 9054 | 9154 | 8054 | 38354 | -| Viacoin | 9055 | 9155 | 8055 | 38355 | -| VIPSTARCOIN | 9056 | 9156 | 8056 | 38356 | -| MonetaryUnit | 9057 | 9157 | 8057 | 38357 | -| Flux | 9058 | 9158 | 8058 | 38358 | -| Ravencoin | 9059 | 9159 | 8059 | 38359 | -| Ritocoin | 9060 | 9160 | 8060 | 38360 | -| Decred | 9061 | 9161 | 8061 | 38361 | -| SnowGem | 9062 | 9162 | 8062 | 38362 | -| Flo | 9066 | 9166 | 8066 | 38366 | -| Polis | 9067 | 9167 | 8067 | 38367 | -| Qtum | 9088 | 9188 | 8088 | 38388 | -| Divi Project | 9089 | 9189 | 8089 | 38389 | -| CPUchain | 9090 | 9190 | 8090 | 38390 | -| DeepOnion | 9091 | 9191 | 8091 | 38391 | -| Unobtanium | 9092 | 9192 | 65535 | 38392 | -| Omotenashicoin | 9094 | 9194 | 8094 | 38394 | -| BitZeny | 9095 | 9195 | 8095 | 38395 | -| Trezarcoin | 9096 | 9196 | 8096 | 38396 | -| eCash | 9097 | 9197 | 8097 | 38397 | -| Avalanche | 9098 | 9198 | 8098 | 38398 p2p | -| Avalanche Archive | 9099 | 9199 | 8099 | 38399 p2p | -| Bitcoin Signet | 19020 | 19120 | 18020 | 48320 | -| Bitcoin Regtest | 19021 | 19121 | 18021 | 48321 | -| Ethereum Goerli | 19026 | 19126 | 18026 | 48326 p2p | -| Ethereum Sepolia | 19176 | 19176 | 18076 | 48376 p2p | -| Bitcoin Testnet | 19030 | 19130 | 18030 | 48330 | -| Bitcoin Cash Testnet | 19031 | 19131 | 18031 | 48331 | -| Zcash Testnet | 19032 | 19132 | 18032 | 48332 | -| Dash Testnet | 19033 | 19133 | 18033 | 48333 | -| Litecoin Testnet | 19034 | 19134 | 18034 | 48334 | -| Bitcoin Gold Testnet | 19035 | 19135 | 18035 | 48335 | -| Ethereum Ropsten | 19036 | 19136 | 18036 | 48336 p2p | -| Dogecoin Testnet | 19038 | 19138 | 18038 | 48338 | -| Vertcoin Testnet | 19040 | 19140 | 18040 | 48340 | -| Monacoin Testnet | 19041 | 19141 | 18041 | 48341 | -| DigiByte Testnet | 19042 | 19142 | 18042 | 48342 | -| Groestlcoin Testnet | 19045 | 19145 | 18045 | 48345 | -| Groestlcoin Regtest | 19046 | 19146 | 18046 | 48346 | -| Groestlcoin Signet | 19047 | 19147 | 18047 | 48347 | -| PIVX Testnet | 19049 | 19149 | 18049 | 48349 | -| Koto Testnet | 19051 | 19151 | 18051 | 48351 | -| Decred Testnet | 19061 | 19161 | 18061 | 48361 | -| Flo Testnet | 19066 | 19166 | 18066 | 48366 | -| Qtum Testnet | 19088 | 19188 | 18088 | 48388 | -| Omotenashicoin Testnet | 19089 | 19189 | 18089 | 48389 | +| coin | blockbook public | blockbook internal | backend rpc | backend service ports (zmq) | +|----------------------------------|------------------|--------------------|-------------|-----------------------------------------------------| +| Ethereum Archive | 9116 | 9016 | 8016 | 38316 p2p, 8116 http, 8516 authrpc | +| Bitcoin | 9130 | 9030 | 8030 | 38330 | +| Bitcoin Cash | 9131 | 9031 | 8031 | 38331 | +| Zcash | 9132 | 9032 | 8032 | 38332 | +| Dash | 9133 | 9033 | 8033 | 38333 | +| Litecoin | 9134 | 9034 | 8034 | 38334 | +| Bitcoin Gold | 9135 | 9035 | 8035 | 38335 | +| Ethereum | 9136 | 9036 | 8036 | 38336 p2p, 8136 http, 8536 authrpc | +| Ethereum Classic | 9137 | 9037 | 8037 | 38337 p2p, 8137 http | +| Dogecoin | 9138 | 9038 | 8038 | 38338 | +| Namecoin | 9139 | 9039 | 8039 | 38339 | +| Vertcoin | 9140 | 9040 | 8040 | 38340 | +| Monacoin | 9141 | 9041 | 8041 | 38341 | +| DigiByte | 9142 | 9042 | 8042 | 38342 | +| Myriad | 9143 | 9043 | 8043 | 38343 | +| GameCredits | 9144 | 9044 | 8044 | 38344 | +| Groestlcoin | 9145 | 9045 | 8045 | 38345 | +| Bitcoin Cash SV | 9146 | 9046 | 8046 | 38346 | +| Liquid | 9147 | 9047 | 8047 | 38347 | +| Fujicoin | 9148 | 9048 | 8048 | 38348 | +| PIVX | 9149 | 9049 | 8049 | 38349 | +| Firo | 9150 | 9050 | 8050 | 38350 | +| Koto | 9151 | 9051 | 8051 | 38351 | +| Bellcoin | 9152 | 9052 | 8052 | 38352 | +| NULS | 9153 | 9053 | 8053 | 38353 | +| Bitcore | 9154 | 9054 | 8054 | 38354 | +| Viacoin | 9155 | 9055 | 8055 | 38355 | +| VIPSTARCOIN | 9156 | 9056 | 8056 | 38356 | +| MonetaryUnit | 9157 | 9057 | 8057 | 38357 | +| Flux | 9158 | 9058 | 8058 | 38358 | +| Ravencoin | 9159 | 9059 | 8059 | 38359 | +| Ritocoin | 9160 | 9060 | 8060 | 38360 | +| Decred | 9161 | 9061 | 8061 | 38361 | +| SnowGem | 9162 | 9062 | 8062 | 38362 | +| BNB Smart Chain | 9164 | 9064 | 8064 | 38364 p2p, 8164 http | +| BNB Smart Chain Archive | 9165 | 9065 | 8065 | 38365 p2p, 8165 http | +| Flo | 9166 | 9066 | 8066 | 38366 | +| Polis | 9167 | 9067 | 8067 | 38367 | +| Polygon | 9170 | 9070 | 8070 | 38370 p2p, 8170 http | +| Polygon Archive | 9172 | 9072 | 8072 | 38372 p2p, 8172 http | +| Qtum | 9188 | 9088 | 8088 | 38388 | +| Divi Project | 9189 | 9089 | 8089 | 38389 | +| CPUchain | 9190 | 9090 | 8090 | 38390 | +| DeepOnion | 9191 | 9091 | 8091 | 38391 | +| Unobtanium | 9192 | 9092 | 65535 | 38392 | +| Omotenashicoin | 9194 | 9094 | 8094 | 38394 | +| BitZeny | 9195 | 9095 | 8095 | 38395 | +| Trezarcoin | 9196 | 9096 | 8096 | 38396 | +| eCash | 9197 | 9097 | 8097 | 38397 | +| Avalanche | 9198 | 9098 | 8098 | 38398 p2p | +| Avalanche Archive | 9199 | 9099 | 8099 | 38399 p2p | +| Optimism | 9300 | 9200 | 8200 | 38400 p2p, 8300 http, 8400 authrpc | +| Optimism Archive | 9302 | 9202 | 8202 | 38402 p2p, 8302 http, 8402 authrpc | +| Arbitrum | 9305 | 9205 | 8205 | 38405 p2p, 8305 http | +| Arbitrum Archive | 9306 | 9206 | 8306 | 38406 p2p | +| Arbitrum Nova | 9307 | 9207 | 8207 | 38407 p2p, 8307 http | +| Arbitrum Nova Archive | 9308 | 9208 | 8308 | 38408 p2p | +| Base | 9309 | 9209 | 8309 | 38409 p2p, 8209 http, 8409 authrpc | +| Base Archive | 9311 | 9211 | 8211 | 38411 p2p, 8311 http, 8411 authrpc | +| Tron | 9312 | 9212 | 8545 | 1111 p2p, 5555, 8090 http | +| Bitcoin Signet | 19120 | 19020 | 18020 | 48320 | +| Bitcoin Regtest | 19121 | 19021 | 18021 | 48321 | +| Bitcoin Testnet4 | 19129 | 19029 | 18029 | 48329 | +| Bitcoin Testnet | 19130 | 19030 | 18030 | 48330 | +| Bitcoin Cash Testnet | 19131 | 19031 | 18031 | 48331 | +| Zcash Testnet | 19132 | 19032 | 18032 | 48332 | +| Dash Testnet | 19133 | 19033 | 18033 | 48333 | +| Litecoin Testnet | 19134 | 19034 | 18034 | 48334 | +| Bitcoin Gold Testnet | 19135 | 19035 | 18035 | 48335 | +| Dogecoin Testnet | 19138 | 19038 | 18038 | 48338 | +| Vertcoin Testnet | 19140 | 19040 | 18040 | 48340 | +| Monacoin Testnet | 19141 | 19041 | 18041 | 48341 | +| DigiByte Testnet | 19142 | 19042 | 18042 | 48342 | +| Groestlcoin Testnet | 19145 | 19045 | 18045 | 48345 | +| Groestlcoin Regtest | 19146 | 19046 | 18046 | 48346 | +| Groestlcoin Signet | 19147 | 19047 | 18047 | 48347 | +| PIVX Testnet | 19149 | 19049 | 18049 | 48349 | +| Koto Testnet | 19151 | 19051 | 18051 | 48351 | +| Decred Testnet | 19161 | 19061 | 18061 | 48361 | +| Flo Testnet | 19166 | 19066 | 18066 | 48366 | +| Ethereum Testnet Holesky | 19116 | 19016 | 18016 | 18116 http, 18516 authrpc, 48316 p2p | +| Ethereum Testnet Holesky Archive | 19136 | 19036 | 18036 | 18136 http, 18136 torrent, 18536 authrpc, 48336 p2p | +| Ethereum Testnet Hoodi | 19106 | 19006 | 18006 | 18106 http, 18506 authrpc, 48306 p2p | +| Ethereum Testnet Hoodi Archive | 19126 | 19026 | 18026 | 18126 http, 18126 torrent, 18526 authrpc, 48326 p2p | +| Ethereum Testnet Sepolia | 19176 | 19076 | 18076 | 18176 http, 18576 authrpc, 48376 p2p | +| Ethereum Testnet Sepolia Archive | 19186 | 19086 | 18086 | 18186 http, 18186 torrent, 18586 authrpc, 48386 p2p | +| Qtum Testnet | 19188 | 19088 | 18088 | 48388 | +| Omotenashicoin Testnet | 19189 | 19089 | 18089 | 48389 | +| Tron Nile | 19190 | 19090 | 8545 | 18888 p2p, 5555, 8090 http | -> NOTE: This document is generated from coin definitions in `configs/coins`. +> NOTE: This document is generated from coin definitions in `configs/coins` using command `go run contrib/scripts/check-and-generate-port-registry.go -w`. diff --git a/docs/rocksdb.md b/docs/rocksdb.md index ddc2356f93..0e95606443 100644 --- a/docs/rocksdb.md +++ b/docs/rocksdb.md @@ -25,7 +25,7 @@ **Database structure:** -The database structure described here is of Blockbook version **0.4.0** (internal data format version 6). +The database structure described here is of Blockbook version **0.5.0** (internal data format version 7). The database structure for **Bitcoin type** and **Ethereum type** coins is different. Column families used for both types: @@ -100,13 +100,36 @@ Column families used only by **Ethereum type** coins: and array of _contracts_ with _number of transfers_ of given address. ``` - (addrDesc []byte) -> (total_txs vuint)+(non-contract_txs vuint)+(internal_txs vuint)+ + (addrDesc []byte) -> (total_txs vuint)+(non-contract_txs vuint)+(internal_txs vuint)+(contracts vuint)+ []((contractAddrDesc []byte)+(type+4*nr_transfers vuint))+ <(value bigInt) if ERC20> or <(nr_values vuint)+[](id bigInt) if ERC721> or <(nr_values vuint)+[]((id bigInt)+(value bigInt)) if ERC1155> ``` + This is a **dense counted-entry record**: + + - a small fixed prefix with address-level counters + - a counted list of per-contract entries + - each contract entry stores a compact standard-discriminated payload + + It is optimized for compactness and hot-path account reads, not for extensibility. + + - Contract ordering & hotness lookup + + Contract entries are appended in discovery order (they are not sorted). Lookups are normally a linear scan, but for + mid-size lists we lazily build an in-memory index map when an address becomes "hot" (frequently looked up within the + current block). A size-limited LRU keeps hot addresses; once the cache is full, the least-recently used hot address is + evicted and will fall back to linear scans until it becomes hot again. + + - Large addressContracts cache + + To reduce repeated RocksDB reads/writes for very large entries, Blockbook caches addressContracts blobs whose packed + size exceeds `address_contracts_cache_min_size`. The cache is flushed periodically, and also flushed early when its + total size crosses the active cache cap. Chain-tip sync uses `address_contracts_cache_max_bytes`; bulk connect uses + `address_contracts_cache_bulk_max_bytes`. Early flush avoids unbounded memory growth at the cost of more frequent + writes. + - **internalData** (used only by Ethereum type coins) Maps _txid_ to _type (CALL 0 | CREATE 1)_, _addrDesc of created contract for CREATE type_, array of _type (CALL 0 | CREATE 1 | SELFDESTRUCT 2)_, _from addrDesc_, _to addrDesc_, _value bigInt_ and possible _error_. @@ -164,13 +187,55 @@ Column families used only by **Ethereum type** coins: - **contracts** (used only by Ethereum type coins) - Maps contract _addrDesc_ to information about contract - _name_, _symbol_, _type_ (ERC20,ERC721 or ERC1155), _decimals_, _created_ and _destructed_ in block height + Maps contract _addrDesc_ to indexed contract metadata. Sync owns this column + family; API code does not write here. Protocol-specific detection records + (e.g. ERC-4626 vault status) live in **ercProtocols** so API-time + writes cannot collide with sync's whole-row writes. ``` (addrDesc []byte) -> (name string)+(symbol string)+(type string)+(decimals vuint)+ (createdInBlock vuint)+(destroyedInBlock vuint) ``` +- **ercProtocols** (used only by EVM coins) + + Per-protocol detection records keyed by contract address. Decoupled from + **contracts** so API-driven protocol writes never clobber sync-driven + contract metadata, and so disconnect can revert protocol records + independently. + + Two prefixes share the column family: + + ``` + (0x00 || protocolId byte || addrDesc []byte) -> (persistHeight vuint)+(payload []byte) + (0x01 || protocolId byte || persistHeight uint32 || addrDesc []byte) -> () + ``` + + - **byContract** (prefix `0x00`) is the read path: one row per + `(contract, protocolId)`, value carries the persist-height and the + protocol-specific payload. + - **byHeight** (prefix `0x01`) is the secondary index used by `DisconnectBlockRangeEthereumType`: + a small range scan over the disconnected height range yields exactly the rows + whose persistence is no longer canonical, and both rows are deleted + in the same batch as the rest of the disconnect. + + Reserved protocol IDs: + + - `protocolId = 1` (`erc4626`): payload is the ERC-4626 vault's underlying + asset address. + + ``` + erc4626 payload := (underlyingAssetContract string) + ``` + + Presence of the row implies the contract was observed as a vault at + `persistHeight`; absence means either not-a-vault or never observed. + The asset address is captured by the contractInfo API path on a + successful Multicall3 probe of `asset()` and `totalAssets()`; indexing + itself does not mark vaults. + + Future protocol IDs append the next free byte. `0x00` is reserved. + - **functionSignatures** (used only by Ethereum type coins) Database of four byte signatures downloaded from https://www.4byte.directory/. diff --git a/docs/testing.md b/docs/testing.md index aa6de9ee7d..66a1e292f4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,9 @@ distinguish which tests should be executed. There are several ways to run tests: * `make test` – run unit tests only (note that `make deb*` and `make all*` commands always run also *test* target) -* `make test-integration` – run integration tests only +* `make test-connectivity` – run connectivity checks only +* `make test-integration` – run RPC and sync integration tests only +* `make test-e2e` – run Blockbook API end-to-end tests only * `make test-all` – run all tests above You can use Go's flag *-run* to filter which tests should be executed. Use *ARGS* variable, e.g. @@ -16,7 +18,7 @@ You can use Go's flag *-run* to filter which tests should be executed. Use *ARGS ## Unit tests -Unit test file must start with constraint `// +build unittest` followed by blank line (constraints are described +Unit test file must start with constraint `//go:build unittest` followed by blank line (constraints are described [here](https://golang.org/pkg/go/build/#hdr-Build_Constraints)). Every coin implementation must have unit tests. At least for parser. Usual test suite define real transaction data @@ -29,20 +31,27 @@ and try pack and unpack them. Specialities of particular coin are tested too. Se ## Integration tests Integration tests test interface between either Blockbook's components or back-end services. Integration tests are -located in *tests* directory and every test suite has its own package. Because RPC and synchronization are crucial -components of Blockbook, it is mandatory that coin implementations have these integration tests defined. They are -implemented in packages `blockbook/tests/rpc` and `blockbook/tests/sync` and both of them are declarative. For each coin +located in *tests* directory and every test suite has its own package. Because RPC, synchronization and Blockbook API +surface are crucial components of Blockbook, it is mandatory that coin implementations have these integration tests +defined. They are implemented in packages `blockbook/tests/rpc`, `blockbook/tests/sync` and `blockbook/tests/api`, and +all of them are declarative. For each coin there are test definition that enables particular tests of test suite and *testdata* file that contains test fixtures. -Not every coin implementation support full set of back-end API so it is necessary define which tests of test suite +Not every coin implementation supports full set of back-end API so it is necessary to define which tests of test suite are able to run. That is done in test definition file *blockbook/tests/tests.json*. Configuration is hierarchical and test implementations call each level as separate subtest. Go's *test* command allows filter tests to run by `-run` flag. It perfectly fits with layered test definitions. For example, you can: +* run connectivity tests for all coins – `make test-connectivity` +* run connectivity tests for a single coin – `make test-connectivity ARGS="-run=TestIntegration/bitcoin=main/connectivity"` * run tests for single coin – `make test-integration ARGS="-run=TestIntegration/bitcoin/"` * run single test suite – `make test-integration ARGS="-run=TestIntegration//sync/"` * run single test – `make test-integration ARGS="-run=TestIntegration//sync/HandleFork"` * run tests for set of coins – `make test-integration ARGS="-run='TestIntegration/(bcash|bgold|bitcoin|dash|dogecoin|litecoin|snowgem|vertcoin|zcash|zelcash)/'"` +* run e2e tests for all coins – `make test-e2e` +* run e2e tests for single coin – `make test-e2e ARGS="-run=TestIntegration/bitcoin=main/api"` + +Integration targets run with `go test -timeout 30m` inside Docker tooling. Test fixtures are defined in *testdata* directory in package of particular test suite. They are separate JSON files named by coin. File schemes are very similar with verbose results of CLI tools and are described below. Integration tests @@ -52,10 +61,61 @@ For simplicity, URLs and credentials of back-end services, where are tests going from *blockbook/configs/coins*, the same place from where are production configuration files generated. There are general URLs that link to *localhost*. If you need run tests against remote servers, there are few options how to do it: +* tests use `BB_BUILD_ENV=dev` +* set `BB_DEV_RPC_URL_HTTP_` to override `rpc_url_template` during template generation (forwarded into Docker by the root `Makefile`) +* set `BB_DEV_RPC_URL_WS_` to override `rpc_url_ws_template` for WebSocket subscriptions when needed +* set `BB_DEV_MQ_URL_` to override `message_queue_binding_template` when tests need a non-local MQ binding * temporarily change config * SSH tunneling – `ssh -nNT -L 8030:localhost:8030 remote-server` * HTTP proxy +### Connectivity integration tests + +Connectivity tests are lightweight checks that verify back-end availability before running heavier RPC or sync suites. +They are configured per coin in *blockbook/tests/tests.json* using the `connectivity` list: + +* `["http"]` – verify HTTP RPC connectivity +* `["http", "ws"]` – verify HTTP RPC plus WebSocket subscription connectivity + +Example: + +``` +"bitcoin": { + "connectivity": ["http"] +}, +"ethereum": { + "connectivity": ["http", "ws"] +} +``` + +HTTP connectivity verifies both back-end and Blockbook accessibility: + +* back-end: UTXO chains call `getblockchaininfo`, EVM chains call `web3_clientVersion` +* Blockbook: calls `GET /api/status` (resolved from `BB_DEV_API_URL_HTTP_` or local `ports.blockbook_public`) + +WebSocket connectivity also verifies both surfaces: + +* back-end: validates `web3_clientVersion` and opens a `newHeads` subscription +* Blockbook: connects to `/websocket` (or `BB_DEV_API_URL_WS_`) and calls `getInfo` + +### Blockbook API end-to-end tests + +Public Blockbook API checks are implemented in package `blockbook/tests/api` and configured per coin by the `api` list +in *blockbook/tests/tests.json*. +Use `make test-e2e` to run this suite only. + +Phase 1 covers smoke checks for: + +* HTTP: `Status`, `GetBlockIndex`, `GetBlockByHeight`, `GetBlock`, `GetTransaction`, `GetTransactionSpecific`, `GetAddress`, `GetAddressTxids`, `GetAddressTxs`, `GetUtxo`, `GetUtxoConfirmedFilter` +* WebSocket: `WsGetInfo`, `WsGetBlockHash`, `WsGetTransaction`, `WsGetAccountInfo`, `WsGetAccountUtxo`, `WsPing` + +Endpoint resolution uses the test name from `coin.test_name` in `configs/coins/.json` +(or the config file name when `test_name` is omitted) and this precedence: + +1. `BB_DEV_API_URL_HTTP_` and `BB_DEV_API_URL_WS_` +2. localhost fallback from coin config port `ports.blockbook_public` +3. when WS env var is missing, WS URL is derived from HTTP URL with `/websocket` path + ### Synchronization integration tests Synchronization is crucial part of Blockbook and these tests test whether it is doing well. They sync few blocks from diff --git a/docs/tron-config.md b/docs/tron-config.md new file mode 100644 index 0000000000..8cfd24546c --- /dev/null +++ b/docs/tron-config.md @@ -0,0 +1,67 @@ +# Tron Configuration + +This document describes the Tron-specific backend configuration used by Blockbook. + +## Overview + +Tron uses three backend HTTP surfaces: + +* JSON-RPC, typically exposed on `8545/jsonrpc` +* Full node HTTP API, typically exposed on `8090` +* Solidity node HTTP API, typically exposed on `8091` + +Blockbook uses the JSON-RPC endpoint for Ethereum-compatible RPC calls and the two Tron HTTP APIs for Tron-specific lookups such as `/wallet/...` and `/walletsolidity/...`. + +## Config Fields + +The primary Tron backend endpoint is configured in `ipc.rpc_url_template` in: + +* [`configs/coins/tron.json`](/configs/coins/tron.json) +* [`configs/coins/tron_testnet_nile.json`](/configs/coins/tron_testnet_nile.json) + +Example: + +```json +"ipc": { + "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}/jsonrpc" +} +``` + +Optional Tron-specific HTTP endpoints can also be defined via `blockbook.block_chain.additional_params`: + +* `tron_fullnode_http_url_template` + Used for full node HTTP endpoints such as `/wallet/getblockbynum` and `/wallet/gettransactioninfobyid`. +* `tron_solidity_http_url_template` + Used for solidity node HTTP endpoints such as `/walletsolidity/getblockbynum` and `/walletsolidity/gettransactioninfobyid`. + +## Fallback Behavior + +If `tron_fullnode_http_url_template` and `tron_solidity_http_url_template` are omitted, Blockbook derives them from `rpc_url`. + +It keeps the same scheme and host and uses: + +* port `8090` for the full node HTTP API +* port `8091` for the solidity node HTTP API + +Example: + +* `rpc_url = http://tron-node.example:8545/jsonrpc` +* full node HTTP URL = `http://tron-node.example:8090` +* solidity node HTTP URL = `http://tron-node.example:8091` + +This makes the common deployment case work with a single override: + +## When To Set Explicit Tron HTTP URLs + +Most setups should rely on the fallback. + +Set explicit `tron_fullnode_http_url_template` and `tron_solidity_http_url_template` only when: + +* the full node and solidity APIs are exposed on different hosts +* the scheme differs from the JSON-RPC endpoint +* the deployment uses non-standard ports + +## Related Docs + +* [Config](/docs/config.md) +* [Testing](/docs/testing.md) diff --git a/fiat/bootstrap_state.go b/fiat/bootstrap_state.go new file mode 100644 index 0000000000..2290a506e9 --- /dev/null +++ b/fiat/bootstrap_state.go @@ -0,0 +1,66 @@ +package fiat + +import "github.com/trezor/blockbook/db" + +const maxHistoricalBootstrapAttempts = 3 + +// historicalBootstrapInProgress returns whether historical fiat bootstrap is in progress. +// stateFound indicates if the persisted bootstrap marker already exists. +func historicalBootstrapInProgress(database *db.RocksDB) (inProgress bool, stateFound bool, err error) { + bootstrapComplete, bootstrapStateFound, err := database.FiatRatesGetHistoricalBootstrapComplete() + if err != nil { + return false, false, err + } + if bootstrapStateFound { + return !bootstrapComplete, true, nil + } + lastFiatTicker, err := database.FiatRatesFindLastTicker("", "") + if err != nil { + return false, false, err + } + return lastFiatTicker == nil, false, nil +} + +// ensureHistoricalBootstrapState ensures persisted bootstrap marker exists and returns current in-progress state. +func ensureHistoricalBootstrapState(database *db.RocksDB) (inProgress bool, err error) { + inProgress, stateFound, err := historicalBootstrapInProgress(database) + if err != nil { + return false, err + } + if !stateFound { + if err := database.FiatRatesSetHistoricalBootstrapComplete(!inProgress); err != nil { + return false, err + } + if err := database.FiatRatesSetHistoricalBootstrapAttempts(0); err != nil { + return false, err + } + } + return inProgress, nil +} + +// registerHistoricalBootstrapAttemptFailure increases failed bootstrap attempt count. +// Once the limit is reached, bootstrap is finalized to stop further bootstrap retries. +func registerHistoricalBootstrapAttemptFailure(database *db.RocksDB) (attempts int, exhausted bool, err error) { + attempts, _, err = database.FiatRatesGetHistoricalBootstrapAttempts() + if err != nil { + return 0, false, err + } + attempts++ + if err := database.FiatRatesSetHistoricalBootstrapAttempts(attempts); err != nil { + return 0, false, err + } + if attempts < maxHistoricalBootstrapAttempts { + return attempts, false, nil + } + if err := database.FiatRatesSetHistoricalBootstrapComplete(true); err != nil { + return attempts, false, err + } + if err := database.FiatRatesSetHistoricalBootstrapAttempts(0); err != nil { + return attempts, false, err + } + return attempts, true, nil +} + +func resetHistoricalBootstrapAttempts(database *db.RocksDB) error { + return database.FiatRatesSetHistoricalBootstrapAttempts(0) +} diff --git a/fiat/coingecko.go b/fiat/coingecko.go index 1f5087c392..4dc732477e 100644 --- a/fiat/coingecko.go +++ b/fiat/coingecko.go @@ -2,10 +2,12 @@ package fiat import ( "encoding/json" + "errors" "fmt" - "io/ioutil" + "io" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -16,9 +18,49 @@ import ( "github.com/trezor/blockbook/db" ) +const ( + DefaultHTTPTimeout = 15 * time.Second + DefaultThrottleDelayMs = 100 // 100 ms delay between requests + coingeckoHistoryDaysLimit = 365 + coingeckoRangeHistorical = "historical" + coingeckoRangeTip = "tip" + coingeckoRangeCapped = "capped" + coingeckoBootstrapURL = "https://cdn.trezor.io/dynamic/coingecko/api/v3" + coingeckoProURL = "https://pro-api.coingecko.com/api/v3" + coingeckoFreeURL = "https://api.coingecko.com/api/v3" + coingeckoPlanFree = "free" + coingeckoPlanPro = "pro" + coingeckoAPIKeyEnv = "COINGECKO_API_KEY" + coingeckoAPIKeyEnvSuffix = "_" + coingeckoAPIKeyEnv +) + +var coingeckoThrottleRetryBackoff = []time.Duration{ + 1 * time.Minute, + 2 * time.Minute, + 3 * time.Minute, + 4 * time.Minute, +} + +var errCoingeckoHistoricalTokenUpdateInProgress = errors.New("coingecko historical token update already in progress") + +type coingeckoThrottleRetriesExhaustedError struct { + cause error + retries int +} + +func (e *coingeckoThrottleRetriesExhaustedError) Error() string { + return fmt.Sprintf("coingecko throttle retries exhausted after %d retries: %v", e.retries, e.cause) +} + +func (e *coingeckoThrottleRetriesExhaustedError) Unwrap() error { + return e.cause +} + // Coingecko is a structure that implements RatesDownloaderInterface type Coingecko struct { - url string + tipURL string + bootstrapURL string + apiKey string coin string platformIdentifier string platformVsCurrency string @@ -30,6 +72,8 @@ type Coingecko struct { db *db.RocksDB updatingCurrent bool updatingTokens bool + metrics *common.Metrics + plan string } // simpleSupportedVSCurrencies https://api.coingecko.com/api/v3/simple/supported_vs_currencies @@ -50,35 +94,119 @@ type marketChartPrices struct { Prices []marketPoint `json:"prices"` } +func coinGeckoScopedAPIKeyEnvNames(network string, coinShortcut string) []string { + prefixes := []string{network, coinShortcut} + seen := make(map[string]struct{}, len(prefixes)) + envNames := make([]string, 0, len(prefixes)) + for _, prefix := range prefixes { + normalized := strings.ToUpper(strings.TrimSpace(prefix)) + if normalized == "" { + continue + } + envName := normalized + coingeckoAPIKeyEnvSuffix + if _, exists := seen[envName]; exists { + continue + } + seen[envName] = struct{}{} + envNames = append(envNames, envName) + } + return envNames +} + +func resolveCoinGeckoAPIKey(network string, coinShortcut string) string { + // Preserve network-prefixed variables for backward compatibility, but also + // support documented _COINGECKO_API_KEY as a fallback. + for _, envName := range coinGeckoScopedAPIKeyEnvNames(network, coinShortcut) { + if apiKey := strings.TrimSpace(os.Getenv(envName)); apiKey != "" { + return apiKey + } + } + return strings.TrimSpace(os.Getenv(coingeckoAPIKeyEnv)) +} + +func validateCoinGeckoAPIKeyEnv(network string, coinShortcut string) error { + for _, envName := range coinGeckoScopedAPIKeyEnvNames(network, coinShortcut) { + if value, exists := os.LookupEnv(envName); exists && strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is set but empty", envName) + } + } + + if value, exists := os.LookupEnv(coingeckoAPIKeyEnv); exists && strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is set but empty", coingeckoAPIKeyEnv) + } + + return nil +} + +func normalizeCoinGeckoPlan(plan string) string { + normalizedPlan := strings.ToLower(strings.TrimSpace(plan)) + if normalizedPlan == coingeckoPlanPro { + return coingeckoPlanPro + } + return coingeckoPlanFree +} + +func coingeckoPlanRequiresAPIKey(plan string) bool { + return normalizeCoinGeckoPlan(plan) == coingeckoPlanPro +} + +func resolveCoinGeckoBootstrapURL(bootstrapURL string) string { + trimmedURL := strings.TrimSpace(bootstrapURL) + if trimmedURL != "" { + return trimmedURL + } + return coingeckoBootstrapURL +} + // NewCoinGeckoDownloader creates a coingecko structure that implements the RatesDownloaderInterface -func NewCoinGeckoDownloader(db *db.RocksDB, url string, coin string, platformIdentifier string, platformVsCurrency string, allowedVsCurrencies string, timeFormat string, throttleDown bool) RatesDownloaderInterface { - var throttlingDelayMs int +func NewCoinGeckoDownloader(db *db.RocksDB, network string, coinShortcut string, bootstrapURL string, coin string, platformIdentifier string, platformVsCurrency string, allowedVsCurrencies string, timeFormat string, plan string, metrics *common.Metrics, throttleDown bool) RatesDownloaderInterface { + throttlingDelayMs := 0 // No delay by default if throttleDown { - throttlingDelayMs = 100 + throttlingDelayMs = DefaultThrottleDelayMs } - httpTimeout := 15 * time.Second - allowedVsCurrenciesMap := make(map[string]struct{}) - if len(allowedVsCurrencies) > 0 { - for _, c := range strings.Split(strings.ToLower(allowedVsCurrencies), ",") { - allowedVsCurrenciesMap[c] = struct{}{} - } + + allowedVsCurrenciesMap := getAllowedVsCurrenciesMap(allowedVsCurrencies) + + apiKey := resolveCoinGeckoAPIKey(network, coinShortcut) + normalizedPlan := normalizeCoinGeckoPlan(plan) + resolvedBootstrapURL := resolveCoinGeckoBootstrapURL(bootstrapURL) + tipURL := coingeckoFreeURL + if normalizedPlan == coingeckoPlanPro { + tipURL = coingeckoProURL } + glog.Infof("Coingecko downloader bootstrap url %s, tip url %s", resolvedBootstrapURL, tipURL) + return &Coingecko{ - url: url, + tipURL: tipURL, + bootstrapURL: resolvedBootstrapURL, + apiKey: apiKey, coin: coin, platformIdentifier: platformIdentifier, platformVsCurrency: platformVsCurrency, allowedVsCurrencies: allowedVsCurrenciesMap, - httpTimeout: httpTimeout, + httpTimeout: DefaultHTTPTimeout, timeFormat: timeFormat, httpClient: &http.Client{ - Timeout: httpTimeout, + Timeout: DefaultHTTPTimeout, }, db: db, throttlingDelay: time.Duration(throttlingDelayMs) * time.Millisecond, + metrics: metrics, + plan: normalizedPlan, } } +// getAllowedVsCurrenciesMap returns a map of allowed vs currencies +func getAllowedVsCurrenciesMap(currenciesString string) map[string]struct{} { + allowedVsCurrenciesMap := make(map[string]struct{}) + if len(currenciesString) > 0 { + for _, c := range strings.Split(strings.ToLower(currenciesString), ",") { + allowedVsCurrenciesMap[c] = struct{}{} + } + } + return allowedVsCurrenciesMap +} + // doReq HTTP client func doReq(req *http.Request, client *http.Client) ([]byte, error) { resp, err := client.Do(req) @@ -86,7 +214,7 @@ func doReq(req *http.Request, client *http.Client) ([]byte, error) { return nil, err } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } @@ -96,33 +224,77 @@ func doReq(req *http.Request, client *http.Client) ([]byte, error) { return body, nil } -// makeReq HTTP request helper - will retry the call after 1 minute on error -func (cg *Coingecko) makeReq(url string) ([]byte, error) { - for { +func isCoingeckoThrottleError(err error) bool { + if err == nil { + return false + } + lowerError := strings.ToLower(err.Error()) + return err.Error() == "error code: 1015" || + strings.Contains(lowerError, "exceeded the rate limit") || + strings.Contains(lowerError, "throttled") +} + +func isCoingeckoThrottleRetriesExhaustedError(err error) bool { + var exhaustedErr *coingeckoThrottleRetriesExhaustedError + return errors.As(err, &exhaustedErr) +} + +func isCoingeckoHistoricalTokenUpdateInProgressError(err error) bool { + return errors.Is(err, errCoingeckoHistoricalTokenUpdateInProgress) +} + +// makeReq HTTP request helper with bounded retries for throttling errors. +func (cg *Coingecko) makeReq(url string, endpoint string, plan string) ([]byte, error) { + for attempt := 0; ; attempt++ { // glog.Infof("Coingecko makeReq %v", url) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") + if cg.apiKey != "" { + // Use the paid-tier header by default when an API key is provided. + if plan == "free" { + req.Header.Set("x-cg-demo-api-key", cg.apiKey) + } else { + req.Header.Set("x-cg-pro-api-key", cg.apiKey) + } + } resp, err := doReq(req, cg.httpClient) if err == nil { + if cg.metrics != nil { + cg.metrics.CoingeckoRequests.With(common.Labels{"endpoint": endpoint, "status": "success"}).Inc() + } return resp, err } - if err.Error() != "error code: 1015" && !strings.Contains(strings.ToLower(err.Error()), "exceeded the rate limit") { + if !isCoingeckoThrottleError(err) { + if cg.metrics != nil { + cg.metrics.CoingeckoRequests.With(common.Labels{"endpoint": endpoint, "status": "error"}).Inc() + } glog.Errorf("Coingecko makeReq %v error %v", url, err) return nil, err } - // if there is a throttling error, wait 60 seconds and retry - glog.Warningf("Coingecko makeReq %v error %v, will retry in 60 seconds", url, err) - time.Sleep(60 * time.Second) + if cg.metrics != nil { + cg.metrics.CoingeckoRequests.With(common.Labels{"endpoint": endpoint, "status": "throttle"}).Inc() + } + if attempt >= len(coingeckoThrottleRetryBackoff) { + if cg.metrics != nil { + cg.metrics.CoingeckoRequests.With(common.Labels{"endpoint": endpoint, "status": "error"}).Inc() + } + glog.Warningf("Coingecko makeReq %v error %v, retries exhausted after %d retries", url, err, len(coingeckoThrottleRetryBackoff)) + return nil, &coingeckoThrottleRetriesExhaustedError{ + cause: err, + retries: len(coingeckoThrottleRetryBackoff), + } + } + time.Sleep(coingeckoThrottleRetryBackoff[attempt]) } } // SimpleSupportedVSCurrencies /simple/supported_vs_currencies -func (cg *Coingecko) simpleSupportedVSCurrencies() (simpleSupportedVSCurrencies, error) { - url := cg.url + "/simple/supported_vs_currencies" - resp, err := cg.makeReq(url) +func (cg *Coingecko) simpleSupportedVSCurrenciesAt(baseURL string) (simpleSupportedVSCurrencies, error) { + url := baseURL + "/simple/supported_vs_currencies" + resp, err := cg.makeReq(url, "supported_vs_currencies", cg.plan) if err != nil { return nil, err } @@ -152,8 +324,8 @@ func (cg *Coingecko) simplePrice(ids []string, vsCurrencies []string) (*map[stri params.Add("ids", idsParam) params.Add("vs_currencies", vsCurrenciesParam) - url := fmt.Sprintf("%s/simple/price?%s", cg.url, params.Encode()) - resp, err := cg.makeReq(url) + url := fmt.Sprintf("%s/simple/price?%s", cg.tipURL, params.Encode()) + resp, err := cg.makeReq(url, "simple/price", cg.plan) if err != nil { return nil, err } @@ -168,15 +340,15 @@ func (cg *Coingecko) simplePrice(ids []string, vsCurrencies []string) (*map[stri } // CoinsList /coins/list -func (cg *Coingecko) coinsList() (coinList, error) { +func (cg *Coingecko) coinsListAt(baseURL string) (coinList, error) { params := url.Values{} platform := "false" if cg.platformIdentifier != "" { platform = "true" } params.Add("include_platform", platform) - url := fmt.Sprintf("%s/coins/list?%s", cg.url, params.Encode()) - resp, err := cg.makeReq(url) + url := fmt.Sprintf("%s/coins/list?%s", baseURL, params.Encode()) + resp, err := cg.makeReq(url, "coins/list", cg.plan) if err != nil { return nil, err } @@ -190,18 +362,20 @@ func (cg *Coingecko) coinsList() (coinList, error) { } // coinMarketChart /coins/{id}/market_chart?vs_currency={usd, eur, jpy, etc.}&days={1,14,30,max} -func (cg *Coingecko) coinMarketChart(id string, vs_currency string, days string) (*marketChartPrices, error) { +func (cg *Coingecko) coinMarketChartAt(baseURL string, id string, vs_currency string, days string, daily bool) (*marketChartPrices, error) { if len(id) == 0 || len(vs_currency) == 0 || len(days) == 0 { return nil, fmt.Errorf("id, vs_currency, and days is required") } params := url.Values{} - params.Add("interval", "daily") + if daily { + params.Add("interval", "daily") + } params.Add("vs_currency", vs_currency) params.Add("days", days) - url := fmt.Sprintf("%s/coins/%s/market_chart?%s", cg.url, id, params.Encode()) - resp, err := cg.makeReq(url) + url := fmt.Sprintf("%s/coins/%s/market_chart?%s", baseURL, id, params.Encode()) + resp, err := cg.makeReq(url, "market_chart", cg.plan) if err != nil { return nil, err } @@ -219,11 +393,11 @@ var vsCurrencies []string var platformIds []string var platformIdsToTokens map[string]string -func (cg *Coingecko) platformIds() error { +func (cg *Coingecko) platformIdsAt(baseURL string) error { if cg.platformIdentifier == "" { return nil } - cl, err := cg.coinsList() + cl, err := cg.coinsListAt(baseURL) if err != nil { return err } @@ -241,6 +415,7 @@ func (cg *Coingecko) platformIds() error { return nil } +// CurrentTickers returns the latest exchange rates func (cg *Coingecko) CurrentTickers() (*common.CurrencyRatesTicker, error) { cg.updatingCurrent = true defer func() { cg.updatingCurrent = false }() @@ -248,7 +423,7 @@ func (cg *Coingecko) CurrentTickers() (*common.CurrencyRatesTicker, error) { var newTickers = common.CurrencyRatesTicker{} if vsCurrencies == nil { - vs, err := cg.simpleSupportedVSCurrencies() + vs, err := cg.simpleSupportedVSCurrenciesAt(cg.tipURL) if err != nil { return nil, err } @@ -265,7 +440,7 @@ func (cg *Coingecko) CurrentTickers() (*common.CurrencyRatesTicker, error) { if cg.platformIdentifier != "" && cg.platformVsCurrency != "" { if platformIdsToTokens == nil { - err = cg.platformIds() + err = cg.platformIdsAt(cg.tipURL) if err != nil { return nil, err } @@ -296,23 +471,101 @@ func (cg *Coingecko) CurrentTickers() (*common.CurrencyRatesTicker, error) { return &newTickers, nil } -func (cg *Coingecko) getHistoricalTicker(tickersToUpdate map[uint]*common.CurrencyRatesTicker, coinId string, vsCurrency string, token string) (bool, error) { - lastTicker, err := cg.db.FiatRatesFindLastTicker(vsCurrency, token) +func (cg *Coingecko) getHighGranularityTickers(days string) (*[]common.CurrencyRatesTicker, error) { + mc, err := cg.coinMarketChartAt(cg.tipURL, cg.coin, highGranularityVsCurrency, days, false) if err != nil { - return false, err + return nil, err + } + if len(mc.Prices) < 2 { + return nil, fmt.Errorf("not enough price points: %d", len(mc.Prices)) + } + // ignore the last point, it is not in granularity + tickers := make([]common.CurrencyRatesTicker, len(mc.Prices)-1) + for i, p := range mc.Prices[:len(mc.Prices)-1] { + var timestamp uint + timestamp = uint(p[0]) + if timestamp > 100000000000 { + // convert timestamp from milliseconds to seconds + timestamp /= 1000 + } + rate := float32(p[1]) + u := time.Unix(int64(timestamp), 0).UTC() + ticker := common.CurrencyRatesTicker{ + Timestamp: u, + Rates: make(map[string]float32), + } + ticker.Rates[highGranularityVsCurrency] = rate + tickers[i] = ticker } - var days string + return &tickers, nil +} + +// HourlyTickers returns the array of the exchange rates in hourly granularity +func (cg *Coingecko) HourlyTickers() (*[]common.CurrencyRatesTicker, error) { + return cg.getHighGranularityTickers("90") +} + +// FiveMinutesTickers returns the array of the exchange rates in five minutes granularity +func (cg *Coingecko) FiveMinutesTickers() (*[]common.CurrencyRatesTicker, error) { + return cg.getHighGranularityTickers("1") +} + +func coingeckoBootstrapURLAllowed(bootstrapURL string) bool { + return strings.TrimSpace(bootstrapURL) != "" +} + +func coingeckoBootstrapPreconditionError() error { + return fmt.Errorf("coingecko bootstrap is not possible: missing bootstrap URL") +} + +func (cg *Coingecko) canUseBootstrapMax() bool { + return coingeckoBootstrapURLAllowed(cg.bootstrapURL) +} + +func (cg *Coingecko) historicalSyncURL(bootstrapInProgress bool) string { + if bootstrapInProgress { + return cg.bootstrapURL + } + return cg.tipURL +} + +func (cg *Coingecko) resolveHistoricalDays(lastTicker *common.CurrencyRatesTicker, allowMax bool) (string, bool, string) { if lastTicker == nil { - days = "max" - } else { - diff := time.Since(lastTicker.Timestamp) - d := int(diff / (24 * 3600 * 1000000000)) - if d == 0 { // nothing to do, the last ticker exist - return false, nil + if allowMax { + // Bootstrap mode only: for the very first full historical population use full range. + return "max", true, coingeckoRangeHistorical } - days = strconv.Itoa(d) + // Non-bootstrap mode: first-seen token/vsCurrency must stay within free-plan-compatible window. + return strconv.Itoa(coingeckoHistoryDaysLimit), true, coingeckoRangeCapped + } + diff := time.Since(lastTicker.Timestamp) + d := int(diff / (24 * 3600 * 1000000000)) + if d == 0 { // nothing to do, the last ticker exist + return "", false, "" + } + if d > coingeckoHistoryDaysLimit { + // This happens when the latest stored ticker for a given series is older than 365 days + // (for example after downtime, stale/partial historical data, or a newly tracked series + // after bootstrap). Outside bootstrap we intentionally cap backfill to 365 days. + d = coingeckoHistoryDaysLimit + return strconv.Itoa(d), true, coingeckoRangeCapped + } + return strconv.Itoa(d), true, coingeckoRangeTip +} + +func (cg *Coingecko) getHistoricalTicker(baseURL string, tickersToUpdate map[uint]*common.CurrencyRatesTicker, coinId string, vsCurrency string, token string, allowMax bool) (bool, error) { + lastTicker, err := cg.db.FiatRatesFindLastTicker(vsCurrency, token) + if err != nil { + return false, err + } + days, shouldRequest, rangeKind := cg.resolveHistoricalDays(lastTicker, allowMax) + if !shouldRequest { + return false, nil } - mc, err := cg.coinMarketChart(coinId, vsCurrency, days) + if cg.metrics != nil { + cg.metrics.CoingeckoRangeRequests.With(common.Labels{"range": rangeKind}).Inc() + } + mc, err := cg.coinMarketChartAt(baseURL, coinId, vsCurrency, days, true) if err != nil { return false, err } @@ -390,19 +643,38 @@ func (cg *Coingecko) throttleHistoricalDownload() { // UpdateHistoricalTickers gets historical tickers for the main crypto currency func (cg *Coingecko) UpdateHistoricalTickers() error { tickersToUpdate := make(map[uint]*common.CurrencyRatesTicker) + allowMax := false + bootstrapInProgress, _, err := historicalBootstrapInProgress(cg.db) + if err != nil { + return err + } + historicalSyncURL := cg.historicalSyncURL(bootstrapInProgress) + if bootstrapInProgress { + if !cg.canUseBootstrapMax() { + return coingeckoBootstrapPreconditionError() + } + allowMax = true + } // reload vs_currencies - vs, err := cg.simpleSupportedVSCurrencies() + vs, err := cg.simpleSupportedVSCurrenciesAt(historicalSyncURL) if err != nil { return err } vsCurrencies = vs + hadFailures := false + var throttleErr error for _, currency := range vsCurrencies { // get historical rates for each currency var err error var req bool - if req, err = cg.getHistoricalTicker(tickersToUpdate, cg.coin, currency, ""); err != nil { + if req, err = cg.getHistoricalTicker(historicalSyncURL, tickersToUpdate, cg.coin, currency, "", allowMax); err != nil { + hadFailures = true + if isCoingeckoThrottleRetriesExhaustedError(err) { + throttleErr = err + break + } // report error and continue, Coingecko may return error like "Could not find coin with the given id" // the rates will be updated next run glog.Errorf("getHistoricalTicker %s-%s %v", cg.coin, currency, err) @@ -411,22 +683,44 @@ func (cg *Coingecko) UpdateHistoricalTickers() error { cg.throttleHistoricalDownload() } } - - return cg.storeTickers(tickersToUpdate) + if err := cg.storeTickers(tickersToUpdate); err != nil { + return err + } + if throttleErr != nil { + return throttleErr + } + if bootstrapInProgress && hadFailures { + return fmt.Errorf("coingecko historical bootstrap incomplete: one or more currency updates failed") + } + return nil } // UpdateHistoricalTokenTickers gets historical tickers for the tokens func (cg *Coingecko) UpdateHistoricalTokenTickers() error { if cg.updatingTokens { - return nil + return errCoingeckoHistoricalTokenUpdateInProgress } cg.updatingTokens = true defer func() { cg.updatingTokens = false }() tickersToUpdate := make(map[uint]*common.CurrencyRatesTicker) + var throttleErr error if cg.platformIdentifier != "" && cg.platformVsCurrency != "" { + allowMax := false + bootstrapInProgress, _, err := historicalBootstrapInProgress(cg.db) + if err != nil { + return err + } + historicalSyncURL := cg.historicalSyncURL(bootstrapInProgress) + if bootstrapInProgress { + if !cg.canUseBootstrapMax() { + return coingeckoBootstrapPreconditionError() + } + allowMax = true + } + // reload platform ids - if err := cg.platformIds(); err != nil { + if err := cg.platformIdsAt(historicalSyncURL); err != nil { return err } glog.Infof("Coingecko returned %d %s tokens ", len(platformIds), cg.coin) @@ -435,7 +729,11 @@ func (cg *Coingecko) UpdateHistoricalTokenTickers() error { for tokenId, token := range platformIdsToTokens { var err error var req bool - if req, err = cg.getHistoricalTicker(tickersToUpdate, tokenId, cg.platformVsCurrency, token); err != nil { + if req, err = cg.getHistoricalTicker(historicalSyncURL, tickersToUpdate, tokenId, cg.platformVsCurrency, token, allowMax); err != nil { + if isCoingeckoThrottleRetriesExhaustedError(err) { + throttleErr = err + break + } // report error and continue, Coingecko may return error like "Could not find coin with the given id" // the rates will be updated next run glog.Errorf("getHistoricalTicker %s-%s %v", tokenId, cg.platformVsCurrency, err) @@ -455,5 +753,11 @@ func (cg *Coingecko) UpdateHistoricalTokenTickers() error { } } - return cg.storeTickers(tickersToUpdate) + if err := cg.storeTickers(tickersToUpdate); err != nil { + return err + } + if throttleErr != nil { + return throttleErr + } + return nil } diff --git a/fiat/coingecko_test.go b/fiat/coingecko_test.go new file mode 100644 index 0000000000..857388f9fd --- /dev/null +++ b/fiat/coingecko_test.go @@ -0,0 +1,544 @@ +//go:build unittest + +package fiat + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/trezor/blockbook/common" +) + +func testCoinGeckoScopedAPIKeyEnvName(prefix string) string { + return strings.ToUpper(strings.TrimSpace(prefix)) + coingeckoAPIKeyEnvSuffix +} + +func TestResolveCoinGeckoAPIKey(t *testing.T) { + t.Run("prefers network-specific key", func(t *testing.T) { + t.Setenv(testCoinGeckoScopedAPIKeyEnvName("OP"), "network-key") + t.Setenv(testCoinGeckoScopedAPIKeyEnvName("ETH"), "shortcut-key") + t.Setenv(coingeckoAPIKeyEnv, "global-key") + + got := resolveCoinGeckoAPIKey("op", "eth") + if got != "network-key" { + t.Fatalf("unexpected api key: got %q, want %q", got, "network-key") + } + }) + + t.Run("falls back to shortcut key when network is unrecognized", func(t *testing.T) { + t.Setenv(testCoinGeckoScopedAPIKeyEnvName("ETH"), "shortcut-key") + t.Setenv(coingeckoAPIKeyEnv, "global-key") + + got := resolveCoinGeckoAPIKey("unrecognized", "eth") + if got != "shortcut-key" { + t.Fatalf("unexpected api key: got %q, want %q", got, "shortcut-key") + } + }) + + t.Run("falls back to global key when prefixed keys are missing", func(t *testing.T) { + t.Setenv(coingeckoAPIKeyEnv, "global-key") + + got := resolveCoinGeckoAPIKey("unrecognized", "unknown") + if got != "global-key" { + t.Fatalf("unexpected api key: got %q, want %q", got, "global-key") + } + }) +} + +func TestValidateCoinGeckoAPIKeyEnv(t *testing.T) { + t.Run("network key set empty returns error", func(t *testing.T) { + networkEnvName := testCoinGeckoScopedAPIKeyEnvName("OP") + t.Setenv(networkEnvName, "") + err := validateCoinGeckoAPIKeyEnv("op", "eth") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), networkEnvName) { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("shortcut key set empty returns error when network key unset", func(t *testing.T) { + shortcutEnvName := testCoinGeckoScopedAPIKeyEnvName("ETH") + t.Setenv(shortcutEnvName, " ") + err := validateCoinGeckoAPIKeyEnv("op", "eth") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), shortcutEnvName) { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("global key set empty returns error", func(t *testing.T) { + t.Setenv(coingeckoAPIKeyEnv, "") + err := validateCoinGeckoAPIKeyEnv("op", "eth") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), coingeckoAPIKeyEnv) { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("unset keys are allowed", func(t *testing.T) { + if err := validateCoinGeckoAPIKeyEnv("op", "eth"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("set non-empty keys are allowed", func(t *testing.T) { + t.Setenv(testCoinGeckoScopedAPIKeyEnvName("OP"), "network-key") + t.Setenv(testCoinGeckoScopedAPIKeyEnvName("ETH"), "shortcut-key") + t.Setenv(coingeckoAPIKeyEnv, "global-key") + if err := validateCoinGeckoAPIKeyEnv("op", "eth"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestCanUseBootstrapMax(t *testing.T) { + tests := []struct { + name string + cg Coingecko + expectAllow bool + }{ + { + name: "bootstrap url allows max", + cg: Coingecko{bootstrapURL: "https://cdn.trezor.io/dynamic/coingecko/api/v3"}, + expectAllow: true, + }, + { + name: "missing bootstrap url does not allow max", + cg: Coingecko{}, + expectAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cg.canUseBootstrapMax(); got != tt.expectAllow { + t.Fatalf("unexpected bootstrap-max eligibility: got %v, want %v", got, tt.expectAllow) + } + }) + } +} + +func TestNormalizeCoinGeckoPlan(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "pro", in: "pro", want: coingeckoPlanPro}, + {name: "pro uppercase", in: "PRO", want: coingeckoPlanPro}, + {name: "free", in: "free", want: coingeckoPlanFree}, + {name: "empty defaults to free", in: "", want: coingeckoPlanFree}, + {name: "unknown defaults to free", in: "demo", want: coingeckoPlanFree}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeCoinGeckoPlan(tt.in) + if got != tt.want { + t.Fatalf("unexpected plan normalization: got %q, want %q", got, tt.want) + } + }) + } +} + +func TestCoingeckoPlanRequiresAPIKey(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {name: "pro requires key", in: "pro", want: true}, + {name: "pro uppercase requires key", in: "PRO", want: true}, + {name: "free does not require key", in: "free", want: false}, + {name: "empty does not require key", in: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := coingeckoPlanRequiresAPIKey(tt.in) + if got != tt.want { + t.Fatalf("unexpected API-key requirement: got %v, want %v", got, tt.want) + } + }) + } +} + +func TestResolveHistoricalDays(t *testing.T) { + t.Run("nil last ticker uses max only when allowed", func(t *testing.T) { + cg := Coingecko{} + days, shouldRequest, rangeKind := cg.resolveHistoricalDays(nil, true) + if !shouldRequest || days != "max" { + t.Fatalf("unexpected max result: days=%q shouldRequest=%v", days, shouldRequest) + } + if rangeKind != coingeckoRangeHistorical { + t.Fatalf("unexpected range kind: got %q, want %q", rangeKind, coingeckoRangeHistorical) + } + + days, shouldRequest, rangeKind = cg.resolveHistoricalDays(nil, false) + if !shouldRequest || days != "365" { + t.Fatalf("unexpected capped result: days=%q shouldRequest=%v", days, shouldRequest) + } + if rangeKind != coingeckoRangeCapped { + t.Fatalf("unexpected range kind: got %q, want %q", rangeKind, coingeckoRangeCapped) + } + }) + + t.Run("same day ticker skips request", func(t *testing.T) { + cg := Coingecko{} + days, shouldRequest, rangeKind := cg.resolveHistoricalDays(&common.CurrencyRatesTicker{ + Timestamp: time.Now().Add(-1 * time.Hour), + }, false) + if shouldRequest || days != "" { + t.Fatalf("unexpected same-day result: days=%q shouldRequest=%v", days, shouldRequest) + } + if rangeKind != "" { + t.Fatalf("unexpected range kind: got %q, want empty", rangeKind) + } + }) + + t.Run("older ticker is capped to 365 days", func(t *testing.T) { + cg := Coingecko{} + days, shouldRequest, rangeKind := cg.resolveHistoricalDays(&common.CurrencyRatesTicker{ + Timestamp: time.Now().AddDate(0, 0, -500), + }, true) + if !shouldRequest || days != "365" { + t.Fatalf("unexpected capped result: days=%q shouldRequest=%v", days, shouldRequest) + } + if rangeKind != coingeckoRangeCapped { + t.Fatalf("unexpected range kind: got %q, want %q", rangeKind, coingeckoRangeCapped) + } + }) + + t.Run("recent ticker is tip query", func(t *testing.T) { + cg := Coingecko{} + days, shouldRequest, rangeKind := cg.resolveHistoricalDays(&common.CurrencyRatesTicker{ + Timestamp: time.Now().AddDate(0, 0, -5), + }, false) + if !shouldRequest || days != "5" { + t.Fatalf("unexpected tip result: days=%q shouldRequest=%v", days, shouldRequest) + } + if rangeKind != coingeckoRangeTip { + t.Fatalf("unexpected range kind: got %q, want %q", rangeKind, coingeckoRangeTip) + } + }) +} + +func TestUpdateHistoricalTickers_BootstrapStoresSuccessfulCurrenciesEvenWhenSomeFail(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + } + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + if err := d.FiatRatesSetHistoricalBootstrapComplete(false); err != nil { + t.Fatalf("FiatRatesSetHistoricalBootstrapComplete failed: %v", err) + } + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/simple/supported_vs_currencies": + _, _ = w.Write([]byte(`["usd","eur"]`)) + case "/coins/ethereum/market_chart": + switch r.URL.Query().Get("vs_currency") { + case "usd": + _, _ = w.Write([]byte(`{"prices":[[1654732800000,1234.5]]}`)) + case "eur": + http.Error(w, "forced-failure", http.StatusInternalServerError) + default: + http.Error(w, "unexpected vs_currency", http.StatusBadRequest) + } + default: + http.Error(w, fmt.Sprintf("unexpected path %s", r.URL.Path), http.StatusNotFound) + } + })) + defer mockServer.Close() + + cg := &Coingecko{ + coin: "ethereum", + bootstrapURL: mockServer.URL, + tipURL: mockServer.URL, + httpClient: mockServer.Client(), + db: d, + plan: coingeckoPlanFree, + } + + err := cg.UpdateHistoricalTickers() + if err == nil { + t.Fatal("expected bootstrap incomplete error") + } + if !strings.Contains(err.Error(), "bootstrap incomplete") { + t.Fatalf("unexpected error: %v", err) + } + + usdTicker, err := d.FiatRatesFindLastTicker("usd", "") + if err != nil { + t.Fatalf("FiatRatesFindLastTicker usd failed: %v", err) + } + if usdTicker == nil { + t.Fatal("expected usd ticker to be stored despite partial failure") + } + eurTicker, err := d.FiatRatesFindLastTicker("eur", "") + if err != nil { + t.Fatalf("FiatRatesFindLastTicker eur failed: %v", err) + } + if eurTicker != nil { + t.Fatalf("expected eur ticker to be missing due to forced failure, got %+v", eurTicker) + } +} + +func TestMakeReq_ThrottleRetriesExhausted(t *testing.T) { + originalBackoff := coingeckoThrottleRetryBackoff + coingeckoThrottleRetryBackoff = []time.Duration{0, 0, 0, 0} + defer func() { + coingeckoThrottleRetryBackoff = originalBackoff + }() + + var requests atomic.Int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + })) + defer mockServer.Close() + + cg := &Coingecko{ + httpClient: mockServer.Client(), + } + _, err := cg.makeReq(mockServer.URL, "market_chart", coingeckoPlanFree) + if err == nil { + t.Fatal("expected makeReq to fail after retries are exhausted") + } + wantRequests := 1 + len(coingeckoThrottleRetryBackoff) + if got := int(requests.Load()); got != wantRequests { + t.Fatalf("unexpected number of requests: got %d, want %d", got, wantRequests) + } +} + +func TestMakeReq_ThrottleRetriesEventuallySuccess(t *testing.T) { + originalBackoff := coingeckoThrottleRetryBackoff + coingeckoThrottleRetryBackoff = []time.Duration{0, 0, 0, 0} + defer func() { + coingeckoThrottleRetryBackoff = originalBackoff + }() + + var requests atomic.Int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) <= 2 { + http.Error(w, "throttled", http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{ + httpClient: mockServer.Client(), + } + resp, err := cg.makeReq(mockServer.URL, "market_chart", coingeckoPlanFree) + if err != nil { + t.Fatalf("makeReq unexpectedly failed: %v", err) + } + if string(resp) != `{"ok":true}` { + t.Fatalf("unexpected response body: %s", string(resp)) + } + if got := int(requests.Load()); got != 3 { + t.Fatalf("unexpected number of requests: got %d, want %d", got, 3) + } +} + +func TestUpdateHistoricalTickers_StopsOnThrottleExhaustion(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + } + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + if err := d.FiatRatesSetHistoricalBootstrapComplete(true); err != nil { + t.Fatalf("FiatRatesSetHistoricalBootstrapComplete failed: %v", err) + } + originalVsCurrencies := vsCurrencies + originalPlatformIds := platformIds + originalPlatformIdsToTokens := platformIdsToTokens + defer func() { + vsCurrencies = originalVsCurrencies + platformIds = originalPlatformIds + platformIdsToTokens = originalPlatformIdsToTokens + }() + + originalBackoff := coingeckoThrottleRetryBackoff + coingeckoThrottleRetryBackoff = []time.Duration{0, 0, 0, 0} + defer func() { + coingeckoThrottleRetryBackoff = originalBackoff + }() + + var usdRequests atomic.Int32 + var eurRequests atomic.Int32 + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/simple/supported_vs_currencies": + _, _ = w.Write([]byte(`["usd","eur"]`)) + case "/coins/ethereum/market_chart": + switch r.URL.Query().Get("vs_currency") { + case "usd": + usdRequests.Add(1) + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + case "eur": + eurRequests.Add(1) + _, _ = w.Write([]byte(`{"prices":[[1654732800000,1234.5]]}`)) + default: + http.Error(w, "unexpected vs_currency", http.StatusBadRequest) + } + default: + http.Error(w, fmt.Sprintf("unexpected path %s", r.URL.Path), http.StatusNotFound) + } + })) + defer mockServer.Close() + + cg := &Coingecko{ + coin: "ethereum", + bootstrapURL: mockServer.URL, + tipURL: mockServer.URL, + httpClient: mockServer.Client(), + db: d, + plan: coingeckoPlanFree, + } + + err := cg.UpdateHistoricalTickers() + if err == nil { + t.Fatal("expected throttle exhaustion error") + } + if !isCoingeckoThrottleRetriesExhaustedError(err) { + t.Fatalf("expected throttle exhaustion error, got %v", err) + } + + wantUSDRequests := 1 + len(coingeckoThrottleRetryBackoff) + if got := int(usdRequests.Load()); got != wantUSDRequests { + t.Fatalf("unexpected usd request count: got %d, want %d", got, wantUSDRequests) + } + if got := int(eurRequests.Load()); got != 0 { + t.Fatalf("expected eur request count 0 after throttle exhaustion, got %d", got) + } +} + +func TestUpdateHistoricalTokenTickers_StopsOnThrottleExhaustion(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + } + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + if err := d.FiatRatesSetHistoricalBootstrapComplete(true); err != nil { + t.Fatalf("FiatRatesSetHistoricalBootstrapComplete failed: %v", err) + } + originalVsCurrencies := vsCurrencies + originalPlatformIds := platformIds + originalPlatformIdsToTokens := platformIdsToTokens + defer func() { + vsCurrencies = originalVsCurrencies + platformIds = originalPlatformIds + platformIdsToTokens = originalPlatformIdsToTokens + }() + + originalBackoff := coingeckoThrottleRetryBackoff + coingeckoThrottleRetryBackoff = []time.Duration{0, 0, 0, 0} + defer func() { + coingeckoThrottleRetryBackoff = originalBackoff + }() + + var marketChartRequests atomic.Int32 + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/coins/list": + _, _ = w.Write([]byte(`[ + {"id":"token-a","symbol":"a","name":"A","platforms":{"ethereum":"0xa"}}, + {"id":"token-b","symbol":"b","name":"B","platforms":{"ethereum":"0xb"}} + ]`)) + case "/coins/token-a/market_chart", "/coins/token-b/market_chart": + marketChartRequests.Add(1) + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + default: + http.Error(w, fmt.Sprintf("unexpected path %s", r.URL.Path), http.StatusNotFound) + } + })) + defer mockServer.Close() + + cg := &Coingecko{ + coin: "ethereum", + platformIdentifier: "ethereum", + platformVsCurrency: "eth", + bootstrapURL: mockServer.URL, + tipURL: mockServer.URL, + httpClient: mockServer.Client(), + db: d, + plan: coingeckoPlanFree, + } + + err := cg.UpdateHistoricalTokenTickers() + if err == nil { + t.Fatal("expected throttle exhaustion error") + } + if !isCoingeckoThrottleRetriesExhaustedError(err) { + t.Fatalf("expected throttle exhaustion error, got %v", err) + } + + wantRequests := 1 + len(coingeckoThrottleRetryBackoff) + if got := int(marketChartRequests.Load()); got != wantRequests { + t.Fatalf("unexpected market_chart request count: got %d, want %d", got, wantRequests) + } +} + +func TestUpdateHistoricalTokenTickers_ReturnsInProgressError(t *testing.T) { + cg := &Coingecko{ + updatingTokens: true, + } + err := cg.UpdateHistoricalTokenTickers() + if err == nil { + t.Fatal("expected non-nil in-progress error") + } + if !errors.Is(err, errCoingeckoHistoricalTokenUpdateInProgress) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGetHighGranularityTickers_NotEnoughPricePoints(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // return only 1 price point + _, _ = w.Write([]byte(`{"prices":[[1654732800000,1234.5]]}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{ + coin: "ethereum", + tipURL: mockServer.URL, + httpClient: mockServer.Client(), + plan: coingeckoPlanFree, + } + + tickers, err := cg.HourlyTickers() + if err == nil { + t.Fatal("expected error for not enough price points") + } + if !strings.Contains(err.Error(), "not enough price points") { + t.Fatalf("unexpected error message: %v", err) + } + if tickers != nil { + t.Fatal("expected nil tickers") + } +} diff --git a/fiat/fiat_rates.go b/fiat/fiat_rates.go index 4719bece01..dd0bc90133 100644 --- a/fiat/fiat_rates.go +++ b/fiat/fiat_rates.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "math/rand" + "sort" "strings" + "sync" "time" "github.com/golang/glog" @@ -13,134 +15,658 @@ import ( "github.com/trezor/blockbook/db" ) +const currentTickersKey = "CurrentTickers" +const hourlyTickersKey = "HourlyTickers" +const fiveMinutesTickersKey = "FiveMinutesTickers" + +const highGranularityVsCurrency = "usd" + +const secondsInDay = 24 * 60 * 60 +const secondsInHour = 60 * 60 +const secondsInFiveMinutes = 5 * 60 + // OnNewFiatRatesTicker is used to send notification about a new FiatRates ticker type OnNewFiatRatesTicker func(ticker *common.CurrencyRatesTicker) -// RatesDownloaderInterface provides method signatures for specific fiat rates downloaders +// RatesDownloaderInterface provides method signatures for a specific fiat rates downloader type RatesDownloaderInterface interface { CurrentTickers() (*common.CurrencyRatesTicker, error) + HourlyTickers() (*[]common.CurrencyRatesTicker, error) + FiveMinutesTickers() (*[]common.CurrencyRatesTicker, error) UpdateHistoricalTickers() error UpdateHistoricalTokenTickers() error } -// RatesDownloader stores FiatRates API parameters -type RatesDownloader struct { - periodSeconds int64 - db *db.RocksDB - timeFormat string - callbackOnNewTicker OnNewFiatRatesTicker - downloader RatesDownloaderInterface - downloadTokens bool +// FiatRates is used to fetch and refresh fiat rates +type FiatRates struct { + Enabled bool + periodSeconds int64 + db *db.RocksDB + metrics *common.Metrics + timeFormat string + callbackOnNewTicker OnNewFiatRatesTicker + downloader RatesDownloaderInterface + downloadTokens bool + provider string + allowedVsCurrencies string + mux sync.RWMutex + currentTicker *common.CurrencyRatesTicker + hourlyTickers map[int64]*common.CurrencyRatesTicker + hourlyTickersFrom int64 + hourlyTickersTo int64 + fiveMinutesTickers map[int64]*common.CurrencyRatesTicker + fiveMinutesTickersFrom int64 + fiveMinutesTickersTo int64 + dailyTickers map[int64]*common.CurrencyRatesTicker + dailyTickersFrom int64 + dailyTickersTo int64 +} + +var fiatRatesFindTickers = func(d *db.RocksDB, timestamps []int64, vsCurrency string, token string) ([]*common.CurrencyRatesTicker, error) { + return d.FiatRatesFindTickers(timestamps, vsCurrency, token) } -// NewFiatRatesDownloader initializes the downloader for FiatRates API. -func NewFiatRatesDownloader(db *db.RocksDB, apiType string, params string, allowedVsCurrencies string, callback OnNewFiatRatesTicker) (*RatesDownloader, error) { - var rd = &RatesDownloader{} +// NewFiatRates initializes the FiatRates handler +func NewFiatRates(db *db.RocksDB, config *common.Config, metrics *common.Metrics, callback OnNewFiatRatesTicker) (*FiatRates, error) { + + var fr = &FiatRates{ + provider: config.FiatRates, + allowedVsCurrencies: config.FiatRatesVsCurrencies, + } + + if config.FiatRates == "" || config.FiatRatesParams == "" { + glog.Infof("FiatRates config is empty, not downloading fiat rates") + fr.Enabled = false + return fr, nil + } + type fiatRatesParams struct { URL string `json:"url"` Coin string `json:"coin"` PlatformIdentifier string `json:"platformIdentifier"` PlatformVsCurrency string `json:"platformVsCurrency"` PeriodSeconds int64 `json:"periodSeconds"` + Plan string `json:"plan"` } rdParams := &fiatRatesParams{} - err := json.Unmarshal([]byte(params), &rdParams) + err := json.Unmarshal([]byte(config.FiatRatesParams), &rdParams) if err != nil { return nil, err } - if rdParams.URL == "" || rdParams.PeriodSeconds == 0 { - return nil, errors.New("Missing parameters") + if rdParams.PeriodSeconds == 0 { + return nil, errors.New("missing parameters") } - rd.timeFormat = "02-01-2006" // Layout string for FiatRates date formatting (DD-MM-YYYY) - rd.periodSeconds = rdParams.PeriodSeconds // Time period for syncing the latest market data - if rd.periodSeconds < 60 { // minimum is one minute - rd.periodSeconds = 60 + fr.timeFormat = "02-01-2006" // Layout string for FiatRates date formatting (DD-MM-YYYY) + fr.periodSeconds = rdParams.PeriodSeconds // Time period for syncing the latest market data + if fr.periodSeconds < 60 { // minimum is one minute + fr.periodSeconds = 60 } - rd.db = db - rd.callbackOnNewTicker = callback - rd.downloadTokens = rdParams.PlatformIdentifier != "" && rdParams.PlatformVsCurrency != "" - if rd.downloadTokens { + fr.db = db + fr.metrics = metrics + fr.callbackOnNewTicker = callback + fr.downloadTokens = rdParams.PlatformIdentifier != "" && rdParams.PlatformVsCurrency != "" + if fr.downloadTokens { common.TickerRecalculateTokenRate = strings.ToLower(db.GetInternalState().CoinShortcut) != rdParams.PlatformVsCurrency common.TickerTokenVsCurrency = rdParams.PlatformVsCurrency } - is := rd.db.GetInternalState() - if apiType == "coingecko" { + is := fr.db.GetInternalState() + if fr.provider == "coingecko" { throttle := true if callback == nil { // a small hack - in tests the callback is not used, therefore there is no delay slowing down the test throttle = false } - rd.downloader = NewCoinGeckoDownloader(db, rdParams.URL, rdParams.Coin, rdParams.PlatformIdentifier, rdParams.PlatformVsCurrency, allowedVsCurrencies, rd.timeFormat, throttle) + network := "" + coinShortcut := "" if is != nil { - is.HasFiatRates = true - is.HasTokenFiatRates = rd.downloadTokens + network = is.GetNetwork() + coinShortcut = is.CoinShortcut + } + if err := validateCoinGeckoAPIKeyEnv(network, coinShortcut); err != nil { + return nil, fmt.Errorf("coingecko api key configuration error: %w", err) + } + coingeckoPlan := normalizeCoinGeckoPlan(rdParams.Plan) + apiKey := resolveCoinGeckoAPIKey(network, coinShortcut) + if coingeckoPlanRequiresAPIKey(coingeckoPlan) && apiKey == "" { + return nil, fmt.Errorf("coingecko plan %q requires API key in one of COINGECKO_API_KEY, _COINGECKO_API_KEY, _COINGECKO_API_KEY", coingeckoPlanPro) } + bootstrapInProgress, err := ensureHistoricalBootstrapState(fr.db) + if err != nil { + return nil, err + } + if bootstrapInProgress { + bootstrapURL := resolveCoinGeckoBootstrapURL(rdParams.URL) + if !coingeckoBootstrapURLAllowed(bootstrapURL) { + return nil, coingeckoBootstrapPreconditionError() + } + } + fr.downloader = NewCoinGeckoDownloader(db, network, coinShortcut, rdParams.URL, rdParams.Coin, rdParams.PlatformIdentifier, rdParams.PlatformVsCurrency, fr.allowedVsCurrencies, fr.timeFormat, rdParams.Plan, metrics, throttle) + if is != nil { + is.HasFiatRates = true + is.HasTokenFiatRates = fr.downloadTokens + fr.Enabled = true + + if err := fr.loadDailyTickers(); err != nil { + return nil, err + } + + currentTickers, err := db.FiatRatesGetSpecialTickers(currentTickersKey) + if err != nil { + glog.Error("FiatRatesDownloader: get CurrentTickers from DB error ", err) + } + if currentTickers != nil && len(*currentTickers) > 0 { + fr.currentTicker = &(*currentTickers)[0] + } + hourlyTickers, err := db.FiatRatesGetSpecialTickers(hourlyTickersKey) + if err != nil { + glog.Error("FiatRatesDownloader: get HourlyTickers from DB error ", err) + } + fr.hourlyTickers, fr.hourlyTickersFrom, fr.hourlyTickersTo = fr.tickersToMap(hourlyTickers, secondsInHour) + + fiveMinutesTickers, err := db.FiatRatesGetSpecialTickers(fiveMinutesTickersKey) + if err != nil { + glog.Error("FiatRatesDownloader: get FiveMinutesTickers from DB error ", err) + } + fr.fiveMinutesTickers, fr.fiveMinutesTickersFrom, fr.fiveMinutesTickersTo = fr.tickersToMap(fiveMinutesTickers, secondsInFiveMinutes) + + } } else { - return nil, fmt.Errorf("NewFiatRatesDownloader: incorrect API type %q", apiType) + return nil, fmt.Errorf("unknown provider %q", fr.provider) + } + fr.logTickersInfo() + return fr, nil +} + +// GetCurrentTicker returns current ticker +func (fr *FiatRates) GetCurrentTicker(vsCurrency string, token string) *common.CurrencyRatesTicker { + fr.mux.RLock() + currentTicker := fr.currentTicker + fr.mux.RUnlock() + if currentTicker != nil && common.IsSuitableTicker(currentTicker, vsCurrency, token) { + return currentTicker + } + return nil +} + +// getTokenTickersForTimestamps returns tickers for slice of timestamps, that contain requested vsCurrency and token +func (fr *FiatRates) getTokenTickersForTimestamps(timestamps []int64, vsCurrency string, token string) (*[]*common.CurrencyRatesTicker, error) { + currentTicker := fr.GetCurrentTicker("", token) + tickers := make([]*common.CurrencyRatesTicker, len(timestamps)) + if currentTicker == nil { + // If token is missing in current ticker, keep nil entries and skip + // expensive DB lookups; this preserves the existing response shape. + return &tickers, nil + } + + // Query unique timestamps in ascending order to enable a single forward DB scan. + uniqueMap := make(map[int64]struct{}, len(timestamps)) + uniqueTimestamps := make([]int64, 0, len(timestamps)) + for _, ts := range timestamps { + if _, found := uniqueMap[ts]; found { + continue + } + uniqueMap[ts] = struct{}{} + uniqueTimestamps = append(uniqueTimestamps, ts) + } + sort.Slice(uniqueTimestamps, func(i, j int) bool { + return uniqueTimestamps[i] < uniqueTimestamps[j] + }) + + foundTickers, err := fiatRatesFindTickers(fr.db, uniqueTimestamps, vsCurrency, token) + if err != nil { + return nil, err + } + resolvedTickers := make(map[int64]*common.CurrencyRatesTicker, len(uniqueTimestamps)) + for i, t := range uniqueTimestamps { + ticker := foundTickers[i] + // if ticker not found in DB, use current ticker + if ticker == nil { + resolvedTickers[t] = currentTicker + } else { + resolvedTickers[t] = ticker + } + } + + for i, t := range timestamps { + tickers[i] = resolvedTickers[t] + } + + return &tickers, nil +} + +// GetTickersForTimestamps returns tickers for slice of timestamps, that contain requested vsCurrency and token +func (fr *FiatRates) GetTickersForTimestamps(timestamps []int64, vsCurrency string, token string) (*[]*common.CurrencyRatesTicker, error) { + if !fr.Enabled { + return nil, nil + } + // token rates are not in memory, them load from DB + if token != "" { + return fr.getTokenTickersForTimestamps(timestamps, vsCurrency, token) + } + // Snapshot all cache references under a short read lock so readers do not + // block writers while iterating over potentially large timestamp slices. + fr.mux.RLock() + currentTicker := fr.currentTicker + fiveMinutesTickers := fr.fiveMinutesTickers + fiveMinutesTickersFrom := fr.fiveMinutesTickersFrom + fiveMinutesTickersTo := fr.fiveMinutesTickersTo + hourlyTickers := fr.hourlyTickers + hourlyTickersFrom := fr.hourlyTickersFrom + hourlyTickersTo := fr.hourlyTickersTo + dailyTickers := fr.dailyTickers + dailyTickersFrom := fr.dailyTickersFrom + dailyTickersTo := fr.dailyTickersTo + fr.mux.RUnlock() + + tickers := make([]*common.CurrencyRatesTicker, len(timestamps)) + var prevTicker *common.CurrencyRatesTicker + var prevTs int64 + for i, t := range timestamps { + dailyTs := ceilUnix(t, secondsInDay) + // use higher granularity only for non daily timestamps + if t != dailyTs { + if t >= fiveMinutesTickersFrom && t <= fiveMinutesTickersTo { + if ticker, found := fiveMinutesTickers[ceilUnix(t, secondsInFiveMinutes)]; found && ticker != nil { + if common.IsSuitableTicker(ticker, vsCurrency, token) { + tickers[i] = ticker + continue + } + } + } + if t >= hourlyTickersFrom && t <= hourlyTickersTo { + if ticker, found := hourlyTickers[ceilUnix(t, secondsInHour)]; found && ticker != nil { + if common.IsSuitableTicker(ticker, vsCurrency, token) { + tickers[i] = ticker + continue + } + } + } + } + if prevTicker != nil && t >= prevTs && t <= prevTicker.Timestamp.Unix() { + tickers[i] = prevTicker + continue + } else { + var found bool + if dailyTs < dailyTickersFrom { + dailyTs = dailyTickersFrom + } + var ticker *common.CurrencyRatesTicker + for ; dailyTs <= dailyTickersTo; dailyTs += secondsInDay { + if ticker, found = dailyTickers[dailyTs]; found && ticker != nil { + if common.IsSuitableTicker(ticker, vsCurrency, token) { + tickers[i] = ticker + prevTicker = ticker + prevTs = t + break + } else { + found = false + } + } + } + if !found { + tickers[i] = currentTicker + prevTicker = currentTicker + prevTs = t + } + } } - return rd, nil + return &tickers, nil +} +func (fr *FiatRates) logTickersInfo() { + glog.Infof("fiat rates %s handler, %d (%s - %s) daily tickers, %d (%s - %s) hourly tickers, %d (%s - %s) 5 minute tickers", fr.provider, + len(fr.dailyTickers), time.Unix(fr.dailyTickersFrom, 0).Format("2006-01-02"), time.Unix(fr.dailyTickersTo, 0).Format("2006-01-02"), + len(fr.hourlyTickers), time.Unix(fr.hourlyTickersFrom, 0).Format("2006-01-02 15:04"), time.Unix(fr.hourlyTickersTo, 0).Format("2006-01-02 15:04"), + len(fr.fiveMinutesTickers), time.Unix(fr.fiveMinutesTickersFrom, 0).Format("2006-01-02 15:04"), time.Unix(fr.fiveMinutesTickersTo, 0).Format("2006-01-02 15:04")) } -// Run periodically downloads current (every 15 minutes) and historical (once a day) tickers -func (rd *RatesDownloader) Run() error { +func roundTimeUnix(t time.Time, granularity int64) int64 { + return roundUnix(t.UTC().Unix(), granularity) +} + +func roundUnix(t int64, granularity int64) int64 { + unix := t + (granularity >> 1) + return unix - unix%granularity +} + +func ceilUnix(t int64, granularity int64) int64 { + unix := t + (granularity - 1) + return unix - unix%granularity +} + +// loadDailyTickers loads daily tickers to cache +func (fr *FiatRates) loadDailyTickers() error { + // Build the daily map outside the lock: loading historical fiat data can be + // expensive and we only need the lock for the final cache swap. + dailyTickers := make(map[int64]*common.CurrencyRatesTicker) + dailyTickersFrom := int64(0) + dailyTickersTo := int64(0) + err := fr.db.FiatRatesGetAllTickers(func(ticker *common.CurrencyRatesTicker) error { + normalizedTime := roundTimeUnix(ticker.Timestamp, secondsInDay) + if normalizedTime == dailyTickersFrom { + // there are multiple tickers on the first day, use only the first one + return nil + } + // remove token rates from cache to save memory (tickers with token rates are hundreds of kb big) + ticker.TokenRates = nil + if len(dailyTickers) > 0 { + // check that there is a ticker for every day, if missing, set it from current value if missing + prevTime := normalizedTime + for { + prevTime -= secondsInDay + if _, found := dailyTickers[prevTime]; found { + break + } + dailyTickers[prevTime] = ticker + } + } else { + dailyTickersFrom = normalizedTime + } + dailyTickers[normalizedTime] = ticker + dailyTickersTo = normalizedTime + return nil + }) + if err != nil { + return err + } + + fr.mux.Lock() + fr.dailyTickers = dailyTickers + fr.dailyTickersFrom = dailyTickersFrom + fr.dailyTickersTo = dailyTickersTo + fr.mux.Unlock() + return nil +} + +// setCurrentTicker sets current ticker +func (fr *FiatRates) setCurrentTicker(t *common.CurrencyRatesTicker) { + fr.mux.Lock() + fr.currentTicker = t + fr.mux.Unlock() + // Persisting to DB can take longer than an in-memory pointer swap. + // Keep the mutex scope tight so readers are not blocked on storage I/O. + fr.db.FiatRatesStoreSpecialTickers(currentTickersKey, &[]common.CurrencyRatesTicker{*t}) +} + +func (fr *FiatRates) tickersToMap(tickers *[]common.CurrencyRatesTicker, granularitySeconds int64) (map[int64]*common.CurrencyRatesTicker, int64, int64) { + if tickers == nil || len(*tickers) == 0 { + return make(map[int64]*common.CurrencyRatesTicker), 0, 0 + } + m := make(map[int64]*common.CurrencyRatesTicker, len(*tickers)) + from := int64(0) + to := int64(0) + for i := range *tickers { + ticker := (*tickers)[i] + normalizedTime := roundTimeUnix(ticker.Timestamp, granularitySeconds) + dailyTime := roundTimeUnix(ticker.Timestamp, secondsInDay) + dailyTicker, found := fr.dailyTickers[dailyTime] + if !found { + // if not found in historical tickers, use current ticker + dailyTicker = fr.currentTicker + } + if dailyTicker != nil { + // high granularity tickers are loaded only in one currency, add other currencies based on daily rate between fiat currencies + vsRate, foundVs := ticker.Rates[highGranularityVsCurrency] + dailyVsRate, foundDaily := dailyTicker.Rates[highGranularityVsCurrency] + if foundDaily && dailyVsRate != 0 && foundVs && vsRate != 0 { + for currency, rate := range dailyTicker.Rates { + if currency != highGranularityVsCurrency { + ticker.Rates[currency] = vsRate * rate / dailyVsRate + } + } + } + } + if len(m) > 0 { + if normalizedTime == from { + // there are multiple normalized tickers for the first entry, skip + continue + } + // check that there is a ticker for each period, set it from current value if missing + prevTime := normalizedTime + for { + prevTime -= granularitySeconds + if _, found := m[prevTime]; found { + break + } + m[prevTime] = &ticker + } + } else { + from = normalizedTime + } + m[normalizedTime] = &ticker + to = normalizedTime + } + return m, from, to +} + +// setHourlyTickers sets hourly tickers +func (fr *FiatRates) setHourlyTickers(t *[]common.CurrencyRatesTicker) { + fr.db.FiatRatesStoreSpecialTickers(hourlyTickersKey, t) + fr.mux.Lock() + defer fr.mux.Unlock() + fr.hourlyTickers, fr.hourlyTickersFrom, fr.hourlyTickersTo = fr.tickersToMap(t, secondsInHour) +} + +// setFiveMinutesTickers sets five minutes tickers +func (fr *FiatRates) setFiveMinutesTickers(t *[]common.CurrencyRatesTicker) { + fr.db.FiatRatesStoreSpecialTickers(fiveMinutesTickersKey, t) + fr.mux.Lock() + defer fr.mux.Unlock() + fr.fiveMinutesTickers, fr.fiveMinutesTickersFrom, fr.fiveMinutesTickersTo = fr.tickersToMap(t, secondsInFiveMinutes) +} + +func (fr *FiatRates) observeUpdateDuration(stage, status string, start time.Time) { + if fr.metrics == nil { + return + } + fr.metrics.FiatRatesUpdateDuration.With(common.Labels{ + "stage": stage, + "status": status, + }).Observe(time.Since(start).Seconds()) +} + +func logFiatRatesDownloaderError(message string, err error) { + if err == nil { + glog.Errorf("%sno data from provider", message) + return + } + if isCoingeckoThrottleRetriesExhaustedError(err) { + glog.Warning(message, err) + return + } + glog.Error(message, err) +} + +// RunDownloader periodically downloads current (every 15 minutes) and historical (once a day) tickers +func (fr *FiatRates) RunDownloader() error { + glog.Infof("Starting %v FiatRates downloader...", fr.provider) var lastHistoricalTickers time.Time - is := rd.db.GetInternalState() - tickerFromIs := is.GetCurrentTicker("", "") + is := fr.db.GetInternalState() + tickerFromIs := fr.GetCurrentTicker("", "") firstRun := true for { unix := time.Now().Unix() - next := unix + rd.periodSeconds - next -= next % rd.periodSeconds + next := unix + fr.periodSeconds + next -= next % fr.periodSeconds // skip waiting for the period for the first run if there are no tickerFromIs or they are too old - if !firstRun || (tickerFromIs != nil && next-tickerFromIs.Timestamp.Unix() < rd.periodSeconds) { + if !firstRun || (tickerFromIs != nil && next-tickerFromIs.Timestamp.Unix() < fr.periodSeconds) { // wait for the next run with a slight random value to avoid too many request at the same time - next += int64(rand.Intn(12)) + next += int64(rand.Intn(3)) time.Sleep(time.Duration(next-unix) * time.Second) } firstRun = false - tickers, err := rd.downloader.CurrentTickers() - if err != nil || tickers == nil { - glog.Error("FiatRatesDownloader: CurrentTickers error ", err) + + // load current tickers + currentTickersStart := time.Now() + currentTicker, err := fr.downloader.CurrentTickers() + if err != nil || currentTicker == nil { + fr.observeUpdateDuration("current_tickers", "error", currentTickersStart) + logFiatRatesDownloaderError("FiatRatesDownloader: CurrentTickers error ", err) } else { - is.SetCurrentTicker(tickers) + fr.setCurrentTicker(currentTicker) + fr.observeUpdateDuration("current_tickers", "success", currentTickersStart) glog.Info("FiatRatesDownloader: CurrentTickers updated") - if rd.callbackOnNewTicker != nil { - rd.callbackOnNewTicker(tickers) + if fr.callbackOnNewTicker != nil { + fr.callbackOnNewTicker(currentTicker) } } - now := time.Now().UTC() + + // load hourly tickers, it is necessary to wait about 1 hour to prepare the tickers + if time.Now().UTC().Unix() >= fr.hourlyTickersTo+secondsInHour+secondsInHour { + hourlyTickersStart := time.Now() + hourlyTickers, err := fr.downloader.HourlyTickers() + if err != nil || hourlyTickers == nil { + fr.observeUpdateDuration("hourly_tickers", "error", hourlyTickersStart) + logFiatRatesDownloaderError("FiatRatesDownloader: HourlyTickers error ", err) + } else { + fr.setHourlyTickers(hourlyTickers) + fr.observeUpdateDuration("hourly_tickers", "success", hourlyTickersStart) + glog.Info("FiatRatesDownloader: HourlyTickers updated") + } + } + + // load five minute tickers, it is necessary to wait about 10 minutes to prepare the tickers + if time.Now().UTC().Unix() >= fr.fiveMinutesTickersTo+3*secondsInFiveMinutes { + fiveMinutesTickersStart := time.Now() + fiveMinutesTickers, err := fr.downloader.FiveMinutesTickers() + if err != nil || fiveMinutesTickers == nil { + fr.observeUpdateDuration("five_minutes_tickers", "error", fiveMinutesTickersStart) + logFiatRatesDownloaderError("FiatRatesDownloader: FiveMinutesTickers error ", err) + } else { + fr.setFiveMinutesTickers(fiveMinutesTickers) + fr.observeUpdateDuration("five_minutes_tickers", "success", fiveMinutesTickersStart) + glog.Info("FiatRatesDownloader: FiveMinutesTickers updated") + } + } + // once a day, 1 hour after UTC midnight (to let the provider prepare historical rates) update historical tickers + now := time.Now().UTC() if (now.YearDay() != lastHistoricalTickers.YearDay() || now.Year() != lastHistoricalTickers.Year()) && now.Hour() > 0 { - err = rd.downloader.UpdateHistoricalTickers() + bootstrapInProgress, _, bootstrapErr := historicalBootstrapInProgress(fr.db) + if bootstrapErr != nil { + glog.Error("FiatRatesDownloader: bootstrap state check error ", bootstrapErr) + continue + } + + historicalTickersStart := time.Now() + err = fr.downloader.UpdateHistoricalTickers() if err != nil { - glog.Error("FiatRatesDownloader: UpdateHistoricalTickers error ", err) + fr.observeUpdateDuration("historical_tickers", "error", historicalTickersStart) + logFiatRatesDownloaderError("FiatRatesDownloader: UpdateHistoricalTickers error ", err) + if bootstrapInProgress { + // Bootstrap policy: count failed cycles and stop bootstrap mode after the + // configured limit so we do not retry full-history downloads forever. + attempts, exhausted, attemptsErr := registerHistoricalBootstrapAttemptFailure(fr.db) + if attemptsErr != nil { + glog.Error("FiatRatesDownloader: recording bootstrap attempt failure failed ", attemptsErr) + } else if exhausted { + glog.Warningf("FiatRatesDownloader: bootstrap failed %d/%d times, stopping bootstrap retries", attempts, maxHistoricalBootstrapAttempts) + // Also advance the in-memory daily guard to avoid re-entering the + // historical block again in the same UTC day. + lastHistoricalTickers = time.Now().UTC() + } else { + glog.Warningf("FiatRatesDownloader: bootstrap attempt %d/%d failed", attempts, maxHistoricalBootstrapAttempts) + } + } + // Base historical pass failed; skip token/bootstrap-completion handling for this cycle. + continue + } + + fr.observeUpdateDuration("historical_tickers", "success", historicalTickersStart) + loadDailyTickersStart := time.Now() + if err = fr.loadDailyTickers(); err != nil { + fr.observeUpdateDuration("load_daily_tickers", "error", loadDailyTickersStart) + // Cache refresh failure does not mean downloaded historical data is invalid; + // keep processing the cycle and rely on next runs to refresh in-memory cache. + glog.Error("FiatRatesDownloader: loadDailyTickers error ", err) } else { - lastHistoricalTickers = time.Now().UTC() - ticker, err := rd.db.FiatRatesFindLastTicker("", "") - if err != nil || ticker == nil { - glog.Error("FiatRatesDownloader: FiatRatesFindLastTicker error ", err) + fr.observeUpdateDuration("load_daily_tickers", "success", loadDailyTickersStart) + ticker, found := fr.dailyTickers[fr.dailyTickersTo] + if !found || ticker == nil { + glog.Error("FiatRatesDownloader: dailyTickers not loaded") } else { glog.Infof("FiatRatesDownloader: UpdateHistoricalTickers finished, last ticker from %v", ticker.Timestamp) + fr.logTickersInfo() if is != nil { is.HistoricalFiatRatesTime = ticker.Timestamp } } - if rd.downloadTokens { + } + + cycleSuccessful := true + if fr.downloadTokens { + if bootstrapInProgress { + // During bootstrap keep completion state incomplete until token bootstrap succeeds. + historicalTokenTickersStart := time.Now() + err = fr.downloader.UpdateHistoricalTokenTickers() + if err != nil { + cycleSuccessful = false + if isCoingeckoHistoricalTokenUpdateInProgressError(err) { + fr.observeUpdateDuration("historical_token_tickers", "skipped", historicalTokenTickersStart) + glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers skipped, update already in progress") + } else { + fr.observeUpdateDuration("historical_token_tickers", "error", historicalTokenTickersStart) + logFiatRatesDownloaderError("FiatRatesDownloader: UpdateHistoricalTokenTickers error ", err) + } + } else { + fr.observeUpdateDuration("historical_token_tickers", "success", historicalTokenTickersStart) + glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers finished") + if is != nil { + is.HistoricalTokenFiatRatesTime = time.Now().UTC() + } + } + } else { // UpdateHistoricalTokenTickers in a goroutine, it can take quite some time as there are many tokens go func() { - err := rd.downloader.UpdateHistoricalTokenTickers() + historicalTokenTickersStart := time.Now() + err := fr.downloader.UpdateHistoricalTokenTickers() if err != nil { - glog.Error("FiatRatesDownloader: UpdateHistoricalTokenTickers error ", err) + if isCoingeckoHistoricalTokenUpdateInProgressError(err) { + fr.observeUpdateDuration("historical_token_tickers", "skipped", historicalTokenTickersStart) + glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers skipped, update already in progress") + return + } + fr.observeUpdateDuration("historical_token_tickers", "error", historicalTokenTickersStart) + logFiatRatesDownloaderError("FiatRatesDownloader: UpdateHistoricalTokenTickers error ", err) } else { + fr.observeUpdateDuration("historical_token_tickers", "success", historicalTokenTickersStart) glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers finished") if is != nil { - is.HistoricalTokenFiatRatesTime = time.Now() + is.HistoricalTokenFiatRatesTime = time.Now().UTC() } } }() } } + + if bootstrapInProgress && cycleSuccessful { + // Bootstrap can be marked complete only after both base and token historical + // updates finished successfully in this cycle. + if err := fr.db.FiatRatesSetHistoricalBootstrapComplete(true); err != nil { + cycleSuccessful = false + glog.Error("FiatRatesDownloader: setting bootstrap completion failed ", err) + } else if err := resetHistoricalBootstrapAttempts(fr.db); err != nil { + cycleSuccessful = false + glog.Error("FiatRatesDownloader: resetting bootstrap attempt counter failed ", err) + } + } + + if bootstrapInProgress && !cycleSuccessful { + // Token/bootstrap-finalization failures count as a failed bootstrap cycle too. + attempts, exhausted, attemptsErr := registerHistoricalBootstrapAttemptFailure(fr.db) + if attemptsErr != nil { + glog.Error("FiatRatesDownloader: recording bootstrap attempt failure failed ", attemptsErr) + } else if exhausted { + cycleSuccessful = true + glog.Warningf("FiatRatesDownloader: bootstrap failed %d/%d times, stopping bootstrap retries", attempts, maxHistoricalBootstrapAttempts) + } else { + glog.Warningf("FiatRatesDownloader: bootstrap attempt %d/%d failed", attempts, maxHistoricalBootstrapAttempts) + } + } + + if cycleSuccessful { + lastHistoricalTickers = time.Now().UTC() + } } } } diff --git a/fiat/fiat_rates_test.go b/fiat/fiat_rates_test.go index 417c960be1..e740c8adb2 100644 --- a/fiat/fiat_rates_test.go +++ b/fiat/fiat_rates_test.go @@ -3,17 +3,18 @@ package fiat import ( - "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "os" "reflect" + "sync" "testing" "time" "github.com/golang/glog" + "github.com/linxGnu/grocksdb" "github.com/martinboehm/btcutil/chaincfg" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/btc" @@ -31,16 +32,21 @@ func TestMain(m *testing.M) { os.Exit(c) } -func setupRocksDB(t *testing.T, parser bchain.BlockChainParser) (*db.RocksDB, *common.InternalState, string) { - tmp, err := ioutil.TempDir("", "testdb") +func setupRocksDB(t *testing.T, parser bchain.BlockChainParser, config *common.Config) (*db.RocksDB, *common.InternalState, string) { + tmp, err := os.MkdirTemp("", "testdb") if err != nil { t.Fatal(err) } - d, err := db.NewRocksDB(tmp, 100000, -1, parser, nil) + d, err := db.NewRocksDB(tmp, 100000, -1, parser, nil, false) if err != nil { t.Fatal(err) } - is, err := d.LoadInternalState("fakecoin") + // Force synchronous block-times initialization in tests. + // For non-"coin-unittest" names, LoadInternalState starts a background + // goroutine that can race with test DB teardown. + loadConfig := *config + loadConfig.CoinName = "coin-unittest" + is, err := d.LoadInternalState(&loadConfig) if err != nil { t.Fatal(err) } @@ -75,7 +81,7 @@ func getFiatRatesMockData(name string) (string, error) { glog.Errorf("Cannot open file %v", filename) return "", err } - b, err := ioutil.ReadAll(mockFile) + b, err := io.ReadAll(mockFile) if err != nil { glog.Errorf("Cannot read file %v", filename) return "", err @@ -83,11 +89,15 @@ func getFiatRatesMockData(name string) (string, error) { return string(b), nil } +func resetCoingeckoTestCaches() { + vsCurrencies = nil + platformIds = nil + platformIdsToTokens = nil +} + func TestFiatRates(t *testing.T) { - d, _, tmp := setupRocksDB(t, &testBitcoinParser{ - BitcoinParser: bitcoinTestnetParser(), - }) - defer closeAndDestroyRocksDB(t, d, tmp) + resetCoingeckoTestCaches() + t.Cleanup(resetCoingeckoTestCaches) mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error @@ -130,168 +140,793 @@ func TestFiatRates(t *testing.T) { })) defer mockServer.Close() - // mocked CoinGecko API - configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"` + mockServer.URL + `\", \"coin\": \"ethereum\",\"platformIdentifier\":\"ethereum\",\"platformVsCurrency\": \"eth\",\"periodSeconds\": 60}"}` - - type fiatRatesConfig struct { - FiatRates string `json:"fiat_rates"` - FiatRatesParams string `json:"fiat_rates_params"` + // config with mocked CoinGecko API + config := common.Config{ + CoinName: "fakecoin", + FiatRates: "coingecko", + FiatRatesParams: `{"url": "` + mockServer.URL + `", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "eth","periodSeconds": 60}`, } - var config fiatRatesConfig - err := json.Unmarshal([]byte(configJSON), &config) + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + fiatRates, err := NewFiatRates(d, &config, nil, nil) if err != nil { - t.Fatalf("Error parsing config: %v", err) + t.Fatalf("FiatRates init error: %v", err) + } + // In the current model, FiatRatesParams.url is bootstrap URL only. + // Point tip/current calls to the mock explicitly to keep this test isolated. + coingeckoDownloader, ok := fiatRates.downloader.(*Coingecko) + if !ok { + t.Fatalf("unexpected downloader type: %T", fiatRates.downloader) } + coingeckoDownloader.tipURL = mockServer.URL - if config.FiatRates == "" || config.FiatRatesParams == "" { - t.Fatalf("Error parsing FiatRates config - empty parameter") + // get current tickers + currentTickers, err := fiatRates.downloader.CurrentTickers() + if err != nil { + t.Fatalf("Error in CurrentTickers: %v", err) + return + } + if currentTickers == nil { + t.Fatalf("CurrentTickers returned nil value") return } - fiatRates, err := NewFiatRatesDownloader(d, config.FiatRates, config.FiatRatesParams, "", nil) + + wantCurrentTickers := common.CurrencyRatesTicker{ + Rates: map[string]float32{ + "aed": 8447.1, + "ars": 268901, + "aud": 3314.36, + "btc": 0.07531005, + "eth": 1, + "eur": 2182.99, + "ltc": 29.097696, + "usd": 2299.72, + }, + TokenRates: map[string]float32{ + "0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 5.58195e-07, + "0x906710835d1ae85275eb770f06873340ca54274b": 1.39852e-10, + }, + Timestamp: currentTickers.Timestamp, + } + if !reflect.DeepEqual(currentTickers, &wantCurrentTickers) { + t.Fatalf("CurrentTickers() = %v, want %v", *currentTickers, wantCurrentTickers) + } + + ticker, err := fiatRates.db.FiatRatesFindLastTicker("usd", "") if err != nil { - t.Fatalf("FiatRates init error: %v", err) + t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + } + if ticker != nil { + t.Fatalf("FiatRatesFindLastTicker found unexpected data") } - if config.FiatRates == "coingecko" { - // get current tickers - currentTickers, err := fiatRates.downloader.CurrentTickers() - if err != nil { - t.Fatalf("Error in CurrentTickers: %v", err) - return + // update historical tickers for the first time + err = fiatRates.downloader.UpdateHistoricalTickers() + if err != nil { + t.Fatalf("UpdateHistoricalTickers 1st pass failed with error: %v", err) + } + err = fiatRates.downloader.UpdateHistoricalTokenTickers() + if err != nil { + t.Fatalf("UpdateHistoricalTokenTickers 1st pass failed with error: %v", err) + } + + ticker, err = fiatRates.db.FiatRatesFindLastTicker("usd", "") + if err != nil || ticker == nil { + t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + } + wantTicker := common.CurrencyRatesTicker{ + Rates: map[string]float32{ + "aed": 241272.48, + "ars": 241272.48, + "aud": 241272.48, + "btc": 241272.48, + "eth": 241272.48, + "eur": 241272.48, + "ltc": 241272.48, + "usd": 1794.5397, + }, + TokenRates: map[string]float32{ + "0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.161734e+07, + "0x906710835d1ae85275eb770f06873340ca54274b": 4.161734e+07, + }, + Timestamp: time.Unix(1654732800, 0).UTC(), + } + if !reflect.DeepEqual(ticker, &wantTicker) { + t.Fatalf("UpdateHistoricalTickers(usd) 1st pass = %v, want %v", *ticker, wantTicker) + } + + ticker, err = fiatRates.db.FiatRatesFindLastTicker("eur", "") + if err != nil || ticker == nil { + t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + } + wantTicker = common.CurrencyRatesTicker{ + Rates: map[string]float32{ + "aed": 240402.97, + "ars": 240402.97, + "aud": 240402.97, + "btc": 240402.97, + "eth": 240402.97, + "eur": 240402.97, + "ltc": 240402.97, + }, + TokenRates: map[string]float32{ + "0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.1464476e+07, + "0x906710835d1ae85275eb770f06873340ca54274b": 4.1464476e+07, + }, + Timestamp: time.Unix(1654819200, 0).UTC(), + } + if !reflect.DeepEqual(ticker, &wantTicker) { + t.Fatalf("UpdateHistoricalTickers(eur) 1st pass = %v, want %v", *ticker, wantTicker) + } + + // update historical tickers for the second time + err = fiatRates.downloader.UpdateHistoricalTickers() + if err != nil { + t.Fatalf("UpdateHistoricalTickers 2nd pass failed with error: %v", err) + } + err = fiatRates.downloader.UpdateHistoricalTokenTickers() + if err != nil { + t.Fatalf("UpdateHistoricalTokenTickers 2nd pass failed with error: %v", err) + } + ticker, err = fiatRates.db.FiatRatesFindLastTicker("usd", "") + if err != nil || ticker == nil { + t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + } + wantTicker = common.CurrencyRatesTicker{ + Rates: map[string]float32{ + "aed": 240402.97, + "ars": 240402.97, + "aud": 240402.97, + "btc": 240402.97, + "eth": 240402.97, + "eur": 240402.97, + "ltc": 240402.97, + "usd": 1788.4183, + }, + TokenRates: map[string]float32{ + "0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.1464476e+07, + "0x906710835d1ae85275eb770f06873340ca54274b": 4.1464476e+07, + }, + Timestamp: time.Unix(1654819200, 0).UTC(), + } + if !reflect.DeepEqual(ticker, &wantTicker) { + t.Fatalf("UpdateHistoricalTickers(usd) 2nd pass = %v, want %v", *ticker, wantTicker) + } + ticker, err = fiatRates.db.FiatRatesFindLastTicker("eur", "") + if err != nil || ticker == nil { + t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + } + if !reflect.DeepEqual(ticker, &wantTicker) { + t.Fatalf("UpdateHistoricalTickers(eur) 2nd pass = %v, want %v", *ticker, wantTicker) + } +} + +func TestFiatRatesTronCurrentTickers_PreserveBase58TokenAddress(t *testing.T) { + resetCoingeckoTestCaches() + t.Cleanup(resetCoingeckoTestCaches) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + var mockData string + + switch r.URL.Path { + case "/coins/list": + mockData, err = getFiatRatesMockData("coinlist_tron") + case "/simple/supported_vs_currencies": + mockData, err = getFiatRatesMockData("vs_currencies_tron") + case "/simple/price": + if r.URL.Query().Get("ids") == "tron" { + mockData, err = getFiatRatesMockData("simpleprice_base_tron") + } else { + mockData, err = getFiatRatesMockData("simpleprice_tokens_tron") + } + default: + t.Fatalf("Unknown URL path: %v", r.URL.Path) } - if currentTickers == nil { - t.Fatalf("CurrentTickers returned nil value") - return + + if err != nil { + t.Fatalf("Error loading stub data: %v", err) } + fmt.Fprintln(w, mockData) + })) + defer mockServer.Close() + + config := common.Config{ + CoinName: "fakecoin", + CoinShortcut: "TRX", + FiatRates: "coingecko", + FiatRatesParams: `{"url": "` + mockServer.URL + `", "coin": "tron","platformIdentifier": "tron","platformVsCurrency": "trx","periodSeconds": 60}`, + } + + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + fiatRates, err := NewFiatRates(d, &config, nil, nil) + if err != nil { + t.Fatalf("FiatRates init error: %v", err) + } + coingeckoDownloader, ok := fiatRates.downloader.(*Coingecko) + if !ok { + t.Fatalf("unexpected downloader type: %T", fiatRates.downloader) + } + coingeckoDownloader.tipURL = mockServer.URL + + currentTickers, err := fiatRates.downloader.CurrentTickers() + if err != nil { + t.Fatalf("Error in CurrentTickers: %v", err) + } + if currentTickers == nil { + t.Fatal("CurrentTickers returned nil value") + } + + const tronUSDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + if got := currentTickers.TokenRates[tronUSDT]; got != 9 { + t.Fatalf("unexpected canonical tron token rate: got %v, want %v", got, float32(9)) + } + + rate, found := currentTickers.GetTokenRate(tronUSDT) + if !found { + t.Fatalf("expected tron token rate for canonical Base58 address %q", tronUSDT) + } + if rate != 9 { + t.Fatalf("unexpected tron token base rate: got %v, want %v", rate, float32(9)) + } +} - wantCurrentTickers := common.CurrencyRatesTicker{ - Rates: map[string]float32{ - "aed": 8447.1, - "ars": 268901, - "aud": 3314.36, - "btc": 0.07531005, - "eth": 1, - "eur": 2182.99, - "ltc": 29.097696, - "usd": 2299.72, +func TestGetTickersForTimestamps_UsesGranularityAndFallback(t *testing.T) { + fr := &FiatRates{ + Enabled: true, + currentTicker: &common.CurrencyRatesTicker{ + Timestamp: time.Unix(123456, 0).UTC(), + Rates: map[string]float32{"usd": 4}, + }, + fiveMinutesTickers: map[int64]*common.CurrencyRatesTicker{ + 600: { + Timestamp: time.Unix(600, 0).UTC(), + Rates: map[string]float32{"usd": 1}, + }, + }, + fiveMinutesTickersFrom: 600, + fiveMinutesTickersTo: 600, + hourlyTickers: map[int64]*common.CurrencyRatesTicker{ + 3600: { + Timestamp: time.Unix(3600, 0).UTC(), + Rates: map[string]float32{"usd": 2}, }, - TokenRates: map[string]float32{ - "0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 5.58195e-07, - "0x906710835d1ae85275eb770f06873340ca54274b": 1.39852e-10, + }, + hourlyTickersFrom: 3600, + hourlyTickersTo: 3600, + dailyTickers: map[int64]*common.CurrencyRatesTicker{ + 86400: { + Timestamp: time.Unix(86400, 0).UTC(), + Rates: map[string]float32{"usd": 3}, }, - Timestamp: currentTickers.Timestamp, + }, + dailyTickersFrom: 86400, + dailyTickersTo: 86400, + } + + tickers, err := fr.GetTickersForTimestamps([]int64{600, 3600, 86400, 90000}, "usd", "") + if err != nil { + t.Fatalf("GetTickersForTimestamps returned error: %v", err) + } + if tickers == nil || len(*tickers) != 4 { + t.Fatalf("unexpected ticker result shape: %+v", tickers) + } + + got := []float32{ + (*tickers)[0].Rates["usd"], + (*tickers)[1].Rates["usd"], + (*tickers)[2].Rates["usd"], + (*tickers)[3].Rates["usd"], + } + want := []float32{1, 2, 3, 4} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected rates: got %v, want %v", got, want) + } +} + +func TestGetTickersForTimestamps_ConcurrentReadersAndWriters(t *testing.T) { + fr := &FiatRates{Enabled: true} + + const ( + writers = 2 + readers = 8 + testDuration = 1200 * time.Millisecond + waitTimeout = 3 * time.Second + ) + + stop := make(chan struct{}) + errCh := make(chan error, readers) + readerCalls := make([]int, readers) + var wg sync.WaitGroup + + setState := func(counter int64) { + currentTicker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(123456+counter, 0).UTC(), + Rates: map[string]float32{"usd": float32(100 + counter%100)}, } - if !reflect.DeepEqual(currentTickers, &wantCurrentTickers) { - t.Fatalf("CurrentTickers() = %v, want %v", *currentTickers, wantCurrentTickers) + fr.mux.Lock() + fr.currentTicker = currentTicker + fr.fiveMinutesTickers = map[int64]*common.CurrencyRatesTicker{ + 600: { + Timestamp: time.Unix(600, 0).UTC(), + Rates: map[string]float32{"usd": float32(1 + counter%10)}, + }, } - - ticker, err := fiatRates.db.FiatRatesFindLastTicker("usd", "") - if err != nil { - t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + fr.fiveMinutesTickersFrom = 600 + fr.fiveMinutesTickersTo = 600 + fr.hourlyTickers = map[int64]*common.CurrencyRatesTicker{ + 3600: { + Timestamp: time.Unix(3600, 0).UTC(), + Rates: map[string]float32{"usd": float32(10 + counter%10)}, + }, } - if ticker != nil { - t.Fatalf("FiatRatesFindLastTicker found unexpected data") + fr.hourlyTickersFrom = 3600 + fr.hourlyTickersTo = 3600 + fr.dailyTickers = map[int64]*common.CurrencyRatesTicker{ + 86400: { + Timestamp: time.Unix(86400, 0).UTC(), + Rates: map[string]float32{"usd": float32(20 + counter%10)}, + }, } + fr.dailyTickersFrom = 86400 + fr.dailyTickersTo = 86400 + fr.mux.Unlock() + } - // update historical tickers for the first time - err = fiatRates.downloader.UpdateHistoricalTickers() - if err != nil { - t.Fatalf("UpdateHistoricalTickers 1st pass failed with error: %v", err) - } - err = fiatRates.downloader.UpdateHistoricalTokenTickers() + // Seed cache state before readers start. + setState(0) + + for w := 0; w < writers; w++ { + wg.Add(1) + go func(seed int) { + defer wg.Done() + + counter := int64(seed) + for { + select { + case <-stop: + return + default: + } + + setState(counter) + + counter++ + time.Sleep(100 * time.Microsecond) + } + }(w) + } + + for r := 0; r < readers; r++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + timestamps := []int64{600, 3600, 86400, 90000} + calls := 0 + for { + select { + case <-stop: + readerCalls[idx] = calls + return + default: + } + + tickers, err := fr.GetTickersForTimestamps(timestamps, "usd", "") + if err != nil { + errCh <- fmt.Errorf("reader %d returned error: %w", idx, err) + readerCalls[idx] = calls + return + } + if tickers == nil || len(*tickers) != len(timestamps) { + errCh <- fmt.Errorf("reader %d unexpected ticker shape: %+v", idx, tickers) + readerCalls[idx] = calls + return + } + for i, ticker := range *tickers { + if ticker == nil { + errCh <- fmt.Errorf("reader %d got nil ticker at index %d", idx, i) + readerCalls[idx] = calls + return + } + if _, found := ticker.Rates["usd"]; !found { + errCh <- fmt.Errorf("reader %d ticker at index %d missing usd rate", idx, i) + readerCalls[idx] = calls + return + } + } + calls++ + } + }(r) + } + + time.Sleep(testDuration) + close(stop) + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(waitTimeout): + t.Fatal("concurrent fiat readers/writers did not finish in time") + } + + close(errCh) + for err := range errCh { if err != nil { - t.Fatalf("UpdateHistoricalTokenTickers 1st pass failed with error: %v", err) + t.Fatal(err) } + } - ticker, err = fiatRates.db.FiatRatesFindLastTicker("usd", "") - if err != nil || ticker == nil { - t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + totalCalls := 0 + for i, calls := range readerCalls { + if calls == 0 { + t.Fatalf("reader %d did not make any successful calls", i) } - wantTicker := common.CurrencyRatesTicker{ - Rates: map[string]float32{ - "aed": 241272.48, - "ars": 241272.48, - "aud": 241272.48, - "btc": 241272.48, - "eth": 241272.48, - "eur": 241272.48, - "ltc": 241272.48, - "usd": 1794.5397, - }, - TokenRates: map[string]float32{ - "0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.161734e+07, - "0x906710835d1ae85275eb770f06873340ca54274b": 4.161734e+07, - }, - Timestamp: time.Unix(1654732800, 0).UTC(), + totalCalls += calls + } + if totalCalls < readers { + t.Fatalf("too few reader calls made: got %d", totalCalls) + } +} + +func TestGetTokenTickersForTimestamps_QueriesUniqueSortedTimestamps(t *testing.T) { + originalFindTickers := fiatRatesFindTickers + defer func() { + fiatRatesFindTickers = originalFindTickers + }() + + lookupCalls := make([]int64, 0) + batchCalls := 0 + fiatRatesFindTickers = func(_ *db.RocksDB, timestamps []int64, _, _ string) ([]*common.CurrencyRatesTicker, error) { + batchCalls++ + lookupCalls = append(lookupCalls, timestamps...) + tickers := make([]*common.CurrencyRatesTicker, len(timestamps)) + for i, ts := range timestamps { + tickers[i] = &common.CurrencyRatesTicker{ + Timestamp: time.Unix(ts, 0).UTC(), + Rates: map[string]float32{"usd": float32(ts)}, + TokenRates: map[string]float32{"token": 1}, + } } - if !reflect.DeepEqual(ticker, &wantTicker) { - t.Fatalf("UpdateHistoricalTickers(usd) 1st pass = %v, want %v", *ticker, wantTicker) + return tickers, nil + } + + fr := &FiatRates{ + currentTicker: &common.CurrencyRatesTicker{ + Timestamp: time.Unix(999, 0).UTC(), + Rates: map[string]float32{"usd": 1}, + TokenRates: map[string]float32{"token": 1}, + }, + } + input := []int64{300, 100, 200, 100, 250} + tickers, err := fr.getTokenTickersForTimestamps(input, "", "token") + if err != nil { + t.Fatalf("getTokenTickersForTimestamps returned error: %v", err) + } + if tickers == nil { + t.Fatal("expected non-nil tickers") + } + + if !reflect.DeepEqual(lookupCalls, []int64{100, 200, 250, 300}) { + t.Fatalf("unexpected DB lookup order: got %v", lookupCalls) + } + if batchCalls != 1 { + t.Fatalf("unexpected number of batch DB calls: got %d, want %d", batchCalls, 1) + } + + got := make([]float32, len(input)) + for i := range input { + if (*tickers)[i] == nil { + t.Fatalf("ticker at index %d is nil", i) } + got[i] = (*tickers)[i].Rates["usd"] + } + want := []float32{300, 100, 200, 100, 250} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected returned rates: got %v, want %v", got, want) + } +} + +func TestGetTokenTickersForTimestamps_SkipsDBLookupWhenCurrentTickerHasNoToken(t *testing.T) { + originalFindTickers := fiatRatesFindTickers + defer func() { + fiatRatesFindTickers = originalFindTickers + }() + + lookupCalls := 0 + fiatRatesFindTickers = func(_ *db.RocksDB, _ []int64, _, _ string) ([]*common.CurrencyRatesTicker, error) { + lookupCalls++ + return nil, nil + } + + fr := &FiatRates{ + currentTicker: &common.CurrencyRatesTicker{ + Timestamp: time.Unix(999, 0).UTC(), + Rates: map[string]float32{"usd": 1}, + TokenRates: map[string]float32{"another-token": 1}, + }, + } + tickers, err := fr.getTokenTickersForTimestamps([]int64{100, 200}, "", "token") + if err != nil { + t.Fatalf("getTokenTickersForTimestamps returned error: %v", err) + } + if lookupCalls != 0 { + t.Fatalf("expected 0 DB lookups, got %d", lookupCalls) + } + if tickers == nil || len(*tickers) != 2 { + t.Fatalf("unexpected ticker result shape: %+v", tickers) + } + if (*tickers)[0] != nil || (*tickers)[1] != nil { + t.Fatalf("expected nil tickers when current ticker does not include token, got %+v", *tickers) + } +} + +func TestNewFiatRates_AllowsBootstrapOnDefaultHistoricalURLWithoutAPIKey(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + FiatRates: "coingecko", + FiatRatesParams: `{"coin":"ethereum","periodSeconds":60}`, + } + d, is, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) - ticker, err = fiatRates.db.FiatRatesFindLastTicker("eur", "") - if err != nil || ticker == nil { - t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + // Ensure this test is deterministic even if host env has CoinGecko keys set. + envNames := append([]string{coingeckoAPIKeyEnv}, coinGeckoScopedAPIKeyEnvNames(is.GetNetwork(), is.CoinShortcut)...) + originalEnv := make(map[string]*string, len(envNames)) + for _, envName := range envNames { + if v, ok := os.LookupEnv(envName); ok { + value := v + originalEnv[envName] = &value + } else { + originalEnv[envName] = nil } - wantTicker = common.CurrencyRatesTicker{ - Rates: map[string]float32{ - "aed": 240402.97, - "ars": 240402.97, - "aud": 240402.97, - "btc": 240402.97, - "eth": 240402.97, - "eur": 240402.97, - "ltc": 240402.97, - }, - TokenRates: map[string]float32{ - "0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.1464476e+07, - "0x906710835d1ae85275eb770f06873340ca54274b": 4.1464476e+07, - }, - Timestamp: time.Unix(1654819200, 0).UTC(), + _ = os.Unsetenv(envName) + } + defer func() { + for _, envName := range envNames { + if v := originalEnv[envName]; v == nil { + _ = os.Unsetenv(envName) + } else { + _ = os.Setenv(envName, *v) + } + } + }() + + _, err := NewFiatRates(d, &config, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + complete, found, err := d.FiatRatesGetHistoricalBootstrapComplete() + if err != nil { + t.Fatalf("FiatRatesGetHistoricalBootstrapComplete failed: %v", err) + } + if !found || complete { + t.Fatalf("unexpected bootstrap state after init: found=%v complete=%v", found, complete) + } +} + +func TestNewFiatRates_AllowsNoKeyOrURLWhenHistoricalFiatAlreadyExists(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + FiatRates: "coingecko", + FiatRatesParams: `{"coin":"ethereum","periodSeconds":60}`, + } + d, is, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + // Seed any historical fiat ticker so the instance is no longer bootstrap-empty. + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + seedTicker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000000, 0).UTC(), + Rates: map[string]float32{ + "usd": 1, + }, + } + if err := d.FiatRatesStoreTicker(wb, seedTicker); err != nil { + t.Fatalf("FiatRatesStoreTicker failed: %v", err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatalf("WriteBatch failed: %v", err) + } + + envNames := append([]string{coingeckoAPIKeyEnv}, coinGeckoScopedAPIKeyEnvNames(is.GetNetwork(), is.CoinShortcut)...) + originalEnv := make(map[string]*string, len(envNames)) + for _, envName := range envNames { + if v, ok := os.LookupEnv(envName); ok { + value := v + originalEnv[envName] = &value + } else { + originalEnv[envName] = nil } - if !reflect.DeepEqual(ticker, &wantTicker) { - t.Fatalf("UpdateHistoricalTickers(eur) 1st pass = %v, want %v", *ticker, wantTicker) + _ = os.Unsetenv(envName) + } + defer func() { + for _, envName := range envNames { + if v := originalEnv[envName]; v == nil { + _ = os.Unsetenv(envName) + } else { + _ = os.Setenv(envName, *v) + } } + }() - // update historical tickers for the second time - err = fiatRates.downloader.UpdateHistoricalTickers() - if err != nil { - t.Fatalf("UpdateHistoricalTickers 2nd pass failed with error: %v", err) + _, err := NewFiatRates(d, &config, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + complete, found, err := d.FiatRatesGetHistoricalBootstrapComplete() + if err != nil { + t.Fatalf("FiatRatesGetHistoricalBootstrapComplete failed: %v", err) + } + if !found || !complete { + t.Fatalf("unexpected bootstrap state after successful init: found=%v complete=%v", found, complete) + } +} + +func TestNewFiatRates_AllowsBootstrapStateInProgressWithoutURLOrAPIKey(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + FiatRates: "coingecko", + FiatRatesParams: `{"coin":"ethereum","periodSeconds":60}`, + } + d, is, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + // Simulate interrupted bootstrap with partially populated DB. + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + seedTicker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000000, 0).UTC(), + Rates: map[string]float32{ + "usd": 1, + }, + } + if err := d.FiatRatesStoreTicker(wb, seedTicker); err != nil { + t.Fatalf("FiatRatesStoreTicker failed: %v", err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatalf("WriteBatch failed: %v", err) + } + if err := d.FiatRatesSetHistoricalBootstrapComplete(false); err != nil { + t.Fatalf("FiatRatesSetHistoricalBootstrapComplete failed: %v", err) + } + + envNames := append([]string{coingeckoAPIKeyEnv}, coinGeckoScopedAPIKeyEnvNames(is.GetNetwork(), is.CoinShortcut)...) + originalEnv := make(map[string]*string, len(envNames)) + for _, envName := range envNames { + if v, ok := os.LookupEnv(envName); ok { + value := v + originalEnv[envName] = &value + } else { + originalEnv[envName] = nil } - err = fiatRates.downloader.UpdateHistoricalTokenTickers() - if err != nil { - t.Fatalf("UpdateHistoricalTokenTickers 2nd pass failed with error: %v", err) + _ = os.Unsetenv(envName) + } + defer func() { + for _, envName := range envNames { + if v := originalEnv[envName]; v == nil { + _ = os.Unsetenv(envName) + } else { + _ = os.Setenv(envName, *v) + } } - ticker, err = fiatRates.db.FiatRatesFindLastTicker("usd", "") - if err != nil || ticker == nil { - t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + }() + + _, err := NewFiatRates(d, &config, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + complete, found, err := d.FiatRatesGetHistoricalBootstrapComplete() + if err != nil { + t.Fatalf("FiatRatesGetHistoricalBootstrapComplete failed: %v", err) + } + if !found || complete { + t.Fatalf("unexpected bootstrap state after init: found=%v complete=%v", found, complete) + } +} + +func TestRegisterHistoricalBootstrapAttemptFailure_MarksBootstrapCompleteAfterThreeFailures(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + } + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + if err := d.FiatRatesSetHistoricalBootstrapComplete(false); err != nil { + t.Fatalf("FiatRatesSetHistoricalBootstrapComplete failed: %v", err) + } + + for i := 1; i < maxHistoricalBootstrapAttempts; i++ { + attempts, exhausted, err := registerHistoricalBootstrapAttemptFailure(d) + if err != nil { + t.Fatalf("registerHistoricalBootstrapAttemptFailure failed: %v", err) } - wantTicker = common.CurrencyRatesTicker{ - Rates: map[string]float32{ - "aed": 240402.97, - "ars": 240402.97, - "aud": 240402.97, - "btc": 240402.97, - "eth": 240402.97, - "eur": 240402.97, - "ltc": 240402.97, - "usd": 1788.4183, - }, - TokenRates: map[string]float32{ - "0x5e9997684d061269564f94e5d11ba6ce6fa9528c": 4.1464476e+07, - "0x906710835d1ae85275eb770f06873340ca54274b": 4.1464476e+07, - }, - Timestamp: time.Unix(1654819200, 0).UTC(), + if exhausted { + t.Fatalf("attempt %d unexpectedly exhausted", i) } - if !reflect.DeepEqual(ticker, &wantTicker) { - t.Fatalf("UpdateHistoricalTickers(usd) 2nd pass = %v, want %v", *ticker, wantTicker) + if attempts != i { + t.Fatalf("unexpected attempts value: got %d, want %d", attempts, i) } - ticker, err = fiatRates.db.FiatRatesFindLastTicker("eur", "") - if err != nil || ticker == nil { - t.Fatalf("FiatRatesFindLastTicker failed with error: %v", err) + complete, found, err := d.FiatRatesGetHistoricalBootstrapComplete() + if err != nil { + t.Fatalf("FiatRatesGetHistoricalBootstrapComplete failed: %v", err) } - if !reflect.DeepEqual(ticker, &wantTicker) { - t.Fatalf("UpdateHistoricalTickers(eur) 2nd pass = %v, want %v", *ticker, wantTicker) + if !found || complete { + t.Fatalf("bootstrap state should remain incomplete before limit: found=%v complete=%v", found, complete) } } + + attempts, exhausted, err := registerHistoricalBootstrapAttemptFailure(d) + if err != nil { + t.Fatalf("registerHistoricalBootstrapAttemptFailure failed on limit: %v", err) + } + if !exhausted { + t.Fatalf("expected exhausted=true on attempt limit") + } + if attempts != maxHistoricalBootstrapAttempts { + t.Fatalf("unexpected attempts value on limit: got %d, want %d", attempts, maxHistoricalBootstrapAttempts) + } + + complete, found, err := d.FiatRatesGetHistoricalBootstrapComplete() + if err != nil { + t.Fatalf("FiatRatesGetHistoricalBootstrapComplete failed: %v", err) + } + if !found || !complete { + t.Fatalf("bootstrap should be marked complete after attempt limit: found=%v complete=%v", found, complete) + } + + storedAttempts, found, err := d.FiatRatesGetHistoricalBootstrapAttempts() + if err != nil { + t.Fatalf("FiatRatesGetHistoricalBootstrapAttempts failed: %v", err) + } + if !found || storedAttempts != 0 { + t.Fatalf("bootstrap attempts should be reset after exhaustion: found=%v attempts=%d", found, storedAttempts) + } +} + +func TestResetHistoricalBootstrapAttempts(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + } + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + if err := d.FiatRatesSetHistoricalBootstrapAttempts(2); err != nil { + t.Fatalf("FiatRatesSetHistoricalBootstrapAttempts failed: %v", err) + } + if err := resetHistoricalBootstrapAttempts(d); err != nil { + t.Fatalf("resetHistoricalBootstrapAttempts failed: %v", err) + } + attempts, found, err := d.FiatRatesGetHistoricalBootstrapAttempts() + if err != nil { + t.Fatalf("FiatRatesGetHistoricalBootstrapAttempts failed: %v", err) + } + if !found || attempts != 0 { + t.Fatalf("unexpected attempts after reset: found=%v attempts=%d", found, attempts) + } } diff --git a/fiat/mock_data/coinlist_tron.json b/fiat/mock_data/coinlist_tron.json new file mode 100644 index 0000000000..a0b0072b3a --- /dev/null +++ b/fiat/mock_data/coinlist_tron.json @@ -0,0 +1,16 @@ +[ + { + "id": "tron", + "symbol": "trx", + "name": "TRON", + "platforms": {} + }, + { + "id": "tether", + "symbol": "usdt", + "name": "Tether", + "platforms": { + "tron": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + } + } +] diff --git a/fiat/mock_data/simpleprice_base_tron.json b/fiat/mock_data/simpleprice_base_tron.json new file mode 100644 index 0000000000..a9104ac21b --- /dev/null +++ b/fiat/mock_data/simpleprice_base_tron.json @@ -0,0 +1,7 @@ +{ + "tron": { + "trx": 1.0, + "usd": 0.11110456, + "eur": 0.09508211 + } +} diff --git a/fiat/mock_data/simpleprice_tokens_tron.json b/fiat/mock_data/simpleprice_tokens_tron.json new file mode 100644 index 0000000000..07da8a6739 --- /dev/null +++ b/fiat/mock_data/simpleprice_tokens_tron.json @@ -0,0 +1,5 @@ +{ + "tether": { + "trx": 9.0 + } +} diff --git a/fiat/mock_data/vs_currencies_tron.json b/fiat/mock_data/vs_currencies_tron.json new file mode 100644 index 0000000000..8ac58840ab --- /dev/null +++ b/fiat/mock_data/vs_currencies_tron.json @@ -0,0 +1,5 @@ +[ + "trx", + "usd", + "eur" +] diff --git a/go.mod b/go.mod index aa12c96fc2..4154b8bd37 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,12 @@ module github.com/trezor/blockbook -go 1.19 +go 1.25.0 require ( - github.com/Groestlcoin/go-groestl-hash v0.0.0-20181012171753-790653ac190c // indirect - github.com/ava-labs/avalanchego v1.9.7 - github.com/ava-labs/coreth v0.11.6 + github.com/ava-labs/avalanchego v1.14.0 github.com/bsm/go-vlq v0.0.0-20150828105119-ec6e8d4f5f4e - github.com/dchest/blake256 v1.0.0 // indirect github.com/deckarep/golang-set v1.8.0 + github.com/decred/base58 v1.0.3 github.com/decred/dcrd/chaincfg/chainhash v1.0.2 github.com/decred/dcrd/chaincfg/v3 v3.0.0 github.com/decred/dcrd/dcrec v1.0.0 @@ -16,98 +14,74 @@ require ( github.com/decred/dcrd/dcrutil/v3 v3.0.0 github.com/decred/dcrd/hdkeychain/v3 v3.0.0 github.com/decred/dcrd/txscript/v3 v3.0.0 - github.com/ethereum/go-ethereum v1.10.26 - github.com/golang/glog v1.0.0 - github.com/golang/protobuf v1.5.2 - github.com/gorilla/websocket v1.4.2 + github.com/ethereum/go-ethereum v1.16.7 + github.com/golang/glog v1.2.1 + github.com/gorilla/websocket v1.5.0 github.com/juju/errors v0.0.0-20170703010042-c7d06af17c68 - github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8 // indirect - github.com/juju/testing v0.0.0-20191001232224-ce9dec17d28b // indirect - github.com/linxGnu/grocksdb v1.7.7 + github.com/linxGnu/grocksdb v1.9.8 github.com/martinboehm/bchutil v0.0.0-20190104112650-6373f11b6efe github.com/martinboehm/btcd v0.0.0-20221101112928-408689e15809 github.com/martinboehm/btcutil v0.0.0-20211010173611-6ef1889c1819 - github.com/martinboehm/golang-socketio v0.0.0-20180414165752-f60b0a8befde - github.com/mr-tron/base58 v1.2.0 // indirect github.com/pebbe/zmq4 v1.2.1 github.com/pirk/ecashaddr-converter v0.0.0-20220121162910-c6cb45163b29 github.com/pirk/ecashutil v0.0.0-20220124103933-d37f548d249e - github.com/prometheus/client_golang v1.13.0 + github.com/prometheus/client_golang v1.23.2 github.com/schancel/cashaddr-converter v0.0.0-20181111022653-4769e7add95a - golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d - google.golang.org/protobuf v1.28.1 - gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect + github.com/stretchr/testify v1.11.1 + github.com/tkrajina/typescriptify-golang-structs v0.1.11 + golang.org/x/crypto v0.43.0 + golang.org/x/sync v0.17.0 + google.golang.org/protobuf v1.36.10 ) require ( - github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/Groestlcoin/go-groestl-hash v0.0.0-20181012171753-790653ac190c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/PiRK/cashaddr-converter v0.0.0-20220121162910-c6cb45163b29 // indirect - github.com/VictoriaMetrics/fastcache v1.10.0 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/aead/siphash v1.0.1 // indirect github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect - github.com/cenkalti/backoff/v4 v4.1.3 // indirect - github.com/cespare/xxhash/v2 v2.1.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dchest/siphash v1.2.1 // indirect - github.com/decred/base58 v1.0.3 // indirect - github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect + github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dchest/blake256 v1.0.0 // indirect + github.com/dchest/siphash v1.2.3 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/crypto/ripemd160 v1.0.1 // indirect github.com/decred/dcrd/dcrec/edwards/v2 v2.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/wire v1.4.0 // indirect github.com/decred/slog v1.1.0 // indirect - github.com/go-logr/logr v1.2.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-stack/stack v1.8.0 // indirect - github.com/golang/mock v1.6.0 // indirect - github.com/golang/snappy v0.0.4 // indirect - github.com/google/uuid v1.2.0 // indirect - github.com/gorilla/mux v1.8.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect + github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/gorilla/rpc v1.2.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.12.0 // indirect - github.com/holiman/uint256 v1.2.0 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect - github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.37.0 // indirect - github.com/prometheus/procfs v0.8.0 // indirect - github.com/rjeczalik/notify v0.9.2 // indirect - github.com/rs/cors v1.7.0 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8 // indirect + github.com/juju/testing v0.0.0-20191001232224-ce9dec17d28b // indirect + github.com/kkdai/bstream v0.0.0-20171226095907-f71540b9dfdc // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.3 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect - github.com/stretchr/testify v1.8.1 // indirect - github.com/supranational/blst v0.3.11-0.20220920110316-f72618070295 // indirect - github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a // indirect - github.com/tklauser/go-sysconf v0.3.5 // indirect - github.com/tklauser/numcpus v0.2.2 // indirect - github.com/yusufpapurcu/wmi v1.2.2 // indirect - go.opentelemetry.io/otel v1.11.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.11.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.11.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.11.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.11.0 // indirect - go.opentelemetry.io/otel/sdk v1.11.0 // indirect - go.opentelemetry.io/otel/trace v1.11.0 // indirect - go.opentelemetry.io/proto/otlp v0.19.0 // indirect - go.uber.org/atomic v1.10.0 // indirect - go.uber.org/multierr v1.8.0 // indirect - go.uber.org/zap v1.24.0 // indirect - golang.org/x/exp v0.0.0-20220426173459-3bcf042a4bf5 // indirect - golang.org/x/net v0.7.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/term v0.5.0 // indirect - golang.org/x/text v0.7.0 // indirect - golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect - gonum.org/v1/gonum v0.11.0 // indirect - google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c // indirect - google.golang.org/grpc v1.50.1 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect - gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect + github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect + github.com/tkrajina/go-reflector v0.5.5 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/sys v0.37.0 // indirect + gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 548d00ef43..f7a41ec587 100644 --- a/go.sum +++ b/go.sum @@ -1,73 +1,27 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= +github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Groestlcoin/go-groestl-hash v0.0.0-20181012171753-790653ac190c h1:8bYNmjELeCj7DEh/dN7zFzkJ0upK3GkbOC/0u1HMQ5s= github.com/Groestlcoin/go-groestl-hash v0.0.0-20181012171753-790653ac190c/go.mod h1:DwgC62sAn4RgH4L+O8REgcE7f0XplHPNeRYFy+ffy1M= -github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= -github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PiRK/cashaddr-converter v0.0.0-20220121162910-c6cb45163b29 h1:B11BryeZQ1LrAzzM0lCpblwleB7SyxPfvN2AsNbyvQc= github.com/PiRK/cashaddr-converter v0.0.0-20220121162910-c6cb45163b29/go.mod h1:+39XiGr9m9TPY49sG4XIH5CVaRxHGFWT0U4MOY6dy3o= -github.com/VictoriaMetrics/fastcache v1.10.0 h1:5hDJnLsKLpnUEToub7ETuRu8RCkb40woBZAUiKonXzY= -github.com/VictoriaMetrics/fastcache v1.10.0/go.mod h1:tjiYeEfYXCqacuvYw/7UoDIeJaNxq6132xHICNP77w8= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= -github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/ava-labs/avalanchego v1.9.7 h1:f2vS8jUBZmrqPcfU5NEa7dSHXbKfTB0EyjcCyvqxqPw= -github.com/ava-labs/avalanchego v1.9.7/go.mod h1:ckdSQHeoRN6PmQ3TLgWAe6Kh9tFpU4Lu6MgDW4GrU/Q= -github.com/ava-labs/coreth v0.11.6 h1:kMCHfb37k4UyxkHwoUuciXC92eyIeowB/EKv15XKQ6s= -github.com/ava-labs/coreth v0.11.6/go.mod h1:xgjjJdl50zhHlWPP+3Ux5LxfvFcbSG60tGK6QUkFDhI= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/ava-labs/avalanchego v1.14.0 h1:0j314N1fEwstKSymvyhvvxi8Hr752xc6MQvjq6kGIJY= +github.com/ava-labs/avalanchego v1.14.0/go.mod h1:7sYTcQknONY5x5qzS+GrN+UtyB8kX7Q5ClHhGj1DgXg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bsm/go-vlq v0.0.0-20150828105119-ec6e8d4f5f4e h1:D64GF/Xr5zSUnM3q1Jylzo4sK7szhP/ON+nb2DB5XJA= github.com/bsm/go-vlq v0.0.0-20150828105119-ec6e8d4f5f4e/go.mod h1:N+BjUcTjSxc2mtRGSCPsat1kze3CUtvJN3/jTXlp29k= -github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= -github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= @@ -79,44 +33,51 @@ github.com/btcsuite/snappy-go v1.0.0 h1:ZxaA6lo2EpxGddsA8JwWOcxlzRybb444sgmeJQMJ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= -github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= -github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= +github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/blake256 v1.0.0 h1:6gUgI5MHdz9g0TdrgKqXsoDX+Zjxmm1Sc6OsoGru50I= github.com/dchest/blake256 v1.0.0/go.mod h1:xXNWCE1jsAP8DAjP+rKw2MbeqLczjI3TRx2VK+9OEYY= -github.com/dchest/siphash v1.2.1 h1:4cLinnzVJDKxTCl9B01807Yiy+W7ZzVHj/KIroQRvT4= github.com/dchest/siphash v1.2.1/go.mod h1:q+IRvb2gOSrUnYoPqHiyHXS0FOBBOdl6tONBlVnOnt4= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/base58 v1.0.3 h1:KGZuh8d1WEMIrK0leQRM47W85KqCAdl2N+uagbctdDI= github.com/decred/base58 v1.0.3/go.mod h1:pXP9cXCfM2sFLb2viz2FNIdeMWmZDBKG3ZBYbiSM78E= github.com/decred/dcrd/chaincfg/chainhash v1.0.2 h1:rt5Vlq/jM3ZawwiacWjPa+smINyLRN07EO0cNBV6DGU= github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= github.com/decred/dcrd/chaincfg/v3 v3.0.0 h1:+TFbu7ZmvBwM+SZz5mrj6cun9ts/6DAL5sqnsaFBHGQ= github.com/decred/dcrd/chaincfg/v3 v3.0.0/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= -github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/crypto/ripemd160 v1.0.1 h1:TjRL4LfftzTjXzaufov96iDAkbY2R3aTvH2YMYa1IOc= github.com/decred/dcrd/crypto/ripemd160 v1.0.1/go.mod h1:F0H8cjIuWTRoixr/LM3REB8obcWkmYx0gbxpQWR8RPg= github.com/decred/dcrd/dcrec v1.0.0 h1:W+z6Es+Rai3MXYVoPAxYr5U1DGis0Co33scJ6uH2J6o= @@ -125,8 +86,8 @@ github.com/decred/dcrd/dcrec/edwards/v2 v2.0.1 h1:V6eqU1crZzuoFT4KG2LhaU5xDSdkHu github.com/decred/dcrd/dcrec/edwards/v2 v2.0.1/go.mod h1:d0H8xGMWbiIQP7gN3v2rByWUcuZPm9YsgmnfoxgbINc= github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0 h1:sgNeV1VRMDzs6rzyPpxyM0jp317hnwiq58Filgag2xw= github.com/decred/dcrd/dcrec/secp256k1/v3 v3.0.0/go.mod h1:J70FGZSbzsjecRTiTzER+3f1KZLNaXkuv+yeFTKoxM8= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/dcrjson/v3 v3.0.1 h1:b9cpplNJG+nutE2jS8K/BtSGIJihEQHhFjFAsvJF/iI= github.com/decred/dcrd/dcrjson/v3 v3.0.1/go.mod h1:fnTHev/ABGp8IxFudDhjGi9ghLiXRff1qZz/wvq12Mg= github.com/decred/dcrd/dcrutil/v3 v3.0.0 h1:n6uQaTQynIhCY89XsoDk2WQqcUcnbD+zUM9rnZcIOZo= @@ -140,163 +101,81 @@ github.com/decred/dcrd/wire v1.4.0 h1:KmSo6eTQIvhXS0fLBQ/l7hG7QLcSJQKSwSyzSqJYDk github.com/decred/dcrd/wire v1.4.0/go.mod h1:WxC/0K+cCAnBh+SKsRjIX9YPgvrjhmE+6pZlel1G7Ro= github.com/decred/slog v1.1.0 h1:uz5ZFfmaexj1rEDgZvzQ7wjGkoSPjw2LCh8K+K1VrW4= github.com/decred/slog v1.1.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= -github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/ethereum/go-ethereum v1.10.26 h1:i/7d9RBBwiXCEuyduBQzJw/mKmnvzsN14jqBmytw72s= -github.com/ethereum/go-ethereum v1.10.26/go.mod h1:EYFyF19u3ezGLD4RqOkLq+ZCXzYbLoNDdZlMt7kyKFg= -github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5 h1:FtmdgXiUlNeRsoNMFlKLDt+S+6hbjVMEW6RGQ7aUf7c= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08 h1:f6D9Hr8xV8uYKlyuj8XIruxlh9WjVjdh1gIicAS7ays= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= -github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= +github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ= +github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= +github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= +github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/getsentry/sentry-go v0.35.0 h1:+FJNlnjJsZMG3g0/rmmP7GiKjQoUF5EXfEtBwtPtkzY= +github.com/getsentry/sentry-go v0.35.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang-jwt/jwt/v4 v4.3.0 h1:kHL1vqdqWNfATmA0FNMdmZNMyZI1U6O31X4rlIPoBog= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ= -github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/glog v1.2.1 h1:OptwRhECazUx5ix5TTWC3EZhsZEHWcYWY4FQHTIubm4= +github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/rpc v1.2.0 h1:WvvdC2lNeT1SP32zrIce5l0ECBfbAlmrmSBsuc57wfk= github.com/gorilla/rpc v1.2.0/go.mod h1:V4h9r+4sF5HnzqbwIez0fKSpANP0zlYd3qR7p36jkTQ= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.12.0 h1:kr3j8iIMR4ywO/O0rvksXaJvauGGCMg2zAZIiNZ9uIQ= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.12.0/go.mod h1:ummNFgdgLhhX7aIiy35vVmQNS0rWXknfPE0qe6fmFXg= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d h1:dg1dEPuWpEqDnvIw251EVy4zlP8gWbsGj4BsUKCRpYs= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= -github.com/holiman/uint256 v1.2.0 h1:gpSYcPLWGv4sG43I2mVLiDZCNDh/EpGjSk8tmtxitHM= -github.com/holiman/uint256 v1.2.0/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/juju/errors v0.0.0-20170703010042-c7d06af17c68 h1:d2hBkTvi7B89+OXY8+bBBshPlc+7JYacGrG/dFak8SQ= github.com/juju/errors v0.0.0-20170703010042-c7d06af17c68/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q= github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8 h1:UUHMLvzt/31azWTN/ifGWef4WUqvXk0iRqdhdy/2uzI= github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= github.com/juju/testing v0.0.0-20191001232224-ce9dec17d28b h1:Rrp0ByJXEjhREMPGTt3aWYjoIsUGCbt21ekbeJcTWv0= github.com/juju/testing v0.0.0-20191001232224-ce9dec17d28b/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kkdai/bstream v0.0.0-20171226095907-f71540b9dfdc h1:I1QApI4r4SG8Hh45H0yRjVnThWRn1oOwod76rrAe5KE= github.com/kkdai/bstream v0.0.0-20171226095907-f71540b9dfdc/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/linxGnu/grocksdb v1.7.7 h1:b6o8gagb4FL+P55qUzPchBR/C0u1lWjJOWQSWbhvTWg= -github.com/linxGnu/grocksdb v1.7.7/go.mod h1:0hTf+iA+GOr0jDX4CgIYyJZxqOH9XlBh6KVj8+zmF34= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/linxGnu/grocksdb v1.9.8 h1:vOIKv9/+HKiqJAElJIEYv3ZLcihRxyP7Suu/Mu8Dxjs= +github.com/linxGnu/grocksdb v1.9.8/go.mod h1:C3CNe9UYc9hlEM2pC82AqiGS3LRW537u9LFV4wIZuHk= github.com/martinboehm/bchutil v0.0.0-20190104112650-6373f11b6efe h1:khZWpHuxJNh2EGzBbaS6EQ2d6KxgK31WeG0TnlTMUD4= github.com/martinboehm/bchutil v0.0.0-20190104112650-6373f11b6efe/go.mod h1:0hw4tpGU+9slqN/DrevhjTMb0iR9esxzpCdx8I6/UzU= github.com/martinboehm/btcd v0.0.0-20190104121910-8e7c0427fee5/go.mod h1:rKQj/jGwFruYjpM6vN+syReFoR0DsLQaajhyH/5mwUE= @@ -306,516 +185,136 @@ github.com/martinboehm/btcutil v0.0.0-20180706230648-ab6388e0c60a/go.mod h1:NIvi github.com/martinboehm/btcutil v0.0.0-20210922221517-e83b0c752949/go.mod h1:8iJaVY/VHW6lnojpTXf5X4gF2dx81Xtj2R6lJp2colA= github.com/martinboehm/btcutil v0.0.0-20211010173611-6ef1889c1819 h1:ra2UymMEDhR0CVxqz/0minCNXO8YMeZwxdnnFDpWVJ0= github.com/martinboehm/btcutil v0.0.0-20211010173611-6ef1889c1819/go.mod h1:/Z9FhVDXTih0kZExhK2hRvM+z68XkmbqZhFDU3bU1jY= -github.com/martinboehm/golang-socketio v0.0.0-20180414165752-f60b0a8befde h1:Tz7WkXgQjeQVymqSQkEapbe/ZuzKCvb6GANFHnl0uAE= -github.com/martinboehm/golang-socketio v0.0.0-20180414165752-f60b0a8befde/go.mod h1:p35TWcm7GkAwvPcUCEq4H+yTm0gA8Aq7UvGnbK6olQk= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d h1:AREM5mwr4u1ORQBMvzfzBgpsctsbQikCVpvC+tX285E= -github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.4.1 h1:PZSj/UFNaVp3KxrzHOcS7oyuWA7LoOY/77yCTEFu21U= github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.24.0 h1:+0glovB9Jd6z3VR+ScSwQqXVTIfJcGA9UBM8yzQxhqg= github.com/pebbe/zmq4 v1.2.1 h1:jrXQW3mD8Si2mcSY/8VBs2nNkK/sKCOEM0rHAfxyc8c= github.com/pebbe/zmq4 v1.2.1/go.mod h1:7N4y5R18zBiu3l0vajMUWQgZyjv464prE8RCyBcmnZM= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pirk/ecashaddr-converter v0.0.0-20220121162910-c6cb45163b29 h1:awILOeL107zIYvPB1zhkz6ZTp0AaMpLGMoV16DMairA= github.com/pirk/ecashaddr-converter v0.0.0-20220121162910-c6cb45163b29/go.mod h1:ATZjpmb9u55Kcrd5M/ca/40H73BZLhduMzCmGwpfWw0= github.com/pirk/ecashutil v0.0.0-20220124103933-d37f548d249e h1:WrnL52yXO0jNpHC7UbthJl9mnHPHY7bW3xzmWIuWzh8= github.com/pirk/ecashutil v0.0.0-20220124103933-d37f548d249e/go.mod h1:y/B3gomTdd1s23RvcBij/X738fcTobeupT30EhV6nPE= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU= -github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE= -github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= -github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= -github.com/prometheus/tsdb v0.10.0 h1:If5rVCMTp6W2SiRAQFlbpJNgVlgMEd+U2GZckwK38ic= -github.com/rjeczalik/notify v0.9.2 h1:MiTWrPj55mNDHEiIX5YUSKefw/+lCQVoAFmD6oQm5w8= -github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schancel/cashaddr-converter v0.0.0-20181111022653-4769e7add95a h1:q2+wHBv8gDQRRPfxvRez8etJUp9VNnBDQhiUW4W5AKg= github.com/schancel/cashaddr-converter v0.0.0-20181111022653-4769e7add95a/go.mod h1:FdhEqBlgflrdbBs+Wh94EXSNJT+s6DTVvsHGMo0+u80= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/status-im/keycard-go v0.0.0-20200402102358-957c09536969 h1:Oo2KZNP70KE0+IUJSidPj/BFS/RXNHmKIJOdckzml2E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/supranational/blst v0.3.11-0.20220920110316-f72618070295 h1:rVKS9JjtqE4/PscoIsP46sRnJhfq8YFbjlk0fUJTRnY= -github.com/supranational/blst v0.3.11-0.20220920110316-f72618070295/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a h1:1ur3QoCqvE5fl+nylMaIr9PVV1w343YRDtsy+Rwu7XI= github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= -github.com/tklauser/go-sysconf v0.3.5 h1:uu3Xl4nkLzQfXNsWn15rPc/HQCJKObbt1dKJeWp3vU4= -github.com/tklauser/go-sysconf v0.3.5/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= -github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefldA= -github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= -github.com/tyler-smith/go-bip39 v1.0.2 h1:+t3w+KwLXO6154GNJY+qUtIxLTmFjfUmpguQT1OlOT8= -github.com/urfave/cli/v2 v2.10.2 h1:x3p8awjp/2arX+Nl/G2040AZpOCHS/eMJJ1/a+mye4Y= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= -github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opentelemetry.io/otel v1.11.0 h1:kfToEGMDq6TrVrJ9Vht84Y8y9enykSZzDDZglV0kIEk= -go.opentelemetry.io/otel v1.11.0/go.mod h1:H2KtuEphyMvlhZ+F7tg9GRhAOe60moNx61Ex+WmiKkk= -go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.11.0 h1:0dly5et1i/6Th3WHn0M6kYiJfFNzhhxanrJ0bOfnjEo= -go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.11.0/go.mod h1:+Lq4/WkdCkjbGcBMVHHg2apTbv8oMBf29QCnyCCJjNQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.11.0 h1:eyJ6njZmH16h9dOKCi7lMswAnGsSOwgTqWzfxqcuNr8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.11.0/go.mod h1:FnDp7XemjN3oZ3xGunnfOUTVwd2XcvLbtRAuOSU3oc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.11.0 h1:j2RFV0Qdt38XQ2Jvi4WIsQ56w8T7eSirYbMw19VXRDg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.11.0/go.mod h1:pILgiTEtrqvZpoiuGdblDgS5dbIaTgDrkIuKfEFkt+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.11.0 h1:v29I/NbVp7LXQYMFZhU6q17D0jSEbYOAVONlrO1oH5s= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.11.0/go.mod h1:/RpLsmbQLDO1XCbWAM4S6TSwj8FKwwgyKKyqtvVfAnw= -go.opentelemetry.io/otel/sdk v1.11.0 h1:ZnKIL9V9Ztaq+ME43IUi/eo22mNsb6a7tGfzaOWB5fo= -go.opentelemetry.io/otel/sdk v1.11.0/go.mod h1:REusa8RsyKaq0OlyangWXaw97t2VogoO4SSEeKkSTAk= -go.opentelemetry.io/otel/trace v1.11.0 h1:20U/Vj42SX+mASlXLmSGBg6jpI1jQtv682lZtTAOVFI= -go.opentelemetry.io/otel/trace v1.11.0/go.mod h1:nyYjis9jy0gytE9LXGU+/m1sHTKbRY0fX0hulNNDP1U= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw= -go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= -go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= -go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= -go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/tkrajina/go-reflector v0.5.5 h1:gwoQFNye30Kk7NrExj8zm3zFtrGPqOkzFMLuQZg1DtQ= +github.com/tkrajina/go-reflector v0.5.5/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/tkrajina/typescriptify-golang-structs v0.1.11 h1:zEIVczF/iWgs4eTY7NQqbBe23OVlFVk9sWLX/FDYi4Q= +github.com/tkrajina/typescriptify-golang-structs v0.1.11/go.mod h1:sjU00nti/PMEOZb07KljFlR+lJ+RotsC0GBQMv9EKls= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20220426173459-3bcf042a4bf5 h1:rxKZ2gOnYxjfmakvUUqh9Gyb6KXfrj7JWTxORTYqb0E= -golang.org/x/exp v0.0.0-20220426173459-3bcf042a4bf5/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4= +golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -gonum.org/v1/gonum v0.11.0 h1:f1IJhK4Km5tBJmaiJXtk/PkL4cdVX6J+tGiM187uT5E= -gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c h1:QgY/XxIAIeccR+Ca/rDdKubLIU9rcJ3xfy1DC/Wd2Oo= -google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY= -google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 h1:VpOs+IwYnYBaFnrNAeB8UUWtL3vEUnzSCL1nVjPhqrw= gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= -gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= -gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/urfave/cli.v1 v1.20.0 h1:NdAVW6RYxDif9DhDHaAortIu956m2c0v+09AZBPTbE0= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/server/html_templates.go b/server/html_templates.go new file mode 100644 index 0000000000..d73524313c --- /dev/null +++ b/server/html_templates.go @@ -0,0 +1,424 @@ +package server + +import ( + "encoding/json" + "fmt" + "html" + "html/template" + "math/big" + "net/http" + "runtime/debug" + "strconv" + "strings" + "time" + + "github.com/golang/glog" + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/common" +) + +// getContentSecurityPolicy returns a Content Security Policy header value +// to help prevent XSS attacks by controlling which resources can be loaded. +// +// Note: Uses 'unsafe-inline' for scripts and styles due to inline QRCode initialization +// and Bootstrap requirements. Consider migrating to nonces for better security. +func getContentSecurityPolicy() string { + return "default-src 'self'; " + + "script-src 'self' 'unsafe-inline'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https: ipfs: https://ipfs.io; " + + "connect-src 'self' https: ipfs: https://ipfs.io; " + + "font-src 'self' data:; " + + "object-src 'none'; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self'; " + + "upgrade-insecure-requests;" +} + +type tpl int + +const ( + noTpl = tpl(iota) + errorTpl + errorInternalTpl +) + +// htmlTemplateHandler is a handle to public http server +type htmlTemplates[TD any] struct { + metrics *common.Metrics + templates []*template.Template + debug bool + newTemplateData func(r *http.Request) *TD + newTemplateDataWithError func(error *api.APIError, r *http.Request) *TD + parseTemplates func() []*template.Template + postHtmlTemplateHandler func(data *TD, w http.ResponseWriter, r *http.Request) +} + +func (s *htmlTemplates[TD]) jsonHandler(handler func(r *http.Request, apiVersion int) (interface{}, error), apiVersion int) func(w http.ResponseWriter, r *http.Request) { + type jsonError struct { + Text string `json:"error"` + HTTPStatus int `json:"-"` + } + handlerName := getFunctionName(handler) + return func(w http.ResponseWriter, r *http.Request) { + var data interface{} + var err error + defer func() { + if e := recover(); e != nil { + glog.Error(handlerName, " recovered from panic: ", e) + debug.PrintStack() + if s.debug { + data = jsonError{fmt.Sprint("Internal server error: recovered from panic ", e), http.StatusInternalServerError} + } else { + data = jsonError{"Internal server error", http.StatusInternalServerError} + } + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Security-Policy", getContentSecurityPolicy()) + if e, isError := data.(jsonError); isError { + w.WriteHeader(e.HTTPStatus) + } + err = json.NewEncoder(w).Encode(data) + if err != nil { + glog.Warning("json encode ", err) + } + if s.metrics != nil { + s.metrics.ExplorerPendingRequests.With((common.Labels{"method": handlerName})).Dec() + } + }() + if s.metrics != nil { + s.metrics.ExplorerPendingRequests.With((common.Labels{"method": handlerName})).Inc() + } + data, err = handler(r, apiVersion) + if err != nil || data == nil { + if apiErr, ok := err.(*api.APIError); ok { + if apiErr.Public { + data = jsonError{apiErr.Error(), http.StatusBadRequest} + } else { + data = jsonError{apiErr.Error(), http.StatusInternalServerError} + } + } else { + if err != nil { + glog.Error(handlerName, " error: ", err) + } + if s.debug { + if data != nil { + data = jsonError{fmt.Sprintf("Internal server error: %v, data %+v", err, data), http.StatusInternalServerError} + } else { + data = jsonError{fmt.Sprintf("Internal server error: %v", err), http.StatusInternalServerError} + } + } else { + data = jsonError{"Internal server error", http.StatusInternalServerError} + } + } + } + } +} + +func (s *htmlTemplates[TD]) htmlTemplateHandler(handler func(w http.ResponseWriter, r *http.Request) (tpl, *TD, error)) func(w http.ResponseWriter, r *http.Request) { + handlerName := getFunctionName(handler) + return func(w http.ResponseWriter, r *http.Request) { + var t tpl + var data *TD + var err error + defer func() { + if e := recover(); e != nil { + glog.Error(handlerName, " recovered from panic: ", e) + debug.PrintStack() + t = errorInternalTpl + if s.debug { + data = s.newTemplateDataWithError(&api.APIError{Text: fmt.Sprint("Internal server error: recovered from panic ", e)}, r) + } else { + data = s.newTemplateDataWithError(&api.APIError{Text: "Internal server error"}, r) + } + } + // noTpl means the handler completely handled the request + if t != noTpl { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", getContentSecurityPolicy()) + // return 500 Internal Server Error with errorInternalTpl + if t == errorInternalTpl { + w.WriteHeader(http.StatusInternalServerError) + } + if err := s.templates[t].ExecuteTemplate(w, "base.html", data); err != nil { + glog.Error(err) + } + } + if s.metrics != nil { + s.metrics.ExplorerPendingRequests.With((common.Labels{"method": handlerName})).Dec() + } + }() + if s.metrics != nil { + s.metrics.ExplorerPendingRequests.With((common.Labels{"method": handlerName})).Inc() + } + if s.debug { + // reload templates on each request + // to reflect changes during development + s.templates = s.parseTemplates() + } + t, data, err = handler(w, r) + if err != nil || (data == nil && t != noTpl) { + t = errorInternalTpl + if apiErr, ok := err.(*api.APIError); ok { + data = s.newTemplateDataWithError(apiErr, r) + if apiErr.Public { + t = errorTpl + } + } else { + if err != nil { + glog.Error(handlerName, " error: ", err) + } + if s.debug { + data = s.newTemplateDataWithError(&api.APIError{Text: fmt.Sprintf("Internal server error: %v, data %+v", err, data)}, r) + } else { + data = s.newTemplateDataWithError(&api.APIError{Text: "Internal server error"}, r) + } + } + } + if s.postHtmlTemplateHandler != nil { + s.postHtmlTemplateHandler(data, w, r) + } + + } +} + +func relativeTimeUnit(d int64) string { + var u string + if d < 60 { + if d == 1 { + u = " sec" + } else { + u = " secs" + } + } else if d < 3600 { + d /= 60 + if d == 1 { + u = " min" + } else { + u = " mins" + } + } else if d < 3600*24 { + d /= 3600 + if d == 1 { + u = " hour" + } else { + u = " hours" + } + } else { + d /= 3600 * 24 + if d == 1 { + u = " day" + } else { + u = " days" + } + } + return strconv.FormatInt(d, 10) + u +} + +func relativeTime(d int64) string { + r := relativeTimeUnit(d) + if d > 3600*24 { + d = d % (3600 * 24) + if d >= 3600 { + r += " " + relativeTimeUnit(d) + } + } else if d > 3600 { + d = d % 3600 + if d >= 60 { + r += " " + relativeTimeUnit(d) + } + } + return r +} + +func unixTimeSpan(ut int64) template.HTML { + t := time.Unix(ut, 0) + return timeSpan(&t) +} + +var timeNow = time.Now + +func timeSpan(t *time.Time) template.HTML { + if t == nil { + return "" + } + u := t.Unix() + if u <= 0 { + return "" + } + d := timeNow().Unix() - u + f := t.UTC().Format("2006-01-02 15:04:05") + if d < 0 { + return template.HTML(f) + } + r := relativeTime(d) + return template.HTML(`` + r + " ago") +} + +func toJSON(data interface{}) string { + json, err := json.Marshal(data) + if err != nil { + return "" + } + return string(json) +} + +func formatAmountWithDecimals(a *api.Amount, d int) string { + if a == nil { + return "0" + } + return a.DecimalString(d) +} + +func appendAmountSpan(rv *strings.Builder, class, amount, shortcut, txDate string) { + rv.WriteString(`") + i := strings.IndexByte(amount, '.') + if i < 0 { + appendSeparatedNumberSpans(rv, amount, "nc") + } else { + appendSeparatedNumberSpans(rv, amount[:i], "nc") + rv.WriteString(`.`) + rv.WriteString(``) + appendLeftSeparatedNumberSpans(rv, amount[i+1:], "ns") + rv.WriteString("") + } + if shortcut != "" { + rv.WriteString(" ") + rv.WriteString(html.EscapeString(shortcut)) + } + rv.WriteString("") +} + +func appendAmountSpanBitcoinType(rv *strings.Builder, class, amount, shortcut, txDate string) { + if amount == "0" { + appendAmountSpan(rv, class, amount, shortcut, txDate) + return + } + rv.WriteString(`") + i := strings.IndexByte(amount, '.') + var decimals string + if i < 0 { + appendSeparatedNumberSpans(rv, amount, "nc") + decimals = "00000000" + } else { + appendSeparatedNumberSpans(rv, amount[:i], "nc") + decimals = amount[i+1:] + "00000000" + } + rv.WriteString(`.`) + rv.WriteString(``) + rv.WriteString(decimals[:2]) + rv.WriteString(``) + rv.WriteString(decimals[2:5]) + rv.WriteString("") + rv.WriteString(``) + rv.WriteString(decimals[5:8]) + rv.WriteString("") + rv.WriteString("") + if shortcut != "" { + rv.WriteString(" ") + rv.WriteString(html.EscapeString(shortcut)) + } + rv.WriteString("") +} + +func appendAmountWrapperSpan(rv *strings.Builder, primary, symbol, classes string) { + rv.WriteString(``) +} + +func formatInt(i int) template.HTML { + return formatInt64(int64(i)) +} + +func formatUint32(i uint32) template.HTML { + return formatInt64(int64(i)) +} + +func appendSeparatedNumberSpans(rv *strings.Builder, s, separatorClass string) { + if len(s) > 0 && s[0] == '-' { + s = s[1:] + rv.WriteByte('-') + } + t := (len(s) - 1) / 3 + if t <= 0 { + rv.WriteString(s) + } else { + t *= 3 + rv.WriteString(s[:len(s)-t]) + for i := len(s) - t; i < len(s); i += 3 { + rv.WriteString(``) + rv.WriteString(s[i : i+3]) + rv.WriteString("") + } + } +} + +func appendLeftSeparatedNumberSpans(rv *strings.Builder, s, separatorClass string) { + l := len(s) + if l <= 3 { + rv.WriteString(s) + } else { + rv.WriteString(s[:3]) + for i := 3; i < len(s); i += 3 { + rv.WriteString(``) + e := i + 3 + if e > l { + e = l + } + rv.WriteString(s[i:e]) + rv.WriteString("") + } + } +} + +func formatInt64(i int64) template.HTML { + s := strconv.FormatInt(i, 10) + var rv strings.Builder + appendSeparatedNumberSpans(&rv, s, "ns") + return template.HTML(rv.String()) +} + +func formatBigInt(i *big.Int) template.HTML { + if i == nil { + return "" + } + s := i.String() + var rv strings.Builder + appendSeparatedNumberSpans(&rv, s, "ns") + return template.HTML(rv.String()) +} diff --git a/server/html_templates_test.go b/server/html_templates_test.go new file mode 100644 index 0000000000..22b1b2cde5 --- /dev/null +++ b/server/html_templates_test.go @@ -0,0 +1,444 @@ +//go:build unittest + +package server + +import ( + "bytes" + "html/template" + "reflect" + "strings" + "testing" + "time" + + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/bchain" +) + +func Test_formatInt64(t *testing.T) { + tests := []struct { + name string + n int64 + want template.HTML + }{ + {"1", 1, "1"}, + {"13", 13, "13"}, + {"123", 123, "123"}, + {"1234", 1234, `1234`}, + {"91234", 91234, `91234`}, + {"891234", 891234, `891234`}, + {"7891234", 7891234, `7891234`}, + {"67891234", 67891234, `67891234`}, + {"567891234", 567891234, `567891234`}, + {"4567891234", 4567891234, `4567891234`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatInt64(tt.n); !reflect.DeepEqual(got, tt.want) { + t.Errorf("formatInt64() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_formatTime(t *testing.T) { + timeNow = fixedTimeNow + tests := []struct { + name string + want template.HTML + }{ + { + name: "2020-12-23 15:16:17", + want: `630 days 21 hours ago`, + }, + { + name: "2022-08-23 11:12:13", + want: `23 days 1 hour ago`, + }, + { + name: "2022-09-14 11:12:13", + want: `1 day 1 hour ago`, + }, + { + name: "2022-09-14 14:12:13", + want: `22 hours 31 mins ago`, + }, + { + name: "2022-09-15 09:33:26", + want: `3 hours 10 mins ago`, + }, + { + name: "2022-09-15 12:23:56", + want: `20 mins ago`, + }, + { + name: "2022-09-15 12:24:07", + want: `19 mins ago`, + }, + { + name: "2022-09-15 12:43:21", + want: `35 secs ago`, + }, + { + name: "2022-09-15 12:43:56", + want: `0 secs ago`, + }, + { + name: "2022-09-16 12:43:56", + want: `2022-09-16 12:43:56`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tm, _ := time.Parse("2006-01-02 15:04:05", tt.name) + if got := timeSpan(&tm); !reflect.DeepEqual(got, tt.want) { + t.Errorf("formatTime() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_appendAmountSpan(t *testing.T) { + tests := []struct { + name string + class string + amount string + shortcut string + txDate string + want string + }{ + { + name: "prim-amt 1.23456789 BTC", + class: "prim-amt", + amount: "1.23456789", + shortcut: "BTC", + want: `1.23456789 BTC`, + }, + { + name: "prim-amt 1432134.23456 BTC", + class: "prim-amt", + amount: "1432134.23456", + shortcut: "BTC", + want: `1432134.23456 BTC`, + }, + { + name: "sec-amt 1 EUR", + class: "sec-amt", + amount: "1", + shortcut: "EUR", + want: `1 EUR`, + }, + { + name: "sec-amt -1 EUR", + class: "sec-amt", + amount: "-1", + shortcut: "EUR", + want: `-1 EUR`, + }, + { + name: "sec-amt 432109.23 EUR", + class: "sec-amt", + amount: "432109.23", + shortcut: "EUR", + want: `432109.23 EUR`, + }, + { + name: "sec-amt -432109.23 EUR", + class: "sec-amt", + amount: "-432109.23", + shortcut: "EUR", + want: `-432109.23 EUR`, + }, + { + name: "sec-amt 43141.29 EUR", + class: "sec-amt", + amount: "43141.29", + shortcut: "EUR", + txDate: "2022-03-14", + want: `43141.29 EUR`, + }, + { + name: "sec-amt -43141.29 EUR", + class: "sec-amt", + amount: "-43141.29", + shortcut: "EUR", + txDate: "2022-03-14", + want: `-43141.29 EUR`, + }, + { + name: "prim-amt 1.23456789 BTC", + class: "prim-amt", + amount: "1.23456789", + shortcut: "alert(1)", + want: `1.23456789 <javascript>alert(1)</javascript>`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var rv strings.Builder + appendAmountSpan(&rv, tt.class, tt.amount, tt.shortcut, tt.txDate) + if got := rv.String(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("appendAmountSpan() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_appendAmountSpanBitcoinType(t *testing.T) { + tests := []struct { + name string + class string + amount string + shortcut string + txDate string + want string + }{ + { + name: "prim-amt 1.23456789 BTC", + class: "prim-amt", + amount: "1.23456789", + shortcut: "BTC", + want: `1.23456789 BTC`, + }, + { + name: "prim-amt 1432134.23456 BTC", + class: "prim-amt", + amount: "1432134.23456", + shortcut: "BTC", + want: `1432134.23456000 BTC`, + }, + { + name: "prim-amt 1 BTC", + class: "prim-amt", + amount: "1", + shortcut: "BTC", + want: `1.00000000 BTC`, + }, + { + name: "prim-amt 0 BTC", + class: "prim-amt", + amount: "0", + shortcut: "BTC", + want: `0 BTC`, + }, + { + name: "prim-amt 34.2 BTC", + class: "prim-amt", + amount: "34.2", + shortcut: "BTC", + want: `34.20000000 BTC`, + }, + { + name: "prim-amt -34.2345678 BTC", + class: "prim-amt", + amount: "-34.2345678", + shortcut: "BTC", + want: `-34.23456780 BTC`, + }, + { + name: "prim-amt -1234.2345 BTC", + class: "prim-amt", + amount: "-1234.2345", + shortcut: "BTC", + want: `-1234.23450000 BTC`, + }, + { + name: "prim-amt -123.23 BTC", + class: "prim-amt", + amount: "-123.23", + shortcut: "BTC", + want: `-123.23000000 BTC`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var rv strings.Builder + appendAmountSpanBitcoinType(&rv, tt.class, tt.amount, tt.shortcut, tt.txDate) + if got := rv.String(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("appendAmountSpanBitcoinType() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_addressAliasSpan_XSS(t *testing.T) { + tests := []struct { + name string + address string + td *TemplateData + want string + wantContains string // substring that must be present and properly escaped + wantNotContains string // substring that must NOT be present (raw XSS payload) + }{ + { + name: "no alias", + address: "0x1234567890123456789012345678901234567890", + td: &TemplateData{}, + want: `0x1234567890123456789012345678901234567890`, + }, + { + name: "normal alias", + address: "0x1234567890123456789012345678901234567890", + td: &TemplateData{ + Tx: &api.Tx{ + AddressAliases: api.AddressAliasesMap{ + "0x1234567890123456789012345678901234567890": api.AddressAlias{ + Type: "Contract", + Alias: "MyContract", + }, + }, + }, + }, + want: `MyContract`, + }, + { + name: "XSS in alias.Type - quote injection", + address: "0x1234567890123456789012345678901234567890", + td: &TemplateData{ + Tx: &api.Tx{ + AddressAliases: api.AddressAliasesMap{ + "0x1234567890123456789012345678901234567890": api.AddressAlias{ + Type: `Contract" onclick="alert(1)" data="`, + Alias: "MyContract", + }, + }, + }, + }, + wantContains: `alias-type="Contract" onclick="alert(1)" data="`, + wantNotContains: `onclick="alert(1)"`, + }, + { + name: "XSS in alias.Type - script tag", + address: "0x1234567890123456789012345678901234567890", + td: &TemplateData{ + Tx: &api.Tx{ + AddressAliases: api.AddressAliasesMap{ + "0x1234567890123456789012345678901234567890": api.AddressAlias{ + Type: ``, + Alias: "MyContract", + }, + }, + }, + }, + wantContains: `alias-type="<script>alert(1)</script>"`, + wantNotContains: ``, + td: &TemplateData{ + Tx: &api.Tx{ + AddressAliases: api.AddressAliasesMap{ + `0x1234">`: api.AddressAlias{ + Type: "Contract", + Alias: "MyContract", + }, + }, + }, + }, + wantContains: `cc="0x1234"><script>alert(1)</script>"`, + wantNotContains: ``, + }, + { + name: "XSS payload from real-world example", + address: "0x1234567890123456789012345678901234567890", + td: &TemplateData{ + Tx: &api.Tx{ + AddressAliases: api.AddressAliasesMap{ + "0x1234567890123456789012345678901234567890": api.AddressAlias{ + Type: `Contract" onmouseover="alert('XSS')" data="`, + Alias: "NormalName", + }, + }, + }, + }, + wantContains: `alias-type="Contract" onmouseover="alert('XSS')" data="`, + wantNotContains: `onmouseover="alert('XSS')"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := addressAliasSpan(tt.address, tt.td) + gotStr := string(got) + + if tt.want != "" { + if gotStr != tt.want { + t.Errorf("addressAliasSpan() = %v, want %v", gotStr, tt.want) + } + } + + if tt.wantContains != "" { + if !strings.Contains(gotStr, tt.wantContains) { + t.Errorf("addressAliasSpan() = %v, should contain %v", gotStr, tt.wantContains) + } + } + + if tt.wantNotContains != "" { + if strings.Contains(gotStr, tt.wantNotContains) { + t.Errorf("addressAliasSpan() = %v, should NOT contain raw XSS payload: %v", gotStr, tt.wantNotContains) + } + } + }) + } +} + +func renderTokenDetailSpecific(t *testing.T, uri string) string { + t.Helper() + + tmpl := template.Must(template.New("tokenDetail.html").Funcs(template.FuncMap{ + "jsStr": jsStr, + }).ParseFiles("./static/templates/tokenDetail.html")) + + data := TemplateData{ + TokenId: "1", + URI: uri, + ContractInfo: &bchain.ContractInfo{ + Contract: "0x1234567890123456789012345678901234567890", + Name: "Contract", + Standard: bchain.ERC771TokenStandard, + }, + } + + var rendered bytes.Buffer + if err := tmpl.ExecuteTemplate(&rendered, "specific", data); err != nil { + t.Fatalf("ExecuteTemplate() error = %v", err) + } + return rendered.String() +} + +func Test_tokenDetailTemplateEscapesURIInJSContext(t *testing.T) { + body := renderTokenDetailSpecific(t, `";console.log("XSS_EXEC_OK");//`) + + if !strings.Contains(body, `const uri="\";console.log(\"XSS_EXEC_OK\");//";`) { + t.Fatalf("escaped uri literal not found in output: %s", body) + } + if strings.Contains(body, `const uri="";console.log("XSS_EXEC_OK");//";`) { + t.Fatalf("found unescaped JS breakout payload in output: %s", body) + } +} + +func Test_tokenDetailTemplateEscapesScriptEndTagInJSContext(t *testing.T) { + body := renderTokenDetailSpecific(t, `";//`) + + if strings.Contains(body, ``) { + t.Fatalf("found unescaped script-end-tag payload in output: %s", body) + } + if !strings.Contains(body, `const uri="\";\u003c/script\u003e\u003cscript\u003ealert(1)\u003c/script\u003e//";`) { + t.Fatalf("escaped script-end-tag payload not found in output: %s", body) + } +} diff --git a/server/internal.go b/server/internal.go index 92d0dbd843..e440fbd8e6 100644 --- a/server/internal.go +++ b/server/internal.go @@ -4,18 +4,27 @@ import ( "context" "encoding/json" "fmt" + "html/template" + "io" "net/http" + "path/filepath" + "sort" + "strconv" + "strings" "github.com/golang/glog" + "github.com/juju/errors" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/trezor/blockbook/api" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/fiat" ) // InternalServer is handle to internal http server type InternalServer struct { + htmlTemplates[InternalTemplateData] https *http.Server certFiles string db *db.RocksDB @@ -28,8 +37,8 @@ type InternalServer struct { } // NewInternalServer creates new internal http interface to blockbook and returns its handle -func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, metrics *common.Metrics, is *common.InternalState) (*InternalServer, error) { - api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is) +func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, metrics *common.Metrics, is *common.InternalState, fiatRates *fiat.FiatRates) (*InternalServer, error) { + api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is, fiatRates) if err != nil { return nil, err } @@ -41,6 +50,9 @@ func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.B Handler: serveMux, } s := &InternalServer{ + htmlTemplates: htmlTemplates[InternalTemplateData]{ + debug: true, + }, https: https, certFiles: certFiles, db: db, @@ -51,11 +63,22 @@ func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.B is: is, api: api, } + s.htmlTemplates.newTemplateData = s.newTemplateData + s.htmlTemplates.newTemplateDataWithError = s.newTemplateDataWithError + s.htmlTemplates.parseTemplates = s.parseTemplates + s.templates = s.parseTemplates() serveMux.Handle(path+"favicon.ico", http.FileServer(http.Dir("./static/"))) + serveMux.Handle(path+"static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) serveMux.HandleFunc(path+"metrics", promhttp.Handler().ServeHTTP) serveMux.HandleFunc(path, s.index) - + serveMux.HandleFunc(path+"admin", s.htmlTemplateHandler(s.adminIndex)) + serveMux.HandleFunc(path+"admin/ws-limit-exceeding-ips", s.htmlTemplateHandler(s.wsLimitExceedingIPs)) + if s.chainParser.GetChainType() == bchain.ChainEthereumType { + serveMux.HandleFunc(path+"admin/internal-data-errors", s.htmlTemplateHandler(s.internalDataErrors)) + serveMux.HandleFunc(path+"admin/contract-info", s.htmlTemplateHandler(s.contractInfoPage)) + serveMux.HandleFunc(path+"admin/contract-info/", s.jsonHandler(s.apiContractInfo, 0)) + } return s, nil } @@ -97,3 +120,155 @@ func (s *InternalServer) index(w http.ResponseWriter, r *http.Request) { w.Write(buf) } + +const ( + adminIndexTpl = iota + errorInternalTpl + 1 + adminInternalErrorsTpl + adminLimitExceedingIPSTpl + adminContractInfoTpl + + internalTplCount +) + +// WsLimitExceedingIP is used to transfer data to the templates +type WsLimitExceedingIP struct { + IP string + Count int +} + +// InternalTemplateData is used to transfer data to the templates +type InternalTemplateData struct { + CoinName string + CoinShortcut string + CoinLabel string + ChainType bchain.ChainType + Error *api.APIError + InternalDataErrors []db.BlockInternalDataError + RefetchingInternalData bool + WsGetAccountInfoLimit int + WsLimitExceedingIPs []WsLimitExceedingIP +} + +func (s *InternalServer) newTemplateData(r *http.Request) *InternalTemplateData { + t := &InternalTemplateData{ + CoinName: s.is.Coin, + CoinShortcut: s.is.CoinShortcut, + CoinLabel: s.is.CoinLabel, + ChainType: s.chainParser.GetChainType(), + } + return t +} + +func (s *InternalServer) newTemplateDataWithError(error *api.APIError, r *http.Request) *InternalTemplateData { + td := s.newTemplateData(r) + td.Error = error + return td +} + +func (s *InternalServer) parseTemplates() []*template.Template { + templateFuncMap := template.FuncMap{ + "formatUint32": formatUint32, + } + createTemplate := func(filenames ...string) *template.Template { + if len(filenames) == 0 { + panic("Missing templates") + } + return template.Must(template.New(filepath.Base(filenames[0])).Funcs(templateFuncMap).ParseFiles(filenames...)) + } + t := make([]*template.Template, internalTplCount) + t[errorTpl] = createTemplate("./static/internal_templates/error.html", "./static/internal_templates/base.html") + t[errorInternalTpl] = createTemplate("./static/internal_templates/error.html", "./static/internal_templates/base.html") + t[adminIndexTpl] = createTemplate("./static/internal_templates/index.html", "./static/internal_templates/base.html") + t[adminInternalErrorsTpl] = createTemplate("./static/internal_templates/block_internal_data_errors.html", "./static/internal_templates/base.html") + t[adminLimitExceedingIPSTpl] = createTemplate("./static/internal_templates/ws_limit_exceeding_ips.html", "./static/internal_templates/base.html") + t[adminContractInfoTpl] = createTemplate("./static/internal_templates/contract_info.html", "./static/internal_templates/base.html") + return t +} + +func (s *InternalServer) adminIndex(w http.ResponseWriter, r *http.Request) (tpl, *InternalTemplateData, error) { + data := s.newTemplateData(r) + return adminIndexTpl, data, nil +} + +func (s *InternalServer) internalDataErrors(w http.ResponseWriter, r *http.Request) (tpl, *InternalTemplateData, error) { + if r.Method == http.MethodPost { + err := s.api.RefetchInternalData() + if err != nil { + return errorTpl, nil, err + } + } + data := s.newTemplateData(r) + internalErrors, err := s.db.GetBlockInternalDataErrorsEthereumType() + if err != nil { + return errorTpl, nil, err + } + data.InternalDataErrors = internalErrors + data.RefetchingInternalData = s.api.IsRefetchingInternalData() + return adminInternalErrorsTpl, data, nil +} + +func (s *InternalServer) wsLimitExceedingIPs(w http.ResponseWriter, r *http.Request) (tpl, *InternalTemplateData, error) { + if r.Method == http.MethodPost { + s.is.ResetWsLimitExceedingIPs() + } + data := s.newTemplateData(r) + ips := make([]WsLimitExceedingIP, 0, len(s.is.WsLimitExceedingIPs)) + for k, v := range s.is.WsLimitExceedingIPs { + ips = append(ips, WsLimitExceedingIP{k, v}) + } + sort.Slice(ips, func(i, j int) bool { + return ips[i].Count > ips[j].Count + }) + data.WsLimitExceedingIPs = ips + data.WsGetAccountInfoLimit = s.is.WsGetAccountInfoLimit + return adminLimitExceedingIPSTpl, data, nil +} + +func (s *InternalServer) contractInfoPage(w http.ResponseWriter, r *http.Request) (tpl, *InternalTemplateData, error) { + data := s.newTemplateData(r) + return adminContractInfoTpl, data, nil +} + +func (s *InternalServer) apiContractInfo(r *http.Request, apiVersion int) (interface{}, error) { + if r.Method == http.MethodPost { + return s.updateContracts(r) + } + var contractAddress string + i := strings.LastIndexByte(r.URL.Path, '/') + if i > 0 { + contractAddress = r.URL.Path[i+1:] + } + if len(contractAddress) == 0 { + return nil, api.NewAPIError("Missing contract address", true) + } + + contractInfo, valid, err := s.api.GetContractInfo(contractAddress, bchain.UnknownTokenStandard) + if err != nil { + return nil, api.NewAPIError(err.Error(), true) + } + if !valid { + return nil, api.NewAPIError("Not a contract", true) + } + return contractInfo, nil +} + +func (s *InternalServer) updateContracts(r *http.Request) (interface{}, error) { + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, api.NewAPIError("Cannot get request body", true) + } + var contractInfos []bchain.ContractInfo + err = json.Unmarshal(data, &contractInfos) + if err != nil { + return nil, errors.Annotatef(err, "Cannot unmarshal body to array of ContractInfo objects") + } + for i := range contractInfos { + c := &contractInfos[i] + err := s.db.StoreContractInfo(c) + if err != nil { + return nil, api.NewAPIError("Error updating contract "+c.Contract+" "+err.Error(), true) + } + + } + return "{\"success\":\"Updated " + strconv.Itoa(len(contractInfos)) + " contracts\"}", nil +} diff --git a/server/public.go b/server/public.go index cd57179c43..3b233e35fa 100644 --- a/server/public.go +++ b/server/public.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "html" "html/template" "io" "math/big" @@ -14,7 +15,6 @@ import ( "reflect" "regexp" "runtime" - "runtime/debug" "sort" "strconv" "strings" @@ -25,14 +25,33 @@ import ( "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/fiat" ) const txsOnPage = 25 const blocksOnPage = 50 const mempoolTxsOnPage = 50 const txsInAPI = 1000 +const maxWebsocketBlockPageSize = 10000 +const maxBlockFiltersRange = 10000 +const maxPageNumber = 1000000 +const maxGapValue = 10000 +const maxSafePagingOffset = 1000000000 +const maxAccountHistoryPagingOffset = 100000 +const maxSendTxBodyBytes int64 = 8 * 1024 * 1024 const secondaryCoinCookieName = "secondary_coin" +const templatesDir = "./static/templates" +const ( + txBitcoinTypeTemplate = templatesDir + "/tx_bitcointype.html" + txEthereumTypeTemplate = templatesDir + "/tx_ethereumtype.html" + txTronTemplate = templatesDir + "/tx_tron.html" + txBitcoinTypeDetailTemplate = templatesDir + "/txdetail.html" + txEthereumTypeDetailTemplate = templatesDir + "/txdetail_ethereumtype.html" + txTronDetailTemplate = templatesDir + "/txdetail_tron.html" + addressChainExtraTemplate = templatesDir + "/address_chainextra.html" + addressChainExtraTronTemplate = templatesDir + "/address_chainextra_tron.html" +) const ( _ = iota @@ -40,42 +59,37 @@ const ( apiV2 ) -// PublicServer is a handle to public http server +// PublicServer provides public http server functionality type PublicServer struct { - binding string - certFiles string - socketio *SocketIoServer - websocket *WebsocketServer - https *http.Server - db *db.RocksDB - txCache *db.TxCache - chain bchain.BlockChain - chainParser bchain.BlockChainParser - mempool bchain.Mempool - api *api.Worker - explorerURL string - internalExplorer bool - metrics *common.Metrics - is *common.InternalState - templates []*template.Template - debug bool + htmlTemplates[TemplateData] + binding string + certFiles string + websocket *WebsocketServer + https *http.Server + db *db.RocksDB + txCache *db.TxCache + chain bchain.BlockChain + chainParser bchain.BlockChainParser + mempool bchain.Mempool + api *api.Worker + explorerURL string + internalExplorer bool + is *common.InternalState + fiatRates *fiat.FiatRates + useSatsAmountFormat bool + isFullInterface bool } // NewPublicServer creates new public server http interface to blockbook and returns its handle // only basic functionality is mapped, to map all functions, call -func NewPublicServer(binding string, certFiles string, db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, explorerURL string, metrics *common.Metrics, is *common.InternalState, debugMode bool, enableSubNewTx bool) (*PublicServer, error) { - - api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is) - if err != nil { - return nil, err - } +func NewPublicServer(binding string, certFiles string, db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, explorerURL string, metrics *common.Metrics, is *common.InternalState, fiatRates *fiat.FiatRates, debugMode bool) (*PublicServer, error) { - socketio, err := NewSocketIoServer(db, chain, mempool, txCache, metrics, is) + api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is, fiatRates) if err != nil { return nil, err } - websocket, err := NewWebsocketServer(db, chain, mempool, txCache, metrics, is, enableSubNewTx) + websocket, err := NewWebsocketServer(db, chain, mempool, txCache, metrics, is, fiatRates) if err != nil { return nil, err } @@ -88,23 +102,30 @@ func NewPublicServer(binding string, certFiles string, db *db.RocksDB, chain bch } s := &PublicServer{ - binding: binding, - certFiles: certFiles, - https: https, - api: api, - socketio: socketio, - websocket: websocket, - db: db, - txCache: txCache, - chain: chain, - chainParser: chain.GetChainParser(), - mempool: mempool, - explorerURL: explorerURL, - internalExplorer: explorerURL == "", - metrics: metrics, - is: is, - debug: debugMode, - } + htmlTemplates: htmlTemplates[TemplateData]{ + metrics: metrics, + debug: debugMode, + }, + binding: binding, + certFiles: certFiles, + https: https, + api: api, + websocket: websocket, + db: db, + txCache: txCache, + chain: chain, + chainParser: chain.GetChainParser(), + mempool: mempool, + explorerURL: explorerURL, + internalExplorer: explorerURL == "", + is: is, + fiatRates: fiatRates, + useSatsAmountFormat: chain.GetChainParser().GetChainType() == bchain.ChainBitcoinType && chain.GetChainParser().AmountDecimals() == 8, + } + s.htmlTemplates.newTemplateData = s.newTemplateData + s.htmlTemplates.newTemplateDataWithError = s.newTemplateDataWithError + s.htmlTemplates.parseTemplates = s.parseTemplates + s.htmlTemplates.postHtmlTemplateHandler = s.postHtmlTemplateHandler s.templates = s.parseTemplates() // map only basic functions, the rest is enabled by method MapFullPublicInterface @@ -133,7 +154,6 @@ func (s *PublicServer) ConnectFullPublicInterface() { serveMux := s.https.Handler.(*http.ServeMux) _, path := splitBinding(s.binding) // support for test pages - serveMux.Handle(path+"test-socketio.html", http.FileServer(http.Dir("./static/"))) serveMux.Handle(path+"test-websocket.html", http.FileServer(http.Dir("./static/"))) if s.internalExplorer { // internal explorer handlers @@ -175,9 +195,12 @@ func (s *PublicServer) ConnectFullPublicInterface() { serveMux.HandleFunc(path+"api/v1/estimatefee/", s.jsonHandler(s.apiEstimateFee, apiV1)) } serveMux.HandleFunc(path+"api/block-index/", s.jsonHandler(s.apiBlockIndex, apiDefault)) + serveMux.HandleFunc(path+"api/block-filters/", s.jsonHandler(s.apiBlockFilters, apiDefault)) serveMux.HandleFunc(path+"api/tx-specific/", s.jsonHandler(s.apiTxSpecific, apiDefault)) serveMux.HandleFunc(path+"api/tx/", s.jsonHandler(s.apiTx, apiDefault)) + serveMux.HandleFunc(path+"api/rawtx/", s.jsonHandler(s.apiRawTx, apiDefault)) serveMux.HandleFunc(path+"api/address/", s.jsonHandler(s.apiAddress, apiDefault)) + serveMux.HandleFunc(path+"api/contract/", s.jsonHandler(s.apiContract, apiDefault)) serveMux.HandleFunc(path+"api/xpub/", s.jsonHandler(s.apiXpub, apiDefault)) serveMux.HandleFunc(path+"api/utxo/", s.jsonHandler(s.apiUtxo, apiDefault)) serveMux.HandleFunc(path+"api/block/", s.jsonHandler(s.apiBlock, apiDefault)) @@ -187,9 +210,11 @@ func (s *PublicServer) ConnectFullPublicInterface() { serveMux.HandleFunc(path+"api/balancehistory/", s.jsonHandler(s.apiBalanceHistory, apiDefault)) // v2 format serveMux.HandleFunc(path+"api/v2/block-index/", s.jsonHandler(s.apiBlockIndex, apiV2)) + serveMux.HandleFunc(path+"api/v2/block-filters/", s.jsonHandler(s.apiBlockFilters, apiV2)) serveMux.HandleFunc(path+"api/v2/tx-specific/", s.jsonHandler(s.apiTxSpecific, apiV2)) serveMux.HandleFunc(path+"api/v2/tx/", s.jsonHandler(s.apiTx, apiV2)) serveMux.HandleFunc(path+"api/v2/address/", s.jsonHandler(s.apiAddress, apiV2)) + serveMux.HandleFunc(path+"api/v2/contract/", s.jsonHandler(s.apiContract, apiV2)) serveMux.HandleFunc(path+"api/v2/xpub/", s.jsonHandler(s.apiXpub, apiV2)) serveMux.HandleFunc(path+"api/v2/utxo/", s.jsonHandler(s.apiUtxo, apiV2)) serveMux.HandleFunc(path+"api/v2/block/", s.jsonHandler(s.apiBlock, apiV2)) @@ -201,10 +226,14 @@ func (s *PublicServer) ConnectFullPublicInterface() { serveMux.HandleFunc(path+"api/v2/tickers/", s.jsonHandler(s.apiTickers, apiV2)) serveMux.HandleFunc(path+"api/v2/multi-tickers/", s.jsonHandler(s.apiMultiTickers, apiV2)) serveMux.HandleFunc(path+"api/v2/tickers-list/", s.jsonHandler(s.apiAvailableVsCurrencies, apiV2)) - // socket.io interface - serveMux.Handle(path+"socket.io/", s.socketio.GetHandler()) // websocket interface serveMux.Handle(path+"websocket", s.websocket.GetHandler()) + s.isFullInterface = true +} + +// IsFullInterface reports whether full public functionality is already enabled. +func (s *PublicServer) IsFullInterface() bool { + return s.isFullInterface } // Close closes the server @@ -213,16 +242,23 @@ func (s *PublicServer) Close() error { return s.https.Close() } -// Shutdown shuts down the server +// Shutdown shuts down the server. http.Server.Shutdown does not drain +// hijacked WebSocket connections, so after the HTTP listener stops we also +// drain the WebSocket server's in-flight DB-touching goroutines; otherwise a +// long getAccountInfo can race rocksdb_close in cgo and SIGSEGV the process. func (s *PublicServer) Shutdown(ctx context.Context) error { glog.Infof("public server: shutdown") - return s.https.Shutdown(ctx) + httpErr := s.https.Shutdown(ctx) + wsErr := s.websocket.Shutdown(ctx) + if httpErr != nil { + return httpErr + } + return wsErr } // OnNewBlock notifies users subscribed to bitcoind/hashblock about new block -func (s *PublicServer) OnNewBlock(hash string, height uint32) { - s.socketio.OnNewBlockHash(hash) - s.websocket.OnNewBlock(hash, height) +func (s *PublicServer) OnNewBlock(block *bchain.Block) { + s.websocket.OnNewBlock(block) } // OnNewFiatRatesTicker notifies users subscribed to bitcoind/fiatrates about new ticker @@ -230,11 +266,6 @@ func (s *PublicServer) OnNewFiatRatesTicker(ticker *common.CurrencyRatesTicker) s.websocket.OnNewFiatRatesTicker(ticker) } -// OnNewTxAddr notifies users subscribed to notification about new tx -func (s *PublicServer) OnNewTxAddr(tx *bchain.Tx, desc bchain.AddressDescriptor) { - s.socketio.OnNewTxAddr(tx.Txid, desc) -} - // OnNewTx notifies users subscribed to notification about new tx func (s *PublicServer) OnNewTx(tx *bchain.MempoolTx) { s.websocket.OnNewTx(tx) @@ -278,62 +309,6 @@ func getFunctionName(i interface{}) string { return name } -func (s *PublicServer) jsonHandler(handler func(r *http.Request, apiVersion int) (interface{}, error), apiVersion int) func(w http.ResponseWriter, r *http.Request) { - type jsonError struct { - Text string `json:"error"` - HTTPStatus int `json:"-"` - } - handlerName := getFunctionName(handler) - return func(w http.ResponseWriter, r *http.Request) { - var data interface{} - var err error - defer func() { - if e := recover(); e != nil { - glog.Error(handlerName, " recovered from panic: ", e) - debug.PrintStack() - if s.debug { - data = jsonError{fmt.Sprint("Internal server error: recovered from panic ", e), http.StatusInternalServerError} - } else { - data = jsonError{"Internal server error", http.StatusInternalServerError} - } - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - if e, isError := data.(jsonError); isError { - w.WriteHeader(e.HTTPStatus) - } - err = json.NewEncoder(w).Encode(data) - if err != nil { - glog.Warning("json encode ", err) - } - s.metrics.ExplorerPendingRequests.With((common.Labels{"method": handlerName})).Dec() - }() - s.metrics.ExplorerPendingRequests.With((common.Labels{"method": handlerName})).Inc() - data, err = handler(r, apiVersion) - if err != nil || data == nil { - if apiErr, ok := err.(*api.APIError); ok { - if apiErr.Public { - data = jsonError{apiErr.Error(), http.StatusBadRequest} - } else { - data = jsonError{apiErr.Error(), http.StatusInternalServerError} - } - } else { - if err != nil { - glog.Error(handlerName, " error: ", err) - } - if s.debug { - if data != nil { - data = jsonError{fmt.Sprintf("Internal server error: %v, data %+v", err, data), http.StatusInternalServerError} - } else { - data = jsonError{fmt.Sprintf("Internal server error: %v", err), http.StatusInternalServerError} - } - } else { - data = jsonError{"Internal server error", http.StatusInternalServerError} - } - } - } - } -} - func (s *PublicServer) newTemplateData(r *http.Request) *TemplateData { t := &TemplateData{ CoinName: s.is.Coin, @@ -343,8 +318,13 @@ func (s *PublicServer) newTemplateData(r *http.Request) *TemplateData { InternalExplorer: s.internalExplorer && !s.is.InitialSync, TOSLink: api.Text.TOSLink, } + if t.ChainType == bchain.ChainEthereumType { + t.FungibleTokenName = bchain.EthereumTokenStandardMap[bchain.FungibleToken] + t.NonFungibleTokenName = bchain.EthereumTokenStandardMap[bchain.NonFungibleToken] + t.MultiTokenName = bchain.EthereumTokenStandardMap[bchain.MultiToken] + } if !s.debug { - t.Minified = ".min.2" + t.Minified = ".min.4" } if s.is.HasFiatRates { // get the secondary coin and if it should be shown either from query parameters "secondary" and "use_secondary" @@ -368,10 +348,10 @@ func (s *PublicServer) newTemplateData(r *http.Request) *TemplateData { secondary = "usd" } } - ticker := s.is.GetCurrentTicker(secondary, "") + ticker := s.fiatRates.GetCurrentTicker(secondary, "") if ticker == nil && secondary != "usd" { secondary = "usd" - ticker = s.is.GetCurrentTicker(secondary, "") + ticker = s.fiatRates.GetCurrentTicker(secondary, "") } if ticker != nil { t.SecondaryCoin = strings.ToUpper(secondary) @@ -391,82 +371,14 @@ func (s *PublicServer) newTemplateData(r *http.Request) *TemplateData { return t } -func (s *PublicServer) newTemplateDataWithError(text string, r *http.Request) *TemplateData { +func (s *PublicServer) newTemplateDataWithError(error *api.APIError, r *http.Request) *TemplateData { td := s.newTemplateData(r) - td.Error = &api.APIError{Text: text} + td.Error = error return td } -func (s *PublicServer) htmlTemplateHandler(handler func(w http.ResponseWriter, r *http.Request) (tpl, *TemplateData, error)) func(w http.ResponseWriter, r *http.Request) { - handlerName := getFunctionName(handler) - return func(w http.ResponseWriter, r *http.Request) { - var t tpl - var data *TemplateData - var err error - defer func() { - if e := recover(); e != nil { - glog.Error(handlerName, " recovered from panic: ", e) - debug.PrintStack() - t = errorInternalTpl - if s.debug { - data = s.newTemplateDataWithError(fmt.Sprint("Internal server error: recovered from panic ", e), r) - } else { - data = s.newTemplateDataWithError("Internal server error", r) - } - } - // noTpl means the handler completely handled the request - if t != noTpl { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - // return 500 Internal Server Error with errorInternalTpl - if t == errorInternalTpl { - w.WriteHeader(http.StatusInternalServerError) - } - if err := s.templates[t].ExecuteTemplate(w, "base.html", data); err != nil { - glog.Error(err) - } - } - s.metrics.ExplorerPendingRequests.With((common.Labels{"method": handlerName})).Dec() - }() - s.metrics.ExplorerPendingRequests.With((common.Labels{"method": handlerName})).Inc() - if s.debug { - // reload templates on each request - // to reflect changes during development - s.templates = s.parseTemplates() - } - t, data, err = handler(w, r) - if err != nil || (data == nil && t != noTpl) { - t = errorInternalTpl - if apiErr, ok := err.(*api.APIError); ok { - data = s.newTemplateData(r) - data.Error = apiErr - if apiErr.Public { - t = errorTpl - } - } else { - if err != nil { - glog.Error(handlerName, " error: ", err) - } - if s.debug { - data = s.newTemplateDataWithError(fmt.Sprintf("Internal server error: %v, data %+v", err, data), r) - } else { - data = s.newTemplateDataWithError("Internal server error", r) - } - } - } - // if SecondaryCoin is specified, set secondary_coin cookie - if data != nil && data.SecondaryCoin != "" { - http.SetCookie(w, &http.Cookie{Name: secondaryCoinCookieName, Value: data.SecondaryCoin + "=" + strconv.FormatBool(data.UseSecondaryCoin), Path: "/"}) - } - } -} - -type tpl int - const ( - noTpl = tpl(iota) - errorTpl - errorInternalTpl - indexTpl + indexTpl = iota + errorInternalTpl + 1 txTpl addressTpl xpubTpl @@ -476,7 +388,7 @@ const ( mempoolTpl nftDetailTpl - tplCount + publicTplCount ) // TemplateData is used to transfer data to the templates @@ -486,6 +398,9 @@ type TemplateData struct { CoinLabel string InternalExplorer bool ChainType bchain.ChainType + FungibleTokenName bchain.TokenStandardName + NonFungibleTokenName bchain.TokenStandardName + MultiTokenName bchain.TokenStandardName Address *api.Address AddrStr string Tx *api.Tx @@ -517,6 +432,41 @@ type TemplateData struct { TxTicker *common.CurrencyRatesTicker } +func defaultTxTemplate(chainType bchain.ChainType) string { + if chainType == bchain.ChainEthereumType { + return txEthereumTypeTemplate + } + return txBitcoinTypeTemplate +} + +func resolveTxTemplate(chainType bchain.ChainType, coinShortcut string) string { + if strings.EqualFold(strings.TrimSpace(coinShortcut), "TRX") { + return txTronTemplate + } + return defaultTxTemplate(chainType) +} + +func defaultTxDetailTemplate(chainType bchain.ChainType) string { + if chainType == bchain.ChainEthereumType { + return txEthereumTypeDetailTemplate + } + return txBitcoinTypeDetailTemplate +} + +func resolveTxDetailTemplate(chainType bchain.ChainType, coinShortcut string) string { + if strings.EqualFold(strings.TrimSpace(coinShortcut), "TRX") { + return txTronDetailTemplate + } + return defaultTxDetailTemplate(chainType) +} + +func resolveAddressChainExtraTemplate(coinShortcut string) string { + if strings.EqualFold(strings.TrimSpace(coinShortcut), "TRX") { + return addressChainExtraTronTemplate + } + return addressChainExtraTemplate +} + func (s *PublicServer) parseTemplates() []*template.Template { templateFuncMap := template.FuncMap{ "timeSpan": timeSpan, @@ -544,6 +494,7 @@ func (s *PublicServer) parseTemplates() []*template.Template { "hasPrefix": strings.HasPrefix, "jsStr": jsStr, } + applyTemplateFuncs(templateFuncMap) var createTemplate func(filenames ...string) *template.Template if s.debug { createTemplate = func(filenames ...string) *template.Template { @@ -582,106 +533,36 @@ func (s *PublicServer) parseTemplates() []*template.Template { return t } } - t := make([]*template.Template, tplCount) + t := make([]*template.Template, publicTplCount) + txTemplate := resolveTxTemplate(s.chainParser.GetChainType(), s.is.CoinShortcut) + txDetailTemplate := resolveTxDetailTemplate(s.chainParser.GetChainType(), s.is.CoinShortcut) + resolvedAddressChainExtraTemplate := resolveAddressChainExtraTemplate(s.is.CoinShortcut) t[errorTpl] = createTemplate("./static/templates/error.html", "./static/templates/base.html") t[errorInternalTpl] = createTemplate("./static/templates/error.html", "./static/templates/base.html") t[indexTpl] = createTemplate("./static/templates/index.html", "./static/templates/base.html") t[blocksTpl] = createTemplate("./static/templates/blocks.html", "./static/templates/paging.html", "./static/templates/base.html") t[sendTransactionTpl] = createTemplate("./static/templates/sendtx.html", "./static/templates/base.html") if s.chainParser.GetChainType() == bchain.ChainEthereumType { - t[txTpl] = createTemplate("./static/templates/tx.html", "./static/templates/txdetail_ethereumtype.html", "./static/templates/base.html") - t[addressTpl] = createTemplate("./static/templates/address.html", "./static/templates/txdetail_ethereumtype.html", "./static/templates/paging.html", "./static/templates/base.html") - t[blockTpl] = createTemplate("./static/templates/block.html", "./static/templates/txdetail_ethereumtype.html", "./static/templates/paging.html", "./static/templates/base.html") + t[txTpl] = createTemplate(txTemplate, txDetailTemplate, "./static/templates/base.html") + t[addressTpl] = createTemplate("./static/templates/address.html", resolvedAddressChainExtraTemplate, txDetailTemplate, "./static/templates/paging.html", "./static/templates/base.html") + t[blockTpl] = createTemplate("./static/templates/block.html", txDetailTemplate, "./static/templates/paging.html", "./static/templates/base.html") t[nftDetailTpl] = createTemplate("./static/templates/tokenDetail.html", "./static/templates/base.html") } else { - t[txTpl] = createTemplate("./static/templates/tx.html", "./static/templates/txdetail.html", "./static/templates/base.html") - t[addressTpl] = createTemplate("./static/templates/address.html", "./static/templates/txdetail.html", "./static/templates/paging.html", "./static/templates/base.html") - t[blockTpl] = createTemplate("./static/templates/block.html", "./static/templates/txdetail.html", "./static/templates/paging.html", "./static/templates/base.html") + t[txTpl] = createTemplate(txTemplate, txDetailTemplate, "./static/templates/base.html") + t[addressTpl] = createTemplate("./static/templates/address.html", resolvedAddressChainExtraTemplate, txDetailTemplate, "./static/templates/paging.html", "./static/templates/base.html") + t[blockTpl] = createTemplate("./static/templates/block.html", txDetailTemplate, "./static/templates/paging.html", "./static/templates/base.html") } t[xpubTpl] = createTemplate("./static/templates/xpub.html", "./static/templates/txdetail.html", "./static/templates/paging.html", "./static/templates/base.html") t[mempoolTpl] = createTemplate("./static/templates/mempool.html", "./static/templates/paging.html", "./static/templates/base.html") return t } -func relativeTimeUnit(d int64) string { - var u string - if d < 60 { - if d == 1 { - u = " sec" - } else { - u = " secs" - } - } else if d < 3600 { - d /= 60 - if d == 1 { - u = " min" - } else { - u = " mins" - } - } else if d < 3600*24 { - d /= 3600 - if d == 1 { - u = " hour" - } else { - u = " hours" - } - } else { - d /= 3600 * 24 - if d == 1 { - u = " day" - } else { - u = " days" - } +func (s *PublicServer) postHtmlTemplateHandler(data *TemplateData, w http.ResponseWriter, r *http.Request) { + // // if SecondaryCoin is specified, set secondary_coin cookie + if data != nil && data.SecondaryCoin != "" { + http.SetCookie(w, &http.Cookie{Name: secondaryCoinCookieName, Value: data.SecondaryCoin + "=" + strconv.FormatBool(data.UseSecondaryCoin), Path: "/"}) } - return strconv.FormatInt(d, 10) + u -} -func relativeTime(d int64) string { - r := relativeTimeUnit(d) - if d > 3600*24 { - d = d % (3600 * 24) - if d >= 3600 { - r += " " + relativeTimeUnit(d) - } - } else if d > 3600 { - d = d % 3600 - if d >= 60 { - r += " " + relativeTimeUnit(d) - } - } - return r -} - -func unixTimeSpan(ut int64) template.HTML { - t := time.Unix(ut, 0) - return timeSpan(&t) -} - -var timeNow = time.Now - -func timeSpan(t *time.Time) template.HTML { - if t == nil { - return "" - } - u := t.Unix() - if u <= 0 { - return "" - } - d := timeNow().Unix() - u - f := t.UTC().Format("2006-01-02 15:04:05") - if d < 0 { - return template.HTML(f) - } - r := relativeTime(d) - return template.HTML(`` + r + " ago") -} - -func toJSON(data interface{}) string { - json, err := json.Marshal(data) - if err != nil { - return "" - } - return string(json) } func (s *PublicServer) formatAmount(a *api.Amount) string { @@ -691,68 +572,15 @@ func (s *PublicServer) formatAmount(a *api.Amount) string { return s.chainParser.AmountToDecimalString((*big.Int)(a)) } -func formatAmountWithDecimals(a *api.Amount, d int) string { - if a == nil { - return "0" - } - return a.DecimalString(d) -} - -func appendAmountSpan(rv *strings.Builder, class, amount, shortcut, txDate string) { - rv.WriteString(`") - i := strings.IndexByte(amount, '.') - if i < 0 { - appendSeparatedNumberSpans(rv, amount, "nc") - } else { - appendSeparatedNumberSpans(rv, amount[:i], "nc") - rv.WriteString(`.`) - rv.WriteString(``) - appendLeftSeparatedNumberSpans(rv, amount[i+1:], "ns") - rv.WriteString("") - } - if shortcut != "" { - rv.WriteString(" ") - rv.WriteString(shortcut) - } - rv.WriteString("") -} - -func appendAmountWrapperSpan(rv *strings.Builder, primary, symbol, classes string) { - rv.WriteString(``) -} - -func formatSecondaryAmount(a float64, td *TemplateData) string { - if td.SecondaryCoin == "BTC" || td.SecondaryCoin == "ETH" { - return strconv.FormatFloat(a, 'f', 6, 64) - } - return strconv.FormatFloat(a, 'f', 2, 64) -} - func (s *PublicServer) amountSpan(a *api.Amount, td *TemplateData, classes string) template.HTML { primary := s.formatAmount(a) var rv strings.Builder appendAmountWrapperSpan(&rv, primary, td.CoinShortcut, classes) - appendAmountSpan(&rv, "prim-amt", primary, td.CoinShortcut, "") + if s.useSatsAmountFormat { + appendAmountSpanBitcoinType(&rv, "prim-amt", primary, td.CoinShortcut, "") + } else { + appendAmountSpan(&rv, "prim-amt", primary, td.CoinShortcut, "") + } if td.SecondaryCoin != "" { p, err := strconv.ParseFloat(primary, 64) if err == nil { @@ -763,7 +591,11 @@ func (s *PublicServer) amountSpan(a *api.Amount, td *TemplateData, classes strin if td.TxTicker == nil { date := time.Unix(td.Tx.Blocktime, 0).UTC() secondary := strings.ToLower(td.SecondaryCoin) - ticker, _ := s.db.FiatRatesFindTicker(&date, secondary, "") + var ticker *common.CurrencyRatesTicker + tickers, err := s.fiatRates.GetTickersForTimestamps([]int64{int64(td.Tx.Blocktime)}, "", "") + if err == nil && tickers != nil && len(*tickers) > 0 { + ticker = (*tickers)[0] + } if ticker != nil { td.TxSecondaryCoinRate = float64(ticker.Rates[secondary]) // the ticker is from the midnight, valid for the whole day before @@ -890,70 +722,11 @@ func (s *PublicServer) summaryValuesSpan(baseValue float64, secondaryValue float return template.HTML(rv.String()) } -func formatInt(i int) template.HTML { - return formatInt64(int64(i)) -} - -func formatUint32(i uint32) template.HTML { - return formatInt64(int64(i)) -} - -func appendSeparatedNumberSpans(rv *strings.Builder, s, separatorClass string) { - if len(s) > 0 && s[0] == '-' { - s = s[1:] - rv.WriteByte('-') - } - t := (len(s) - 1) / 3 - if t <= 0 { - rv.WriteString(s) - } else { - t *= 3 - rv.WriteString(s[:len(s)-t]) - for i := len(s) - t; i < len(s); i += 3 { - rv.WriteString(``) - rv.WriteString(s[i : i+3]) - rv.WriteString("") - } - } -} - -func appendLeftSeparatedNumberSpans(rv *strings.Builder, s, separatorClass string) { - l := len(s) - if l <= 3 { - rv.WriteString(s) - } else { - rv.WriteString(s[:3]) - for i := 3; i < len(s); i += 3 { - rv.WriteString(``) - e := i + 3 - if e > l { - e = l - } - rv.WriteString(s[i:e]) - rv.WriteString("") - } - } -} - -func formatInt64(i int64) template.HTML { - s := strconv.FormatInt(i, 10) - var rv strings.Builder - appendSeparatedNumberSpans(&rv, s, "ns") - return template.HTML(rv.String()) -} - -func formatBigInt(i *big.Int) template.HTML { - if i == nil { - return "" +func formatSecondaryAmount(a float64, td *TemplateData) string { + if td.SecondaryCoin == "BTC" || td.SecondaryCoin == "ETH" { + return strconv.FormatFloat(a, 'f', 6, 64) } - s := i.String() - var rv strings.Builder - appendSeparatedNumberSpans(&rv, s, "ns") - return template.HTML(rv.String()) + return strconv.FormatFloat(a, 'f', 2, 64) } func getAddressAlias(a string, td *TemplateData) *api.AddressAlias { @@ -985,14 +758,14 @@ func addressAliasSpan(a string, td *TemplateData) template.HTML { alias := getAddressAlias(a, td) if alias == nil { rv.WriteString(``) - rv.WriteString(a) + rv.WriteString(html.EscapeString(a)) } else { rv.WriteString(``) - rv.WriteString(alias.Alias) + rv.WriteString(html.EscapeString(alias.Alias)) } rv.WriteString("") return template.HTML(rv.String()) @@ -1028,10 +801,10 @@ func isOwnAddress(td *TemplateData, a string) bool { } // called from template, returns count of token transfers of given type in a tx -func tokenTransfersCount(tx *api.Tx, t bchain.TokenTypeName) int { +func tokenTransfersCount(tx *api.Tx, t bchain.TokenStandardName) int { count := 0 for i := range tx.TokenTransfers { - if tx.TokenTransfers[i].Type == t { + if tx.TokenTransfers[i].Standard == t { count++ } } @@ -1039,10 +812,10 @@ func tokenTransfersCount(tx *api.Tx, t bchain.TokenTypeName) int { } // called from template, returns count of tokens in array of given type -func tokenCount(tokens []api.Token, t bchain.TokenTypeName) int { +func tokenCount(tokens []api.Token, t bchain.TokenStandardName) int { count := 0 for i := range tokens { - if tokens[i].Type == t { + if tokens[i].Standard == t { count++ } } @@ -1090,24 +863,72 @@ func (s *PublicServer) explorerSpendingTx(w http.ResponseWriter, r *http.Request return errorTpl, nil, err } -func (s *PublicServer) getAddressQueryParams(r *http.Request, accountDetails api.AccountDetails, maxPageSize int) (int, int, api.AccountDetails, *api.AddressFilter, string, int) { - var voutFilter = api.AddressFilterVoutOff - page, ec := strconv.Atoi(r.URL.Query().Get("page")) - if ec != nil { - page = 0 +func validateIntValue(val, defaultValue int, min int, max int) int { + if val < min { + return defaultValue + } + if max > 0 && val > max { + return max + } + return val +} + +func parseProtocolsQuery(values []string) []string { + if len(values) == 0 { + return nil } - pageSize, ec := strconv.Atoi(r.URL.Query().Get("pageSize")) - if ec != nil || pageSize > maxPageSize { - pageSize = maxPageSize + protocols := make([]string, 0, len(values)) + for _, value := range values { + for _, protocol := range strings.Split(value, ",") { + protocol = strings.TrimSpace(protocol) + if protocol != "" { + protocols = append(protocols, protocol) + } + } } - from, ec := strconv.Atoi(r.URL.Query().Get("from")) - if ec != nil { - from = 0 + return protocols +} + +// validateIntParam validates and sanitizes integer parameters from query strings +func validateIntParam(value string, defaultValue int, min int, max int) int { + if value == "" { + return defaultValue } - to, ec := strconv.Atoi(r.URL.Query().Get("to")) - if ec != nil { - to = 0 + val, err := strconv.Atoi(value) + if err != nil { + return defaultValue } + return validateIntValue(val, defaultValue, min, max) +} + +func sanitizePagingParams(page, pageSize, defaultPageSize, maxPageSize int) (int, int) { + return sanitizePagingParamsWithMaxOffset(page, pageSize, defaultPageSize, maxPageSize, maxSafePagingOffset) +} + +func sanitizeAccountPagingParams(page, pageSize, defaultPageSize, maxPageSize int) (int, int) { + return sanitizePagingParamsWithMaxOffset(page, pageSize, defaultPageSize, maxPageSize, maxAccountHistoryPagingOffset) +} + +func sanitizePagingParamsWithMaxOffset(page, pageSize, defaultPageSize, maxPageSize, maxPagingOffset int) (int, int) { + page = validateIntValue(page, 0, 0, maxPageNumber) + pageSize = validateIntValue(pageSize, defaultPageSize, 0, maxPageSize) + if pageSize == 0 { + pageSize = defaultPageSize + } + if page > 0 && pageSize > 0 && page > maxPagingOffset/pageSize { + page = maxPagingOffset / pageSize + } + return page, pageSize +} + +func (s *PublicServer) getAddressQueryParams(r *http.Request, accountDetails api.AccountDetails, maxPageSize int) (int, int, api.AccountDetails, *api.AddressFilter, string, int) { + var voutFilter = api.AddressFilterVoutOff + page := validateIntParam(r.URL.Query().Get("page"), 0, 0, maxPageNumber) + pageSize := validateIntParam(r.URL.Query().Get("pageSize"), maxPageSize, 0, maxPageSize) + page, pageSize = sanitizeAccountPagingParams(page, pageSize, maxPageSize, maxPageSize) + from := validateIntParam(r.URL.Query().Get("from"), 0, 0, 10000000000) + to := validateIntParam(r.URL.Query().Get("to"), 0, 0, 10000000000) + filterParam := r.URL.Query().Get("filter") if len(filterParam) > 0 { if filterParam == "inputs" { @@ -1115,7 +936,7 @@ func (s *PublicServer) getAddressQueryParams(r *http.Request, accountDetails api } else if filterParam == "outputs" { voutFilter = api.AddressFilterVoutOutputs } else { - voutFilter, ec = strconv.Atoi(filterParam) + voutFilter, ec := strconv.Atoi(filterParam) if ec != nil || voutFilter < 0 { voutFilter = api.AddressFilterVoutOff } @@ -1144,10 +965,8 @@ func (s *PublicServer) getAddressQueryParams(r *http.Request, accountDetails api case "nonzero": tokensToReturn = api.TokensToReturnNonzeroBalance } - gap, ec := strconv.Atoi(r.URL.Query().Get("gap")) - if ec != nil { - gap = 0 - } + // Validate gap: non-negative, reasonable max (gap limit typically small, maxGapValue) + gap := validateIntParam(r.URL.Query().Get("gap"), 0, 0, maxGapValue) contract := r.URL.Query().Get("contract") return page, pageSize, accountDetails, &api.AddressFilter{ Vout: voutFilter, @@ -1155,6 +974,7 @@ func (s *PublicServer) getAddressQueryParams(r *http.Request, accountDetails api FromHeight: uint32(from), ToHeight: uint32(to), Contract: contract, + Protocols: parseProtocolsQuery(r.URL.Query()["protocols"]), }, filterParam, gap } @@ -1247,10 +1067,7 @@ func (s *PublicServer) explorerBlocks(w http.ResponseWriter, r *http.Request) (t var blocks *api.Blocks var err error s.metrics.ExplorerViews.With(common.Labels{"action": "blocks"}).Inc() - page, ec := strconv.Atoi(r.URL.Query().Get("page")) - if ec != nil { - page = 0 - } + page := validateIntParam(r.URL.Query().Get("page"), 0, 0, maxPageNumber) blocks, err = s.api.GetBlocks(page, blocksOnPage) if err != nil { return errorTpl, nil, err @@ -1267,10 +1084,7 @@ func (s *PublicServer) explorerBlock(w http.ResponseWriter, r *http.Request) (tp var err error s.metrics.ExplorerViews.With(common.Labels{"action": "block"}).Inc() if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { - page, ec := strconv.Atoi(r.URL.Query().Get("page")) - if ec != nil { - page = 0 - } + page := validateIntParam(r.URL.Query().Get("page"), 0, 0, maxPageNumber) block, err = s.api.GetBlock(r.URL.Path[i+1:], page, txsOnPage) if err != nil { return errorTpl, nil, err @@ -1284,6 +1098,11 @@ func (s *PublicServer) explorerBlock(w http.ResponseWriter, r *http.Request) (tp } func (s *PublicServer) explorerIndex(w http.ResponseWriter, r *http.Request) (tpl, *TemplateData, error) { + if !s.isFullInterface && r.URL.Path != "/" { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("Service unavailable")) + return noTpl, nil, nil + } var si *api.SystemInfo var err error s.metrics.ExplorerViews.With(common.Labels{"action": "index"}).Inc() @@ -1304,6 +1123,23 @@ func (s *PublicServer) explorerSearch(w http.ResponseWriter, r *http.Request) (t var err error s.metrics.ExplorerViews.With(common.Labels{"action": "search"}).Inc() if len(q) > 0 { + // Check if this is an ENS name for Ethereum chains and check if it is not expired and unique + if s.chainParser.GetChainType() == bchain.ChainEthereumType && + strings.HasSuffix(strings.ToLower(q), ".eth") { + if ensResolver, ok := s.chain.(interface { + ResolveENS(string) (*bchain.ENSResolution, error) + CheckENSExpiration(string) (bool, error) + }); ok { + expired, err := ensResolver.CheckENSExpiration(q) + if err == nil && !expired { + ensRes, err := ensResolver.ResolveENS(q) + if err == nil && ensRes.Address != "" { + http.Redirect(w, r, joinURL("/address/", ensRes.Address), http.StatusFound) + return noTpl, nil, nil + } + } + } + } address, err = s.api.GetXpubAddress(q, 0, 1, api.AccountDetailsBasic, &api.AddressFilter{Vout: api.AddressFilterVoutOff}, 0, "") if err == nil { http.Redirect(w, r, joinURL("/xpub/", url.QueryEscape(address.AddrStr)), http.StatusFound) @@ -1338,7 +1174,7 @@ func (s *PublicServer) explorerSendTx(w http.ResponseWriter, r *http.Request) (t } hex := r.FormValue("hex") if len(hex) > 0 { - res, err := s.chain.SendRawTransaction(hex) + res, err := s.chain.SendRawTransaction(hex, false) if err != nil { data.SendTxHex = hex data.Error = &api.APIError{Text: err.Error(), Public: true} @@ -1354,10 +1190,7 @@ func (s *PublicServer) explorerMempool(w http.ResponseWriter, r *http.Request) ( var mempoolTxids *api.MempoolTxids var err error s.metrics.ExplorerViews.With(common.Labels{"action": "mempool"}).Inc() - page, ec := strconv.Atoi(r.URL.Query().Get("page")) - if ec != nil { - page = 0 - } + page := validateIntParam(r.URL.Query().Get("page"), 0, 0, maxPageNumber) mempoolTxids, err = s.api.GetMempool(page, mempoolTxsOnPage) if err != nil { return errorTpl, nil, err @@ -1428,6 +1261,9 @@ func getPagingRange(page int, total int) ([]int, int, int) { } func (s *PublicServer) apiIndex(r *http.Request, apiVersion int) (interface{}, error) { + if !s.isFullInterface && r.URL.Path != "/api/" { + return nil, api.NewAPIError("Service unavailable", false) + } s.metrics.ExplorerViews.With(common.Labels{"action": "api-index"}).Inc() return s.api.GetSystemInfo(false) } @@ -1458,6 +1294,90 @@ func (s *PublicServer) apiBlockIndex(r *http.Request, apiVersion int) (interface }, nil } +func (s *PublicServer) apiBlockFilters(r *http.Request, apiVersion int) (interface{}, error) { + // Define return type + type blockFilterResult struct { + BlockHash string `json:"blockHash"` + Filter string `json:"filter"` + } + type resBlockFilters struct { + ParamP uint8 `json:"P"` + ParamM uint64 `json:"M"` + ZeroedKey bool `json:"zeroedKey"` + BlockFilters map[int]blockFilterResult `json:"blockFilters"` + } + + lastN := validateIntParam(r.URL.Query().Get("lastN"), 0, 0, maxBlockFiltersRange) + from := validateIntParam(r.URL.Query().Get("from"), 0, 0, 10000000000) + to := validateIntParam(r.URL.Query().Get("to"), 0, 0, 10000000000) + scriptType := r.URL.Query().Get("scriptType") + if scriptType != s.is.BlockFilterScripts { + return nil, api.NewAPIError(fmt.Sprintf("Invalid scriptType %s. Use %s", scriptType, s.is.BlockFilterScripts), true) + } + // NOTE: technically, we are also accepting "m: uint64" param, but we do not use it currently + + // Sanity checks + if lastN == 0 && from == 0 && to == 0 { + return nil, api.NewAPIError("Missing parameters", true) + } + if from > to { + return nil, api.NewAPIError("Invalid parameters - from > to", true) + } + + // Best height is needed more than once + bestHeight, _, err := s.db.GetBestBlock() + if err != nil { + glog.Error(err) + return nil, err + } + + // Modify to/from if needed + if lastN > 0 { + // Get data for last N blocks + to = int(bestHeight) + from = to - lastN + 1 + } else { + // Get data for specified from-to range + // From will always stay the same (even if 0) + // To will be the best block if not specified + if to == 0 { + to = int(bestHeight) + } + } + + if to-from+1 > maxBlockFiltersRange { + return nil, api.NewAPIError(fmt.Sprintf("Requested block filter range too large, max %d", maxBlockFiltersRange), true) + } + + handleBlockFiltersResultFromTo := func(fromHeight int, toHeight int) (interface{}, error) { + blockFiltersMap := make(map[int]blockFilterResult) + for i := fromHeight; i <= toHeight; i++ { + blockHash, err := s.db.GetBlockHash(uint32(i)) + if err != nil { + glog.Error(err) + return nil, err + } + blockFilter, err := s.db.GetBlockFilter(blockHash) + if err != nil { + glog.Error(err) + return nil, err + } + blockFiltersMap[i] = blockFilterResult{ + BlockHash: blockHash, + Filter: blockFilter, + } + } + return resBlockFilters{ + ParamP: s.is.BlockGolombFilterP, + ParamM: bchain.GetGolombParamM(s.is.BlockGolombFilterP), + ZeroedKey: s.is.BlockFilterUseZeroedKey, + BlockFilters: blockFiltersMap, + }, nil + } + + return handleBlockFiltersResultFromTo(from, to) +} + func (s *PublicServer) apiTx(r *http.Request, apiVersion int) (interface{}, error) { var txid string i := strings.LastIndexByte(r.URL.Path, '/') @@ -1485,6 +1405,19 @@ func (s *PublicServer) apiTx(r *http.Request, apiVersion int) (interface{}, erro return tx, err } +func (s *PublicServer) apiRawTx(r *http.Request, apiVersion int) (interface{}, error) { + var txid string + i := strings.LastIndexByte(r.URL.Path, '/') + if i > 0 { + txid = r.URL.Path[i+1:] + } + if len(txid) == 0 { + return "", api.NewAPIError("Missing txid", true) + } + s.metrics.ExplorerViews.With(common.Labels{"action": "api-raw-tx"}).Inc() + return s.api.GetRawTransaction(txid) +} + func (s *PublicServer) apiTxSpecific(r *http.Request, apiVersion int) (interface{}, error) { var txid string i := strings.LastIndexByte(r.URL.Path, '/') @@ -1517,6 +1450,9 @@ func (s *PublicServer) apiAddress(r *http.Request, apiVersion int) (interface{}, var err error s.metrics.ExplorerViews.With(common.Labels{"action": "api-address"}).Inc() page, pageSize, details, filter, _, _ := s.getAddressQueryParams(r, api.AccountDetailsTxidHistory, txsInAPI) + if err := s.api.ValidateProtocolsForChain(filter.Protocols); err != nil { + return nil, err + } secondaryCoin := strings.ToLower(r.URL.Query().Get("secondary")) address, err = s.api.GetAddress(addressParam, page, pageSize, details, filter, secondaryCoin) if err == nil && apiVersion == apiV1 { @@ -1525,6 +1461,19 @@ func (s *PublicServer) apiAddress(r *http.Request, apiVersion int) (interface{}, return address, err } +func (s *PublicServer) apiContract(r *http.Request, apiVersion int) (interface{}, error) { + var contract string + i := strings.LastIndex(r.URL.Path, "contract/") + if i > 0 { + contract = r.URL.Path[i+9:] + } + if len(contract) == 0 { + return nil, api.NewAPIError("Missing contract", true) + } + s.metrics.ExplorerViews.With(common.Labels{"action": "api-contract"}).Inc() + return s.api.GetContractInfoData(contract, strings.ToLower(r.URL.Query().Get("currency")), parseProtocolsQuery(r.URL.Query()["protocols"])) +} + func (s *PublicServer) apiXpub(r *http.Request, apiVersion int) (interface{}, error) { var xpub string i := strings.LastIndex(r.URL.Path, "xpub/") @@ -1562,10 +1511,7 @@ func (s *PublicServer) apiUtxo(r *http.Request, apiVersion int) (interface{}, er return nil, api.NewAPIError("Parameter 'confirmed' cannot be converted to boolean", true) } } - gap, ec := strconv.Atoi(r.URL.Query().Get("gap")) - if ec != nil { - gap = 0 - } + gap := validateIntParam(r.URL.Query().Get("gap"), 0, 0, maxGapValue) utxo, err = s.api.GetXpubUtxo(desc, onlyConfirmed, gap) if err == nil { s.metrics.ExplorerViews.With(common.Labels{"action": "api-xpub-utxo"}).Inc() @@ -1585,10 +1531,7 @@ func (s *PublicServer) apiBalanceHistory(r *http.Request, apiVersion int) (inter var fromTimestamp, toTimestamp int64 var err error if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { - gap, ec := strconv.Atoi(r.URL.Query().Get("gap")) - if ec != nil { - gap = 0 - } + gap := validateIntParam(r.URL.Query().Get("gap"), 0, 0, maxGapValue) from := r.URL.Query().Get("from") if from != "" { fromTimestamp, err = strconv.ParseInt(from, 10, 64) @@ -1629,10 +1572,7 @@ func (s *PublicServer) apiBlock(r *http.Request, apiVersion int) (interface{}, e var err error s.metrics.ExplorerViews.With(common.Labels{"action": "api-block"}).Inc() if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { - page, ec := strconv.Atoi(r.URL.Query().Get("page")) - if ec != nil { - page = 0 - } + page := validateIntParam(r.URL.Query().Get("page"), 0, 0, maxPageNumber) block, err = s.api.GetBlock(r.URL.Path[i+1:], page, txsInAPI) if err == nil && apiVersion == apiV1 { return s.api.BlockToV1(block), nil @@ -1665,24 +1605,38 @@ type resultSendTransaction struct { Result string `json:"result"` } +func readSendTxHexFromBody(body io.Reader, maxBodyBytes int64) (string, error) { + var hex strings.Builder + n, err := io.Copy(&hex, io.LimitReader(body, maxBodyBytes+1)) + if err != nil { + return "", api.NewAPIError("Missing tx blob", true) + } + if n > maxBodyBytes { + return "", api.NewAPIError("Tx blob too large", true) + } + return hex.String(), nil +} + func (s *PublicServer) apiSendTx(r *http.Request, apiVersion int) (interface{}, error) { var err error var res resultSendTransaction var hex string s.metrics.ExplorerViews.With(common.Labels{"action": "api-sendtx"}).Inc() if r.Method == http.MethodPost { - data, err := io.ReadAll(r.Body) + if r.ContentLength > maxSendTxBodyBytes { + return nil, api.NewAPIError("Tx blob too large", true) + } + hex, err = readSendTxHexFromBody(r.Body, maxSendTxBodyBytes) if err != nil { - return nil, api.NewAPIError("Missing tx blob", true) + return nil, err } - hex = string(data) } else { if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { hex = r.URL.Path[i+1:] } } if len(hex) > 0 { - res.Result, err = s.chain.SendRawTransaction(hex) + res.Result, err = s.chain.SendRawTransaction(hex, false) if err != nil { return nil, api.NewAPIError(err.Error(), true) } @@ -1699,7 +1653,7 @@ func (s *PublicServer) apiAvailableVsCurrencies(r *http.Request, apiVersion int) if err != nil { return nil, api.NewAPIError("Parameter \"timestamp\" is not a valid Unix timestamp.", true) } - token := strings.ToLower(r.URL.Query().Get("token")) + token := r.URL.Query().Get("token") result, err := s.api.GetAvailableVsCurrencies(timestamp, token) return result, err } @@ -1714,7 +1668,7 @@ func (s *PublicServer) apiTickers(r *http.Request, apiVersion int) (interface{}, if currency != "" { currencies = []string{currency} } - token := strings.ToLower(r.URL.Query().Get("token")) + token := r.URL.Query().Get("token") if block := r.URL.Query().Get("block"); block != "" { // Get tickers for specified block height or block hash @@ -1755,7 +1709,7 @@ func (s *PublicServer) apiMultiTickers(r *http.Request, apiVersion int) (interfa if currency != "" { currencies = []string{currency} } - token := strings.ToLower(r.URL.Query().Get("token")) + token := r.URL.Query().Get("token") if timestampString := r.URL.Query().Get("timestamp"); timestampString != "" { // Get tickers for specified timestamp s.metrics.ExplorerViews.With(common.Labels{"action": "api-multi-tickers-date"}).Inc() diff --git a/server/public_ethereumtype_test.go b/server/public_ethereumtype_test.go index 9094ca1751..bd21cc3998 100644 --- a/server/public_ethereumtype_test.go +++ b/server/public_ethereumtype_test.go @@ -6,12 +6,15 @@ package server import ( "net/http" "net/http/httptest" + "reflect" "testing" + "time" "github.com/golang/glog" "github.com/linxGnu/grocksdb" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" "github.com/trezor/blockbook/tests/dbtestdata" ) @@ -24,7 +27,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Address address7b.eth

0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b

0.000000000123450123 FAKE

Confirmed
Balance0.000000000123450123 FAKE
Transactions2
Non-contract Transactions0
Internal Transactions0
Nonce123
ContractQuantityValueTransfers#
Contract 130.000000001000123013 S131
Contract 740.001000123074 S741
ContractTokensTransfers#
Contract 20511

Transactions

ERC721 Token Transfers
`, + `Trezor Fake Coin Explorer

Address address7b.eth

0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b

0.000000000123450123 FAKE
0.00 USD

Confirmed
Balance0.000000000123450123 FAKE0.00 USD
Transactions2
Non-contract Transactions0
Internal Transactions0
Nonce123
ContractQuantityValueTransfers#
Contract 130.000000001000123013 S13-1
Contract 740.001000123074 S74-1
ContractTokensTransfers#
Contract 20511

Transactions

ERC721 Token Transfers
ERC20 Token Transfers
address7b.eth
 
871.180000950184 S74-
 
address7b.eth
7.674999999999991915 S13-
`, }, }, { @@ -33,7 +36,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Address

0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e

0.000000000123450093 FAKE

Confirmed
Balance0.000000000123450093 FAKE
Transactions1
Non-contract Transactions1
Internal Transactions0
Nonce93
ContractTokensTransfers#
Contract 1111 S111 of ID 1776, 10 S111 of ID 18981

Transactions

0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e
 
0 FAKE
ERC1155 Token Transfers
 
0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e
1 S111 of ID 1776, 10 S111 of ID 1898
`, + `Trezor Fake Coin Explorer

Address

0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e

0.000000000123450093 FAKE
0.00 USD

Confirmed
Balance0.000000000123450093 FAKE0.00 USD
Transactions1
Non-contract Transactions1
Internal Transactions0
Nonce93
ContractTokensTransfers#
Contract 1111 S111 of ID 1776, 10 S111 of ID 18981

Transactions

0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e
 
0 FAKE0.00 USD0.00 USD
ERC1155 Token Transfers
 
0x5Dc6288b35E0807A3d6fEB89b3a2Ff4aB773168e
1 S111 of ID 1776, 10 S111 of ID 1898
`, }, }, { @@ -42,14 +45,14 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Transaction

0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101
In BlockUnconfirmed
StatusSuccess
Value0 FAKE
Gas Used / Limit52025 / 78037
Gas Price0.00000004 FAKE (40 Gwei)
Fees0.002081 FAKE
RBFON
ERC20 Token Transfers
Input Data

0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000
transfer(address, uint256)
#TypeData
0address0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f
1uint25610000000000000000000000
Raw Transaction
`, + `Trezor Fake Coin Explorer

Transaction

0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101
In BlockUnconfirmed
StatusSuccess
Value0 FAKE0.00 USD0.00 USD
Gas Used / Limit52025 / 78037
Gas Price0.00000004 FAKE0.00 USD0.00 USD (40 Gwei)
Max Priority Fee Per Gas0.000000040000000001 FAKE0.00 USD0.00 USD (40.000000001 Gwei)
Max Fee Per Gas0.000000040000000002 FAKE0.00 USD0.00 USD (40.000000002 Gwei)
Base Fee Per Gas0.000000040000000003 FAKE0.00 USD0.00 USD (40.000000003 Gwei)
Fees0.002081 FAKE4.16 USD18.55 USD
RBFON
Nonce208
 
0 FAKE0.00 USD0.00 USD
ERC20 Token Transfers
Input Data

0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000
transfer(address, uint256)
#TypeData
0address0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f
1uint25610000000000000000000000
`, }, }, { name: "explorerTokenDetail " + dbtestdata.EthAddr7b, r: newGetRequest(ts.URL + "/nft/" + dbtestdata.EthAddrContractCd + "/" + "1"), status: http.StatusOK, contentType: "text/html; charset=utf-8", - body: []string{`Trezor Fake Coin Explorer

NFT Token Detail

Token ID1
Contract0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9
Contract 205
Contract typeERC20
`}, + body: []string{`Trezor Fake Coin Explorer

NFT Token Detail

Token ID1
Contract0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9
Contract 205
StandardERC20
`}, }, { name: "apiIndex", @@ -70,7 +73,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","balance":"123450075","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":1,"nonTokenTxs":1,"internalTxs":1,"txids":["0xc92919ad24ffd58f760b18df7949f06e1190cf54a50a0e3745a385608ed3cbf2"],"nonce":"75","tokens":[{"type":"ERC20","name":"Contract 13","contract":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","transfers":2,"symbol":"S13","decimals":18,"balance":"1000075013"},{"type":"ERC20","name":"Contract 74","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","transfers":2,"symbol":"S74","decimals":12,"balance":"1000075074"}]}`, + `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","balance":"123450075","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":1,"nonTokenTxs":1,"internalTxs":1,"txids":["0xc92919ad24ffd58f760b18df7949f06e1190cf54a50a0e3745a385608ed3cbf2"],"nonce":"75","tokens":[{"type":"ERC20","standard":"ERC20","name":"Contract 13","contract":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","transfers":2,"symbol":"S13","decimals":18,"balance":"1000075013"},{"type":"ERC20","standard":"ERC20","name":"Contract 74","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","transfers":2,"symbol":"S74","decimals":12,"balance":"1000075074"}]}`, }, }, { @@ -79,7 +82,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b","balance":"123450123","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"transactions":[{"txid":"0xca7628be5c80cda77163729ec63d218ee868a399d827a4682a478c6f48a6e22a","vin":[{"n":0,"addresses":["0x837E3f699d85a4b0B99894567e9233dFB1DcB081"],"isAddress":true}],"vout":[{"value":"0","n":0,"addresses":["0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9"],"isAddress":true}],"blockHeight":-1,"confirmations":0,"blockTime":0,"value":"0","fees":"87945000410410","rbf":true,"coinSpecificData":{"tx":{"nonce":"0x2","gasPrice":"0x59682f07","gas":"0x173a9","to":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","value":"0x0","input":"0x23b872dd000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb0810000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b0000000000000000000000000000000000000000000000000000000000000001","hash":"0xca7628be5c80cda77163729ec63d218ee868a399d827a4682a478c6f48a6e22a","blockNumber":"0xb33b9f","from":"0x837E3f699d85a4b0B99894567e9233dFB1DcB081","transactionIndex":"0x1"},"receipt":{"gasUsed":"0xe506","status":"0x1","logs":[{"address":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","topics":["0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925","0x000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb081","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000001"],"data":"0x"},{"address":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb081","0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b","0x0000000000000000000000000000000000000000000000000000000000000001"],"data":"0x"}]}},"tokenTransfers":[{"type":"ERC721","from":"0x837E3f699d85a4b0B99894567e9233dFB1DcB081","to":"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b","contract":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","name":"Contract 205","symbol":"S205","decimals":18,"value":"1"}],"ethereumSpecific":{"status":1,"nonce":2,"gasLimit":95145,"gasUsed":58630,"gasPrice":"1500000007","data":"0x23b872dd000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb0810000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b0000000000000000000000000000000000000000000000000000000000000001","parsedData":{"methodId":"0x23b872dd","name":""}}},{"txid":"0xc92919ad24ffd58f760b18df7949f06e1190cf54a50a0e3745a385608ed3cbf2","vin":[{"n":0,"addresses":["0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D"],"isAddress":true}],"vout":[{"value":"0","n":0,"addresses":["0x479CC461fEcd078F766eCc58533D6F69580CF3AC"],"isAddress":true}],"blockHeight":-1,"confirmations":0,"blockTime":0,"value":"0","fees":"216368000000000","rbf":true,"coinSpecificData":{"tx":{"nonce":"0x1df76","gasPrice":"0x3b9aca00","gas":"0x3d090","to":"0x479CC461fEcd078F766eCc58533D6F69580CF3AC","value":"0x0","input":"0x4f15078700000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000048000000000000000000000000000000000000000000000000000000000000004e00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a200000000000000000000000000000000000000000000000000000000000000000000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a20000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000a5ef5a7656bfb0000000000000000000000000000000000000000000000000000004ba78398d5c5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000166cfe0b9579b4ecf7a2801880f644009a324671a79754ea57c3a103c6e70d3dbef6ba69a08000000000000000000000000000000000000000000000000004f937d86afb90000000000000000000000000000000000000000000000000ab280fd8037d500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000166cfb784b7c1f3fbe8b75484603ab8adc58aaee3a46245a6579fac7077b5570018b4e0d4eb0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000308fd0e798ac00000000000000000000000000000000000000000000000006a8313d60b1f606b0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000001b00000000000000000000000000000000000000000000000000000000000000029de0ccec59e8948e3d905b40e5542335ebc1eb4674db517d2f6392ec7fdeb3d45f3449d313ee2589819c6c79eb1c1b047adae68565c1608e3a1d1d70823febb0000000000000000000000000000000000000000000000000000000000000000234d06fe17f1202e8b07177a30eb64d14adc08cdb3fa1b3e3e0bea0f9672c02175b77c01c51d3c7e460723b27ecbc7801fd6482559a8c9999593f9a4d149c7384","hash":"0xc92919ad24ffd58f760b18df7949f06e1190cf54a50a0e3745a385608ed3cbf2","blockNumber":"0x41eee9","from":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","transactionIndex":"0x24"},"internalData":{"type":1,"contract":"0d0f936ee4c93e25944694d6c121de94d9760f11","transfers":[{"type":0,"from":"4bda106325c335df99eab7fe363cac8a0ba2a24d","to":"9f4981531fda132e83c44680787dfa7ee31e4f8d","value":1000010},{"type":2,"from":"4af4114f73d1c1c903ac9e0361b379d1291808a2","to":"9f4981531fda132e83c44680787dfa7ee31e4f8d","value":1000011}],"Error":""},"receipt":{"gasUsed":"0x34d30","status":"0x1","logs":[{"address":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f","0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d"],"data":"0x0000000000000000000000000000000000000000000000006a8313d60b1f8001"},{"address":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f"],"data":"0x000000000000000000000000000000000000000000000000000308fd0e798ac0"},{"address":"0x479CC461fEcd078F766eCc58533D6F69580CF3AC","topics":["0x0d0b9391970d9a25552f37d436d2aae2925e2bfe1b2a923754bada030c498cb3","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f","0x0000000000000000000000000000000000000000000000000000000000000000","0x5af266c0a89a07c1917deaa024414577e6c3c31c8907d079e13eb448c082594f"],"data":"0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a20000000000000000000000000000000000000000000000006a8313d60b1f8001000000000000000000000000000000000000000000000000000308fd0e798ac0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e083a16f4b092c5729a49f9c3ed3cc171bb3d3d0c22e20b1de6063c32f399ac"},{"address":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b","0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d"],"data":"0x00000000000000000000000000000000000000000000000000031855667df7a8"},{"address":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d","0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b"],"data":"0x0000000000000000000000000000000000000000000000006a8313d60b1f606b"},{"address":"0x479CC461fEcd078F766eCc58533D6F69580CF3AC","topics":["0x0d0b9391970d9a25552f37d436d2aae2925e2bfe1b2a923754bada030c498cb3","0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b","0x0000000000000000000000000000000000000000000000000000000000000000","0xb0b69dad58df6032c3b266e19b1045b19c87acd2c06fb0c598090f44b8e263aa"],"data":"0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a20000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f1100000000000000000000000000000000000000000000000000031855667df7a80000000000000000000000000000000000000000000000006a8313d60b1f606b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f2b0d62c44ed08f2a5adef40c875d20310a42a9d4f488bd26323256fe01c7f48"}]}},"tokenTransfers":[{"type":"ERC20","from":"0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f","to":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","contract":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","name":"Contract 13","symbol":"S13","decimals":18,"value":"7675000000000000001"},{"type":"ERC20","from":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","to":"0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","name":"Contract 74","symbol":"S74","decimals":12,"value":"854307892726464"},{"type":"ERC20","from":"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b","to":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","name":"Contract 74","symbol":"S74","decimals":12,"value":"871180000950184"},{"type":"ERC20","from":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","to":"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b","contract":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","name":"Contract 13","symbol":"S13","decimals":18,"value":"7674999999999991915"}],"ethereumSpecific":{"status":1,"nonce":122742,"gasLimit":250000,"gasUsed":216368,"gasPrice":"1000000000","data":"0x4f15078700000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000048000000000000000000000000000000000000000000000000000000000000004e00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a200000000000000000000000000000000000000000000000000000000000000000000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a20000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000a5ef5a7656bfb0000000000000000000000000000000000000000000000000000004ba78398d5c5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000166cfe0b9579b4ecf7a2801880f644009a324671a79754ea57c3a103c6e70d3dbef6ba69a08000000000000000000000000000000000000000000000000004f937d86afb90000000000000000000000000000000000000000000000000ab280fd8037d500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000166cfb784b7c1f3fbe8b75484603ab8adc58aaee3a46245a6579fac7077b5570018b4e0d4eb0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000308fd0e798ac00000000000000000000000000000000000000000000000006a8313d60b1f606b0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000001b00000000000000000000000000000000000000000000000000000000000000029de0ccec59e8948e3d905b40e5542335ebc1eb4674db517d2f6392ec7fdeb3d45f3449d313ee2589819c6c79eb1c1b047adae68565c1608e3a1d1d70823febb0000000000000000000000000000000000000000000000000000000000000000234d06fe17f1202e8b07177a30eb64d14adc08cdb3fa1b3e3e0bea0f9672c02175b77c01c51d3c7e460723b27ecbc7801fd6482559a8c9999593f9a4d149c7384","parsedData":{"methodId":"0x4f150787","name":""}}}],"nonce":"123","tokens":[{"type":"ERC20","name":"Contract 13","contract":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","transfers":1,"symbol":"S13","decimals":18,"balance":"1000123013"},{"type":"ERC721","name":"Contract 205","contract":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","transfers":1,"symbol":"S205","decimals":18,"ids":["1"]},{"type":"ERC20","name":"Contract 74","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","transfers":1,"symbol":"S74","decimals":12,"balance":"1000123074"}],"addressAliases":{"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b":{"Type":"ENS","Alias":"address7b.eth"},"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9":{"Type":"Contract","Alias":"Contract 205"}}}`, + `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b","balance":"123450123","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"transactions":[{"txid":"0xca7628be5c80cda77163729ec63d218ee868a399d827a4682a478c6f48a6e22a","vin":[{"n":0,"addresses":["0x837E3f699d85a4b0B99894567e9233dFB1DcB081"],"isAddress":true}],"vout":[{"value":"0","n":0,"addresses":["0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9"],"isAddress":true}],"blockHeight":-1,"confirmations":0,"blockTime":0,"value":"0","fees":"87945000410410","rbf":true,"coinSpecificData":{"tx":{"nonce":"0x2","gasPrice":"0x59682f07","gas":"0x173a9","to":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","value":"0x0","input":"0x23b872dd000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb0810000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b0000000000000000000000000000000000000000000000000000000000000001","hash":"0xca7628be5c80cda77163729ec63d218ee868a399d827a4682a478c6f48a6e22a","blockNumber":"0xb33b9f","from":"0x837E3f699d85a4b0B99894567e9233dFB1DcB081","transactionIndex":"0x1"},"receipt":{"gasUsed":"0xe506","status":"0x1","logs":[{"address":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","topics":["0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925","0x000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb081","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000001"],"data":"0x"},{"address":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb081","0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b","0x0000000000000000000000000000000000000000000000000000000000000001"],"data":"0x"}]}},"tokenTransfers":[{"type":"ERC721","standard":"ERC721","from":"0x837E3f699d85a4b0B99894567e9233dFB1DcB081","to":"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b","contract":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","name":"Contract 205","symbol":"S205","decimals":18,"value":"1"}],"ethereumSpecific":{"status":1,"nonce":2,"gasLimit":95145,"gasUsed":58630,"gasPrice":"1500000007","data":"0x23b872dd000000000000000000000000837e3f699d85a4b0b99894567e9233dfb1dcb0810000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b0000000000000000000000000000000000000000000000000000000000000001","parsedData":{"methodId":"0x23b872dd","name":""}}},{"txid":"0xc92919ad24ffd58f760b18df7949f06e1190cf54a50a0e3745a385608ed3cbf2","vin":[{"n":0,"addresses":["0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D"],"isAddress":true}],"vout":[{"value":"0","n":0,"addresses":["0x479CC461fEcd078F766eCc58533D6F69580CF3AC"],"isAddress":true}],"blockHeight":-1,"confirmations":0,"blockTime":0,"value":"0","fees":"216368000000000","rbf":true,"coinSpecificData":{"tx":{"nonce":"0x1df76","gasPrice":"0x3b9aca00","gas":"0x3d090","to":"0x479CC461fEcd078F766eCc58533D6F69580CF3AC","value":"0x0","input":"0x4f15078700000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000048000000000000000000000000000000000000000000000000000000000000004e00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a200000000000000000000000000000000000000000000000000000000000000000000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a20000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000a5ef5a7656bfb0000000000000000000000000000000000000000000000000000004ba78398d5c5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000166cfe0b9579b4ecf7a2801880f644009a324671a79754ea57c3a103c6e70d3dbef6ba69a08000000000000000000000000000000000000000000000000004f937d86afb90000000000000000000000000000000000000000000000000ab280fd8037d500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000166cfb784b7c1f3fbe8b75484603ab8adc58aaee3a46245a6579fac7077b5570018b4e0d4eb0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000308fd0e798ac00000000000000000000000000000000000000000000000006a8313d60b1f606b0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000001b00000000000000000000000000000000000000000000000000000000000000029de0ccec59e8948e3d905b40e5542335ebc1eb4674db517d2f6392ec7fdeb3d45f3449d313ee2589819c6c79eb1c1b047adae68565c1608e3a1d1d70823febb0000000000000000000000000000000000000000000000000000000000000000234d06fe17f1202e8b07177a30eb64d14adc08cdb3fa1b3e3e0bea0f9672c02175b77c01c51d3c7e460723b27ecbc7801fd6482559a8c9999593f9a4d149c7384","hash":"0xc92919ad24ffd58f760b18df7949f06e1190cf54a50a0e3745a385608ed3cbf2","blockNumber":"0x41eee9","from":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","transactionIndex":"0x24"},"internalData":{"type":1,"contract":"0d0f936ee4c93e25944694d6c121de94d9760f11","transfers":[{"type":0,"from":"4bda106325c335df99eab7fe363cac8a0ba2a24d","to":"9f4981531fda132e83c44680787dfa7ee31e4f8d","value":1000010},{"type":2,"from":"4af4114f73d1c1c903ac9e0361b379d1291808a2","to":"9f4981531fda132e83c44680787dfa7ee31e4f8d","value":1000011}],"Error":""},"receipt":{"gasUsed":"0x34d30","status":"0x1","logs":[{"address":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f","0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d"],"data":"0x0000000000000000000000000000000000000000000000006a8313d60b1f8001"},{"address":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f"],"data":"0x000000000000000000000000000000000000000000000000000308fd0e798ac0"},{"address":"0x479CC461fEcd078F766eCc58533D6F69580CF3AC","topics":["0x0d0b9391970d9a25552f37d436d2aae2925e2bfe1b2a923754bada030c498cb3","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f","0x0000000000000000000000000000000000000000000000000000000000000000","0x5af266c0a89a07c1917deaa024414577e6c3c31c8907d079e13eb448c082594f"],"data":"0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a20000000000000000000000000000000000000000000000006a8313d60b1f8001000000000000000000000000000000000000000000000000000308fd0e798ac0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e083a16f4b092c5729a49f9c3ed3cc171bb3d3d0c22e20b1de6063c32f399ac"},{"address":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b","0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d"],"data":"0x00000000000000000000000000000000000000000000000000031855667df7a8"},{"address":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d","0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b"],"data":"0x0000000000000000000000000000000000000000000000006a8313d60b1f606b"},{"address":"0x479CC461fEcd078F766eCc58533D6F69580CF3AC","topics":["0x0d0b9391970d9a25552f37d436d2aae2925e2bfe1b2a923754bada030c498cb3","0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b","0x0000000000000000000000000000000000000000000000000000000000000000","0xb0b69dad58df6032c3b266e19b1045b19c87acd2c06fb0c598090f44b8e263aa"],"data":"0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a20000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f1100000000000000000000000000000000000000000000000000031855667df7a80000000000000000000000000000000000000000000000006a8313d60b1f606b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f2b0d62c44ed08f2a5adef40c875d20310a42a9d4f488bd26323256fe01c7f48"}]}},"tokenTransfers":[{"type":"ERC20","standard":"ERC20","from":"0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f","to":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","contract":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","name":"Contract 13","symbol":"S13","decimals":18,"value":"7675000000000000001"},{"type":"ERC20","standard":"ERC20","from":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","to":"0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","name":"Contract 74","symbol":"S74","decimals":12,"value":"854307892726464"},{"type":"ERC20","standard":"ERC20","from":"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b","to":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","name":"Contract 74","symbol":"S74","decimals":12,"value":"871180000950184"},{"type":"ERC20","standard":"ERC20","from":"0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D","to":"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b","contract":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","name":"Contract 13","symbol":"S13","decimals":18,"value":"7674999999999991915"}],"ethereumSpecific":{"status":1,"nonce":122742,"gasLimit":250000,"gasUsed":216368,"gasPrice":"1000000000","data":"0x4f15078700000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000048000000000000000000000000000000000000000000000000000000000000004e00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a200000000000000000000000000000000000000000000000000000000000000000000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000000000000004af4114f73d1c1c903ac9e0361b379d1291808a20000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000a5ef5a7656bfb0000000000000000000000000000000000000000000000000000004ba78398d5c5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000166cfe0b9579b4ecf7a2801880f644009a324671a79754ea57c3a103c6e70d3dbef6ba69a08000000000000000000000000000000000000000000000000004f937d86afb90000000000000000000000000000000000000000000000000ab280fd8037d500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000166cfb784b7c1f3fbe8b75484603ab8adc58aaee3a46245a6579fac7077b5570018b4e0d4eb0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000308fd0e798ac00000000000000000000000000000000000000000000000006a8313d60b1f606b0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000001b00000000000000000000000000000000000000000000000000000000000000029de0ccec59e8948e3d905b40e5542335ebc1eb4674db517d2f6392ec7fdeb3d45f3449d313ee2589819c6c79eb1c1b047adae68565c1608e3a1d1d70823febb0000000000000000000000000000000000000000000000000000000000000000234d06fe17f1202e8b07177a30eb64d14adc08cdb3fa1b3e3e0bea0f9672c02175b77c01c51d3c7e460723b27ecbc7801fd6482559a8c9999593f9a4d149c7384","parsedData":{"methodId":"0x4f150787","name":""}}}],"nonce":"123","tokens":[{"type":"ERC20","standard":"ERC20","name":"Contract 13","contract":"0x0d0F936Ee4c93e25944694D6C121de94D9760F11","transfers":1,"symbol":"S13","decimals":18,"balance":"1000123013"},{"type":"ERC721","standard":"ERC721","name":"Contract 205","contract":"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9","transfers":1,"symbol":"S205","decimals":18,"ids":["1"]},{"type":"ERC20","standard":"ERC20","name":"Contract 74","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","transfers":1,"symbol":"S74","decimals":12,"balance":"1000123074"}],"addressAliases":{"0x7B62EB7fe80350DC7EC945C0B73242cb9877FB1b":{"Type":"ENS","Alias":"address7b.eth"},"0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9":{"Type":"Contract","Alias":"Contract 205"}}}`, }, }, { @@ -88,7 +91,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"txid":"0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101","vin":[{"n":0,"addresses":["0x20cD153de35D469BA46127A0C8F18626b59a256A"],"isAddress":true}],"vout":[{"value":"0","n":0,"addresses":["0x4af4114F73d1c1C903aC9E0361b379D1291808A2"],"isAddress":true}],"blockHeight":-1,"confirmations":0,"blockTime":0,"value":"0","fees":"2081000000000000","rbf":true,"coinSpecificData":{"tx":{"nonce":"0xd0","gasPrice":"0x9502f9000","gas":"0x130d5","to":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","value":"0x0","input":"0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000","hash":"0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101","blockNumber":"0x41eee8","from":"0x20cD153de35D469BA46127A0C8F18626b59a256A","transactionIndex":"0x0"},"internalData":{"type":0,"transfers":[{"type":1,"from":"9f4981531fda132e83c44680787dfa7ee31e4f8d","to":"4af4114f73d1c1c903ac9e0361b379d1291808a2","value":1000000},{"type":0,"from":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","to":"9f4981531fda132e83c44680787dfa7ee31e4f8d","value":1000001},{"type":0,"from":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","to":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","value":1000002}],"Error":""},"receipt":{"gasUsed":"0xcb39","status":"0x1","logs":[{"address":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x00000000000000000000000020cd153de35d469ba46127a0c8f18626b59a256a","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f"],"data":"0x00000000000000000000000000000000000000000000021e19e0c9bab2400000"}]}},"tokenTransfers":[{"type":"ERC20","from":"0x20cD153de35D469BA46127A0C8F18626b59a256A","to":"0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","name":"Contract 74","symbol":"S74","decimals":12,"value":"10000000000000000000000"}],"ethereumSpecific":{"status":1,"nonce":208,"gasLimit":78037,"gasUsed":52025,"gasPrice":"40000000000","data":"0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000","parsedData":{"methodId":"0xa9059cbb","name":"Transfer","function":"transfer(address, uint256)","params":[{"type":"address","values":["0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f"]},{"type":"uint256","values":["10000000000000000000000"]}]}},"addressAliases":{"0x20cD153de35D469BA46127A0C8F18626b59a256A":{"Type":"ENS","Alias":"address20.eth"},"0x4af4114F73d1c1C903aC9E0361b379D1291808A2":{"Type":"Contract","Alias":"Contract 74"}}}`, + `{"txid":"0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101","vin":[{"n":0,"addresses":["0x20cD153de35D469BA46127A0C8F18626b59a256A"],"isAddress":true}],"vout":[{"value":"0","n":0,"addresses":["0x4af4114F73d1c1C903aC9E0361b379D1291808A2"],"isAddress":true}],"blockHeight":-1,"confirmations":0,"blockTime":0,"value":"0","fees":"2081000000000000","rbf":true,"coinSpecificData":{"tx":{"nonce":"0xd0","gasPrice":"0x9502f9000","maxPriorityFeePerGas":"0x9502f9001","maxFeePerGas":"0x9502f9002","baseFeePerGas":"0x9502f9003","gas":"0x130d5","to":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","value":"0x0","input":"0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000","hash":"0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101","blockNumber":"0x41eee8","from":"0x20cD153de35D469BA46127A0C8F18626b59a256A","transactionIndex":"0x0"},"internalData":{"type":0,"transfers":[{"type":1,"from":"9f4981531fda132e83c44680787dfa7ee31e4f8d","to":"4af4114f73d1c1c903ac9e0361b379d1291808a2","value":1000000},{"type":0,"from":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","to":"9f4981531fda132e83c44680787dfa7ee31e4f8d","value":1000001},{"type":0,"from":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","to":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","value":1000002}],"Error":""},"receipt":{"gasUsed":"0xcb39","status":"0x1","logs":[{"address":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x00000000000000000000000020cd153de35d469ba46127a0c8f18626b59a256a","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f"],"data":"0x00000000000000000000000000000000000000000000021e19e0c9bab2400000"}]}},"tokenTransfers":[{"type":"ERC20","standard":"ERC20","from":"0x20cD153de35D469BA46127A0C8F18626b59a256A","to":"0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f","contract":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","name":"Contract 74","symbol":"S74","decimals":12,"value":"10000000000000000000000"}],"ethereumSpecific":{"status":1,"nonce":208,"gasLimit":78037,"gasUsed":52025,"gasPrice":"40000000000","maxPriorityFeePerGas":"40000000001","maxFeePerGas":"40000000002","baseFeePerGas":"40000000003","data":"0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000","parsedData":{"methodId":"0xa9059cbb","name":"Transfer","function":"transfer(address, uint256)","params":[{"type":"address","values":["0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f"]},{"type":"uint256","values":["10000000000000000000000"]}]}},"addressAliases":{"0x20cD153de35D469BA46127A0C8F18626b59a256A":{"Type":"ENS","Alias":"address20.eth"},"0x4af4114F73d1c1C903aC9E0361b379D1291808A2":{"Type":"Contract","Alias":"Contract 74"}}}`, }, }, { @@ -97,7 +100,7 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1574344800,"rates":{"usd":7814.5}}`, + `{"ts":1574380800,"rates":{"usd":7914.5}}`, }, }, { @@ -106,16 +109,16 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1574344800,"rates":{"usd":6251.6}}`, + `{"ts":1574380800,"rates":{"usd":1.2}}`, }, }, { name: "apiFiatRates get token rate by timestamp for all currencies", r: newGetRequest(ts.URL + "/api/v2/tickers?timestamp=1574340000&token=0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3"), - status: http.StatusBadRequest, + status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"error":"Rates for token only for a single currency"}`, + `{"ts":1574380800,"rates":{"eur":1.0816754,"usd":1.2}}`, }, }, { @@ -127,11 +130,109 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { `{"ts":1574340000,"rates":{"usd":-1}}`, }, }, + { + name: "explorerAddress ENS resolution - valid domain", + r: newGetRequest(ts.URL + "/address/vitalik.eth"), + status: http.StatusOK, + contentType: "text/html; charset=utf-8", + body: []string{ + `Address `, // Empty title (current behavior) + `0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045`, + }, + }, } performHttpTests(tests, t, ts) } +var websocketTestsEthereumType = []websocketTest{ + { + name: "websocket getInfo", + req: websocketReq{ + Method: "getInfo", + }, + want: `{"id":"0","data":{"name":"Fakecoin","shortcut":"FAKE","network":"FAKE","decimals":18,"version":"unknown","bestHeight":4321001,"bestHash":"0x2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee","block0Hash":"","testnet":true,"backend":{"version":"001001","subversion":"/Fakecoin:0.0.1/"}}}`, + }, + { + name: "websocket rpcCall", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9", + Data: "0x4567", + }, + }, + want: `{"id":"1","data":{"data":"0x4567abcd"}}`, + }, + { + name: "websocket sendTransaction hex format", + req: websocketReq{ + Method: "sendTransaction", + Params: WsSendTransactionReq{ + Hex: "123456", + DisableAlternativeRPC: true, + }, + }, + want: `{"id":"2","data":{"result":"9876"}}`, + }, + { + name: "websocket getCurrentFiatRates token usd", + req: websocketReq{ + Method: "getCurrentFiatRates", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "token": "0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3", + }, + }, + want: `{"id":"3","data":{"ts":1592821931,"rates":{"usd":8.2}}}`, + }, + { + name: "websocket getCurrentFiatRates unknown token", + req: websocketReq{ + Method: "getCurrentFiatRates", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "token": "0xFFFFFFFFFFe95Af55f0447555c8b6aA3088562f3", + }, + }, + want: `{"id":"4","data":{"error":{"message":"No tickers found!"}}}`, + }, + { + name: "websocket getFiatRatesForTimestamps token usd", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "timestamps": []int64{1574340000}, + "token": "0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3", + }, + }, + want: `{"id":"5","data":{"tickers":[{"ts":1574380800,"rates":{"usd":1.2}}]}}`, + }, + { + name: "websocket getFiatRatesTickersList token", + req: websocketReq{ + Method: "getFiatRatesTickersList", + Params: map[string]interface{}{ + "timestamp": 1574340000, + "token": "0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3", + }, + }, + want: `{"id":"6","data":{"ts":1574380800,"available_currencies":["eur","usd"]}}`, + }, + { + name: "websocket getFiatRatesTickersList unknown token", + req: websocketReq{ + Method: "getFiatRatesTickersList", + Params: map[string]interface{}{ + "timestamp": 1574340000, + "token": "0xFFFFFFFFFFe95Af55f0447555c8b6aA3088562f3", + }, + }, + want: `{"id":"7","data":{"error":{"message":"No tickers found"}}}`, + }, +} + func initEthereumTypeDB(d *db.RocksDB) error { // add 0xa9059cbb transfer(address,uint256) signature wb := grocksdb.NewWriteBatch() @@ -147,7 +248,7 @@ func initEthereumTypeDB(d *db.RocksDB) error { // initTestFiatRatesEthereumType initializes test data for /api/v2/tickers endpoint func initTestFiatRatesEthereumType(d *db.RocksDB) error { - if err := insertFiatRate("20180320020000", map[string]float32{ + if err := insertFiatRate("20180320000000", map[string]float32{ "usd": 2000.0, "eur": 1300.0, }, map[string]float32{ @@ -156,7 +257,7 @@ func initTestFiatRatesEthereumType(d *db.RocksDB) error { }, d); err != nil { return err } - if err := insertFiatRate("20180320030000", map[string]float32{ + if err := insertFiatRate("20180321000000", map[string]float32{ "usd": 2001.0, "eur": 1301.0, }, map[string]float32{ @@ -165,7 +266,7 @@ func initTestFiatRatesEthereumType(d *db.RocksDB) error { }, d); err != nil { return err } - if err := insertFiatRate("20180320040000", map[string]float32{ + if err := insertFiatRate("20180322000000", map[string]float32{ "usd": 2002.0, "eur": 1302.0, }, map[string]float32{ @@ -174,7 +275,7 @@ func initTestFiatRatesEthereumType(d *db.RocksDB) error { }, d); err != nil { return err } - if err := insertFiatRate("20180321055521", map[string]float32{ + if err := insertFiatRate("20180323000000", map[string]float32{ "usd": 2003.0, "eur": 1303.0, }, map[string]float32{ @@ -183,7 +284,7 @@ func initTestFiatRatesEthereumType(d *db.RocksDB) error { }, d); err != nil { return err } - if err := insertFiatRate("20191121140000", map[string]float32{ + if err := insertFiatRate("20190321000000", map[string]float32{ "usd": 7814.5, "eur": 7100.0, }, map[string]float32{ @@ -193,14 +294,31 @@ func initTestFiatRatesEthereumType(d *db.RocksDB) error { }, d); err != nil { return err } - return insertFiatRate("20191121143015", map[string]float32{ + if err := insertFiatRate("20191122000000", map[string]float32{ "usd": 7914.5, "eur": 7134.1, }, map[string]float32{ "0xdac17f958d2ee523a2206206994597c13d831ec7": 7914.1, "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 599.0, "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3": 1.2, - }, d) + }, d); err != nil { + return err + } + + return d.FiatRatesStoreSpecialTickers("CurrentTickers", &[]common.CurrencyRatesTicker{ + { + Timestamp: time.Unix(1592821931, 0), + Rates: map[string]float32{ + "usd": 8914.5, + "eur": 8134.1, + }, + TokenRates: map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 8914.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 899.0, + "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3": 8.2, + }, + }, + }) } func Test_PublicServer_EthereumType(t *testing.T) { @@ -211,7 +329,7 @@ func Test_PublicServer_EthereumType(t *testing.T) { glog.Fatal("fakechain: ", err) } - s, dbpath := setupPublicHTTPServer(parser, chain, t) + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) defer closeAndDestroyPublicServer(t, s, dbpath) s.ConnectFullPublicInterface() // take the handler of the public server and pass it to the test server @@ -219,4 +337,196 @@ func Test_PublicServer_EthereumType(t *testing.T) { defer ts.Close() httpTestsEthereumType(t, ts) + runWebsocketTests(t, ts, websocketTestsEthereumType) +} +func TestENSResolution(t *testing.T) { + parser := eth.NewEthereumParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + if err != nil { + t.Fatalf("Failed to create fake blockchain: %v", err) + } + + ensResolver, ok := chain.(interface { + ResolveENS(string) (*bchain.ENSResolution, error) + }) + if !ok { + t.Fatal("Chain does not support ENS resolution") + } + + testCases := []struct { + name string + domain string + expectError bool + errorMsg string + }{ + { + name: "valid ENS domain", + domain: "vitalik.eth", + expectError: false, + }, + { + name: "invalid domain format", + domain: "not-an-ens-domain", + expectError: true, + errorMsg: "invalid ENS name", + }, + { + name: "expired domain", + domain: "expired.eth", + expectError: true, + errorMsg: "ENS name expired", + }, + { + name: "non-existent domain", + domain: "nonexistent.eth", + expectError: true, + errorMsg: "ENS name not found", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := ensResolver.ResolveENS(tc.domain) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error for domain %s, but got none", tc.domain) + } + if result != nil && result.Error != tc.errorMsg { + t.Errorf("Expected error message '%s', got '%s'", tc.errorMsg, result.Error) + } + } else { + if err != nil { + t.Errorf("Unexpected error for domain %s: %v", tc.domain, err) + } + if result == nil { + t.Errorf("Expected result for domain %s, but got nil", tc.domain) + } + if result != nil && result.Address == "" { + t.Errorf("Expected resolved address for domain %s, but got empty", tc.domain) + } + } + }) + } +} + +func TestENSExpiration(t *testing.T) { + parser := eth.NewEthereumParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + if err != nil { + t.Fatalf("Failed to create fake blockchain: %v", err) + } + + ensResolver, ok := chain.(interface { + CheckENSExpiration(string) (bool, error) + }) + if !ok { + t.Fatal("Chain does not support ENS expiration checking") + } + + testCases := []struct { + name string + domain string + expectExpired bool + expectError bool + }{ + { + name: "valid domain", + domain: "vitalik.eth", + expectExpired: false, + expectError: false, + }, + { + name: "expired domain", + domain: "expired.eth", + expectExpired: true, + expectError: false, + }, + { + name: "nonexistent domain", + domain: "nonexistent.eth", + expectExpired: false, + expectError: false, + }, + { + name: "invalid domain", + domain: "invalid-domain", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + expired, err := ensResolver.CheckENSExpiration(tc.domain) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error for domain %s, but got none", tc.domain) + } + } else { + if err != nil { + t.Errorf("Unexpected error for domain %s: %v", tc.domain, err) + } + if expired != tc.expectExpired { + t.Errorf("Expected expired=%v for domain %s, got %v", tc.expectExpired, tc.domain, expired) + } + } + }) + } +} + +func Test_HTTPFiatRates_EthereumType_TokenCoverage(t *testing.T) { + timeNow = fixedTimeNow + parser := eth.NewEthereumParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + if err != nil { + glog.Fatal("fakechain: ", err) + } + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + token := "0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3" + + var currentToken fiatTickerResponse + mustGetJSON(t, ts.URL+"/api/v2/tickers?currency=USD&token="+token, http.StatusOK, ¤tToken) + if currentToken.Timestamp != 1592821931 { + t.Fatalf("unexpected current token timestamp: got %d, want %d", currentToken.Timestamp, 1592821931) + } + if !reflect.DeepEqual(currentToken.Rates, map[string]float32{"usd": 8.2}) { + t.Fatalf("unexpected current token rates: got %v", currentToken.Rates) + } + + var tickersList fiatTickersListResponse + mustGetJSON(t, ts.URL+"/api/v2/tickers-list?timestamp=1574340000&token="+token, http.StatusOK, &tickersList) + if tickersList.Timestamp != 1574380800 { + t.Fatalf("unexpected tickers-list timestamp: got %d, want %d", tickersList.Timestamp, 1574380800) + } + if !reflect.DeepEqual(tickersList.Tickers, []string{"eur", "usd"}) { + t.Fatalf("unexpected tickers-list currencies: got %v", tickersList.Tickers) + } + + unknownToken := "0xFFFFFFFFFFe95Af55f0447555c8b6aA3088562f3" + var listErr apiErrorResponse + mustGetJSON(t, ts.URL+"/api/v2/tickers-list?timestamp=1574340000&token="+unknownToken, http.StatusBadRequest, &listErr) + if listErr.Error != "No tickers found" { + t.Fatalf("unexpected unknown-token tickers-list error: got %q, want %q", listErr.Error, "No tickers found") + } + + var multiToken []fiatTickerResponse + mustGetJSON( + t, + ts.URL+"/api/v2/multi-tickers?timestamp=1574340000,1521545531¤cy=USD&token="+token, + http.StatusOK, + &multiToken, + ) + wantMulti := []fiatTickerResponse{ + {Timestamp: 1574380800, Rates: map[string]float32{"usd": 1.2}}, + {Timestamp: 1553126400, Rates: map[string]float32{"usd": 0.8}}, + } + if !reflect.DeepEqual(multiToken, wantMulti) { + t.Fatalf("unexpected multi token rates: got %v, want %v", multiToken, wantMulti) + } } diff --git a/server/public_test.go b/server/public_test.go index 7957f5d5d6..22cef210c0 100644 --- a/server/public_test.go +++ b/server/public_test.go @@ -4,8 +4,9 @@ package server import ( "encoding/json" - "html/template" - "io/ioutil" + "errors" + "io" + "net" "net/http" "net/http/httptest" "net/url" @@ -20,12 +21,11 @@ import ( "github.com/gorilla/websocket" "github.com/linxGnu/grocksdb" "github.com/martinboehm/btcutil/chaincfg" - gosocketio "github.com/martinboehm/golang-socketio" - "github.com/martinboehm/golang-socketio/transport" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/btc" "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/fiat" "github.com/trezor/blockbook/tests/dbtestdata" ) @@ -39,16 +39,16 @@ func TestMain(m *testing.M) { os.Exit(c) } -func setupRocksDB(parser bchain.BlockChainParser, chain bchain.BlockChain, t *testing.T) (*db.RocksDB, *common.InternalState, string) { - tmp, err := ioutil.TempDir("", "testdb") +func setupRocksDB(parser bchain.BlockChainParser, chain bchain.BlockChain, t *testing.T, extendedIndex bool, config *common.Config) (*db.RocksDB, *common.InternalState, string) { + tmp, err := os.MkdirTemp("", "testdb") if err != nil { t.Fatal(err) } - d, err := db.NewRocksDB(tmp, 100000, -1, parser, nil) + d, err := db.NewRocksDB(tmp, 100000, -1, parser, nil, extendedIndex) if err != nil { t.Fatal(err) } - is, err := d.LoadInternalState("fakecoin") + is, err := d.LoadInternalState(config) if err != nil { t.Fatal(err) } @@ -95,17 +95,36 @@ func setupRocksDB(parser bchain.BlockChainParser, chain bchain.BlockChain, t *te var metrics *common.Metrics -func setupPublicHTTPServer(parser bchain.BlockChainParser, chain bchain.BlockChain, t *testing.T) (*PublicServer, string) { - d, is, path := setupRocksDB(parser, chain, t) - // setup internal state and match BestHeight to test data - is.Coin = "Fakecoin" - is.CoinLabel = "Fake Coin" - is.CoinShortcut = "FAKE" +func setupPublicHTTPServer(parser bchain.BlockChainParser, chain bchain.BlockChain, t *testing.T, extendedIndex bool) (*PublicServer, string) { + return setupPublicHTTPServerWithFiatFixture(parser, chain, t, extendedIndex, nil) +} + +func setupPublicHTTPServerWithFiatFixture(parser bchain.BlockChainParser, chain bchain.BlockChain, t *testing.T, extendedIndex bool, fiatFixture func(*db.RocksDB) error) (*PublicServer, string) { + // config with mocked CoinGecko API + config := common.Config{ + CoinName: "Fakecoin", + CoinLabel: "Fake Coin", + CoinShortcut: "FAKE", + FiatRates: "coingecko", + FiatRatesParams: `{"url": "none", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "usd","periodSeconds": 60}`, + } + + // add block golomb filters with extended index + if extendedIndex { + config.BlockGolombFilterP = 20 + } + + d, is, path := setupRocksDB(parser, chain, t, extendedIndex, &config) + if fiatFixture != nil { + if err := fiatFixture(d); err != nil { + t.Fatal(err) + } + } var err error // metrics can be setup only once if metrics == nil { - metrics, err = common.GetMetrics("Fakecoin") + metrics, err = common.GetMetrics("Fakecoin" + strconv.FormatBool(extendedIndex)) if err != nil { glog.Fatal("metrics: ", err) } @@ -122,8 +141,13 @@ func setupPublicHTTPServer(parser bchain.BlockChainParser, chain bchain.BlockCha glog.Fatal("txCache: ", err) } + fiatRates, err := fiat.NewFiatRates(d, &config, nil, nil) + if err != nil { + glog.Fatal("fiatRates ", err) + } + // s.Run is never called, binding can be to any port - s, err := NewPublicServer("localhost:12345", "", d, chain, mempool, txCache, "", metrics, is, false, false) + s, err := NewPublicServer("localhost:12345", "", d, chain, mempool, txCache, "", metrics, is, fiatRates, false) if err != nil { t.Fatal(err) } @@ -168,13 +192,42 @@ func newPostRequest(u string, body string) *http.Request { return r } +type repeatedByteReader struct { + remaining int64 +} + +func (r *repeatedByteReader) Read(p []byte) (int, error) { + if r.remaining <= 0 { + return 0, io.EOF + } + n := int64(len(p)) + if n > r.remaining { + n = r.remaining + } + for i := int64(0); i < n; i++ { + p[i] = '0' + } + r.remaining -= n + return int(n), nil +} + +func newPostRequestWithContentLength(u string, contentLength int64) *http.Request { + r, err := http.NewRequest("POST", u, &repeatedByteReader{remaining: contentLength}) + if err != nil { + glog.Fatal(err) + } + r.Header.Add("Content-Type", "application/octet-stream") + r.ContentLength = contentLength + return r +} + func insertFiatRate(date string, rates map[string]float32, tokenRates map[string]float32, d *db.RocksDB) error { - convertedDate, err := db.FiatRatesConvertDate(date) + convertedDate, err := time.Parse("20060102150405", date) if err != nil { return err } ticker := &common.CurrencyRatesTicker{ - Timestamp: *convertedDate, + Timestamp: convertedDate, Rates: rates, TokenRates: tokenRates, } @@ -188,37 +241,37 @@ func insertFiatRate(date string, rates map[string]float32, tokenRates map[string // initTestFiatRates initializes test data for /api/v2/tickers endpoint func initTestFiatRates(d *db.RocksDB) error { - if err := insertFiatRate("20180320020000", map[string]float32{ + if err := insertFiatRate("20180320000000", map[string]float32{ "usd": 2000.0, "eur": 1300.0, }, nil, d); err != nil { return err } - if err := insertFiatRate("20180320030000", map[string]float32{ + if err := insertFiatRate("20180321000000", map[string]float32{ "usd": 2001.0, "eur": 1301.0, }, nil, d); err != nil { return err } - if err := insertFiatRate("20180320040000", map[string]float32{ + if err := insertFiatRate("20180322000000", map[string]float32{ "usd": 2002.0, "eur": 1302.0, }, nil, d); err != nil { return err } - if err := insertFiatRate("20180321055521", map[string]float32{ + if err := insertFiatRate("20180324000000", map[string]float32{ "usd": 2003.0, "eur": 1303.0, }, nil, d); err != nil { return err } - if err := insertFiatRate("20191121140000", map[string]float32{ + if err := insertFiatRate("20191121000000", map[string]float32{ "usd": 7814.5, "eur": 7100.0, }, nil, d); err != nil { return err } - return insertFiatRate("20191121143015", map[string]float32{ + return insertFiatRate("20191122000000", map[string]float32{ "usd": 7914.5, "eur": 7134.1, }, nil, d) @@ -232,6 +285,29 @@ type httpTests struct { body []string } +type fiatTickerResponse struct { + Timestamp int64 `json:"ts"` + Rates map[string]float32 `json:"rates"` +} + +type fiatTickersListResponse struct { + Timestamp int64 `json:"ts"` + Tickers []string `json:"available_currencies"` +} + +type balanceHistoryResponse struct { + Time uint32 `json:"time"` + Txs uint32 `json:"txs"` + Received string `json:"received"` + Sent string `json:"sent"` + SentToSelf string `json:"sentToSelf"` + Rates map[string]float32 `json:"rates"` +} + +type apiErrorResponse struct { + Error string `json:"error"` +} + func performHttpTests(tests []httpTests, t *testing.T, ts *httptest.Server) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -246,7 +322,7 @@ func performHttpTests(tests []httpTests, t *testing.T, ts *httptest.Server) { if resp.Header["Content-Type"][0] != tt.contentType { t.Errorf("Content-Type = %v, want %v", resp.Header["Content-Type"][0], tt.contentType) } - bb, err := ioutil.ReadAll(resp.Body) + bb, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } @@ -261,6 +337,58 @@ func performHttpTests(tests []httpTests, t *testing.T, ts *httptest.Server) { } } +func mustGetJSON(t *testing.T, endpointURL string, statusCode int, out interface{}) { + t.Helper() + + resp, err := http.DefaultClient.Do(newGetRequest(endpointURL)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != statusCode { + t.Fatalf("StatusCode = %v, want %v, body = %s", resp.StatusCode, statusCode, string(body)) + } + if contentType := resp.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" { + t.Fatalf("Content-Type = %q, want %q", contentType, "application/json; charset=utf-8") + } + if err := json.Unmarshal(body, out); err != nil { + t.Fatalf("failed to decode JSON body %q: %v", string(body), err) + } +} + +func TestReadSendTxHexFromBody(t *testing.T) { + const maxBodyLen int64 = 6 + assertAPIError := func(t *testing.T, err error, want string) { + t.Helper() + if err == nil { + t.Fatalf("expected error %q, got nil", want) + } + if err.Error() != want { + t.Fatalf("unexpected error %q, want %q", err.Error(), want) + } + } + + t.Run("accepts body exactly at limit", func(t *testing.T) { + got, err := readSendTxHexFromBody(strings.NewReader("123456"), maxBodyLen) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "123456" { + t.Fatalf("got %q, want %q", got, "123456") + } + }) + + t.Run("rejects body larger than limit by one byte", func(t *testing.T) { + _, err := readSendTxHexFromBody(strings.NewReader("1234567"), maxBodyLen) + assertAPIError(t, err, "Tx blob too large") + }) +} + func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { tests := []httpTests{ { @@ -269,7 +397,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Transaction

fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db
Mined Time1639 days 11 hours ago
In Block00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
In Block Height225494
Total Input0 FAKE
Total Output13.60030331 FAKE
Fees0 FAKE
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
Raw Transaction
`, + `Trezor Fake Coin Explorer

Transaction

fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db
Mined Time1639 days 11 hours ago
In Block00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
In Block Height225494
Total Input0 FAKE
Total Output13.60030331 FAKE
Fees0 FAKE
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
`, }, }, { @@ -278,7 +406,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Address

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz

0.00012345 FAKE

Confirmed
Total Received0.0002469 FAKE
Total Sent0.00012345 FAKE
Final Balance0.00012345 FAKE
No. Transactions2

Transactions

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE
 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs
 
mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE
mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE×
`, + `Trezor Fake Coin Explorer

Address

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz

0.00012345 FAKE

Confirmed
Total Received0.00024690 FAKE
Total Sent0.00012345 FAKE
Final Balance0.00012345 FAKE
No. Transactions2

Transactions

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE
 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs
 
mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE
mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE×
`, }, }, { @@ -287,7 +415,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Transaction

3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71
Mined Time1639 days 11 hours ago
In Block00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
In Block Height225494
Total Input3172.83951062 FAKE
Total Output3172.83951 FAKE
Fees0.00000062 FAKE
Raw Transaction
`, + `Trezor Fake Coin Explorer

Transaction

3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71
Mined Time1639 days 11 hours ago
In Block00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
In Block Height225494
Total Input3172.83951062 FAKE
Total Output3172.83951000 FAKE
Fees0.00000062 FAKE
`, }, }, { @@ -296,7 +424,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Error

Transaction not found

`, + `Trezor Fake Coin Explorer

Error

Transaction not found

`, }, }, { @@ -305,7 +433,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Blocks

HeightHashTimestampTransactionsSize
22549400000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b61639 days 11 hours ago42345678
2254930000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e29971640 days 9 hours ago21234567
`, + `Trezor Fake Coin Explorer

Blocks

HeightHashTimestampTransactionsSize
22549400000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b61639 days 11 hours ago42345678
2254930000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e29971640 days 9 hours ago21234567
`, }, }, { @@ -314,7 +442,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Block

225494
00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
Transactions4
Height225494
Confirmations1
Timestamp1639 days 11 hours ago
Size (bytes)2345678
Version
Merkle Root
Nonce
Bits
Difficulty

Transactions

 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
`, + `Trezor Fake Coin Explorer

Block

225494
00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
Transactions4
Height225494
Confirmations1
Timestamp1639 days 11 hours ago
Size (bytes)2345678
Version
Merkle Root
Nonce
Bits
Difficulty

Transactions

 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
`, }, }, { @@ -323,8 +451,8 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Application status

Synchronization with backend is disabled, the state of index is not up to date.

`, + `Trezor Fake Coin Explorer

Application status

Synchronization with backend is disabled, the state of index is not up to date.

Blockbook

CoinFakecoin
Host
Version / Commit / Buildunknown / unknown / unknown
Synchronized
true
Last Block225494
Last Block Update`, - `
Mempool in Sync
false
Last Mempool Update
Transactions in Mempool0
Size On Disk
`, `

Blockbook

CoinFakecoin
Host
Version / Commit / Buildunknown / unknown / unknown
Synchronized
true
Last Block225494
Last Block Update`, + `
Mempool in Sync
false
Last Mempool Update
Transactions in Mempool0
Current Fiat rates

Backend

Chainfakecoin
Version001001
Subversion/Fakecoin:0.0.1/
Last Block2
Difficulty
Blockbook - blockchain indexer for Trezor Suite https://trezor.io/trezor-suite. Do not use for any other purpose.
`, }, }, @@ -334,7 +462,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Block

225494
00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
Transactions4
Height225494
Confirmations1
Timestamp1639 days 11 hours ago
Size (bytes)2345678
Version
Merkle Root
Nonce
Bits
Difficulty

Transactions

 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
`, + `Trezor Fake Coin Explorer

Block

225494
00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
Transactions4
Height225494
Confirmations1
Timestamp1639 days 11 hours ago
Size (bytes)2345678
Version
Merkle Root
Nonce
Bits
Difficulty

Transactions

 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
`, }, }, { @@ -343,7 +471,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Block

225494
00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
Transactions4
Height225494
Confirmations1
Timestamp1639 days 11 hours ago
Size (bytes)2345678
Version
Merkle Root
Nonce
Bits
Difficulty

Transactions

 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
`, + `Trezor Fake Coin Explorer

Block

225494
00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
Transactions4
Height225494
Confirmations1
Timestamp1639 days 11 hours ago
Size (bytes)2345678
Version
Merkle Root
Nonce
Bits
Difficulty

Transactions

 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
`, }, }, { @@ -352,7 +480,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Transaction

fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db
Mined Time1639 days 11 hours ago
In Block00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
In Block Height225494
Total Input0 FAKE
Total Output13.60030331 FAKE
Fees0 FAKE
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
Raw Transaction
`, + `Trezor Fake Coin Explorer

Transaction

fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db
Mined Time1639 days 11 hours ago
In Block00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6
In Block Height225494
Total Input0 FAKE
Total Output13.60030331 FAKE
Fees0 FAKE
No Inputs (Newly Generated Coins)
 
Unparsed address0 FAKE×
`, }, }, { @@ -361,7 +489,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Address

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz

0.00012345 FAKE

Confirmed
Total Received0.0002469 FAKE
Total Sent0.00012345 FAKE
Final Balance0.00012345 FAKE
No. Transactions2

Transactions

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE
 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs
 
mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE
mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE×
`, + `Trezor Fake Coin Explorer

Address

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz

0.00012345 FAKE

Confirmed
Total Received0.00024690 FAKE
Total Sent0.00012345 FAKE
Final Balance0.00012345 FAKE
No. Transactions2

Transactions

mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE
 
OP_RETURN 2020f1686f6a200 FAKE×
No Inputs
 
mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE
mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz0.00012345 FAKE×
`, }, }, { @@ -370,7 +498,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

XPUB

upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q

1186.419755 FAKE

Confirmed
Total Received1186.41975501 FAKE
Total Sent0.00000001 FAKE
Final Balance1186.419755 FAKE
No. Transactions2
Used XPUB Addresses2
XPUB Addresses with Balance
AddressBalanceTxsPath
2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu1186.419755 FAKE1m/49'/1'/33'/1/3

Transactions

`, + `Trezor Fake Coin Explorer

XPUB

upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q

1186.419755 FAKE

Confirmed
Total Received1186.41975501 FAKE
Total Sent0.00000001 FAKE
Final Balance1186.41975500 FAKE
No. Transactions2
Used XPUB Addresses2
XPUB Addresses with Balance
AddressBalanceTxsPath
2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu1186.41975500 FAKE1m/49'/1'/33'/1/3

Transactions

`, }, }, { @@ -379,7 +507,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

XPUB

tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{0,1}/*)#4rqwxvej

0 FAKE

Confirmed
Total Received0 FAKE
Total Sent0 FAKE
Final Balance0 FAKE
No. Transactions0
Used XPUB Addresses0
XPUB Addresses with Balance
No addresses
`, + `Trezor Fake Coin Explorer

XPUB

tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{0,1}/*)#4rqwxvej

0 FAKE

Confirmed
Total Received0 FAKE
Total Sent0 FAKE
Final Balance0 FAKE
No. Transactions0
Used XPUB Addresses0
XPUB Addresses with Balance
No addresses
`, }, }, { @@ -388,7 +516,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Error

No matching records found for '1234'

`, + `Trezor Fake Coin Explorer

Error

No matching records found for '1234'

`, }, }, { @@ -397,7 +525,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Send Raw Transaction

`, + `Trezor Fake Coin Explorer

Send Raw Transaction

`, }, }, { @@ -406,7 +534,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Trezor Fake Coin Explorer

Send Raw Transaction

Invalid data
`, + `Trezor Fake Coin Explorer

Send Raw Transaction

Invalid data
`, }, }, { @@ -486,12 +614,12 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { }, }, { - name: "apiFiatRates missing currency", + name: "apiFiatRates all currencies", r: newGetRequest(ts.URL + "/api/v2/tickers"), status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1574346615,"rates":{"eur":7134.1,"usd":7914.5}}`, + `{"ts":1574380800,"rates":{"eur":7134.1,"usd":7914.5}}`, }, }, { @@ -500,16 +628,16 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1574346615,"rates":{"usd":7914.5}}`, + `{"ts":1574380800,"rates":{"usd":7914.5}}`, }, }, { name: "apiFiatRates get rate by exact timestamp", - r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd×tamp=1574344800"), + r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd×tamp=1521545531"), status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1574344800,"rates":{"usd":7814.5}}`, + `{"ts":1521590400,"rates":{"usd":2001}}`, }, }, { @@ -545,25 +673,25 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1574344800,"rates":{"eur":7100}}`, + `{"ts":1574380800,"rates":{"eur":7134.1}`, }, }, { name: "apiMultiFiatRates all currencies", - r: newGetRequest(ts.URL + "/api/v2/multi-tickers?timestamp=1574344800,1574346615"), + r: newGetRequest(ts.URL + "/api/v2/multi-tickers?timestamp=1574344800,1521677000"), status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `[{"ts":1574344800,"rates":{"eur":7100,"usd":7814.5}},{"ts":1574346615,"rates":{"eur":7134.1,"usd":7914.5}}]`, + `[{"ts":1574380800,"rates":{"eur":7134.1,"usd":7914.5}},{"ts":1521849600,"rates":{"eur":1303,"usd":2003}}]`, }, }, { name: "apiMultiFiatRates get EUR rate", - r: newGetRequest(ts.URL + "/api/v2/multi-tickers?timestamp=1574344800,1574346615¤cy=eur"), + r: newGetRequest(ts.URL + "/api/v2/multi-tickers?timestamp=1521545531,1574346615¤cy=eur"), status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `[{"ts":1574344800,"rates":{"eur":7100}},{"ts":1574346615,"rates":{"eur":7134.1}}]`, + `[{"ts":1521590400,"rates":{"eur":1301}},{"ts":1574380800,"rates":{"eur":7134.1}}]`, }, }, { @@ -572,7 +700,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1521511200,"rates":{"usd":2000}}`, + `{"ts":1521504000,"rates":{"usd":2000}}`, }, }, { @@ -581,7 +709,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1521611721,"rates":{"usd":2003}}`, + `{"ts":1521676800,"rates":{"usd":2002}}`, }, }, { @@ -590,7 +718,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1574346615,"rates":{"eur":7134.1}}`, + `{"ts":1574380800,"rates":{"eur":7134.1}}`, }, }, { @@ -608,7 +736,43 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"ts":1574346615,"available_currencies":["eur","usd"]}`, + `{"ts":1574380800,"available_currencies":["eur","usd"]}`, + }, + }, + { + name: "apiTickerList missing timestamp", + r: newGetRequest(ts.URL + "/api/v2/tickers-list"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Parameter \"timestamp\" is not a valid Unix timestamp."}`, + }, + }, + { + name: "apiTickerList invalid timestamp", + r: newGetRequest(ts.URL + "/api/v2/tickers-list?timestamp=abc"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Parameter \"timestamp\" is not a valid Unix timestamp."}`, + }, + }, + { + name: "apiMultiFiatRates missing timestamp", + r: newGetRequest(ts.URL + "/api/v2/multi-tickers"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Parameter 'timestamp' is missing."}`, + }, + }, + { + name: "apiMultiFiatRates invalid timestamp item", + r: newGetRequest(ts.URL + "/api/v2/multi-tickers?timestamp=1574344800,abc¤cy=usd"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Parameter 'timestamp' does not contain a valid Unix timestamp."}`, }, }, { @@ -662,7 +826,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"txids":["3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75"],"usedTokens":2,"tokens":[{"type":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"}]}`, + `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"addrTxCount":3,"txids":["3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75"],"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"}]}`, }, }, { @@ -671,7 +835,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"txids":["3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75"],"usedTokens":2,"tokens":[{"type":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"}]}`, + `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"addrTxCount":3,"txids":["3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75"],"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"}]}`, }, }, { @@ -680,7 +844,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"txids":["3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75"],"usedTokens":2,"tokens":[{"type":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuWrWMzoBt8VDFNvPmpJf42M1GTUs85fPx","path":"m/49'/1'/33'/0/6","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuVZ2Ca6Da9zmYynt49Rx7uikAgubGcymF","path":"m/49'/1'/33'/0/7","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzRGWDUmrPP9HwYu4B43QGCTLwoop5cExa","path":"m/49'/1'/33'/0/8","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5C9EEWJzyBXhpyPHqa3UNed73Amsi5b3L","path":"m/49'/1'/33'/0/9","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzNawz2zjwq1L85GDE3YydEJGJYfXxaWkk","path":"m/49'/1'/33'/0/10","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N7NdeuAMgL57WE7QCeV2gTWi2Um8iAu5dA","path":"m/49'/1'/33'/0/11","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8JQEP6DSHEZHNsSDPA1gHMUq9YFndhkfV","path":"m/49'/1'/33'/0/12","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mvbn3YXqKZVpQKugaoQrfjSYPvz76RwZkC","path":"m/49'/1'/33'/0/13","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8MRNxCfwUY9TSW27X9ooGYtqgrGCfLRHx","path":"m/49'/1'/33'/0/14","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N6HvwrHC113KYZAmCtJ9XJNWgaTcnFunCM","path":"m/49'/1'/33'/0/15","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NEo3oNyHUoi7rmRWee7wki37jxPWsWCopJ","path":"m/49'/1'/33'/0/16","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mzm5KY8qdFbDHsQfy4akXbFvbR3FAwDuVo","path":"m/49'/1'/33'/0/17","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NGMwftmQCogp6XZNGvgiybz3WZysvsJzqC","path":"m/49'/1'/33'/0/18","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N3fJrrefndYjLGycvFFfYgevpZtcRKCkRD","path":"m/49'/1'/33'/0/19","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N1T7TnHBwfdpBoyw53EGUL7vuJmb2mU6jF","path":"m/49'/1'/33'/0/20","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"},{"type":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N7HexL4dyAQc7Th4iqcCW4hZuyiZsLWf74","path":"m/49'/1'/33'/1/9","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NF6X5FDGWrQj4nQrfP6hA77zB5WAc1DGup","path":"m/49'/1'/33'/1/10","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4ZRPdvc7BVioBTohy4F6QtxreqcjNj26b","path":"m/49'/1'/33'/1/11","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mtfho1rLmevh4qTnkYWxZEFCWteDMtTcUF","path":"m/49'/1'/33'/1/12","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NFUCphKYvmMcNZRZrF261mRX6iADVB9Qms","path":"m/49'/1'/33'/1/13","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5kBNMB8qgxE4Y4f8J19fScsE49J4aNvoJ","path":"m/49'/1'/33'/1/14","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NANWCaefhCKdXMcW8NbZnnrFRDvhJN2wPy","path":"m/49'/1'/33'/1/15","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NFHw7Yo2Bz8D2wGAYHW9qidbZFLpfJ72qB","path":"m/49'/1'/33'/1/16","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NBDSsBgy5PpFniLCb1eAFHcSxgxwPSDsZa","path":"m/49'/1'/33'/1/17","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NDWCSQHogc7sCuc2WoYt9PX2i2i6a5k6dX","path":"m/49'/1'/33'/1/18","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8vNyDP7iSDjm3BKpXrbDjAxyphqfvnJz8","path":"m/49'/1'/33'/1/19","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4tFKLurSbMusAyq1tv4tzymVjveAFV1Vb","path":"m/49'/1'/33'/1/20","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NBx5WwjAr2cH6Yqrp3Vsf957HtRKwDUVdX","path":"m/49'/1'/33'/1/21","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NBu1seHTaFhQxbcW5L5BkZzqFLGmZqpxsa","path":"m/49'/1'/33'/1/22","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NCDLoea22jGsXuarfT1n2QyCUh6RFhAPnT","path":"m/49'/1'/33'/1/23","transfers":0,"decimals":8}]}`, + `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"addrTxCount":3,"txids":["3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75"],"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuWrWMzoBt8VDFNvPmpJf42M1GTUs85fPx","path":"m/49'/1'/33'/0/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuVZ2Ca6Da9zmYynt49Rx7uikAgubGcymF","path":"m/49'/1'/33'/0/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzRGWDUmrPP9HwYu4B43QGCTLwoop5cExa","path":"m/49'/1'/33'/0/8","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5C9EEWJzyBXhpyPHqa3UNed73Amsi5b3L","path":"m/49'/1'/33'/0/9","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzNawz2zjwq1L85GDE3YydEJGJYfXxaWkk","path":"m/49'/1'/33'/0/10","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N7NdeuAMgL57WE7QCeV2gTWi2Um8iAu5dA","path":"m/49'/1'/33'/0/11","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8JQEP6DSHEZHNsSDPA1gHMUq9YFndhkfV","path":"m/49'/1'/33'/0/12","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mvbn3YXqKZVpQKugaoQrfjSYPvz76RwZkC","path":"m/49'/1'/33'/0/13","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8MRNxCfwUY9TSW27X9ooGYtqgrGCfLRHx","path":"m/49'/1'/33'/0/14","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6HvwrHC113KYZAmCtJ9XJNWgaTcnFunCM","path":"m/49'/1'/33'/0/15","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEo3oNyHUoi7rmRWee7wki37jxPWsWCopJ","path":"m/49'/1'/33'/0/16","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mzm5KY8qdFbDHsQfy4akXbFvbR3FAwDuVo","path":"m/49'/1'/33'/0/17","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NGMwftmQCogp6XZNGvgiybz3WZysvsJzqC","path":"m/49'/1'/33'/0/18","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N3fJrrefndYjLGycvFFfYgevpZtcRKCkRD","path":"m/49'/1'/33'/0/19","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N1T7TnHBwfdpBoyw53EGUL7vuJmb2mU6jF","path":"m/49'/1'/33'/0/20","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N7HexL4dyAQc7Th4iqcCW4hZuyiZsLWf74","path":"m/49'/1'/33'/1/9","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NF6X5FDGWrQj4nQrfP6hA77zB5WAc1DGup","path":"m/49'/1'/33'/1/10","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4ZRPdvc7BVioBTohy4F6QtxreqcjNj26b","path":"m/49'/1'/33'/1/11","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mtfho1rLmevh4qTnkYWxZEFCWteDMtTcUF","path":"m/49'/1'/33'/1/12","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NFUCphKYvmMcNZRZrF261mRX6iADVB9Qms","path":"m/49'/1'/33'/1/13","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5kBNMB8qgxE4Y4f8J19fScsE49J4aNvoJ","path":"m/49'/1'/33'/1/14","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NANWCaefhCKdXMcW8NbZnnrFRDvhJN2wPy","path":"m/49'/1'/33'/1/15","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NFHw7Yo2Bz8D2wGAYHW9qidbZFLpfJ72qB","path":"m/49'/1'/33'/1/16","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBDSsBgy5PpFniLCb1eAFHcSxgxwPSDsZa","path":"m/49'/1'/33'/1/17","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NDWCSQHogc7sCuc2WoYt9PX2i2i6a5k6dX","path":"m/49'/1'/33'/1/18","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8vNyDP7iSDjm3BKpXrbDjAxyphqfvnJz8","path":"m/49'/1'/33'/1/19","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4tFKLurSbMusAyq1tv4tzymVjveAFV1Vb","path":"m/49'/1'/33'/1/20","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBx5WwjAr2cH6Yqrp3Vsf957HtRKwDUVdX","path":"m/49'/1'/33'/1/21","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBu1seHTaFhQxbcW5L5BkZzqFLGmZqpxsa","path":"m/49'/1'/33'/1/22","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NCDLoea22jGsXuarfT1n2QyCUh6RFhAPnT","path":"m/49'/1'/33'/1/23","transfers":0,"decimals":8}]}`, }, }, { @@ -689,7 +853,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{0,1}/*)#4rqwxvej","balance":"0","totalReceived":"0","totalSent":"0","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":0,"tokens":[{"type":"XPUBAddress","name":"tb1pswrqtykue8r89t9u4rprjs0gt4qzkdfuursfnvqaa3f2yql07zmq8s8a5u","path":"m/86'/1'/0'/0/0","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"tb1p8tvmvsvhsee73rhym86wt435qrqm92psfsyhy6a3n5gw455znnpqm8wald","path":"m/86'/1'/0'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"tb1p537ddhyuydg5c2v75xxmn6ac64yz4xns2x0gpdcwj5vzzzgrywlqlqwk43","path":"m/86'/1'/0'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"tb1pn2d0yjeedavnkd8z8lhm566p0f2utm3lgvxrsdehnl94y34txmts5s7t4c","path":"m/86'/1'/0'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"tb1p0pnd6ue5vryymvd28aeq3kdz6rmsdjqrq6eespgtg8wdgnxjzjksujhq4u","path":"m/86'/1'/0'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"tb1p29gpmd96hhgf7wj2vs03ca7x2xx39g8t6e0p55h2d5ssqs4fsj8qtx00wc","path":"m/86'/1'/0'/1/2","transfers":0,"decimals":8}]}`, + `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{0,1}/*)#4rqwxvej","balance":"0","totalReceived":"0","totalSent":"0","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":0,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"tb1pswrqtykue8r89t9u4rprjs0gt4qzkdfuursfnvqaa3f2yql07zmq8s8a5u","path":"m/86'/1'/0'/0/0","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"tb1p8tvmvsvhsee73rhym86wt435qrqm92psfsyhy6a3n5gw455znnpqm8wald","path":"m/86'/1'/0'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"tb1p537ddhyuydg5c2v75xxmn6ac64yz4xns2x0gpdcwj5vzzzgrywlqlqwk43","path":"m/86'/1'/0'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"tb1pn2d0yjeedavnkd8z8lhm566p0f2utm3lgvxrsdehnl94y34txmts5s7t4c","path":"m/86'/1'/0'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"tb1p0pnd6ue5vryymvd28aeq3kdz6rmsdjqrq6eespgtg8wdgnxjzjksujhq4u","path":"m/86'/1'/0'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"tb1p29gpmd96hhgf7wj2vs03ca7x2xx39g8t6e0p55h2d5ssqs4fsj8qtx00wc","path":"m/86'/1'/0'/1/2","transfers":0,"decimals":8}]}`, }, }, { @@ -698,7 +862,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":3,"usedTokens":2}`, + `{"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":3,"addrTxCount":3,"usedTokens":2}`, }, }, { @@ -707,7 +871,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":3,"usedTokens":2,"tokens":[{"type":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8},{"type":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8}]}`, + `{"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":3,"addrTxCount":3,"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8}]}`, }, }, { @@ -716,7 +880,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":3,"usedTokens":2,"tokens":[{"type":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"}]}`, + `{"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":3,"addrTxCount":3,"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"}]}`, }, }, { @@ -725,7 +889,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"page":1,"totalPages":1,"itemsOnPage":3,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"transactions":[{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vin":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","n":0,"addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true,"value":"317283951061"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"n":1,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"isOwn":true,"value":"1"}],"vout":[{"value":"118641975500","n":0,"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":["2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu"],"isAddress":true,"isOwn":true},{"value":"198641975500","n":1,"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":["mmJx9Y8ayz9h14yd9fgCW1bUKoEpkBAquP"],"isAddress":true}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"317283951000","valueIn":"317283951062","fees":"62"}],"usedTokens":2,"tokens":[{"type":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"},{"type":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8}]}`, + `{"page":1,"totalPages":1,"itemsOnPage":3,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"addrTxCount":3,"transactions":[{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vin":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","n":0,"addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true,"value":"317283951061"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"n":1,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"isOwn":true,"value":"1"}],"vout":[{"value":"118641975500","n":0,"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":["2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu"],"isAddress":true,"isOwn":true},{"value":"198641975500","n":1,"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":["mmJx9Y8ayz9h14yd9fgCW1bUKoEpkBAquP"],"isAddress":true}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"317283951000","valueIn":"317283951062","fees":"62"}],"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8}]}`, }, }, { @@ -779,7 +943,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `[{"time":1521514800,"txs":1,"received":"24690","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"0","sent":"12345","sentToSelf":"0","rates":{"eur":1303,"usd":2003}}]`, + `[{"time":1521514800,"txs":1,"received":"24690","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"0","sent":"12345","sentToSelf":"0","rates":{"eur":1302,"usd":2002}}]`, }, }, { @@ -788,7 +952,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `[{"time":1521514800,"txs":1,"received":"9876","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"9000","sent":"9876","sentToSelf":"9000","rates":{"eur":1303,"usd":2003}}]`, + `[{"time":1521514800,"txs":1,"received":"9876","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"9000","sent":"9876","sentToSelf":"9000","rates":{"eur":1302,"usd":2002}}]`, }, }, { @@ -797,7 +961,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `[{"time":1521514800,"txs":1,"received":"9876","sent":"0","sentToSelf":"0","rates":{"eur":1301}},{"time":1521594000,"txs":1,"received":"9000","sent":"9876","sentToSelf":"9000","rates":{"eur":1303}}]`, + `[{"time":1521514800,"txs":1,"received":"9876","sent":"0","sentToSelf":"0","rates":{"eur":1301}},{"time":1521594000,"txs":1,"received":"9000","sent":"9876","sentToSelf":"9000","rates":{"eur":1302}}]`, }, }, { @@ -815,7 +979,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"118641975500","sent":"1","sentToSelf":"118641975500","rates":{"eur":1303,"usd":2003}}]`, + `[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"118641975500","sent":"1","sentToSelf":"118641975500","rates":{"eur":1302,"usd":2002}}]`, }, }, { @@ -842,7 +1006,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `[{"time":1521594000,"txs":1,"received":"118641975500","sent":"1","sentToSelf":"118641975500","rates":{"eur":1303,"usd":2003}}]`, + `[{"time":1521594000,"txs":1,"received":"118641975500","sent":"1","sentToSelf":"118641975500","rates":{"eur":1302,"usd":2002}}]`, }, }, { @@ -872,6 +1036,15 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { `{"error":"Missing tx blob"}`, }, }, + { + name: "apiSendTx POST too large", + r: newPostRequestWithContentLength(ts.URL+"/api/v2/sendtx/", maxSendTxBodyBytes+1), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Tx blob too large"}`, + }, + }, { name: "apiEstimateFee", r: newGetRequest(ts.URL + "/api/estimatefee/123?conservative=false"), @@ -903,539 +1076,642 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { performHttpTests(tests, t, ts) } -func socketioTestsBitcoinType(t *testing.T, ts *httptest.Server) { - type socketioReq struct { - Method string `json:"method"` - Params []interface{} `json:"params"` +type websocketReq struct { + ID string `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} +type websocketResp struct { + ID string `json:"id"` +} + +type websocketRespWithData struct { + ID string `json:"id"` + Data json.RawMessage `json:"data"` +} + +type websocketTest struct { + name string + req websocketReq + want string +} + +func connectWebsocket(t *testing.T, ts *httptest.Server) *websocket.Conn { + t.Helper() + url := strings.Replace(ts.URL, "http://", "ws://", 1) + "/websocket" + s, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatal(err) + } + return s +} + +func readWebsocketResponse(t *testing.T, s *websocket.Conn, timeout time.Duration) websocketRespWithData { + t.Helper() + if err := s.SetReadDeadline(time.Now().Add(timeout)); err != nil { + t.Fatal(err) } + defer s.SetReadDeadline(time.Time{}) - url := strings.Replace(ts.URL, "http://", "ws://", 1) + "/socket.io/" - s, err := gosocketio.Dial(url, transport.GetDefaultWebsocketTransport()) + _, message, err := s.ReadMessage() if err != nil { t.Fatal(err) } - defer s.Close() + var resp websocketRespWithData + if err := json.Unmarshal(message, &resp); err != nil { + t.Fatal(err) + } + return resp +} - tests := []struct { - name string - req socketioReq - want string - }{ - { - name: "socketio getInfo", - req: socketioReq{"getInfo", []interface{}{}}, - want: `{"result":{"blocks":225494,"testnet":true,"network":"fakecoin","subversion":"/Fakecoin:0.0.1/","coin_name":"Fakecoin","about":"Blockbook - blockchain indexer for Trezor Suite https://trezor.io/trezor-suite. Do not use for any other purpose."}}`, - }, - { - name: "socketio estimateFee", - req: socketioReq{"estimateFee", []interface{}{17}}, - want: `{"result":0.000034}`, - }, - { - name: "socketio estimateSmartFee", - req: socketioReq{"estimateSmartFee", []interface{}{19, true}}, - want: `{"result":0.000019}`, - }, - { - name: "socketio getAddressTxids", - req: socketioReq{"getAddressTxids", []interface{}{ - []string{"mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"}, - map[string]interface{}{ - "start": 2000000, - "end": 0, - "queryMempool": false, - }, - }}, - want: `{"result":["7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840"]}`, - }, - { - name: "socketio getAddressTxids limited range", - req: socketioReq{"getAddressTxids", []interface{}{ - []string{"mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"}, - map[string]interface{}{ - "start": 225494, - "end": 225494, - "queryMempool": false, - }, - }}, - want: `{"result":["7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25"]}`, - }, - { - name: "socketio getAddressHistory", - req: socketioReq{"getAddressHistory", []interface{}{ - []string{"mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"}, - map[string]interface{}{ - "start": 2000000, - "end": 0, - "queryMempool": false, - "from": 0, - "to": 5, - }, - }}, - want: `{"result":{"totalCount":2,"items":[{"addresses":{"mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz":{"inputIndexes":[1],"outputIndexes":[]}},"satoshis":-12345,"confirmations":1,"tx":{"hex":"","height":225494,"blockTimestamp":1521595678,"version":0,"hash":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","inputs":[{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","outputIndex":0,"script":"","sequence":0,"address":"mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw","satoshis":1234567890123},{"txid":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","outputIndex":1,"script":"","sequence":0,"address":"mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz","satoshis":12345}],"inputSatoshis":1234567902468,"outputs":[{"satoshis":317283951061,"script":"76a914ccaaaf374e1b06cb83118453d102587b4273d09588ac","address":"mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"},{"satoshis":917283951061,"script":"76a9148d802c045445df49613f6a70ddd2e48526f3701f88ac","address":"mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL"},{"satoshis":0,"script":"6a072020f1686f6a20","address":"OP_RETURN 2020f1686f6a20"}],"outputSatoshis":1234567902122,"feeSatoshis":346}},{"addresses":{"mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz":{"inputIndexes":[],"outputIndexes":[1,2]}},"satoshis":24690,"confirmations":2,"tx":{"hex":"","height":225493,"blockTimestamp":1521515026,"version":0,"hash":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","inputs":[],"outputs":[{"satoshis":100000000,"script":"76a914010d39800f86122416e28f485029acf77507169288ac","address":"mfcWp7DB6NuaZsExybTTXpVgWz559Np4Ti"},{"satoshis":12345,"script":"76a9148bdf0aa3c567aa5975c2e61321b8bebbe7293df688ac","address":"mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"},{"satoshis":12345,"script":"76a9148bdf0aa3c567aa5975c2e61321b8bebbe7293df688ac","address":"mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"}],"outputSatoshis":100024690}}]}}`, - }, - { - name: "socketio getBlockHeader", - req: socketioReq{"getBlockHeader", []interface{}{225493}}, - want: `{"result":{"hash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","version":0,"confirmations":0,"height":0,"chainWork":"","nextHash":"","merkleRoot":"","time":0,"medianTime":0,"nonce":0,"bits":"","difficulty":0}}`, - }, - { - name: "socketio getDetailedTransaction", - req: socketioReq{"getDetailedTransaction", []interface{}{"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71"}}, - want: `{"result":{"hex":"","height":225494,"blockTimestamp":1521595678,"version":0,"hash":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","inputs":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","outputIndex":0,"script":"","sequence":0,"address":"mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX","satoshis":317283951061},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","outputIndex":1,"script":"","sequence":0,"address":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","satoshis":1}],"inputSatoshis":317283951062,"outputs":[{"satoshis":118641975500,"script":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","address":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu"},{"satoshis":198641975500,"script":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","address":"mmJx9Y8ayz9h14yd9fgCW1bUKoEpkBAquP"}],"outputSatoshis":317283951000,"feeSatoshis":62}}`, - }, - { - name: "socketio sendTransaction", - req: socketioReq{"sendTransaction", []interface{}{"010000000001019d64f0c72a0d206001decbffaa722eb1044534c"}}, - want: `{"error":{"message":"Invalid data"}}`, - }, +func assertNoWebsocketMessage(t *testing.T, s *websocket.Conn, timeout time.Duration) { + t.Helper() + if err := s.SetReadDeadline(time.Now().Add(timeout)); err != nil { + t.Fatal(err) + } + _, _, err := s.ReadMessage() + s.SetReadDeadline(time.Time{}) + if err == nil { + t.Fatal("expected no websocket message, got one") } + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("expected timeout error, got %v", err) + } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp, err := s.Ack("message", tt.req, time.Second*3) - if err != nil { - t.Errorf("Socketio error %v", err) - } - if resp != tt.want { - t.Errorf("got %v, want %v", resp, tt.want) - } - }) +func Test_WebsocketRejectsOversizedMessage(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + ws := connectWebsocket(t, ts) + defer ws.Close() + + // Verify the connection is healthy before sending an oversized frame. + if err := ws.WriteJSON(websocketReq{ID: "0", Method: "getInfo"}); err != nil { + t.Fatal(err) + } + resp := readWebsocketResponse(t, ws, time.Second) + if resp.ID != "0" { + t.Fatalf("got response id %q, want %q", resp.ID, "0") + } + + payload := strings.Repeat("a", int(maxWebsocketMessageBytes)+1) + if err := ws.WriteMessage(websocket.TextMessage, []byte(payload)); err != nil { + t.Fatal(err) + } + + if err := ws.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + _, _, err := ws.ReadMessage() + ws.SetReadDeadline(time.Time{}) + if err == nil { + t.Fatal("expected websocket read error after oversized message") + } + if websocket.IsCloseError(err, websocket.CloseMessageTooBig, websocket.CloseAbnormalClosure) { + return } + if errors.Is(err, io.EOF) { + return + } + t.Fatalf("unexpected websocket error after oversized message: %v", err) } -func websocketTestsBitcoinType(t *testing.T, ts *httptest.Server) { - type websocketReq struct { - ID string `json:"id"` - Method string `json:"method"` - Params interface{} `json:"params,omitempty"` +func Test_WebsocketClosesWhenPendingRequestLimitExceeded(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + + releaseRequests := make(chan struct{}) + defer close(releaseRequests) + startedRequests := make(chan struct{}, maxWebsocketPendingRequests) + originalPingHandler := requestHandlers["ping"] + requestHandlers["ping"] = func(s *WebsocketServer, c *websocketChannel, req *WsReq) (interface{}, error) { + startedRequests <- struct{}{} + <-releaseRequests + return struct{}{}, nil + } + defer func() { + requestHandlers["ping"] = originalPingHandler + }() + + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + ws := connectWebsocket(t, ts) + defer ws.Close() + + for i := 0; i < maxWebsocketPendingRequests; i++ { + if err := ws.WriteJSON(websocketReq{ID: strconv.Itoa(i), Method: "ping"}); err != nil { + t.Fatal(err) + } } - type websocketResp struct { - ID string `json:"id"` + for i := 0; i < maxWebsocketPendingRequests; i++ { + select { + case <-startedRequests: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for pending request %d", i) + } } - url := strings.Replace(ts.URL, "http://", "ws://", 1) + "/websocket" - s, _, err := websocket.DefaultDialer.Dial(url, nil) - if err != nil { + + if err := ws.WriteJSON(websocketReq{ID: "overflow", Method: "ping"}); err != nil { t.Fatal(err) } - defer s.Close() - tests := []struct { - name string - req websocketReq - want string - }{ - { - name: "websocket getInfo", - req: websocketReq{ - Method: "getInfo", - }, - want: `{"id":"0","data":{"name":"Fakecoin","shortcut":"FAKE","decimals":8,"version":"unknown","bestHeight":225494,"bestHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","block0Hash":"","testnet":true,"backend":{"version":"001001","subversion":"/Fakecoin:0.0.1/"}}}`, - }, - { - name: "websocket getBlockHash", - req: websocketReq{ - Method: "getBlockHash", - Params: map[string]interface{}{ - "height": 225494, - }, - }, - want: `{"id":"1","data":{"hash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6"}}`, - }, - { - name: "websocket getAccountInfo xpub txs", - req: websocketReq{ - Method: "getAccountInfo", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Xpub, - "details": "txs", - }, - }, - want: `{"id":"2","data":{"page":1,"totalPages":1,"itemsOnPage":25,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"transactions":[{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vin":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","n":0,"addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true,"value":"317283951061"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"n":1,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"isOwn":true,"value":"1"}],"vout":[{"value":"118641975500","n":0,"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":["2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu"],"isAddress":true,"isOwn":true},{"value":"198641975500","n":1,"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":["mmJx9Y8ayz9h14yd9fgCW1bUKoEpkBAquP"],"isAddress":true}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"317283951000","valueIn":"317283951062","fees":"62"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vin":[],"vout":[{"value":"1234567890123","n":0,"spent":true,"hex":"76a914a08eae93007f22668ab5e4a9c83c8cd1c325e3e088ac","addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true},{"value":"1","n":1,"spent":true,"hex":"a91452724c5178682f70e0ba31c6ec0633755a3b41d987","addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"isOwn":true},{"value":"9876","n":2,"spent":true,"hex":"a914e921fc4912a315078f370d959f2c4f7b6d2a683c87","addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}],"usedTokens":2,"tokens":[{"type":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuWrWMzoBt8VDFNvPmpJf42M1GTUs85fPx","path":"m/49'/1'/33'/0/6","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuVZ2Ca6Da9zmYynt49Rx7uikAgubGcymF","path":"m/49'/1'/33'/0/7","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzRGWDUmrPP9HwYu4B43QGCTLwoop5cExa","path":"m/49'/1'/33'/0/8","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5C9EEWJzyBXhpyPHqa3UNed73Amsi5b3L","path":"m/49'/1'/33'/0/9","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzNawz2zjwq1L85GDE3YydEJGJYfXxaWkk","path":"m/49'/1'/33'/0/10","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N7NdeuAMgL57WE7QCeV2gTWi2Um8iAu5dA","path":"m/49'/1'/33'/0/11","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8JQEP6DSHEZHNsSDPA1gHMUq9YFndhkfV","path":"m/49'/1'/33'/0/12","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mvbn3YXqKZVpQKugaoQrfjSYPvz76RwZkC","path":"m/49'/1'/33'/0/13","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8MRNxCfwUY9TSW27X9ooGYtqgrGCfLRHx","path":"m/49'/1'/33'/0/14","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N6HvwrHC113KYZAmCtJ9XJNWgaTcnFunCM","path":"m/49'/1'/33'/0/15","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NEo3oNyHUoi7rmRWee7wki37jxPWsWCopJ","path":"m/49'/1'/33'/0/16","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mzm5KY8qdFbDHsQfy4akXbFvbR3FAwDuVo","path":"m/49'/1'/33'/0/17","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NGMwftmQCogp6XZNGvgiybz3WZysvsJzqC","path":"m/49'/1'/33'/0/18","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N3fJrrefndYjLGycvFFfYgevpZtcRKCkRD","path":"m/49'/1'/33'/0/19","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N1T7TnHBwfdpBoyw53EGUL7vuJmb2mU6jF","path":"m/49'/1'/33'/0/20","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"},{"type":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N7HexL4dyAQc7Th4iqcCW4hZuyiZsLWf74","path":"m/49'/1'/33'/1/9","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NF6X5FDGWrQj4nQrfP6hA77zB5WAc1DGup","path":"m/49'/1'/33'/1/10","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4ZRPdvc7BVioBTohy4F6QtxreqcjNj26b","path":"m/49'/1'/33'/1/11","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mtfho1rLmevh4qTnkYWxZEFCWteDMtTcUF","path":"m/49'/1'/33'/1/12","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NFUCphKYvmMcNZRZrF261mRX6iADVB9Qms","path":"m/49'/1'/33'/1/13","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5kBNMB8qgxE4Y4f8J19fScsE49J4aNvoJ","path":"m/49'/1'/33'/1/14","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NANWCaefhCKdXMcW8NbZnnrFRDvhJN2wPy","path":"m/49'/1'/33'/1/15","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NFHw7Yo2Bz8D2wGAYHW9qidbZFLpfJ72qB","path":"m/49'/1'/33'/1/16","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NBDSsBgy5PpFniLCb1eAFHcSxgxwPSDsZa","path":"m/49'/1'/33'/1/17","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NDWCSQHogc7sCuc2WoYt9PX2i2i6a5k6dX","path":"m/49'/1'/33'/1/18","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8vNyDP7iSDjm3BKpXrbDjAxyphqfvnJz8","path":"m/49'/1'/33'/1/19","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4tFKLurSbMusAyq1tv4tzymVjveAFV1Vb","path":"m/49'/1'/33'/1/20","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NBx5WwjAr2cH6Yqrp3Vsf957HtRKwDUVdX","path":"m/49'/1'/33'/1/21","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NBu1seHTaFhQxbcW5L5BkZzqFLGmZqpxsa","path":"m/49'/1'/33'/1/22","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NCDLoea22jGsXuarfT1n2QyCUh6RFhAPnT","path":"m/49'/1'/33'/1/23","transfers":0,"decimals":8}]}}`, - }, - { - name: "websocket getAccountInfo address", - req: websocketReq{ - Method: "getAccountInfo", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Addr4, - "details": "txids", - }, - }, - want: `{"id":"3","data":{"page":1,"totalPages":1,"itemsOnPage":25,"address":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","balance":"0","totalReceived":"1","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"txids":["3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75"]}}`, - }, - { - name: "websocket getAccountInfo xpub gap", - req: websocketReq{ - Method: "getAccountInfo", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Xpub, - "details": "tokens", - "tokens": "derived", - "gap": 10, - }, - }, - want: `{"id":"4","data":{"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":3,"usedTokens":2,"tokens":[{"type":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8},{"type":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuWrWMzoBt8VDFNvPmpJf42M1GTUs85fPx","path":"m/49'/1'/33'/0/6","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MuVZ2Ca6Da9zmYynt49Rx7uikAgubGcymF","path":"m/49'/1'/33'/0/7","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzRGWDUmrPP9HwYu4B43QGCTLwoop5cExa","path":"m/49'/1'/33'/0/8","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5C9EEWJzyBXhpyPHqa3UNed73Amsi5b3L","path":"m/49'/1'/33'/0/9","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzNawz2zjwq1L85GDE3YydEJGJYfXxaWkk","path":"m/49'/1'/33'/0/10","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8},{"type":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N7HexL4dyAQc7Th4iqcCW4hZuyiZsLWf74","path":"m/49'/1'/33'/1/9","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NF6X5FDGWrQj4nQrfP6hA77zB5WAc1DGup","path":"m/49'/1'/33'/1/10","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2N4ZRPdvc7BVioBTohy4F6QtxreqcjNj26b","path":"m/49'/1'/33'/1/11","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2Mtfho1rLmevh4qTnkYWxZEFCWteDMtTcUF","path":"m/49'/1'/33'/1/12","transfers":0,"decimals":8},{"type":"XPUBAddress","name":"2NFUCphKYvmMcNZRZrF261mRX6iADVB9Qms","path":"m/49'/1'/33'/1/13","transfers":0,"decimals":8}]}}`, - }, - { - name: "websocket getAccountUtxo", - req: websocketReq{ - Method: "getAccountUtxo", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Addr1, - }, - }, - want: `{"id":"5","data":[{"txid":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","vout":0,"value":"100000000","height":225493,"confirmations":2}]}`, - }, - { - name: "websocket getAccountUtxo", - req: websocketReq{ - Method: "getAccountUtxo", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Addr4, - }, - }, - want: `{"id":"6","data":[]}`, - }, - { - name: "websocket getTransaction", - req: websocketReq{ - Method: "getTransaction", - Params: map[string]interface{}{ - "txid": dbtestdata.TxidB2T2, - }, - }, - want: `{"id":"7","data":{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vin":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","n":0,"addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true,"value":"317283951061"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"n":1,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"value":"1"}],"vout":[{"value":"118641975500","n":0,"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":["2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu"],"isAddress":true},{"value":"198641975500","n":1,"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":["mmJx9Y8ayz9h14yd9fgCW1bUKoEpkBAquP"],"isAddress":true}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"317283951000","valueIn":"317283951062","fees":"62"}}`, - }, - { - name: "websocket getTransaction", - req: websocketReq{ - Method: "getTransaction", - Params: map[string]interface{}{ - "txid": "not a tx", - }, - }, - want: `{"id":"8","data":{"error":{"message":"Transaction 'not a tx' not found"}}}`, - }, - { - name: "websocket getTransactionSpecific", - req: websocketReq{ - Method: "getTransactionSpecific", - Params: map[string]interface{}{ - "txid": dbtestdata.TxidB2T2, - }, - }, - want: `{"id":"9","data":{"hex":"","txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","version":0,"locktime":0,"vin":[{"coinbase":"","txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","vout":0,"scriptSig":{"hex":""},"sequence":0,"addresses":null},{"coinbase":"","txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"scriptSig":{"hex":""},"sequence":0,"addresses":null}],"vout":[{"ValueSat":118641975500,"value":0,"n":0,"scriptPubKey":{"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":null}},{"ValueSat":198641975500,"value":0,"n":1,"scriptPubKey":{"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":null}}],"confirmations":1,"time":1521595678,"blocktime":1521595678,"vsize":400}}`, - }, - { - name: "websocket estimateFee", - req: websocketReq{ - Method: "estimateFee", - Params: map[string]interface{}{ - "blocks": []int{2, 5, 10, 20}, - "specific": map[string]interface{}{ - "conservative": false, - "txsize": 1234, - }, - }, - }, - want: `{"id":"10","data":[{"feePerTx":"246","feePerUnit":"199"},{"feePerTx":"616","feePerUnit":"499"},{"feePerTx":"1233","feePerUnit":"999"},{"feePerTx":"2467","feePerUnit":"1999"}]}`, - }, - { - name: "websocket estimateFee second time, from cache", - req: websocketReq{ - Method: "estimateFee", - Params: map[string]interface{}{ - "blocks": []int{2, 5, 10, 20}, - "specific": map[string]interface{}{ - "conservative": false, - "txsize": 1234, - }, - }, - }, - want: `{"id":"11","data":[{"feePerTx":"246","feePerUnit":"199"},{"feePerTx":"616","feePerUnit":"499"},{"feePerTx":"1233","feePerUnit":"999"},{"feePerTx":"2467","feePerUnit":"1999"}]}`, - }, - { - name: "websocket sendTransaction", - req: websocketReq{ - Method: "sendTransaction", - Params: map[string]interface{}{ - "hex": "123456", - }, - }, - want: `{"id":"12","data":{"result":"9876"}}`, - }, - { - name: "websocket subscribeNewBlock", - req: websocketReq{ - Method: "subscribeNewBlock", - }, - want: `{"id":"13","data":{"subscribed":true}}`, - }, - { - name: "websocket unsubscribeNewBlock", - req: websocketReq{ - Method: "unsubscribeNewBlock", - }, - want: `{"id":"14","data":{"subscribed":false}}`, - }, - { - name: "websocket subscribeAddresses", - req: websocketReq{ - Method: "subscribeAddresses", - Params: map[string]interface{}{ - "addresses": []string{dbtestdata.Addr1, dbtestdata.Addr2}, - }, - }, - want: `{"id":"15","data":{"subscribed":true}}`, - }, - { - name: "websocket unsubscribeAddresses", - req: websocketReq{ - Method: "unsubscribeAddresses", - }, - want: `{"id":"16","data":{"subscribed":false}}`, - }, - { - name: "websocket ping", - req: websocketReq{ - Method: "ping", - }, - want: `{"id":"17","data":{}}`, - }, - { - name: "websocket getCurrentFiatRates all currencies", - req: websocketReq{ - Method: "getCurrentFiatRates", - Params: map[string]interface{}{ - "currencies": []string{}, + if err := ws.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + _, _, err := ws.ReadMessage() + ws.SetReadDeadline(time.Time{}) + if err == nil { + t.Fatal("expected websocket read error after pending request limit was exceeded") + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Fatal("expected connection close after pending request limit was exceeded, got timeout") + } +} + +var websocketTestsBitcoinType = []websocketTest{ + { + name: "websocket getInfo", + req: websocketReq{ + Method: "getInfo", + }, + want: `{"id":"0","data":{"name":"Fakecoin","shortcut":"FAKE","network":"FAKE","decimals":8,"version":"unknown","bestHeight":225494,"bestHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","block0Hash":"","testnet":true,"backend":{"version":"001001","subversion":"/Fakecoin:0.0.1/"}}}`, + }, + { + name: "websocket getBlockHash", + req: websocketReq{ + Method: "getBlockHash", + Params: map[string]interface{}{ + "height": 225494, + }, + }, + want: `{"id":"1","data":{"hash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6"}}`, + }, + { + name: "websocket getAccountInfo xpub txs", + req: websocketReq{ + Method: "getAccountInfo", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Xpub, + "details": "txs", + }, + }, + want: `{"id":"2","data":{"page":1,"totalPages":1,"itemsOnPage":25,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"addrTxCount":3,"transactions":[{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vin":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","n":0,"addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true,"value":"317283951061"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"n":1,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"isOwn":true,"value":"1"}],"vout":[{"value":"118641975500","n":0,"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":["2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu"],"isAddress":true,"isOwn":true},{"value":"198641975500","n":1,"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":["mmJx9Y8ayz9h14yd9fgCW1bUKoEpkBAquP"],"isAddress":true}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"317283951000","valueIn":"317283951062","fees":"62"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vin":[],"vout":[{"value":"1234567890123","n":0,"spent":true,"hex":"76a914a08eae93007f22668ab5e4a9c83c8cd1c325e3e088ac","addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true},{"value":"1","n":1,"spent":true,"hex":"a91452724c5178682f70e0ba31c6ec0633755a3b41d987","addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"isOwn":true},{"value":"9876","n":2,"spent":true,"hex":"a914e921fc4912a315078f370d959f2c4f7b6d2a683c87","addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}],"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuWrWMzoBt8VDFNvPmpJf42M1GTUs85fPx","path":"m/49'/1'/33'/0/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuVZ2Ca6Da9zmYynt49Rx7uikAgubGcymF","path":"m/49'/1'/33'/0/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzRGWDUmrPP9HwYu4B43QGCTLwoop5cExa","path":"m/49'/1'/33'/0/8","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5C9EEWJzyBXhpyPHqa3UNed73Amsi5b3L","path":"m/49'/1'/33'/0/9","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzNawz2zjwq1L85GDE3YydEJGJYfXxaWkk","path":"m/49'/1'/33'/0/10","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N7NdeuAMgL57WE7QCeV2gTWi2Um8iAu5dA","path":"m/49'/1'/33'/0/11","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8JQEP6DSHEZHNsSDPA1gHMUq9YFndhkfV","path":"m/49'/1'/33'/0/12","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mvbn3YXqKZVpQKugaoQrfjSYPvz76RwZkC","path":"m/49'/1'/33'/0/13","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8MRNxCfwUY9TSW27X9ooGYtqgrGCfLRHx","path":"m/49'/1'/33'/0/14","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6HvwrHC113KYZAmCtJ9XJNWgaTcnFunCM","path":"m/49'/1'/33'/0/15","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEo3oNyHUoi7rmRWee7wki37jxPWsWCopJ","path":"m/49'/1'/33'/0/16","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mzm5KY8qdFbDHsQfy4akXbFvbR3FAwDuVo","path":"m/49'/1'/33'/0/17","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NGMwftmQCogp6XZNGvgiybz3WZysvsJzqC","path":"m/49'/1'/33'/0/18","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N3fJrrefndYjLGycvFFfYgevpZtcRKCkRD","path":"m/49'/1'/33'/0/19","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N1T7TnHBwfdpBoyw53EGUL7vuJmb2mU6jF","path":"m/49'/1'/33'/0/20","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N7HexL4dyAQc7Th4iqcCW4hZuyiZsLWf74","path":"m/49'/1'/33'/1/9","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NF6X5FDGWrQj4nQrfP6hA77zB5WAc1DGup","path":"m/49'/1'/33'/1/10","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4ZRPdvc7BVioBTohy4F6QtxreqcjNj26b","path":"m/49'/1'/33'/1/11","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mtfho1rLmevh4qTnkYWxZEFCWteDMtTcUF","path":"m/49'/1'/33'/1/12","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NFUCphKYvmMcNZRZrF261mRX6iADVB9Qms","path":"m/49'/1'/33'/1/13","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5kBNMB8qgxE4Y4f8J19fScsE49J4aNvoJ","path":"m/49'/1'/33'/1/14","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NANWCaefhCKdXMcW8NbZnnrFRDvhJN2wPy","path":"m/49'/1'/33'/1/15","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NFHw7Yo2Bz8D2wGAYHW9qidbZFLpfJ72qB","path":"m/49'/1'/33'/1/16","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBDSsBgy5PpFniLCb1eAFHcSxgxwPSDsZa","path":"m/49'/1'/33'/1/17","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NDWCSQHogc7sCuc2WoYt9PX2i2i6a5k6dX","path":"m/49'/1'/33'/1/18","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8vNyDP7iSDjm3BKpXrbDjAxyphqfvnJz8","path":"m/49'/1'/33'/1/19","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4tFKLurSbMusAyq1tv4tzymVjveAFV1Vb","path":"m/49'/1'/33'/1/20","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBx5WwjAr2cH6Yqrp3Vsf957HtRKwDUVdX","path":"m/49'/1'/33'/1/21","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBu1seHTaFhQxbcW5L5BkZzqFLGmZqpxsa","path":"m/49'/1'/33'/1/22","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NCDLoea22jGsXuarfT1n2QyCUh6RFhAPnT","path":"m/49'/1'/33'/1/23","transfers":0,"decimals":8}]}}`, + }, + { + name: "websocket getAccountInfo address", + req: websocketReq{ + Method: "getAccountInfo", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Addr4, + "details": "txids", + }, + }, + want: `{"id":"3","data":{"page":1,"totalPages":1,"itemsOnPage":25,"address":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","balance":"0","totalReceived":"1","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"txids":["3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75"]}}`, + }, + { + name: "websocket getAccountInfo xpub gap", + req: websocketReq{ + Method: "getAccountInfo", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Xpub, + "details": "tokens", + "tokens": "derived", + "gap": 10, + }, + }, + want: `{"id":"4","data":{"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":3,"addrTxCount":3,"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuWrWMzoBt8VDFNvPmpJf42M1GTUs85fPx","path":"m/49'/1'/33'/0/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuVZ2Ca6Da9zmYynt49Rx7uikAgubGcymF","path":"m/49'/1'/33'/0/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzRGWDUmrPP9HwYu4B43QGCTLwoop5cExa","path":"m/49'/1'/33'/0/8","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5C9EEWJzyBXhpyPHqa3UNed73Amsi5b3L","path":"m/49'/1'/33'/0/9","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzNawz2zjwq1L85GDE3YydEJGJYfXxaWkk","path":"m/49'/1'/33'/0/10","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N7HexL4dyAQc7Th4iqcCW4hZuyiZsLWf74","path":"m/49'/1'/33'/1/9","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NF6X5FDGWrQj4nQrfP6hA77zB5WAc1DGup","path":"m/49'/1'/33'/1/10","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4ZRPdvc7BVioBTohy4F6QtxreqcjNj26b","path":"m/49'/1'/33'/1/11","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mtfho1rLmevh4qTnkYWxZEFCWteDMtTcUF","path":"m/49'/1'/33'/1/12","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NFUCphKYvmMcNZRZrF261mRX6iADVB9Qms","path":"m/49'/1'/33'/1/13","transfers":0,"decimals":8}]}}`, + }, + { + name: "websocket getAccountUtxo", + req: websocketReq{ + Method: "getAccountUtxo", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Addr1, + }, + }, + want: `{"id":"5","data":[{"txid":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","vout":0,"value":"100000000","height":225493,"confirmations":2}]}`, + }, + { + name: "websocket getAccountUtxo", + req: websocketReq{ + Method: "getAccountUtxo", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Addr4, + }, + }, + want: `{"id":"6","data":[]}`, + }, + { + name: "websocket getTransaction", + req: websocketReq{ + Method: "getTransaction", + Params: map[string]interface{}{ + "txid": dbtestdata.TxidB2T2, + }, + }, + want: `{"id":"7","data":{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vin":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","n":0,"addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true,"value":"317283951061"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"n":1,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"value":"1"}],"vout":[{"value":"118641975500","n":0,"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":["2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu"],"isAddress":true},{"value":"198641975500","n":1,"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":["mmJx9Y8ayz9h14yd9fgCW1bUKoEpkBAquP"],"isAddress":true}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"317283951000","valueIn":"317283951062","fees":"62"}}`, + }, + { + name: "websocket getTransaction", + req: websocketReq{ + Method: "getTransaction", + Params: map[string]interface{}{ + "txid": "not a tx", + }, + }, + want: `{"id":"8","data":{"error":{"message":"Transaction 'not a tx' not found"}}}`, + }, + { + name: "websocket getTransactionSpecific", + req: websocketReq{ + Method: "getTransactionSpecific", + Params: map[string]interface{}{ + "txid": dbtestdata.TxidB2T2, + }, + }, + want: `{"id":"9","data":{"hex":"","txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","version":0,"locktime":0,"vin":[{"coinbase":"","txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","vout":0,"scriptSig":{"hex":""},"sequence":0,"addresses":null},{"coinbase":"","txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"scriptSig":{"hex":""},"sequence":0,"addresses":null}],"vout":[{"ValueSat":118641975500,"value":0,"n":0,"scriptPubKey":{"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":null}},{"ValueSat":198641975500,"value":0,"n":1,"scriptPubKey":{"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":null}}],"confirmations":1,"time":1521595678,"blocktime":1521595678,"vsize":400}}`, + }, + { + name: "websocket estimateFee", + req: websocketReq{ + Method: "estimateFee", + Params: map[string]interface{}{ + "blocks": []int{2, 5, 10, 20}, + "specific": map[string]interface{}{ + "conservative": false, + "txsize": 1234, }, }, - want: `{"id":"18","data":{"ts":1574346615,"rates":{"eur":7134.1,"usd":7914.5}}}`, }, - { - name: "websocket getCurrentFiatRates usd", - req: websocketReq{ - Method: "getCurrentFiatRates", - Params: map[string]interface{}{ - "currencies": []string{"usd"}, + want: `{"id":"10","data":[{"feePerTx":"246","feePerUnit":"199"},{"feePerTx":"616","feePerUnit":"499"},{"feePerTx":"1233","feePerUnit":"999"},{"feePerTx":"2467","feePerUnit":"1999"}]}`, + }, + { + name: "websocket estimateFee second time, from cache", + req: websocketReq{ + Method: "estimateFee", + Params: map[string]interface{}{ + "blocks": []int{2, 5, 10, 20}, + "specific": map[string]interface{}{ + "conservative": false, + "txsize": 1234, }, }, - want: `{"id":"19","data":{"ts":1574346615,"rates":{"usd":7914.5}}}`, - }, - { - name: "websocket getCurrentFiatRates eur", - req: websocketReq{ - Method: "getCurrentFiatRates", - Params: map[string]interface{}{ - "currencies": []string{"eur"}, - }, - }, - want: `{"id":"20","data":{"ts":1574346615,"rates":{"eur":7134.1}}}`, - }, - { - name: "websocket getCurrentFiatRates incorrect currency", - req: websocketReq{ - Method: "getCurrentFiatRates", - Params: map[string]interface{}{ - "currencies": []string{"does-not-exist"}, - }, - }, - want: `{"id":"21","data":{"error":{"message":"No tickers found!"}}}`, - }, - { - name: "websocket getFiatRatesForTimestamps missing date", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"usd"}, - }, - }, - want: `{"id":"22","data":{"error":{"message":"No timestamps provided"}}}`, - }, - { - name: "websocket getFiatRatesForTimestamps incorrect date", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"usd"}, - "timestamps": []string{"yesterday"}, - }, - }, - want: `{"id":"23","data":{"error":{"message":"json: cannot unmarshal string into Go struct field .timestamps of type int64"}}}`, - }, - { - name: "websocket getFiatRatesForTimestamps empty currency", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "timestamps": []int64{7885693815}, - "currencies": []string{""}, - }, - }, - want: `{"id":"24","data":{"tickers":[{"ts":7885693815,"rates":{}}]}}`, - }, - { - name: "websocket getFiatRatesForTimestamps incorrect (future) date", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"usd"}, - "timestamps": []int64{7885693815}, - }, - }, - want: `{"id":"25","data":{"tickers":[{"ts":7885693815,"rates":{"usd":-1}}]}}`, - }, - { - name: "websocket getFiatRatesForTimestamps exact date", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"usd"}, - "timestamps": []int64{1574346615}, - }, - }, - want: `{"id":"26","data":{"tickers":[{"ts":1574346615,"rates":{"usd":7914.5}}]}}`, - }, - { - name: "websocket getFiatRatesForTimestamps closest date, eur", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"eur"}, - "timestamps": []int64{1521507600}, - }, - }, - want: `{"id":"27","data":{"tickers":[{"ts":1521511200,"rates":{"eur":1300}}]}}`, - }, - { - name: "websocket getFiatRatesForTimestamps multiple timestamps usd", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"usd"}, - "timestamps": []int64{1570346615, 1574346615}, - }, - }, - want: `{"id":"28","data":{"tickers":[{"ts":1574344800,"rates":{"usd":7814.5}},{"ts":1574346615,"rates":{"usd":7914.5}}]}}`, - }, - { - name: "websocket getFiatRatesForTimestamps multiple timestamps eur", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"eur"}, - "timestamps": []int64{1570346615, 1574346615}, - }, - }, - want: `{"id":"29","data":{"tickers":[{"ts":1574344800,"rates":{"eur":7100}},{"ts":1574346615,"rates":{"eur":7134.1}}]}}`, - }, - { - name: "websocket getFiatRatesForTimestamps multiple timestamps with an error", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"usd"}, - "timestamps": []int64{1570346615, 1574346615, 2000000000}, - }, - }, - want: `{"id":"30","data":{"tickers":[{"ts":1574344800,"rates":{"usd":7814.5}},{"ts":1574346615,"rates":{"usd":7914.5}},{"ts":2000000000,"rates":{"usd":-1}}]}}`, - }, - { - name: "websocket getFiatRatesForTimestamps multiple errors", - req: websocketReq{ - Method: "getFiatRatesForTimestamps", - Params: map[string]interface{}{ - "currencies": []string{"usd"}, - "timestamps": []int64{7832854800, 2000000000}, - }, - }, - want: `{"id":"31","data":{"tickers":[{"ts":7832854800,"rates":{"usd":-1}},{"ts":2000000000,"rates":{"usd":-1}}]}}`, - }, - { - name: "websocket getTickersList", - req: websocketReq{ - Method: "getFiatRatesTickersList", - Params: map[string]interface{}{ - "timestamp": 1570346615, - }, - }, - want: `{"id":"32","data":{"ts":1574344800,"available_currencies":["eur","usd"]}}`, - }, - { - name: "websocket getBalanceHistory Addr2", - req: websocketReq{ - Method: "getBalanceHistory", - Params: map[string]interface{}{ - "descriptor": "mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz", - }, - }, - want: `{"id":"33","data":[{"time":1521514800,"txs":1,"received":"24690","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"0","sent":"12345","sentToSelf":"0","rates":{"eur":1303,"usd":2003}}]}`, - }, - { - name: "websocket getBalanceHistory xpub", - req: websocketReq{ - Method: "getBalanceHistory", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Xpub, - }, - }, - want: `{"id":"34","data":[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"118641975500","sent":"1","sentToSelf":"118641975500","rates":{"eur":1303,"usd":2003}}]}`, - }, - { - name: "websocket getBalanceHistory xpub from=1521504000&to=1521590400 currencies=[usd]", - req: websocketReq{ - Method: "getBalanceHistory", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Xpub, - "from": 1521504000, - "to": 1521590400, - "currencies": []string{"usd"}, - }, - }, - want: `{"id":"35","data":[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"usd":2001}}]}`, - }, - { - name: "websocket getBalanceHistory xpub from=1521504000&to=1521590400 currencies=[usd, eur, incorrect]", - req: websocketReq{ - Method: "getBalanceHistory", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Xpub, - "from": 1521504000, - "to": 1521590400, - "currencies": []string{"usd", "eur", "incorrect"}, - }, - }, - want: `{"id":"36","data":[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"eur":1301,"incorrect":-1,"usd":2001}}]}`, - }, - { - name: "websocket getBalanceHistory xpub from=1521504000&to=1521590400 currencies=[]", - req: websocketReq{ - Method: "getBalanceHistory", - Params: map[string]interface{}{ - "descriptor": dbtestdata.Xpub, - "from": 1521504000, - "to": 1521590400, - "currencies": []string{}, - }, - }, - want: `{"id":"37","data":[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}}]}`, - }, - { - name: "websocket subscribeNewTransaction", - req: websocketReq{ - Method: "subscribeNewTransaction", - }, - want: `{"id":"38","data":{"subscribed":false,"message":"subscribeNewTransaction not enabled, use -enablesubnewtx flag to enable."}}`, - }, - { - name: "websocket unsubscribeNewTransaction", - req: websocketReq{ - Method: "unsubscribeNewTransaction", - }, - want: `{"id":"39","data":{"subscribed":false,"message":"unsubscribeNewTransaction not enabled, use -enablesubnewtx flag to enable."}}`, }, + want: `{"id":"11","data":[{"feePerTx":"246","feePerUnit":"199"},{"feePerTx":"616","feePerUnit":"499"},{"feePerTx":"1233","feePerUnit":"999"},{"feePerTx":"2467","feePerUnit":"1999"}]}`, + }, + { + name: "websocket sendTransaction", + req: websocketReq{ + Method: "sendTransaction", + Params: map[string]interface{}{ + "hex": "123456", + }, + }, + want: `{"id":"12","data":{"result":"9876"}}`, + }, + { + name: "websocket subscribeNewBlock", + req: websocketReq{ + Method: "subscribeNewBlock", + }, + want: `{"id":"13","data":{"subscribed":true}}`, + }, + { + name: "websocket unsubscribeNewBlock", + req: websocketReq{ + Method: "unsubscribeNewBlock", + }, + want: `{"id":"14","data":{"subscribed":false}}`, + }, + { + name: "websocket subscribeAddresses", + req: websocketReq{ + Method: "subscribeAddresses", + Params: map[string]interface{}{ + "addresses": []string{dbtestdata.Addr1, dbtestdata.Addr2}, + }, + }, + want: `{"id":"15","data":{"subscribed":true}}`, + }, + { + name: "websocket unsubscribeAddresses", + req: websocketReq{ + Method: "unsubscribeAddresses", + }, + want: `{"id":"16","data":{"subscribed":false}}`, + }, + { + name: "websocket ping", + req: websocketReq{ + Method: "ping", + }, + want: `{"id":"17","data":{}}`, + }, + { + name: "websocket getCurrentFiatRates all currencies", + req: websocketReq{ + Method: "getCurrentFiatRates", + Params: map[string]interface{}{ + "currencies": []string{}, + }, + }, + want: `{"id":"18","data":{"ts":1574380800,"rates":{"eur":7134.1,"usd":7914.5}}}`, + }, + { + name: "websocket getCurrentFiatRates usd", + req: websocketReq{ + Method: "getCurrentFiatRates", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + }, + }, + want: `{"id":"19","data":{"ts":1574380800,"rates":{"usd":7914.5}}}`, + }, + { + name: "websocket getCurrentFiatRates eur", + req: websocketReq{ + Method: "getCurrentFiatRates", + Params: map[string]interface{}{ + "currencies": []string{"eur"}, + }, + }, + want: `{"id":"20","data":{"ts":1574380800,"rates":{"eur":7134.1}}}`, + }, + { + name: "websocket getCurrentFiatRates incorrect currency", + req: websocketReq{ + Method: "getCurrentFiatRates", + Params: map[string]interface{}{ + "currencies": []string{"does-not-exist"}, + }, + }, + want: `{"id":"21","data":{"error":{"message":"No tickers found!"}}}`, + }, + { + name: "websocket getFiatRatesForTimestamps missing date", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + }, + }, + want: `{"id":"22","data":{"error":{"message":"No timestamps provided"}}}`, + }, + { + name: "websocket getFiatRatesForTimestamps incorrect date", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "timestamps": []string{"yesterday"}, + }, + }, + want: `{"id":"23","data":{"error":{"message":"json: cannot unmarshal string into Go struct field WsFiatRatesForTimestampsReq.timestamps of type int64"}}}`, + }, + { + name: "websocket getFiatRatesForTimestamps empty currency", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "timestamps": []int64{7885693815}, + "currencies": []string{""}, + }, + }, + want: `{"id":"24","data":{"tickers":[{"ts":7885693815,"rates":{}}]}}`, + }, + { + name: "websocket getFiatRatesForTimestamps incorrect (future) date", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "timestamps": []int64{7885693815}, + }, + }, + want: `{"id":"25","data":{"tickers":[{"ts":7885693815,"rates":{"usd":-1}}]}}`, + }, + { + name: "websocket getFiatRatesForTimestamps exact date", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "timestamps": []int64{1574380800}, + }, + }, + want: `{"id":"26","data":{"tickers":[{"ts":1574380800,"rates":{"usd":7914.5}}]}}`, + }, + { + name: "websocket getFiatRatesForTimestamps closest date, eur", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"eur"}, + "timestamps": []int64{1521507600}, + }, + }, + want: `{"id":"27","data":{"tickers":[{"ts":1521590400,"rates":{"eur":1301}}]}}`, + }, + { + name: "websocket getFiatRatesForTimestamps multiple timestamps usd", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "timestamps": []int64{1570346615, 1574346615}, + }, + }, + want: `{"id":"28","data":{"tickers":[{"ts":1574294400,"rates":{"usd":7814.5}},{"ts":1574380800,"rates":{"usd":7914.5}}]}}`, + }, + { + name: "websocket getFiatRatesForTimestamps multiple timestamps eur", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"eur"}, + "timestamps": []int64{1570346615, 1574346615}, + }, + }, + want: `{"id":"29","data":{"tickers":[{"ts":1574294400,"rates":{"eur":7100}},{"ts":1574380800,"rates":{"eur":7134.1}}]}}`, + }, + { + name: "websocket getFiatRatesForTimestamps multiple timestamps with an error", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "timestamps": []int64{1570346615, 1574346615, 2000000000}, + }, + }, + want: `{"id":"30","data":{"tickers":[{"ts":1574294400,"rates":{"usd":7814.5}},{"ts":1574380800,"rates":{"usd":7914.5}},{"ts":2000000000,"rates":{"usd":-1}}]}}`, + }, + { + name: "websocket getFiatRatesForTimestamps multiple errors", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "timestamps": []int64{7832854800, 2000000000}, + }, + }, + want: `{"id":"31","data":{"tickers":[{"ts":7832854800,"rates":{"usd":-1}},{"ts":2000000000,"rates":{"usd":-1}}]}}`, + }, + { + name: "websocket getTickersList", + req: websocketReq{ + Method: "getFiatRatesTickersList", + Params: map[string]interface{}{ + "timestamp": 1570346615, + }, + }, + want: `{"id":"32","data":{"ts":1574294400,"available_currencies":["eur","usd"]}}`, + }, + { + name: "websocket getBalanceHistory Addr2", + req: websocketReq{ + Method: "getBalanceHistory", + Params: map[string]interface{}{ + "descriptor": "mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz", + }, + }, + want: `{"id":"33","data":[{"time":1521514800,"txs":1,"received":"24690","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"0","sent":"12345","sentToSelf":"0","rates":{"eur":1302,"usd":2002}}]}`, + }, + { + name: "websocket getBalanceHistory xpub", + req: websocketReq{ + Method: "getBalanceHistory", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Xpub, + }, + }, + want: `{"id":"34","data":[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}},{"time":1521594000,"txs":1,"received":"118641975500","sent":"1","sentToSelf":"118641975500","rates":{"eur":1302,"usd":2002}}]}`, + }, + { + name: "websocket getBalanceHistory xpub from=1521504000&to=1521590400 currencies=[usd]", + req: websocketReq{ + Method: "getBalanceHistory", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Xpub, + "from": 1521504000, + "to": 1521590400, + "currencies": []string{"usd"}, + }, + }, + want: `{"id":"35","data":[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"usd":2001}}]}`, + }, + { + name: "websocket getBalanceHistory xpub from=1521504000&to=1521590400 currencies=[usd, eur, incorrect]", + req: websocketReq{ + Method: "getBalanceHistory", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Xpub, + "from": 1521504000, + "to": 1521590400, + "currencies": []string{"usd", "eur", "incorrect"}, + }, + }, + want: `{"id":"36","data":[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"eur":1301,"incorrect":-1,"usd":2001}}]}`, + }, + { + name: "websocket getBalanceHistory xpub from=1521504000&to=1521590400 currencies=[]", + req: websocketReq{ + Method: "getBalanceHistory", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Xpub, + "from": 1521504000, + "to": 1521590400, + "currencies": []string{}, + }, + }, + want: `{"id":"37","data":[{"time":1521514800,"txs":1,"received":"1","sent":"0","sentToSelf":"0","rates":{"eur":1301,"usd":2001}}]}`, + }, + { + name: "websocket subscribeNewTransaction", + req: websocketReq{ + Method: "subscribeNewTransaction", + }, + want: `{"id":"38","data":{"subscribed":false,"message":"subscribeNewTransaction not enabled, use -enablesubnewtx flag to enable."}}`, + }, + { + name: "websocket unsubscribeNewTransaction", + req: websocketReq{ + Method: "unsubscribeNewTransaction", + }, + want: `{"id":"39","data":{"subscribed":false,"message":"unsubscribeNewTransaction not enabled, use -enablesubnewtx flag to enable."}}`, + }, + { + name: "websocket getBlock", + req: websocketReq{ + Method: "getBlock", + Params: map[string]interface{}{ + "id": "00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6", + }, + }, + want: `{"id":"40","data":{"error":{"message":"Not supported"}}}`, + }, + { + name: "websocket getMempoolFilters", + req: websocketReq{ + Method: "getMempoolFilters", + Params: map[string]interface{}{ + "scriptType": "", + }, + }, + want: `{"id":"41","data":{"P":0,"M":1,"zeroedKey":false,"entries":{}}}`, + }, + { + name: "websocket getMempoolFilters invalid type", + req: websocketReq{ + Method: "getMempoolFilters", + Params: map[string]interface{}{ + "scriptType": "invalid", + }, + }, + want: `{"id":"42","data":{"error":{"message":"Unsupported script filter invalid"}}}`, + }, + { + name: "websocket getBlockFilter", + req: websocketReq{ + Method: "getBlockFilter", + Params: map[string]interface{}{ + "blockHash": "abcd", + }, + }, + want: `{"id":"43","data":{"P":0,"M":1,"zeroedKey":false,"blockFilter":""}}`, + }, + { + name: "websocket rpcCall", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x123", + Data: "0x456", + }, + }, + want: `{"id":"44","data":{"error":{"message":"not supported"}}}`, + }, +} + +func runWebsocketTests(t *testing.T, ts *httptest.Server, tests []websocketTest) { + url := strings.Replace(ts.URL, "http://", "ws://", 1) + "/websocket" + s, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatal(err) } + defer s.Close() // send all requests at once for i, tt := range tests { @@ -1489,7 +1765,7 @@ func fixedTimeNow() time.Time { return time.Date(2022, 9, 15, 12, 43, 56, 0, time.UTC) } -func Test_PublicServer_BitcoinType(t *testing.T) { +func setupChain(t *testing.T) (bchain.BlockChainParser, bchain.BlockChain) { timeNow = fixedTimeNow parser := btc.NewBitcoinParser( btc.GetChainParams("test"), @@ -1505,8 +1781,13 @@ func Test_PublicServer_BitcoinType(t *testing.T) { if err != nil { glog.Fatal("fakechain: ", err) } + return parser, chain +} - s, dbpath := setupPublicHTTPServer(parser, chain, t) +func Test_PublicServer_BitcoinType(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) defer closeAndDestroyPublicServer(t, s, dbpath) s.ConnectFullPublicInterface() // take the handler of the public server and pass it to the test server @@ -1514,167 +1795,878 @@ func Test_PublicServer_BitcoinType(t *testing.T) { defer ts.Close() httpTestsBitcoinType(t, ts) - socketioTestsBitcoinType(t, ts) - websocketTestsBitcoinType(t, ts) + runWebsocketTests(t, ts, websocketTestsBitcoinType) } -func Test_formatInt64(t *testing.T) { - tests := []struct { - name string - n int64 - want template.HTML - }{ - {"1", 1, "1"}, - {"13", 13, "13"}, - {"123", 123, "123"}, - {"1234", 1234, `1234`}, - {"91234", 91234, `91234`}, - {"891234", 891234, `891234`}, - {"7891234", 7891234, `7891234`}, - {"67891234", 67891234, `67891234`}, - {"567891234", 567891234, `567891234`}, - {"4567891234", 4567891234, `4567891234`}, +func Test_HTTPFiatRates_CrossEndpointConsistency_BitcoinType(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + var singleByTimestamp fiatTickerResponse + mustGetJSON(t, ts.URL+"/api/v2/tickers?timestamp=1574344800¤cy=eur", http.StatusOK, &singleByTimestamp) + + var multiByTimestamp []fiatTickerResponse + mustGetJSON(t, ts.URL+"/api/v2/multi-tickers?timestamp=1574344800¤cy=eur", http.StatusOK, &multiByTimestamp) + if len(multiByTimestamp) != 1 { + t.Fatalf("unexpected multi ticker count: got %d, want %d", len(multiByTimestamp), 1) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := formatInt64(tt.n); !reflect.DeepEqual(got, tt.want) { - t.Errorf("formatInt64() = %v, want %v", got, tt.want) - } - }) + if !reflect.DeepEqual(singleByTimestamp, multiByTimestamp[0]) { + t.Fatalf("tickers and multi-tickers mismatch: got %v vs %v", singleByTimestamp, multiByTimestamp[0]) + } + + var byBlock fiatTickerResponse + mustGetJSON(t, ts.URL+"/api/v2/tickers?block=225494¤cy=usd", http.StatusOK, &byBlock) + + var byBlockTime fiatTickerResponse + mustGetJSON(t, ts.URL+"/api/v2/tickers?timestamp=1521595678¤cy=usd", http.StatusOK, &byBlockTime) + if !reflect.DeepEqual(byBlock, byBlockTime) { + t.Fatalf("block and timestamp ticker mismatch: got %v vs %v", byBlock, byBlockTime) } } -func Test_formatTime(t *testing.T) { - timeNow = fixedTimeNow +func Test_HTTPFiatRates_Endpoints_TokenContractsCaseHandling_BitcoinType(t *testing.T) { + parser, chain := setupChain(t) + + const ( + tronUSDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + ethLowercase = "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3" + ethMixedCase = "0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3" + tickerUnixTs = int64(1700000000) + ) + + s, dbpath := setupPublicHTTPServerWithFiatFixture(parser, chain, t, false, func(d *db.RocksDB) error { + ticker := common.CurrencyRatesTicker{ + Timestamp: time.Unix(tickerUnixTs, 0).UTC(), + Rates: map[string]float32{ + "usd": 1, + }, + TokenRates: map[string]float32{ + tronUSDT: 9, + ethLowercase: 4, + }, + } + if err := insertFiatRate(ticker.Timestamp.UTC().Format(db.FiatRatesTimeFormat), ticker.Rates, ticker.TokenRates, d); err != nil { + return err + } + currentTickers := []common.CurrencyRatesTicker{ticker} + return d.FiatRatesStoreSpecialTickers("CurrentTickers", ¤tTickers) + }) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + tests := []struct { - name string - want template.HTML + name string + token string + want float32 }{ - { - name: "2020-12-23 15:16:17", - want: `630 days 21 hours ago`, + {name: "tron usdt base58", token: tronUSDT, want: 9}, + {name: "eth lowercase", token: ethLowercase, want: 4}, + {name: "eth mixed-case", token: ethMixedCase, want: 4}, + } + t.Run("tickers", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got fiatTickerResponse + mustGetJSON(t, ts.URL+"/api/v2/tickers?currency=usd&token="+url.QueryEscape(tt.token), http.StatusOK, &got) + want := fiatTickerResponse{ + Timestamp: tickerUnixTs, + Rates: map[string]float32{"usd": tt.want}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected ticker for token %q: got %v, want %v", tt.token, got, want) + } + }) + } + }) + + t.Run("multi-tickers", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []fiatTickerResponse + u := ts.URL + "/api/v2/multi-tickers?timestamp=" + strconv.FormatInt(tickerUnixTs, 10) + "¤cy=usd&token=" + url.QueryEscape(tt.token) + mustGetJSON(t, u, http.StatusOK, &got) + want := []fiatTickerResponse{ + { + Timestamp: tickerUnixTs, + Rates: map[string]float32{"usd": tt.want}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected multi-tickers for token %q: got %v, want %v", tt.token, got, want) + } + }) + } + }) + + t.Run("tickers-list", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got fiatTickersListResponse + u := ts.URL + "/api/v2/tickers-list?timestamp=" + strconv.FormatInt(tickerUnixTs, 10) + "&token=" + url.QueryEscape(tt.token) + mustGetJSON(t, u, http.StatusOK, &got) + want := fiatTickersListResponse{ + Timestamp: tickerUnixTs, + Tickers: []string{"usd"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected tickers-list for token %q: got %v, want %v", tt.token, got, want) + } + }) + } + }) +} + +func Test_HTTPBalanceHistory_GroupByAndInvalidCurrency_BitcoinType(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + addr := "2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1" + + var grouped []balanceHistoryResponse + mustGetJSON( + t, + ts.URL+"/api/v2/balancehistory/"+addr+"?groupBy=200000&fiatcurrency=eur", + http.StatusOK, + &grouped, + ) + wantGrouped := []balanceHistoryResponse{ + { + Time: 1521400000, + Txs: 2, + Received: "18876", + Sent: "9876", + SentToSelf: "9000", + Rates: map[string]float32{"eur": 1300}, }, - { - name: "2022-08-23 11:12:13", - want: `23 days 1 hour ago`, + } + if !reflect.DeepEqual(grouped, wantGrouped) { + t.Fatalf("unexpected grouped balance history: got %v, want %v", grouped, wantGrouped) + } + + var invalidCurrency []balanceHistoryResponse + mustGetJSON( + t, + ts.URL+"/api/v2/balancehistory/"+addr+"?fiatcurrency=does_not_exist", + http.StatusOK, + &invalidCurrency, + ) + if len(invalidCurrency) != 2 { + t.Fatalf("unexpected invalid-currency balance history count: got %d, want %d", len(invalidCurrency), 2) + } + for i := range invalidCurrency { + if !reflect.DeepEqual(invalidCurrency[i].Rates, map[string]float32{"does_not_exist": -1}) { + t.Fatalf("unexpected invalid-currency rates at index %d: got %v", i, invalidCurrency[i].Rates) + } + } +} + +func Test_WebsocketFiatRates_SubscribeBroadcastAndUnsubscribe(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + ws := connectWebsocket(t, ts) + defer ws.Close() + + token := "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3" + subscribe := websocketReq{ + ID: "sub-fiat", + Method: "subscribeFiatRates", + Params: map[string]interface{}{ + "currency": "USD", + "tokens": []string{strings.ToUpper(token)}, }, - { - name: "2022-09-14 11:12:13", - want: `1 day 1 hour ago`, + } + if err := ws.WriteJSON(subscribe); err != nil { + t.Fatal(err) + } + ack := readWebsocketResponse(t, ws, time.Second) + if ack.ID != subscribe.ID { + t.Fatalf("unexpected subscribe response id: got %q, want %q", ack.ID, subscribe.ID) + } + var ackData struct { + Subscribed bool `json:"subscribed"` + } + if err := json.Unmarshal(ack.Data, &ackData); err != nil { + t.Fatal(err) + } + if !ackData.Subscribed { + t.Fatalf("expected subscribed=true, got false") + } + + ticker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000000, 0), + Rates: map[string]float32{ + "usd": 2.5, + "eur": 1.1, }, - { - name: "2022-09-14 14:12:13", - want: `22 hours 31 mins ago`, + TokenRates: map[string]float32{ + token: 4, }, - { - name: "2022-09-15 09:33:26", - want: `3 hours 10 mins ago`, + } + expectedTokenRate := ticker.TokenRateInCurrency(token, "usd") + s.OnNewFiatRatesTicker(ticker) + + push := readWebsocketResponse(t, ws, time.Second) + if push.ID != subscribe.ID { + t.Fatalf("unexpected push response id: got %q, want %q", push.ID, subscribe.ID) + } + var pushData struct { + Rates map[string]float32 `json:"rates"` + TokenRates map[string]float32 `json:"tokenRates,omitempty"` + } + if err := json.Unmarshal(push.Data, &pushData); err != nil { + t.Fatal(err) + } + if len(pushData.Rates) != 1 || pushData.Rates["usd"] != 2.5 { + t.Fatalf("unexpected pushed rates: %v", pushData.Rates) + } + upperToken := strings.ToUpper(token) + if len(pushData.TokenRates) != 1 || pushData.TokenRates[upperToken] != expectedTokenRate { + t.Fatalf("unexpected pushed token rates: %v", pushData.TokenRates) + } + + unsubscribe := websocketReq{ + ID: "unsub-fiat", + Method: "unsubscribeFiatRates", + } + if err := ws.WriteJSON(unsubscribe); err != nil { + t.Fatal(err) + } + unsubAck := readWebsocketResponse(t, ws, time.Second) + if unsubAck.ID != unsubscribe.ID { + t.Fatalf("unexpected unsubscribe response id: got %q, want %q", unsubAck.ID, unsubscribe.ID) + } + var unsubData struct { + Subscribed bool `json:"subscribed"` + } + if err := json.Unmarshal(unsubAck.Data, &unsubData); err != nil { + t.Fatal(err) + } + if unsubData.Subscribed { + t.Fatalf("expected subscribed=false after unsubscribe") + } + + s.OnNewFiatRatesTicker(&common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000060, 0), + Rates: map[string]float32{ + "usd": 3.5, }, - { - name: "2022-09-15 12:23:56", - want: `20 mins ago`, + TokenRates: map[string]float32{ + token: 5, }, - { - name: "2022-09-15 12:24:07", - want: `19 mins ago`, + }) + assertNoWebsocketMessage(t, ws, 300*time.Millisecond) +} + +func Test_WebsocketFiatRates_GetCurrentFiatRates_TokenContractsCaseHandling_BitcoinType(t *testing.T) { + parser, chain := setupChain(t) + + const ( + tronUSDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + ethLowercase = "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3" + ethMixedCase = "0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3" + tickerUnixTs = int64(1700000000) + ) + + s, dbpath := setupPublicHTTPServerWithFiatFixture(parser, chain, t, false, func(d *db.RocksDB) error { + ticker := common.CurrencyRatesTicker{ + Timestamp: time.Unix(tickerUnixTs, 0).UTC(), + Rates: map[string]float32{ + "usd": 1, + }, + TokenRates: map[string]float32{ + tronUSDT: 9, + ethLowercase: 4, + }, + } + if err := insertFiatRate(ticker.Timestamp.UTC().Format(db.FiatRatesTimeFormat), ticker.Rates, ticker.TokenRates, d); err != nil { + return err + } + currentTickers := []common.CurrencyRatesTicker{ticker} + return d.FiatRatesStoreSpecialTickers("CurrentTickers", ¤tTickers) + }) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + ws := connectWebsocket(t, ts) + defer ws.Close() + + tests := []struct { + name string + id string + token string + want float32 + }{ + {name: "tron usdt base58", id: "ws-tron", token: tronUSDT, want: 9}, + {name: "eth lowercase", id: "ws-eth-lower", token: ethLowercase, want: 4}, + {name: "eth mixed-case", id: "ws-eth-mixed", token: ethMixedCase, want: 4}, + } + + t.Run("getCurrentFiatRates", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := websocketReq{ + ID: tt.id + "-current", + Method: "getCurrentFiatRates", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "token": tt.token, + }, + } + if err := ws.WriteJSON(req); err != nil { + t.Fatal(err) + } + resp := readWebsocketResponse(t, ws, time.Second) + if resp.ID != req.ID { + t.Fatalf("unexpected response id: got %q, want %q", resp.ID, req.ID) + } + + var got fiatTickerResponse + if err := json.Unmarshal(resp.Data, &got); err != nil { + t.Fatal(err) + } + want := fiatTickerResponse{ + Timestamp: tickerUnixTs, + Rates: map[string]float32{"usd": tt.want}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected websocket ticker for token %q: got %v, want %v", tt.token, got, want) + } + }) + } + }) + + t.Run("getFiatRatesForTimestamps", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := websocketReq{ + ID: tt.id + "-timestamps", + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "timestamps": []int64{tickerUnixTs}, + "currencies": []string{"usd"}, + "token": tt.token, + }, + } + if err := ws.WriteJSON(req); err != nil { + t.Fatal(err) + } + resp := readWebsocketResponse(t, ws, time.Second) + if resp.ID != req.ID { + t.Fatalf("unexpected response id: got %q, want %q", resp.ID, req.ID) + } + + var got struct { + Tickers []fiatTickerResponse `json:"tickers"` + } + if err := json.Unmarshal(resp.Data, &got); err != nil { + t.Fatal(err) + } + want := struct { + Tickers []fiatTickerResponse `json:"tickers"` + }{ + Tickers: []fiatTickerResponse{ + { + Timestamp: tickerUnixTs, + Rates: map[string]float32{"usd": tt.want}, + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected websocket timestamp tickers for token %q: got %v, want %v", tt.token, got, want) + } + }) + } + }) + + t.Run("getFiatRatesTickersList", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := websocketReq{ + ID: tt.id + "-list", + Method: "getFiatRatesTickersList", + Params: map[string]interface{}{ + "timestamp": tickerUnixTs, + "token": tt.token, + }, + } + if err := ws.WriteJSON(req); err != nil { + t.Fatal(err) + } + resp := readWebsocketResponse(t, ws, time.Second) + if resp.ID != req.ID { + t.Fatalf("unexpected response id: got %q, want %q", resp.ID, req.ID) + } + + var got fiatTickersListResponse + if err := json.Unmarshal(resp.Data, &got); err != nil { + t.Fatal(err) + } + want := fiatTickersListResponse{ + Timestamp: tickerUnixTs, + Tickers: []string{"usd"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected websocket tickers list for token %q: got %v, want %v", tt.token, got, want) + } + }) + } + }) +} + +func Test_WebsocketFiatRates_SubscribeBroadcastPreservesBase58TokenAddress(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + ws := connectWebsocket(t, ts) + defer ws.Close() + + const tronUSDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + subscribe := websocketReq{ + ID: "sub-tron-fiat", + Method: "subscribeFiatRates", + Params: map[string]interface{}{ + "currency": "USD", + "tokens": []string{tronUSDT}, }, - { - name: "2022-09-15 12:43:21", - want: `35 secs ago`, + } + if err := ws.WriteJSON(subscribe); err != nil { + t.Fatal(err) + } + _ = readWebsocketResponse(t, ws, time.Second) + + ticker := &common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000000, 0), + Rates: map[string]float32{ + "usd": 2.5, }, - { - name: "2022-09-15 12:43:56", - want: `0 secs ago`, + TokenRates: map[string]float32{ + tronUSDT: 9, }, - { - name: "2022-09-16 12:43:56", - want: `2022-09-16 12:43:56`, + } + s.OnNewFiatRatesTicker(ticker) + + push := readWebsocketResponse(t, ws, time.Second) + var pushData struct { + TokenRates map[string]float32 `json:"tokenRates,omitempty"` + } + if err := json.Unmarshal(push.Data, &pushData); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(pushData.TokenRates, map[string]float32{tronUSDT: 9}) { + t.Fatalf("unexpected pushed tron token rates: %v", pushData.TokenRates) + } +} + +func Test_WebsocketFiatRates_ResubscribeReplacesPreviousCurrency(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + ws := connectWebsocket(t, ts) + defer ws.Close() + + subscribeUSD := websocketReq{ + ID: "sub-usd", + Method: "subscribeFiatRates", + Params: map[string]interface{}{ + "currency": "usd", }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tm, _ := time.Parse("2006-01-02 15:04:05", tt.name) - if got := timeSpan(&tm); !reflect.DeepEqual(got, tt.want) { - t.Errorf("formatTime() = %v, want %v", got, tt.want) - } - }) + if err := ws.WriteJSON(subscribeUSD); err != nil { + t.Fatal(err) } + _ = readWebsocketResponse(t, ws, time.Second) + + subscribeEUR := websocketReq{ + ID: "sub-eur", + Method: "subscribeFiatRates", + Params: map[string]interface{}{ + "currency": "eur", + }, + } + if err := ws.WriteJSON(subscribeEUR); err != nil { + t.Fatal(err) + } + _ = readWebsocketResponse(t, ws, time.Second) + + s.OnNewFiatRatesTicker(&common.CurrencyRatesTicker{ + Timestamp: time.Unix(1700000120, 0), + Rates: map[string]float32{ + "usd": 100, + "eur": 200, + }, + }) + + push := readWebsocketResponse(t, ws, time.Second) + if push.ID != subscribeEUR.ID { + t.Fatalf("unexpected push response id: got %q, want %q", push.ID, subscribeEUR.ID) + } + var pushData struct { + Rates map[string]float32 `json:"rates"` + } + if err := json.Unmarshal(push.Data, &pushData); err != nil { + t.Fatal(err) + } + if len(pushData.Rates) != 1 || pushData.Rates["eur"] != 200 { + t.Fatalf("unexpected pushed rates after resubscribe: %v", pushData.Rates) + } + + assertNoWebsocketMessage(t, ws, 300*time.Millisecond) } -func Test_appendAmountSpan(t *testing.T) { +func httpTestsBitcoinTypeExtendedIndex(t *testing.T, ts *httptest.Server) { tests := []struct { - name string - class string - amount string - shortcut string - txDate string - want string + name string + r *http.Request + status int + contentType string + body []string }{ { - name: "prim-amt 1.23456789 BTC", - class: "prim-amt", - amount: "1.23456789", - shortcut: "BTC", - want: `1.23456789 BTC`, - }, - { - name: "prim-amt 1432134.23456 BTC", - class: "prim-amt", - amount: "1432134.23456", - shortcut: "BTC", - want: `1432134.23456 BTC`, + name: "apiIndex", + r: newGetRequest(ts.URL + "/api"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"blockbook":{"coin":"Fakecoin"`, + `"bestHeight":225494`, + `"decimals":8`, + `"backend":{"chain":"fakecoin","blocks":2,"headers":2,"bestBlockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6"`, + `"version":"001001","subversion":"/Fakecoin:0.0.1/"`, + }, }, { - name: "sec-amt 1 EUR", - class: "sec-amt", - amount: "1", - shortcut: "EUR", - want: `1 EUR`, + name: "apiTx v2", + r: newGetRequest(ts.URL + "/api/v2/tx/7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","vin":[{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","n":0,"addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true,"value":"1234567890123"},{"txid":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","vout":1,"n":1,"addresses":["mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"],"isAddress":true,"value":"12345"}],"vout":[{"value":"317283951061","n":0,"spent":true,"spentTxId":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","spentHeight":225494,"hex":"76a914ccaaaf374e1b06cb83118453d102587b4273d09588ac","addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true},{"value":"917283951061","n":1,"hex":"76a9148d802c045445df49613f6a70ddd2e48526f3701f88ac","addresses":["mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL"],"isAddress":true},{"value":"0","n":2,"hex":"6a072020f1686f6a20","addresses":["OP_RETURN 2020f1686f6a20"],"isAddress":false}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"1234567902122","valueIn":"1234567902468","fees":"346"}`, + }, }, { - name: "sec-amt -1 EUR", - class: "sec-amt", - amount: "-1", - shortcut: "EUR", - want: `-1 EUR`, + name: "apiAddress v2 details=txs", + r: newGetRequest(ts.URL + "/api/v2/address/mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw?details=txs"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw","balance":"0","totalReceived":"1234567890123","totalSent":"1234567890123","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"transactions":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","vin":[{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","n":0,"addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true,"isOwn":true,"value":"1234567890123"},{"txid":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","vout":1,"n":1,"addresses":["mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"],"isAddress":true,"value":"12345"}],"vout":[{"value":"317283951061","n":0,"spent":true,"spentTxId":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","spentHeight":225494,"hex":"76a914ccaaaf374e1b06cb83118453d102587b4273d09588ac","addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true},{"value":"917283951061","n":1,"hex":"76a9148d802c045445df49613f6a70ddd2e48526f3701f88ac","addresses":["mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL"],"isAddress":true},{"value":"0","n":2,"hex":"6a072020f1686f6a20","addresses":["OP_RETURN 2020f1686f6a20"],"isAddress":false}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"1234567902122","valueIn":"1234567902468","fees":"346"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vin":[],"vout":[{"value":"1234567890123","n":0,"spent":true,"spentTxId":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","spentHeight":225494,"hex":"76a914a08eae93007f22668ab5e4a9c83c8cd1c325e3e088ac","addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true,"isOwn":true},{"value":"1","n":1,"spent":true,"spentTxId":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","spentIndex":1,"spentHeight":225494,"hex":"a91452724c5178682f70e0ba31c6ec0633755a3b41d987","addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true},{"value":"9876","n":2,"spent":true,"spentTxId":"05e2e48aeabdd9b75def7b48d756ba304713c2aba7b522bf9dbc893fc4231b07","spentHeight":225494,"hex":"a914e921fc4912a315078f370d959f2c4f7b6d2a683c87","addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}]}`, + }, }, { - name: "sec-amt 432109.23 EUR", - class: "sec-amt", - amount: "432109.23", - shortcut: "EUR", - want: `432109.23 EUR`, + name: "apiGetBlock", + r: newGetRequest(ts.URL + "/api/v2/block/225493"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"page":1,"totalPages":1,"itemsOnPage":1000,"hash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","nextBlockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","height":225493,"confirmations":2,"size":1234567,"time":1521515026,"version":0,"merkleRoot":"","nonce":"","bits":"","difficulty":"","txCount":2,"txs":[{"txid":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","vin":[],"vout":[{"value":"100000000","n":0,"addresses":["mfcWp7DB6NuaZsExybTTXpVgWz559Np4Ti"],"isAddress":true},{"value":"12345","n":1,"spent":true,"spentTxId":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","spentIndex":1,"spentHeight":225494,"addresses":["mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"],"isAddress":true},{"value":"12345","n":2,"addresses":["mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"100024690","valueIn":"0","fees":"0"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vin":[],"vout":[{"value":"1234567890123","n":0,"spent":true,"spentTxId":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","spentHeight":225494,"addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true},{"value":"1","n":1,"spent":true,"spentTxId":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","spentIndex":1,"spentHeight":225494,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true},{"value":"9876","n":2,"spent":true,"spentTxId":"05e2e48aeabdd9b75def7b48d756ba304713c2aba7b522bf9dbc893fc4231b07","spentHeight":225494,"addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}]}`, + }, }, { - name: "sec-amt -432109.23 EUR", - class: "sec-amt", - amount: "-432109.23", - shortcut: "EUR", - want: `-432109.23 EUR`, + name: "apiBlockFilters", + r: newGetRequest(ts.URL + "/api/v2/block-filters?lastN=2"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"P":20,"M":1048576,"zeroedKey":false,"blockFilters":{"225493":{"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","filter":"050079b0d468a27502af2ac08f2fc0"},"225494":{"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","filter":"0a0195bc0a550129e827a9ba4aa44287840cc73d0c27d16832059690"}}}`, + }, }, { - name: "sec-amt 43141.29 EUR", - class: "sec-amt", - amount: "43141.29", - shortcut: "EUR", - txDate: "2022-03-14", - want: `43141.29 EUR`, + name: "apiBlockFilters range too large", + r: newGetRequest(ts.URL + "/api/v2/block-filters?from=0&to=10000"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Requested block filter range too large, max 10000"}`, + }, }, { - name: "sec-amt -43141.29 EUR", - class: "sec-amt", - amount: "-43141.29", - shortcut: "EUR", - txDate: "2022-03-14", - want: `-43141.29 EUR`, + name: "apiBlockFilters scriptType=taproot", + r: newGetRequest(ts.URL + "/api/v2/block-filters?lastN=2&scriptType=taproot"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Invalid scriptType taproot. Use "}`, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var rv strings.Builder - appendAmountSpan(&rv, tt.class, tt.amount, tt.shortcut, tt.txDate) - if got := rv.String(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("formatTime() = %v, want %v", got, tt.want) + resp, err := http.DefaultClient.Do(tt.r) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != tt.status { + t.Errorf("StatusCode = %v, want %v", resp.StatusCode, tt.status) + } + if resp.Header["Content-Type"][0] != tt.contentType { + t.Errorf("Content-Type = %v, want %v", resp.Header["Content-Type"][0], tt.contentType) + } + bb, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + b := string(bb) + for _, c := range tt.body { + if !strings.Contains(b, c) { + t.Errorf("got %v, want to contain %v", b, c) + break + } + } + }) + } +} + +var websocketTestsBitcoinTypeExtendedIndex = []websocketTest{ + { + name: "websocket getInfo", + req: websocketReq{ + Method: "getInfo", + }, + want: `{"id":"0","data":{"name":"Fakecoin","shortcut":"FAKE","network":"FAKE","decimals":8,"version":"unknown","bestHeight":225494,"bestHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","block0Hash":"","testnet":true,"backend":{"version":"001001","subversion":"/Fakecoin:0.0.1/"}}}`, + }, + { + name: "websocket getBlockHash", + req: websocketReq{ + Method: "getBlockHash", + Params: map[string]interface{}{ + "height": 225494, + }, + }, + want: `{"id":"1","data":{"hash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6"}}`, + }, + { + name: "websocket getAccountInfo xpub txs", + req: websocketReq{ + Method: "getAccountInfo", + Params: map[string]interface{}{ + "descriptor": dbtestdata.Xpub, + "details": "txs", + }, + }, + want: `{"id":"2","data":{"page":1,"totalPages":1,"itemsOnPage":25,"address":"upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q","balance":"118641975500","totalReceived":"118641975501","totalSent":"1","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2,"addrTxCount":3,"transactions":[{"txid":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","vin":[{"txid":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","n":0,"addresses":["mzB8cYrfRwFRFAGTDzV8LkUQy5BQicxGhX"],"isAddress":true,"value":"317283951061"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vout":1,"n":1,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"isOwn":true,"value":"1"}],"vout":[{"value":"118641975500","n":0,"hex":"a91495e9fbe306449c991d314afe3c3567d5bf78efd287","addresses":["2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu"],"isAddress":true,"isOwn":true},{"value":"198641975500","n":1,"hex":"76a9143f8ba3fda3ba7b69f5818086e12223c6dd25e3c888ac","addresses":["mmJx9Y8ayz9h14yd9fgCW1bUKoEpkBAquP"],"isAddress":true}],"blockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","blockHeight":225494,"confirmations":1,"blockTime":1521595678,"value":"317283951000","valueIn":"317283951062","fees":"62"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vin":[],"vout":[{"value":"1234567890123","n":0,"spent":true,"spentTxId":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","spentHeight":225494,"hex":"76a914a08eae93007f22668ab5e4a9c83c8cd1c325e3e088ac","addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true},{"value":"1","n":1,"spent":true,"spentTxId":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","spentIndex":1,"spentHeight":225494,"hex":"a91452724c5178682f70e0ba31c6ec0633755a3b41d987","addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true,"isOwn":true},{"value":"9876","n":2,"spent":true,"spentTxId":"05e2e48aeabdd9b75def7b48d756ba304713c2aba7b522bf9dbc893fc4231b07","spentHeight":225494,"hex":"a914e921fc4912a315078f370d959f2c4f7b6d2a683c87","addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}],"usedTokens":2,"tokens":[{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzmAKayJmja784jyHvRUW1bXPget1csRRG","path":"m/49'/1'/33'/0/0","transfers":2,"decimals":8,"balance":"0","totalReceived":"1","totalSent":"1"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MsYfbi6ZdVXLDNrYAQ11ja9Sd3otMk4Pmj","path":"m/49'/1'/33'/0/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuAZNAjLSo6RLFad2fvHSfgqBD7BoEVy4T","path":"m/49'/1'/33'/0/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEqKzw3BosGnBE9by5uaDy5QgwjHac4Zbg","path":"m/49'/1'/33'/0/3","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mw7vJNC8zUK6VNN4CEjtoTYmuNPLewxZzV","path":"m/49'/1'/33'/0/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N1kvo97NFASPXiwephZUxE9PRXunjTxEc4","path":"m/49'/1'/33'/0/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuWrWMzoBt8VDFNvPmpJf42M1GTUs85fPx","path":"m/49'/1'/33'/0/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MuVZ2Ca6Da9zmYynt49Rx7uikAgubGcymF","path":"m/49'/1'/33'/0/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzRGWDUmrPP9HwYu4B43QGCTLwoop5cExa","path":"m/49'/1'/33'/0/8","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5C9EEWJzyBXhpyPHqa3UNed73Amsi5b3L","path":"m/49'/1'/33'/0/9","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzNawz2zjwq1L85GDE3YydEJGJYfXxaWkk","path":"m/49'/1'/33'/0/10","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N7NdeuAMgL57WE7QCeV2gTWi2Um8iAu5dA","path":"m/49'/1'/33'/0/11","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8JQEP6DSHEZHNsSDPA1gHMUq9YFndhkfV","path":"m/49'/1'/33'/0/12","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mvbn3YXqKZVpQKugaoQrfjSYPvz76RwZkC","path":"m/49'/1'/33'/0/13","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8MRNxCfwUY9TSW27X9ooGYtqgrGCfLRHx","path":"m/49'/1'/33'/0/14","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6HvwrHC113KYZAmCtJ9XJNWgaTcnFunCM","path":"m/49'/1'/33'/0/15","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEo3oNyHUoi7rmRWee7wki37jxPWsWCopJ","path":"m/49'/1'/33'/0/16","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mzm5KY8qdFbDHsQfy4akXbFvbR3FAwDuVo","path":"m/49'/1'/33'/0/17","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NGMwftmQCogp6XZNGvgiybz3WZysvsJzqC","path":"m/49'/1'/33'/0/18","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N3fJrrefndYjLGycvFFfYgevpZtcRKCkRD","path":"m/49'/1'/33'/0/19","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N1T7TnHBwfdpBoyw53EGUL7vuJmb2mU6jF","path":"m/49'/1'/33'/0/20","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MzSBtRWHbBjeUcu3H5VRDqkvz5sfmDxJKo","path":"m/49'/1'/33'/1/0","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MtShtAJYb1afWduUTwF1SixJjan7urZKke","path":"m/49'/1'/33'/1/1","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N3cP668SeqyBEr9gnB4yQEmU3VyxeRYith","path":"m/49'/1'/33'/1/2","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu","path":"m/49'/1'/33'/1/3","transfers":1,"decimals":8,"balance":"118641975500","totalReceived":"118641975500","totalSent":"0"},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NEzatauNhf9kPTwwj6ZfYKjUdy52j4hVUL","path":"m/49'/1'/33'/1/4","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4RjsDp4LBpkNqyF91aNjgpF9CwDwBkJZq","path":"m/49'/1'/33'/1/5","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8XygTmQc4NoBBPEy3yybnfCYhsxFtzPDY","path":"m/49'/1'/33'/1/6","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5BjBomZvb48sccK2vwLMiQ5ETKp1fdPVn","path":"m/49'/1'/33'/1/7","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2MybMwbZRPCGU3SMWPwQCpDkbcQFw5Hbwen","path":"m/49'/1'/33'/1/8","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N7HexL4dyAQc7Th4iqcCW4hZuyiZsLWf74","path":"m/49'/1'/33'/1/9","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NF6X5FDGWrQj4nQrfP6hA77zB5WAc1DGup","path":"m/49'/1'/33'/1/10","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4ZRPdvc7BVioBTohy4F6QtxreqcjNj26b","path":"m/49'/1'/33'/1/11","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2Mtfho1rLmevh4qTnkYWxZEFCWteDMtTcUF","path":"m/49'/1'/33'/1/12","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NFUCphKYvmMcNZRZrF261mRX6iADVB9Qms","path":"m/49'/1'/33'/1/13","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N5kBNMB8qgxE4Y4f8J19fScsE49J4aNvoJ","path":"m/49'/1'/33'/1/14","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NANWCaefhCKdXMcW8NbZnnrFRDvhJN2wPy","path":"m/49'/1'/33'/1/15","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NFHw7Yo2Bz8D2wGAYHW9qidbZFLpfJ72qB","path":"m/49'/1'/33'/1/16","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBDSsBgy5PpFniLCb1eAFHcSxgxwPSDsZa","path":"m/49'/1'/33'/1/17","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NDWCSQHogc7sCuc2WoYt9PX2i2i6a5k6dX","path":"m/49'/1'/33'/1/18","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N8vNyDP7iSDjm3BKpXrbDjAxyphqfvnJz8","path":"m/49'/1'/33'/1/19","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2N4tFKLurSbMusAyq1tv4tzymVjveAFV1Vb","path":"m/49'/1'/33'/1/20","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBx5WwjAr2cH6Yqrp3Vsf957HtRKwDUVdX","path":"m/49'/1'/33'/1/21","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NBu1seHTaFhQxbcW5L5BkZzqFLGmZqpxsa","path":"m/49'/1'/33'/1/22","transfers":0,"decimals":8},{"type":"XPUBAddress","standard":"XPUBAddress","name":"2NCDLoea22jGsXuarfT1n2QyCUh6RFhAPnT","path":"m/49'/1'/33'/1/23","transfers":0,"decimals":8}]}}`, + }, + { + name: "websocket getBlock default pagination", + req: websocketReq{ + Method: "getBlock", + Params: map[string]interface{}{ + "id": "0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997", + }, + }, + want: `{"id":"3","data":{"page":1,"totalPages":1,"itemsOnPage":1000,"hash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","nextBlockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","height":225493,"confirmations":2,"size":1234567,"time":1521515026,"version":0,"merkleRoot":"","nonce":"","bits":"","difficulty":"","txCount":2,"txs":[{"txid":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","vin":[],"vout":[{"value":"100000000","n":0,"addresses":["mfcWp7DB6NuaZsExybTTXpVgWz559Np4Ti"],"isAddress":true},{"value":"12345","n":1,"spent":true,"spentTxId":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","spentIndex":1,"spentHeight":225494,"addresses":["mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"],"isAddress":true},{"value":"12345","n":2,"addresses":["mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"100024690","valueIn":"0","fees":"0"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vin":[],"vout":[{"value":"1234567890123","n":0,"spent":true,"spentTxId":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","spentHeight":225494,"addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true},{"value":"1","n":1,"spent":true,"spentTxId":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","spentIndex":1,"spentHeight":225494,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true},{"value":"9876","n":2,"spent":true,"spentTxId":"05e2e48aeabdd9b75def7b48d756ba304713c2aba7b522bf9dbc893fc4231b07","spentHeight":225494,"addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}]}}`, + }, + { + name: "websocket getBlock caps pageSize", + req: websocketReq{ + Method: "getBlock", + Params: map[string]interface{}{ + "id": "0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997", + "pageSize": 1000001, + }, + }, + want: `{"id":"4","data":{"page":1,"totalPages":1,"itemsOnPage":10000,"hash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","nextBlockHash":"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6","height":225493,"confirmations":2,"size":1234567,"time":1521515026,"version":0,"merkleRoot":"","nonce":"","bits":"","difficulty":"","txCount":2,"txs":[{"txid":"00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840","vin":[],"vout":[{"value":"100000000","n":0,"addresses":["mfcWp7DB6NuaZsExybTTXpVgWz559Np4Ti"],"isAddress":true},{"value":"12345","n":1,"spent":true,"spentTxId":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","spentIndex":1,"spentHeight":225494,"addresses":["mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"],"isAddress":true},{"value":"12345","n":2,"addresses":["mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"100024690","valueIn":"0","fees":"0"},{"txid":"effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac75","vin":[],"vout":[{"value":"1234567890123","n":0,"spent":true,"spentTxId":"7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25","spentHeight":225494,"addresses":["mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"],"isAddress":true},{"value":"1","n":1,"spent":true,"spentTxId":"3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71","spentIndex":1,"spentHeight":225494,"addresses":["2MzmAKayJmja784jyHvRUW1bXPget1csRRG"],"isAddress":true},{"value":"9876","n":2,"spent":true,"spentTxId":"05e2e48aeabdd9b75def7b48d756ba304713c2aba7b522bf9dbc893fc4231b07","spentHeight":225494,"addresses":["2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1"],"isAddress":true}],"blockHash":"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997","blockHeight":225493,"confirmations":2,"blockTime":1521515026,"value":"1234567900000","valueIn":"0","fees":"0"}]}}`, + }, + { + name: "websocket getBlockFilter", + req: websocketReq{ + Method: "getBlockFilter", + Params: map[string]interface{}{ + "blockHash": "0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997", + }, + }, + want: `{"id":"5","data":{"P":20,"M":1048576,"zeroedKey":false,"blockFilter":"050079b0d468a27502af2ac08f2fc0"}}`, + }, + { + name: "websocket getBlockFiltersBatch bestKnownBlockHash 1st block", + req: websocketReq{ + Method: "getBlockFiltersBatch", + Params: map[string]interface{}{ + "bestKnownBlockHash": "0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997", + }, + }, + want: `{"id":"6","data":{"P":20,"M":1048576,"zeroedKey":false,"blockFiltersBatch":["225494:00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6:0a0195bc0a550129e827a9ba4aa44287840cc73d0c27d16832059690"]}}`, + }, + { + name: "websocket getBlockFiltersBatch bestKnownBlockHash 2nd block", + req: websocketReq{ + Method: "getBlockFiltersBatch", + Params: map[string]interface{}{ + "bestKnownBlockHash": "00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6", + }, + }, + want: `{"id":"7","data":{"P":20,"M":1048576,"zeroedKey":false,"blockFiltersBatch":[]}}`, + }, + { + name: "websocket getBlockFiltersBatch bestKnownBlockHash 1st block, unsupported script type", + req: websocketReq{ + Method: "getBlockFiltersBatch", + Params: map[string]interface{}{ + "bestKnownBlockHash": "0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997", + "scriptType": "unsupported", + }, + }, + want: `{"id":"8","data":{"error":{"message":"Unsupported script type unsupported"}}}`, + }, +} + +func Test_PublicServer_BitcoinType_ExtendedIndex(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, true) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + // take the handler of the public server and pass it to the test server + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + httpTestsBitcoinTypeExtendedIndex(t, ts) + runWebsocketTests(t, ts, websocketTestsBitcoinTypeExtendedIndex) +} + +func Test_validateIntParam(t *testing.T) { + tests := []struct { + name string + value string + defaultValue int + min int + max int + want int + }{ + {"empty string", "", 0, 0, 100, 0}, + {"empty string with default", "", 42, 0, 100, 42}, + {"valid value", "10", 0, 0, 100, 10}, + {"value at min", "0", 0, 0, 100, 0}, + {"value at max", "100", 0, 0, 100, 100}, + {"value exceeds max", "150", 0, 0, 100, 100}, + {"negative value", "-5", 0, 0, 100, 0}, + {"negative value below min", "-10", 0, 0, 100, 0}, + {"invalid string", "abc", 0, 0, 100, 0}, + {"invalid string with default", "xyz", 42, 0, 100, 42}, + {"zero max (no limit)", "1000", 0, 0, 0, 1000}, + {"very large number", "9223372036854775807", 0, 0, maxPageNumber, maxPageNumber}, + {"negative with min constraint", "-5", 0, 5, 100, 0}, + {"whitespace", " 10 ", 0, 0, 100, 0}, + {"zero value", "0", 0, 0, 100, 0}, + {"max int32", "2147483647", 0, 0, 0, 2147483647}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validateIntParam(tt.value, tt.defaultValue, tt.min, tt.max); got != tt.want { + t.Errorf("validateIntParam(%q, %d, %d, %d) = %d, want %d", tt.value, tt.defaultValue, tt.min, tt.max, got, tt.want) + } + }) + } +} + +func Test_sanitizePagingParams(t *testing.T) { + tests := []struct { + name string + page int + pageSize int + defaultPageSize int + maxPageSize int + wantPage int + wantPageSize int + }{ + {"default page size", 0, 0, txsInAPI, maxWebsocketBlockPageSize, 0, txsInAPI}, + {"oversized page size", 1, maxWebsocketBlockPageSize + 1, txsInAPI, maxWebsocketBlockPageSize, 1, maxWebsocketBlockPageSize}, + {"negative values", -1, -1, txsInAPI, maxWebsocketBlockPageSize, 0, txsInAPI}, + {"safe offset clamp", maxPageNumber, maxPageNumber, maxPageNumber, maxPageNumber, maxSafePagingOffset / maxPageNumber, maxPageNumber}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + page, pageSize := sanitizePagingParams(tt.page, tt.pageSize, tt.defaultPageSize, tt.maxPageSize) + if page != tt.wantPage || pageSize != tt.wantPageSize { + t.Errorf("sanitizePagingParams(%d, %d, %d, %d) = (%d, %d), want (%d, %d)", + tt.page, tt.pageSize, tt.defaultPageSize, tt.maxPageSize, + page, pageSize, tt.wantPage, tt.wantPageSize) + } + }) + } +} + +func Test_sanitizeAccountPagingParams(t *testing.T) { + tests := []struct { + name string + page int + pageSize int + defaultPageSize int + maxPageSize int + wantPage int + wantPageSize int + }{ + {"ws getAccountInfo default", 0, 0, txsOnPage, txsInAPI, 0, txsOnPage}, + {"ws getAccountInfo within limit", 1, 100, txsOnPage, txsInAPI, 1, 100}, + {"ws getAccountInfo caps page size at txsInAPI", 1, txsInAPI + 1, txsOnPage, txsInAPI, 1, txsInAPI}, + {"ws getAccountInfo negative defaults", 0, -5, txsOnPage, txsInAPI, 0, txsOnPage}, + {"api address caps history offset", maxPageNumber, txsInAPI, txsInAPI, txsInAPI, maxAccountHistoryPagingOffset / txsInAPI, txsInAPI}, + {"explorer address caps history offset", maxPageNumber, txsOnPage, txsOnPage, txsOnPage, maxAccountHistoryPagingOffset / txsOnPage, txsOnPage}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + page, pageSize := sanitizeAccountPagingParams(tt.page, tt.pageSize, tt.defaultPageSize, tt.maxPageSize) + if page != tt.wantPage || pageSize != tt.wantPageSize { + t.Errorf("sanitizeAccountPagingParams(%d, %d, %d, %d) = (%d, %d), want (%d, %d)", + tt.page, tt.pageSize, tt.defaultPageSize, tt.maxPageSize, + page, pageSize, tt.wantPage, tt.wantPageSize) + } + }) + } +} + +func Test_validateIntValue_gapClamp(t *testing.T) { + // Mirrors the WS getAccountInfo gap clamp: validateIntValue(req.Gap, 0, 0, maxGapValue). + tests := []struct { + name string + val int + want int + }{ + {"unset passes through as 0", 0, 0}, + {"suite default 20 passes through", 20, 20}, + {"negative defaults to 0", -1, 0}, + {"caps at maxGapValue", maxGapValue + 1, maxGapValue}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := validateIntValue(tt.val, 0, 0, maxGapValue) + if got != tt.want { + t.Errorf("validateIntValue(%d, 0, 0, %d) = %d, want %d", + tt.val, maxGapValue, got, tt.want) } }) } diff --git a/server/public_tron_test.go b/server/public_tron_test.go new file mode 100644 index 0000000000..ae3cdecb8a --- /dev/null +++ b/server/public_tron_test.go @@ -0,0 +1,187 @@ +//go:build unittest +// +build unittest + +package server + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain/coins/tron" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +func httpTestsTron(t *testing.T, ts *httptest.Server) { + tests := []httpTests{ + { + name: "explorerAddress " + dbtestdata.TronAddrTZ, + r: newGetRequest(ts.URL + "/address/" + dbtestdata.TronAddrTZ), + status: http.StatusOK, + contentType: "text/html; charset=utf-8", + body: []string{ + `TZEZWXYQS44388xBoMhQdpL1HrBZFLfDpt`, + `
Resources
`, + `Bandwidth255 / 1`, + `Energy25`, + }, + }, + { + name: "apiBlock", + r: newGetRequest(ts.URL + "/api/v2/block/" + strconv.Itoa(dbtestdata.Block1)), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `"hash":"11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff"`, + `"previousBlockHash":"0000000000000000000000000000000000000000000000000000000000000000"`, + `"txid":"a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"`, + `"coinSpecificData":{"tx":{"nonce":"0x0"`, + `"hash":"a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"`, + `"chainExtraData":{"contractType":"TriggerSmartContract","operation":"contractCall","assetIssueID":"1002001","totalFee":"3076500","energyUsage":"14650","energyUsageTotal":"14650","bandwidthUsage":"345","bandwidthFee":"0","result":"SUCCESS"}`, + `"tokenTransfers":[{"type":"TRC20"`, + `"ethereumSpecific":{"status":1`, + `"addressAliases":{"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf":{"Type":"Contract","Alias":"TronTestContract236"}}`, + }, + }, + { + name: "apiBlock non-existent", + r: newGetRequest(ts.URL + "/api/v2/block/12345678910"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Block not found"}`, + }, + }, + { + name: "apiTx", + r: newGetRequest(ts.URL + "/api/v2/tx/" + dbtestdata.TronTx1Id), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `"txid":"a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"`, + `"coinSpecificData":{"tx":{"nonce":"0x0"`, + `"hash":"a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"`, + `"chainExtraData":{"contractType":"TriggerSmartContract","operation":"contractCall","assetIssueID":"1002001","totalFee":"3076500","energyUsage":"14650","energyUsageTotal":"14650","bandwidthUsage":"345","bandwidthFee":"0","result":"SUCCESS"}`, + `"tokenTransfers":[{"type":"TRC20"`, + `"ethereumSpecific":{"status":1`, + `"addressAliases":{"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf":{"Type":"Contract","Alias":"TronTestContract236"}}`, + }, + }, + { + name: "apiTx non-existent", + r: newGetRequest(ts.URL + "/api/v2/tx/0x123456789"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Transaction '0x123456789' not found"}`, + }, + }, + { + name: "apiAddress TronAddrTJ", + r: newGetRequest(ts.URL + "/api/v2/address/" + dbtestdata.TronAddrTZ), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"page":1,"totalPages":1,"itemsOnPage":1000,"address":"TZEZWXYQS44388xBoMhQdpL1HrBZFLfDpt","balance":"123450255","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":1,"nonTokenTxs":1,"internalTxs":1,"txids":["a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"],"nonce":"255","tokens":[{"type":"TRC20","standard":"TRC20","name":"TronTestContract236","contract":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","transfers":1,"symbol":"TRC236","decimals":6,"balance":"1000255236"}],"chainExtraData":{"payloadType":"tron","payload":{"availableStakedBandwidth":255,"totalStakedBandwidth":1255,"availableFreeBandwidth":755,"totalFreeBandwidth":1755,"availableEnergy":25500,"totalEnergy":35500}}}`, + }, + }, + { + name: "apiAddress TronAddrTX", + r: newGetRequest(ts.URL + "/api/v2/address/" + dbtestdata.TronAddrTD + "?details=txs"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `"address":"TDGSR64oU4QDpViKfdwawSiqwyqpUB6JUD"`, + `"transactions":[{"txid":"a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"`, + `"chainExtraData":{"contractType":"TriggerSmartContract","operation":"contractCall","assetIssueID":"1002001","totalFee":"3076500","energyUsage":"14650","energyUsageTotal":"14650","bandwidthUsage":"345","bandwidthFee":"0","result":"SUCCESS"}`, + `"nonce":"36"`, + `"tokens":[{"type":"TRC20","standard":"TRC20","name":"TronTestContract236"`, + `"addressAliases":{"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf":{"Type":"Contract","Alias":"TronTestContract236"}}`, + }, + }, + { + name: "apiAddress TronAddrContractTX1", + r: newGetRequest(ts.URL + "/api/v2/address/" + dbtestdata.TronAddrContractTX1 + "?details=txs"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `"address":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"`, + `"transactions":[{"txid":"a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"`, + `"chainExtraData":{"contractType":"TriggerSmartContract","operation":"contractCall","assetIssueID":"1002001","totalFee":"3076500","energyUsage":"14650","energyUsageTotal":"14650","bandwidthUsage":"345","bandwidthFee":"0","result":"SUCCESS"}`, + `"nonce":"236"`, + `"contractInfo":{"type":"TRC20","standard":"TRC20","contract":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","name":"TronTestContract236","symbol":"TRC236","decimals":6,"createdInBlock":1000,"blockHeight":100000}`, + `"addressAliases":{"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf":{"Type":"Contract","Alias":"TronTestContract236"}}`, + }, + }, + { + name: "apiIndex", + r: newGetRequest(ts.URL + "/api"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"blockbook":{"coin":"Fakecoin"`, + `"bestHeight":100000`, + `"decimals":6`, + `"backend":{"chain":"fakecoin","blocks":2,"headers":2,"bestBlockHash":"11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff"`, + `"version":"tron_test_1.0","subversion":"MockTron"`, + }, + }, + } + + performHttpTests(tests, t, ts) +} + +var websocketTestsTron = []websocketTest{ + { + name: "websocket getInfo", + req: websocketReq{ + Method: "getInfo", + }, + want: `{"id":"0","data":{"name":"Fakecoin","shortcut":"TRX","network":"TRX","decimals":6,"version":"unknown","bestHeight":100000,"bestHash":"11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff","block0Hash":"","testnet":true,"backend":{"version":"tron_test_1.0","subversion":"MockTron"}}}`, + }, + { + name: "websocket rpcCall", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: dbtestdata.TronAddrContractTX1, + Data: "0x4567", + }, + }, + want: `{"id":"1","data":{"data":"0x4567abcd"}}`, + }, + { + name: "websocket getAccountInfo address", + req: websocketReq{ + Method: "getAccountInfo", + Params: map[string]interface{}{ + "descriptor": dbtestdata.TronAddrTZ, + "details": "txids", + }, + }, + want: `{"id":"2","data":{"page":1,"totalPages":1,"itemsOnPage":25,"address":"TZEZWXYQS44388xBoMhQdpL1HrBZFLfDpt","balance":"123450255","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":1,"nonTokenTxs":1,"internalTxs":1,"txids":["a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"],"nonce":"255","tokens":[{"type":"TRC20","standard":"TRC20","name":"TronTestContract236","contract":"TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf","transfers":1,"symbol":"TRC236","decimals":6,"balance":"1000255236"}],"chainExtraData":{"payloadType":"tron","payload":{"availableStakedBandwidth":255,"totalStakedBandwidth":1255,"availableFreeBandwidth":755,"totalFreeBandwidth":1755,"availableEnergy":25500,"totalEnergy":35500}}}}`, + }, +} + +func Test_PublicServer_Tron(t *testing.T) { + timeNow = fixedTimeNow + parser := tron.NewTronParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainTronType(parser) + if err != nil { + glog.Fatal("fakechain: ", err) + } + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.is.CoinShortcut = "TRX" + s.templates = s.parseTemplates() + s.ConnectFullPublicInterface() + + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + httpTestsTron(t, ts) + runWebsocketTests(t, ts, websocketTestsTron) +} diff --git a/server/socketio.go b/server/socketio.go deleted file mode 100644 index 5919db4522..0000000000 --- a/server/socketio.go +++ /dev/null @@ -1,741 +0,0 @@ -package server - -import ( - "encoding/json" - "math/big" - "net/http" - "runtime/debug" - "strconv" - "strings" - "time" - - "github.com/golang/glog" - "github.com/juju/errors" - gosocketio "github.com/martinboehm/golang-socketio" - "github.com/martinboehm/golang-socketio/transport" - "github.com/trezor/blockbook/api" - "github.com/trezor/blockbook/bchain" - "github.com/trezor/blockbook/common" - "github.com/trezor/blockbook/db" -) - -// SocketIoServer is handle to SocketIoServer -type SocketIoServer struct { - server *gosocketio.Server - db *db.RocksDB - txCache *db.TxCache - chain bchain.BlockChain - chainParser bchain.BlockChainParser - mempool bchain.Mempool - metrics *common.Metrics - is *common.InternalState - api *api.Worker -} - -// NewSocketIoServer creates new SocketIo interface to blockbook and returns its handle -func NewSocketIoServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, metrics *common.Metrics, is *common.InternalState) (*SocketIoServer, error) { - api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is) - if err != nil { - return nil, err - } - - server := gosocketio.NewServer(transport.GetDefaultWebsocketTransport()) - - server.On(gosocketio.OnConnection, func(c *gosocketio.Channel) { - glog.Info("Client connected ", c.Id()) - metrics.SocketIOClients.Inc() - }) - - server.On(gosocketio.OnDisconnection, func(c *gosocketio.Channel) { - glog.Info("Client disconnected ", c.Id()) - metrics.SocketIOClients.Dec() - }) - - server.On(gosocketio.OnError, func(c *gosocketio.Channel) { - glog.Error("Client error ", c.Id()) - }) - - type Message struct { - Name string `json:"name"` - Message string `json:"message"` - } - s := &SocketIoServer{ - server: server, - db: db, - txCache: txCache, - chain: chain, - chainParser: chain.GetChainParser(), - mempool: mempool, - metrics: metrics, - is: is, - api: api, - } - - server.On("message", s.onMessage) - server.On("subscribe", s.onSubscribe) - - return s, nil -} - -// GetHandler returns socket.io http handler -func (s *SocketIoServer) GetHandler() http.Handler { - return s.server -} - -type addrOpts struct { - Start int `json:"start"` - End int `json:"end"` - QueryMempoolOnly bool `json:"queryMempoolOnly"` - From int `json:"from"` - To int `json:"to"` -} - -var onMessageHandlers = map[string]func(*SocketIoServer, json.RawMessage) (interface{}, error){ - "getAddressTxids": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - addr, opts, err := unmarshalGetAddressRequest(params) - if err == nil { - rv, err = s.getAddressTxids(addr, &opts) - } - return - }, - "getAddressHistory": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - addr, opts, err := unmarshalGetAddressRequest(params) - if err == nil { - rv, err = s.getAddressHistory(addr, &opts) - } - return - }, - "getBlockHeader": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - height, hash, err := unmarshalGetBlockHeader(params) - if err == nil { - rv, err = s.getBlockHeader(height, hash) - } - return - }, - "estimateSmartFee": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - blocks, conservative, err := unmarshalEstimateSmartFee(params) - if err == nil { - rv, err = s.estimateSmartFee(blocks, conservative) - } - return - }, - "estimateFee": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - blocks, err := unmarshalEstimateFee(params) - if err == nil { - rv, err = s.estimateFee(blocks) - } - return - }, - "getInfo": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - return s.getInfo() - }, - "getDetailedTransaction": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - txid, err := unmarshalGetDetailedTransaction(params) - if err == nil { - rv, err = s.getDetailedTransaction(txid) - } - return - }, - "sendTransaction": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - tx, err := unmarshalStringParameter(params) - if err == nil { - rv, err = s.sendTransaction(tx) - } - return - }, - "getMempoolEntry": func(s *SocketIoServer, params json.RawMessage) (rv interface{}, err error) { - txid, err := unmarshalStringParameter(params) - if err == nil { - rv, err = s.getMempoolEntry(txid) - } - return - }, -} - -type resultError struct { - Error struct { - Message string `json:"message"` - } `json:"error"` -} - -func (s *SocketIoServer) onMessage(c *gosocketio.Channel, req map[string]json.RawMessage) (rv interface{}) { - var err error - method := strings.Trim(string(req["method"]), "\"") - defer func() { - if r := recover(); r != nil { - glog.Error(c.Id(), " onMessage ", method, " recovered from panic: ", r) - debug.PrintStack() - e := resultError{} - e.Error.Message = "Internal error" - rv = e - } - s.metrics.SocketIOPendingRequests.With((common.Labels{"method": method})).Dec() - }() - t := time.Now() - params := req["params"] - s.metrics.SocketIOPendingRequests.With((common.Labels{"method": method})).Inc() - defer s.metrics.SocketIOReqDuration.With(common.Labels{"method": method}).Observe(float64(time.Since(t)) / 1e3) // in microseconds - f, ok := onMessageHandlers[method] - if ok { - rv, err = f(s, params) - } else { - err = errors.New("unknown method") - } - if err == nil { - glog.V(1).Info(c.Id(), " onMessage ", method, " success") - s.metrics.SocketIORequests.With(common.Labels{"method": method, "status": "success"}).Inc() - return rv - } - glog.Error(c.Id(), " onMessage ", method, ": ", errors.ErrorStack(err), ", data ", string(params)) - s.metrics.SocketIORequests.With(common.Labels{"method": method, "status": "failure"}).Inc() - e := resultError{} - e.Error.Message = err.Error() - return e -} - -func unmarshalGetAddressRequest(params []byte) (addr []string, opts addrOpts, err error) { - var p []json.RawMessage - err = json.Unmarshal(params, &p) - if err != nil { - return - } - if len(p) != 2 { - err = errors.New("incorrect number of parameters") - return - } - err = json.Unmarshal(p[0], &addr) - if err != nil { - return - } - err = json.Unmarshal(p[1], &opts) - return -} - -type resultAddressTxids struct { - Result []string `json:"result"` -} - -func (s *SocketIoServer) getAddressTxids(addr []string, opts *addrOpts) (res resultAddressTxids, err error) { - txids := make([]string, 0, 8) - lower, higher := uint32(opts.End), uint32(opts.Start) - for _, address := range addr { - if !opts.QueryMempoolOnly { - err = s.db.GetTransactions(address, lower, higher, func(txid string, height uint32, indexes []int32) error { - txids = append(txids, txid) - return nil - }) - if err != nil { - return res, err - } - } else { - o, err := s.mempool.GetTransactions(address) - if err != nil { - return res, err - } - for _, m := range o { - txids = append(txids, m.Txid) - } - } - } - res.Result = api.GetUniqueTxids(txids) - return res, nil -} - -type addressHistoryIndexes struct { - InputIndexes []int `json:"inputIndexes"` - OutputIndexes []int `json:"outputIndexes"` -} - -type txInputs struct { - Txid *string `json:"txid"` - OutputIndex int `json:"outputIndex"` - Script *string `json:"script"` - // ScriptAsm *string `json:"scriptAsm"` - Sequence int64 `json:"sequence"` - Address *string `json:"address"` - Satoshis int64 `json:"satoshis"` -} - -type txOutputs struct { - Satoshis int64 `json:"satoshis"` - Script *string `json:"script"` - // ScriptAsm *string `json:"scriptAsm"` - // SpentTxID *string `json:"spentTxId,omitempty"` - // SpentIndex int `json:"spentIndex,omitempty"` - // SpentHeight int `json:"spentHeight,omitempty"` - Address *string `json:"address"` -} - -type resTx struct { - Hex string `json:"hex"` - // BlockHash string `json:"blockHash,omitempty"` - Height int `json:"height"` - BlockTimestamp int64 `json:"blockTimestamp,omitempty"` - Version int `json:"version"` - Hash string `json:"hash"` - Locktime int `json:"locktime,omitempty"` - // Size int `json:"size,omitempty"` - Inputs []txInputs `json:"inputs"` - InputSatoshis int64 `json:"inputSatoshis,omitempty"` - Outputs []txOutputs `json:"outputs"` - OutputSatoshis int64 `json:"outputSatoshis,omitempty"` - FeeSatoshis int64 `json:"feeSatoshis,omitempty"` -} - -type addressHistoryItem struct { - Addresses map[string]*addressHistoryIndexes `json:"addresses"` - Satoshis int64 `json:"satoshis"` - Confirmations int `json:"confirmations"` - Tx resTx `json:"tx"` -} - -type resultGetAddressHistory struct { - Result struct { - TotalCount int `json:"totalCount"` - Items []addressHistoryItem `json:"items"` - } `json:"result"` -} - -func txToResTx(tx *api.Tx) resTx { - inputs := make([]txInputs, len(tx.Vin)) - for i := range tx.Vin { - vin := &tx.Vin[i] - txid := vin.Txid - script := vin.Hex - input := txInputs{ - Txid: &txid, - Script: &script, - Sequence: int64(vin.Sequence), - OutputIndex: int(vin.Vout), - Satoshis: vin.ValueSat.AsInt64(), - } - if len(vin.Addresses) > 0 { - a := vin.Addresses[0] - input.Address = &a - } - inputs[i] = input - } - outputs := make([]txOutputs, len(tx.Vout)) - for i := range tx.Vout { - vout := &tx.Vout[i] - script := vout.Hex - output := txOutputs{ - Satoshis: vout.ValueSat.AsInt64(), - Script: &script, - } - if len(vout.Addresses) > 0 { - a := vout.Addresses[0] - output.Address = &a - } - outputs[i] = output - } - var h int - var blocktime int64 - if tx.Confirmations == 0 { - h = -1 - } else { - h = int(tx.Blockheight) - blocktime = tx.Blocktime - } - return resTx{ - BlockTimestamp: blocktime, - FeeSatoshis: tx.FeesSat.AsInt64(), - Hash: tx.Txid, - Height: h, - Hex: tx.Hex, - Inputs: inputs, - InputSatoshis: tx.ValueInSat.AsInt64(), - Locktime: int(tx.Locktime), - Outputs: outputs, - OutputSatoshis: tx.ValueOutSat.AsInt64(), - Version: int(tx.Version), - } -} - -func addressInSlice(s, t []string) string { - for _, sa := range s { - for _, ta := range t { - if ta == sa { - return sa - } - } - } - return "" -} - -func (s *SocketIoServer) getAddressesFromVout(vout *bchain.Vout) ([]string, error) { - addrDesc, err := s.chainParser.GetAddrDescFromVout(vout) - if err != nil { - return nil, err - } - voutAddr, _, err := s.chainParser.GetAddressesFromAddrDesc(addrDesc) - if err != nil { - return nil, err - } - return voutAddr, nil -} - -func (s *SocketIoServer) getAddressHistory(addr []string, opts *addrOpts) (res resultGetAddressHistory, err error) { - txr, err := s.getAddressTxids(addr, opts) - if err != nil { - return - } - txids := txr.Result - res.Result.TotalCount = len(txids) - res.Result.Items = make([]addressHistoryItem, 0, 8) - to := len(txids) - if to > opts.To { - to = opts.To - } - for txi := opts.From; txi < to; txi++ { - tx, err := s.api.GetTransaction(txids[txi], false, false) - if err != nil { - return res, err - } - ads := make(map[string]*addressHistoryIndexes) - var totalSat big.Int - for i := range tx.Vin { - vin := &tx.Vin[i] - a := addressInSlice(vin.Addresses, addr) - if a != "" { - hi := ads[a] - if hi == nil { - hi = &addressHistoryIndexes{OutputIndexes: []int{}} - ads[a] = hi - } - hi.InputIndexes = append(hi.InputIndexes, int(vin.N)) - if vin.ValueSat != nil { - totalSat.Sub(&totalSat, (*big.Int)(vin.ValueSat)) - } - } - } - for i := range tx.Vout { - vout := &tx.Vout[i] - a := addressInSlice(vout.Addresses, addr) - if a != "" { - hi := ads[a] - if hi == nil { - hi = &addressHistoryIndexes{InputIndexes: []int{}} - ads[a] = hi - } - hi.OutputIndexes = append(hi.OutputIndexes, int(vout.N)) - if vout.ValueSat != nil { - totalSat.Add(&totalSat, (*big.Int)(vout.ValueSat)) - } - } - } - ahi := addressHistoryItem{} - ahi.Addresses = ads - ahi.Confirmations = int(tx.Confirmations) - ahi.Satoshis = totalSat.Int64() - ahi.Tx = txToResTx(tx) - res.Result.Items = append(res.Result.Items, ahi) - // } - } - return -} - -func unmarshalArray(params []byte, np int) (p []interface{}, err error) { - err = json.Unmarshal(params, &p) - if err != nil { - return - } - if len(p) != np { - err = errors.New("incorrect number of parameters") - return - } - return -} - -func unmarshalGetBlockHeader(params []byte) (height uint32, hash string, err error) { - p, err := unmarshalArray(params, 1) - if err != nil { - return - } - fheight, ok := p[0].(float64) - if ok { - return uint32(fheight), "", nil - } - hash, ok = p[0].(string) - if ok { - return - } - err = errors.New("incorrect parameter") - return -} - -type resultGetBlockHeader struct { - Result struct { - Hash string `json:"hash"` - Version int `json:"version"` - Confirmations int `json:"confirmations"` - Height int `json:"height"` - ChainWork string `json:"chainWork"` - NextHash string `json:"nextHash"` - MerkleRoot string `json:"merkleRoot"` - Time int `json:"time"` - MedianTime int `json:"medianTime"` - Nonce int `json:"nonce"` - Bits string `json:"bits"` - Difficulty float64 `json:"difficulty"` - } `json:"result"` -} - -func (s *SocketIoServer) getBlockHeader(height uint32, hash string) (res resultGetBlockHeader, err error) { - if hash == "" { - // trezor is interested only in hash - hash, err = s.db.GetBlockHash(height) - if err != nil { - return - } - res.Result.Hash = hash - return - } - bh, err := s.chain.GetBlockHeader(hash) - if err != nil { - return - } - res.Result.Hash = bh.Hash - res.Result.Confirmations = bh.Confirmations - res.Result.Height = int(bh.Height) - res.Result.NextHash = bh.Next - return -} - -func unmarshalEstimateSmartFee(params []byte) (blocks int, conservative bool, err error) { - p, err := unmarshalArray(params, 2) - if err != nil { - return - } - fblocks, ok := p[0].(float64) - if !ok { - err = errors.New("Invalid parameter blocks") - return - } - blocks = int(fblocks) - conservative, ok = p[1].(bool) - if !ok { - err = errors.New("Invalid parameter conservative") - return - } - return -} - -type resultEstimateSmartFee struct { - // for compatibility reasons use float64 - Result float64 `json:"result"` -} - -func (s *SocketIoServer) estimateSmartFee(blocks int, conservative bool) (res resultEstimateSmartFee, err error) { - fee, err := s.chain.EstimateSmartFee(blocks, conservative) - if err != nil { - return - } - res.Result, err = strconv.ParseFloat(s.chainParser.AmountToDecimalString(&fee), 64) - return -} - -func unmarshalEstimateFee(params []byte) (blocks int, err error) { - p, err := unmarshalArray(params, 1) - if err != nil { - return - } - fblocks, ok := p[0].(float64) - if !ok { - err = errors.New("Invalid parameter nblocks") - return - } - blocks = int(fblocks) - return -} - -type resultEstimateFee struct { - // for compatibility reasons use float64 - Result float64 `json:"result"` -} - -func (s *SocketIoServer) estimateFee(blocks int) (res resultEstimateFee, err error) { - fee, err := s.chain.EstimateFee(blocks) - if err != nil { - return - } - res.Result, err = strconv.ParseFloat(s.chainParser.AmountToDecimalString(&fee), 64) - return -} - -type resultGetInfo struct { - Result struct { - Version int `json:"version,omitempty"` - ProtocolVersion int `json:"protocolVersion,omitempty"` - Blocks int `json:"blocks"` - TimeOffset int `json:"timeOffset,omitempty"` - Connections int `json:"connections,omitempty"` - Proxy string `json:"proxy,omitempty"` - Difficulty float64 `json:"difficulty,omitempty"` - Testnet bool `json:"testnet"` - RelayFee float64 `json:"relayFee,omitempty"` - Errors string `json:"errors,omitempty"` - Network string `json:"network,omitempty"` - Subversion string `json:"subversion,omitempty"` - LocalServices string `json:"localServices,omitempty"` - CoinName string `json:"coin_name,omitempty"` - About string `json:"about,omitempty"` - } `json:"result"` -} - -func (s *SocketIoServer) getInfo() (res resultGetInfo, err error) { - _, height, _ := s.is.GetSyncState() - res.Result.Blocks = int(height) - res.Result.Testnet = s.chain.IsTestnet() - res.Result.Network = s.chain.GetNetworkName() - res.Result.Subversion = s.chain.GetSubversion() - res.Result.CoinName = s.chain.GetCoinName() - res.Result.About = api.Text.BlockbookAbout - return -} - -func unmarshalStringParameter(params []byte) (s string, err error) { - p, err := unmarshalArray(params, 1) - if err != nil { - return - } - s, ok := p[0].(string) - if ok { - return - } - err = errors.New("incorrect parameter") - return -} - -func unmarshalGetDetailedTransaction(params []byte) (txid string, err error) { - var p []json.RawMessage - err = json.Unmarshal(params, &p) - if err != nil { - return - } - if len(p) != 1 { - err = errors.New("incorrect number of parameters") - return - } - err = json.Unmarshal(p[0], &txid) - if err != nil { - return - } - return -} - -type resultGetDetailedTransaction struct { - Result resTx `json:"result"` -} - -func (s *SocketIoServer) getDetailedTransaction(txid string) (res resultGetDetailedTransaction, err error) { - tx, err := s.api.GetTransaction(txid, false, false) - if err != nil { - return res, err - } - res.Result = txToResTx(tx) - return -} - -func (s *SocketIoServer) sendTransaction(tx string) (res resultSendTransaction, err error) { - txid, err := s.chain.SendRawTransaction(tx) - if err != nil { - return res, err - } - res.Result = txid - return -} - -type resultGetMempoolEntry struct { - Result *bchain.MempoolEntry `json:"result"` -} - -func (s *SocketIoServer) getMempoolEntry(txid string) (res resultGetMempoolEntry, err error) { - entry, err := s.chain.GetMempoolEntry(txid) - if err != nil { - return res, err - } - res.Result = entry - return -} - -// onSubscribe expects two event subscriptions based on the req parameter (including the doublequotes): -// "bitcoind/hashblock" -// "bitcoind/addresstxid",["2MzTmvPJLZaLzD9XdN3jMtQA5NexC3rAPww","2NAZRJKr63tSdcTxTN3WaE9ZNDyXy6PgGuv"] -func (s *SocketIoServer) onSubscribe(c *gosocketio.Channel, req []byte) interface{} { - defer func() { - if r := recover(); r != nil { - glog.Error(c.Id(), " onSubscribe recovered from panic: ", r) - debug.PrintStack() - } - }() - - onError := func(id, sc, err, detail string) { - glog.Error(id, " onSubscribe ", err, ": ", detail) - s.metrics.SocketIOSubscribes.With(common.Labels{"channel": sc, "status": "failure"}).Inc() - } - - r := string(req) - glog.V(1).Info(c.Id(), " onSubscribe ", r) - var sc string - i := strings.Index(r, "\",[") - if i > 0 { - var addrs []string - sc = r[1:i] - if sc != "bitcoind/addresstxid" { - onError(c.Id(), sc, "invalid data", "expecting bitcoind/addresstxid, req: "+r) - return nil - } - err := json.Unmarshal([]byte(r[i+2:]), &addrs) - if err != nil { - onError(c.Id(), sc, "invalid data", err.Error()+", req: "+r) - return nil - } - // normalize the addresses to AddressDescriptor - descs := make([]bchain.AddressDescriptor, len(addrs)) - for i, a := range addrs { - d, err := s.chainParser.GetAddrDescFromAddress(a) - if err != nil { - onError(c.Id(), sc, "invalid address "+a, err.Error()+", req: "+r) - return nil - } - descs[i] = d - } - for _, d := range descs { - c.Join("bitcoind/addresstxid-" + string(d)) - } - } else { - sc = r[1 : len(r)-1] - if sc != "bitcoind/hashblock" { - onError(c.Id(), sc, "invalid data", "expecting bitcoind/hashblock, req: "+r) - return nil - } - c.Join(sc) - } - s.metrics.SocketIOSubscribes.With(common.Labels{"channel": sc, "status": "success"}).Inc() - return nil -} - -func (s *SocketIoServer) onNewBlockHashAsync(hash string) { - c := s.server.BroadcastTo("bitcoind/hashblock", "bitcoind/hashblock", hash) - glog.Info("broadcasting new block hash ", hash, " to ", c, " channels") -} - -// OnNewBlockHash notifies users subscribed to bitcoind/hashblock about new block -func (s *SocketIoServer) OnNewBlockHash(hash string) { - go s.onNewBlockHashAsync(hash) -} - -// OnNewTxAddr notifies users subscribed to bitcoind/addresstxid about new block -func (s *SocketIoServer) OnNewTxAddr(txid string, desc bchain.AddressDescriptor) { - addr, searchable, err := s.chainParser.GetAddressesFromAddrDesc(desc) - if err != nil { - glog.Error("GetAddressesFromAddrDesc error ", err, " for descriptor ", desc) - } else if searchable && len(addr) == 1 { - data := map[string]interface{}{"address": addr[0], "txid": txid} - c := s.server.BroadcastTo("bitcoind/addresstxid-"+string(desc), "bitcoind/addresstxid", data) - if c > 0 { - glog.Info("broadcasting new txid ", txid, " for addr ", addr[0], " to ", c, " channels") - } - } -} diff --git a/server/socketio_log_test.go b/server/socketio_log_test.go deleted file mode 100644 index bfc16281a9..0000000000 --- a/server/socketio_log_test.go +++ /dev/null @@ -1,444 +0,0 @@ -//go:build integration - -package server - -import ( - "bufio" - "crypto/tls" - "encoding/json" - "flag" - "os" - "reflect" - "sort" - "strings" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/juju/errors" - "github.com/martinboehm/golang-socketio" - "github.com/martinboehm/golang-socketio/transport" -) - -var ( - // verifier functionality - verifylog = flag.String("verifylog", "", "path to logfile containing socket.io requests/responses") - wsurl = flag.String("wsurl", "", "URL of socket.io interface to verify") - newSocket = flag.Bool("newsocket", false, "Create new socket.io connection for each request") -) - -type verifyStats struct { - Count int - SuccessCount int - TotalLogNs int64 - TotalBlockbookNs int64 -} - -type logMessage struct { - ID int `json:"id"` - Et int64 `json:"et"` - Res json.RawMessage `json:"res"` - Req json.RawMessage `json:"req"` -} - -type logRequestResponse struct { - Request, Response json.RawMessage - LogElapsedTime int64 -} - -func getStat(m string, stats map[string]*verifyStats) *verifyStats { - s, ok := stats[m] - if !ok { - s = &verifyStats{} - stats[m] = s - } - return s -} - -func unmarshalResponses(t *testing.T, id int, lrs *logRequestResponse, bbResStr string, bbResponse interface{}, logResponse interface{}) error { - err := json.Unmarshal([]byte(bbResStr), bbResponse) - if err != nil { - t.Log(id, ": error unmarshal BB request ", err) - return err - } - err = json.Unmarshal([]byte(lrs.Response), logResponse) - if err != nil { - t.Log(id, ": error unmarshal log request ", err) - return err - } - return nil -} - -func getFullAddressHistory(addr []string, rr addrOpts, ws *gosocketio.Client) (*resultGetAddressHistory, error) { - rr.From = 0 - rr.To = 100000000 - rq := map[string]interface{}{ - "method": "getAddressHistory", - "params": []interface{}{ - addr, - rr, - }, - } - rrq, err := json.Marshal(rq) - if err != nil { - return nil, err - } - res, err := ws.Ack("message", json.RawMessage(rrq), time.Second*30) - if err != nil { - return nil, err - } - bbResponse := resultGetAddressHistory{} - err = json.Unmarshal([]byte(res), &bbResponse) - if err != nil { - return nil, err - } - return &bbResponse, nil -} - -func equalTx(logTx resTx, bbTx resTx) error { - if logTx.Hash != bbTx.Hash { - return errors.Errorf("Different Hash bb: %v log: %v", bbTx.Hash, logTx.Hash) - } - if logTx.Hex != bbTx.Hex { - return errors.Errorf("Different Hex bb: %v log: %v", bbTx.Hex, logTx.Hex) - } - if logTx.BlockTimestamp != bbTx.BlockTimestamp && logTx.BlockTimestamp != 0 { - return errors.Errorf("Different BlockTimestamp bb: %v log: %v", bbTx.BlockTimestamp, logTx.BlockTimestamp) - } - if logTx.FeeSatoshis != bbTx.FeeSatoshis { - return errors.Errorf("Different FeeSatoshis bb: %v log: %v", bbTx.FeeSatoshis, logTx.FeeSatoshis) - } - if logTx.Height != bbTx.Height && logTx.Height != -1 { - return errors.Errorf("Different Height bb: %v log: %v", bbTx.Height, logTx.Height) - } - if logTx.InputSatoshis != bbTx.InputSatoshis { - return errors.Errorf("Different InputSatoshis bb: %v log: %v", bbTx.InputSatoshis, logTx.InputSatoshis) - } - if logTx.Locktime != bbTx.Locktime { - return errors.Errorf("Different Locktime bb: %v log: %v", bbTx.Locktime, logTx.Locktime) - } - if logTx.OutputSatoshis != bbTx.OutputSatoshis { - return errors.Errorf("Different OutputSatoshis bb: %v log: %v", bbTx.OutputSatoshis, logTx.OutputSatoshis) - } - if logTx.Version != bbTx.Version { - return errors.Errorf("Different Version bb: %v log: %v", bbTx.Version, logTx.Version) - } - if len(logTx.Inputs) != len(bbTx.Inputs) { - return errors.Errorf("Different number of Inputs bb: %v log: %v", len(bbTx.Inputs), len(logTx.Inputs)) - } - // blockbook parses bech addresses, it is ok for bitcore to return nil address and blockbook parsed address - for i := range logTx.Inputs { - if logTx.Inputs[i].Satoshis != bbTx.Inputs[i].Satoshis || - (bbTx.Inputs[i].Address == nil && logTx.Inputs[i].Address != bbTx.Inputs[i].Address) || - (logTx.Inputs[i].Address != nil && *logTx.Inputs[i].Address != *bbTx.Inputs[i].Address) || - logTx.Inputs[i].OutputIndex != bbTx.Inputs[i].OutputIndex || - logTx.Inputs[i].Sequence != bbTx.Inputs[i].Sequence { - return errors.Errorf("Different Inputs bb: %+v log: %+v", bbTx.Inputs, logTx.Inputs) - } - } - if len(logTx.Outputs) != len(bbTx.Outputs) { - return errors.Errorf("Different number of Outputs bb: %v log: %v", len(bbTx.Outputs), len(logTx.Outputs)) - } - // blockbook parses bech addresses, it is ok for bitcore to return nil address and blockbook parsed address - for i := range logTx.Outputs { - if logTx.Outputs[i].Satoshis != bbTx.Outputs[i].Satoshis || - (bbTx.Outputs[i].Address == nil && logTx.Outputs[i].Address != bbTx.Outputs[i].Address) || - (logTx.Outputs[i].Address != nil && *logTx.Outputs[i].Address != *bbTx.Outputs[i].Address) { - return errors.Errorf("Different Outputs bb: %+v log: %+v", bbTx.Outputs, logTx.Outputs) - } - } - return nil -} - -func equalAddressHistoryItem(logItem addressHistoryItem, bbItem addressHistoryItem) error { - if err := equalTx(logItem.Tx, bbItem.Tx); err != nil { - return err - } - if !reflect.DeepEqual(logItem.Addresses, bbItem.Addresses) { - return errors.Errorf("Different Addresses bb: %v log: %v", bbItem.Addresses, logItem.Addresses) - } - if logItem.Satoshis != bbItem.Satoshis { - return errors.Errorf("Different Satoshis bb: %v log: %v", bbItem.Satoshis, logItem.Satoshis) - } - return nil -} - -func verifyGetAddressHistory(t *testing.T, id int, lrs *logRequestResponse, bbResStr string, stat *verifyStats, ws *gosocketio.Client, bbRequest map[string]json.RawMessage) { - bbResponse := resultGetAddressHistory{} - logResponse := resultGetAddressHistory{} - var bbFullResponse *resultGetAddressHistory - if err := unmarshalResponses(t, id, lrs, bbResStr, &bbResponse, &logResponse); err != nil { - return - } - // parse request to check params - addr, rr, err := unmarshalGetAddressRequest(bbRequest["params"]) - if err != nil { - t.Log(id, ": getAddressHistory error unmarshal BB request ", err) - return - } - // mempool transactions are not comparable - if !rr.QueryMempoolOnly { - if (logResponse.Result.TotalCount != bbResponse.Result.TotalCount) || - len(logResponse.Result.Items) != len(bbResponse.Result.Items) { - t.Log("getAddressHistory", id, "mismatch bb:", bbResponse.Result.TotalCount, len(bbResponse.Result.Items), - "log:", logResponse.Result.TotalCount, len(logResponse.Result.Items)) - return - } - if logResponse.Result.TotalCount > 0 { - for i, logItem := range logResponse.Result.Items { - bbItem := bbResponse.Result.Items[i] - if err = equalAddressHistoryItem(logItem, bbItem); err != nil { - // if multiple addresses are specified, BlockBook returns transactions in different order - // which causes problems in paged responses - // we have to get all transactions from blockbook and check that they are in the logged response - var err1 error - if bbFullResponse == nil { - bbFullResponse, err1 = getFullAddressHistory(addr, rr, ws) - if err1 != nil { - t.Log("getFullAddressHistory error", err) - return - } - if bbFullResponse.Result.TotalCount != logResponse.Result.TotalCount { - t.Log("getFullAddressHistory count mismatch", bbFullResponse.Result.TotalCount, logResponse.Result.TotalCount) - return - } - } - found := false - for _, bbFullItem := range bbFullResponse.Result.Items { - err1 = equalAddressHistoryItem(logItem, bbFullItem) - if err1 == nil { - found = true - break - } - if err1.Error()[:14] != "Different Hash" { - t.Log(err1) - } - } - if !found { - t.Log("getAddressHistory", id, "addresses", addr, "mismatch ", err) - return - } - } - } - } - } - stat.SuccessCount++ -} - -func verifyGetInfo(t *testing.T, id int, lrs *logRequestResponse, bbResStr string, stat *verifyStats) { - bbResponse := resultGetInfo{} - logResponse := resultGetInfo{} - if err := unmarshalResponses(t, id, lrs, bbResStr, &bbResponse, &logResponse); err != nil { - return - } - if logResponse.Result.Blocks <= bbResponse.Result.Blocks && - logResponse.Result.Testnet == bbResponse.Result.Testnet && - logResponse.Result.Network == bbResponse.Result.Network { - stat.SuccessCount++ - } else { - t.Log("getInfo", id, "mismatch bb:", bbResponse.Result.Blocks, bbResponse.Result.Testnet, bbResponse.Result.Network, - "log:", logResponse.Result.Blocks, logResponse.Result.Testnet, logResponse.Result.Network) - } -} - -func verifyGetBlockHeader(t *testing.T, id int, lrs *logRequestResponse, bbResStr string, stat *verifyStats) { - bbResponse := resultGetBlockHeader{} - logResponse := resultGetBlockHeader{} - if err := unmarshalResponses(t, id, lrs, bbResStr, &bbResponse, &logResponse); err != nil { - return - } - if logResponse.Result.Hash == bbResponse.Result.Hash { - stat.SuccessCount++ - } else { - t.Log("getBlockHeader", id, "mismatch bb:", bbResponse.Result.Hash, - "log:", logResponse.Result.Hash) - } -} - -func verifyEstimateSmartFee(t *testing.T, id int, lrs *logRequestResponse, bbResStr string, stat *verifyStats) { - bbResponse := resultEstimateSmartFee{} - logResponse := resultEstimateSmartFee{} - if err := unmarshalResponses(t, id, lrs, bbResStr, &bbResponse, &logResponse); err != nil { - return - } - // it is not possible to compare fee directly, it changes over time, - // verify that the BB fee is in a reasonable range - if bbResponse.Result > 0 && bbResponse.Result < .1 { - stat.SuccessCount++ - } else { - t.Log("estimateSmartFee", id, "mismatch bb:", bbResponse.Result, - "log:", logResponse.Result) - } -} - -func verifySendTransaction(t *testing.T, id int, lrs *logRequestResponse, bbResStr string, stat *verifyStats) { - bbResponse := resultSendTransaction{} - logResponse := resultSendTransaction{} - if err := unmarshalResponses(t, id, lrs, bbResStr, &bbResponse, &logResponse); err != nil { - return - } - bbResponseError := resultError{} - err := json.Unmarshal([]byte(bbResStr), &bbResponseError) - if err != nil { - t.Log(id, ": error unmarshal resultError ", err) - return - } - // it is not possible to repeat sendTransaction, expect error - if bbResponse.Result == "" && bbResponseError.Error.Message != "" { - stat.SuccessCount++ - } else { - t.Log("sendTransaction", id, "problem:", bbResponse.Result, bbResponseError) - } -} - -func verifyGetDetailedTransaction(t *testing.T, id int, lrs *logRequestResponse, bbResStr string, stat *verifyStats) { - bbResponse := resultGetDetailedTransaction{} - logResponse := resultGetDetailedTransaction{} - if err := unmarshalResponses(t, id, lrs, bbResStr, &bbResponse, &logResponse); err != nil { - return - } - if err := equalTx(logResponse.Result, bbResponse.Result); err != nil { - t.Log("getDetailedTransaction", id, err) - return - } - stat.SuccessCount++ -} - -func verifyMessage(t *testing.T, ws *gosocketio.Client, id int, lrs *logRequestResponse, stats map[string]*verifyStats) { - req := make(map[string]json.RawMessage) - err := json.Unmarshal(lrs.Request, &req) - if err != nil { - t.Log(id, ": error unmarshal request ", err) - return - } - method := strings.Trim(string(req["method"]), "\"") - if method == "" { - t.Log(id, ": there is no method specified in request") - return - } - // send the message to blockbook - start := time.Now() - res, err := ws.Ack("message", lrs.Request, time.Second*30) - if err != nil { - t.Log(id, ",", method, ": ws.Ack error ", err) - getStat("ackError", stats).Count++ - return - } - ts := time.Since(start).Nanoseconds() - stat := getStat(method, stats) - stat.Count++ - stat.TotalLogNs += lrs.LogElapsedTime - stat.TotalBlockbookNs += ts - switch method { - case "getAddressHistory": - verifyGetAddressHistory(t, id, lrs, res, stat, ws, req) - case "getBlockHeader": - verifyGetBlockHeader(t, id, lrs, res, stat) - case "getDetailedTransaction": - verifyGetDetailedTransaction(t, id, lrs, res, stat) - case "getInfo": - verifyGetInfo(t, id, lrs, res, stat) - case "estimateSmartFee": - verifyEstimateSmartFee(t, id, lrs, res, stat) - case "sendTransaction": - verifySendTransaction(t, id, lrs, res, stat) - // case "getAddressTxids": - // case "estimateFee": - // case "getMempoolEntry": - default: - t.Log(id, ",", method, ": unknown/unverified method", method) - } -} - -func connectSocketIO(t *testing.T) *gosocketio.Client { - tr := transport.GetDefaultWebsocketTransport() - tr.WebsocketDialer = websocket.Dialer{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - ws, err := gosocketio.Dial(*wsurl, tr) - if err != nil { - t.Fatal("Dial error ", err) - return nil - } - return ws -} - -func Test_VerifyLog(t *testing.T) { - if *verifylog == "" || *wsurl == "" { - t.Skip("skipping test, flags verifylog or wsurl not specified") - } - t.Log("Verifying log", *verifylog, "against service", *wsurl) - var ws *gosocketio.Client - if !*newSocket { - ws = connectSocketIO(t) - defer ws.Close() - } - file, err := os.Open(*verifylog) - if err != nil { - t.Fatal("File read error", err) - return - } - defer file.Close() - scanner := bufio.NewScanner(file) - buf := make([]byte, 1<<25) - scanner.Buffer(buf, 1<<25) - scanner.Split(bufio.ScanLines) - line := 0 - stats := make(map[string]*verifyStats) - pairs := make(map[int]*logRequestResponse, 0) - for scanner.Scan() { - line++ - msg := logMessage{} - err := json.Unmarshal(scanner.Bytes(), &msg) - if err != nil { - t.Log("Line ", line, ": json error ", err) - continue - } - lrs, exists := pairs[msg.ID] - if !exists { - lrs = &logRequestResponse{} - pairs[msg.ID] = lrs - } - if msg.Req != nil { - if lrs.Request != nil { - t.Log("Line ", line, ": duplicate request with id ", msg.ID) - continue - } - lrs.Request = msg.Req - } else if msg.Res != nil { - if lrs.Response != nil { - t.Log("Line ", line, ": duplicate response with id ", msg.ID) - continue - } - lrs.Response = msg.Res - lrs.LogElapsedTime = msg.Et - } - if lrs.Request != nil && lrs.Response != nil { - if *newSocket { - ws = connectSocketIO(t) - } - verifyMessage(t, ws, msg.ID, lrs, stats) - if *newSocket { - ws.Close() - } - delete(pairs, msg.ID) - } - } - var keys []string - for k := range stats { - keys = append(keys, k) - } - failures := 0 - sort.Strings(keys) - t.Log("Processed", line, "lines") - for _, k := range keys { - s := stats[k] - failures += s.Count - s.SuccessCount - t.Log("Method:", k, "\tCount:", s.Count, "\tSuccess:", s.SuccessCount, - "\tTime log:", s.TotalLogNs, "\tTime BB:", s.TotalBlockbookNs, - "\tTime BB/log", float64(s.TotalBlockbookNs)/float64(s.TotalLogNs)) - } - if failures != 0 { - t.Error("Number of failures:", failures) - } -} diff --git a/server/template_ext.go b/server/template_ext.go new file mode 100644 index 0000000000..d6a8868a2f --- /dev/null +++ b/server/template_ext.go @@ -0,0 +1,30 @@ +package server + +import ( + "fmt" + "html/template" +) + +var registeredTemplateFuncs = template.FuncMap{} + +func registerTemplateFunc(name string, fn interface{}) { + if name == "" { + panic("template function name is empty") + } + if fn == nil { + panic(fmt.Sprintf("template function %q is nil", name)) + } + if _, exists := registeredTemplateFuncs[name]; exists { + panic(fmt.Sprintf("template function %q is already registered", name)) + } + registeredTemplateFuncs[name] = fn +} + +func applyTemplateFuncs(dst template.FuncMap) { + for name, fn := range registeredTemplateFuncs { + if _, exists := dst[name]; exists { + panic(fmt.Sprintf("template function %q collides with built-in function map", name)) + } + dst[name] = fn + } +} diff --git a/server/template_ext_test.go b/server/template_ext_test.go new file mode 100644 index 0000000000..4fe055903e --- /dev/null +++ b/server/template_ext_test.go @@ -0,0 +1,28 @@ +//go:build unittest + +package server + +import ( + "html/template" + "testing" +) + +func TestApplyTemplateFuncs_RegistersExtensions(t *testing.T) { + m := template.FuncMap{} + applyTemplateFuncs(m) + if _, ok := m["chainExtra"]; !ok { + t.Fatal("expected chainExtra to be registered in template func map") + } +} + +func TestApplyTemplateFuncs_CollisionPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic on function name collision") + } + }() + m := template.FuncMap{ + "chainExtra": func() {}, + } + applyTemplateFuncs(m) +} diff --git a/server/tron_template.go b/server/tron_template.go new file mode 100644 index 0000000000..f23d288ead --- /dev/null +++ b/server/tron_template.go @@ -0,0 +1,78 @@ +package server + +import ( + "encoding/json" + "math/big" + "strings" + + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/bchain" +) + +func init() { + registerTemplateFunc("chainExtra", chainExtra) + registerTemplateFunc("accountChainExtra", accountChainExtra) +} + +type tronTxExtraTemplateData struct { + bchain.TronChainExtraData + TotalFeeAmount *api.Amount `json:"-"` + EnergyFeeAmount *api.Amount `json:"-"` + BandwidthFeeAmount *api.Amount `json:"-"` + DelegateAmountValue *api.Amount `json:"-"` + StakeAmountValue *api.Amount `json:"-"` + UnstakeAmountValue *api.Amount `json:"-"` + ClaimedVoteRewardValue *api.Amount `json:"-"` +} + +type tronAccountExtraTemplateData struct { + bchain.TronAccountExtraData +} + +func chainExtra(tx *api.Tx) *tronTxExtraTemplateData { + if tx == nil || tx.ChainExtraData == nil { + return nil + } + var extra bchain.TronChainExtraData + if err := json.Unmarshal(tx.ChainExtraData.Payload, &extra); err != nil { + return nil + } + + rv := &tronTxExtraTemplateData{ + TronChainExtraData: extra, + TotalFeeAmount: parseTronSunAmount(extra.TotalFee), + EnergyFeeAmount: parseTronSunAmount(extra.EnergyFee), + BandwidthFeeAmount: parseTronSunAmount(extra.BandwidthFee), + DelegateAmountValue: parseTronSunAmount(extra.DelegateAmount), + StakeAmountValue: parseTronSunAmount(extra.StakeAmount), + UnstakeAmountValue: parseTronSunAmount(extra.UnstakeAmount), + ClaimedVoteRewardValue: parseTronSunAmount(extra.ClaimedVoteReward), + } + return rv +} + +func accountChainExtra(addr *api.Address) *tronAccountExtraTemplateData { + if addr == nil || addr.ChainExtraData == nil { + return nil + } + var extra bchain.TronAccountExtraData + if err := json.Unmarshal(addr.ChainExtraData.Payload, &extra); err != nil { + return nil + } + rv := &tronAccountExtraTemplateData{ + TronAccountExtraData: extra, + } + return rv +} + +func parseTronSunAmount(amount string) *api.Amount { + amount = strings.TrimSpace(amount) + if amount == "" { + return nil + } + bi, ok := new(big.Int).SetString(amount, 10) + if !ok { + return nil + } + return (*api.Amount)(bi) +} diff --git a/server/tron_template_test.go b/server/tron_template_test.go new file mode 100644 index 0000000000..ca7ab48efa --- /dev/null +++ b/server/tron_template_test.go @@ -0,0 +1,117 @@ +//go:build unittest + +package server + +import ( + "encoding/json" + "testing" + + "github.com/trezor/blockbook/api" +) + +func TestChainExtra(t *testing.T) { + t.Run("valid", func(t *testing.T) { + tx := &api.Tx{ + ChainExtraData: &api.TxChainExtraData{ + PayloadType: "tron", + Payload: json.RawMessage(`{"operation":"vote","totalFee":"3076500","energyUsageTotal":"100","energyFee":"250000","bandwidthUsage":"50","bandwidthFee":"345000","stakeAmount":"125000000","unstakeAmount":"88000000","claimedVoteReward":"6500000","votes":[{"address":"TA","count":"2"}]}`), + }, + } + got := chainExtra(tx) + if got == nil { + t.Fatal("expected extra data") + } + if got.Operation != "vote" { + t.Fatalf("unexpected operation %q", got.Operation) + } + if got.EnergyUsageTotal != "100" { + t.Fatalf("unexpected energyUsageTotal %q", got.EnergyUsageTotal) + } + if got.TotalFeeAmount == nil || got.TotalFeeAmount.DecimalString(6) != "3.0765" { + t.Fatalf("unexpected totalFee %+v", got.TotalFeeAmount) + } + if got.EnergyFeeAmount == nil || got.EnergyFeeAmount.DecimalString(6) != "0.25" { + t.Fatalf("unexpected energyFee %+v", got.EnergyFeeAmount) + } + if got.BandwidthFeeAmount == nil || got.BandwidthFeeAmount.DecimalString(6) != "0.345" { + t.Fatalf("unexpected bandwidthFee %+v", got.BandwidthFeeAmount) + } + if got.StakeAmountValue == nil || got.StakeAmountValue.DecimalString(6) != "125" { + t.Fatalf("unexpected stakeAmount %+v", got.StakeAmountValue) + } + if got.UnstakeAmountValue == nil || got.UnstakeAmountValue.DecimalString(6) != "88" { + t.Fatalf("unexpected unstakeAmount %+v", got.UnstakeAmountValue) + } + if got.ClaimedVoteRewardValue == nil || got.ClaimedVoteRewardValue.DecimalString(6) != "6.5" { + t.Fatalf("unexpected claimedVoteReward %+v", got.ClaimedVoteRewardValue) + } + if len(got.Votes) != 1 || got.Votes[0].Address != "TA" || got.Votes[0].Count != "2" { + t.Fatalf("unexpected votes %+v", got.Votes) + } + }) + + t.Run("invalid json", func(t *testing.T) { + tx := &api.Tx{ChainExtraData: &api.TxChainExtraData{PayloadType: "tron", Payload: json.RawMessage("{")}} + if got := chainExtra(tx); got != nil { + t.Fatalf("expected nil for invalid json, got %+v", got) + } + }) + + t.Run("empty object", func(t *testing.T) { + tx := &api.Tx{ChainExtraData: &api.TxChainExtraData{PayloadType: "tron", Payload: json.RawMessage(`{}`)}} + if got := chainExtra(tx); got == nil { + t.Fatal("expected non-nil for valid empty extra") + } + }) + + t.Run("only feeLimit", func(t *testing.T) { + tx := &api.Tx{ + ChainExtraData: &api.TxChainExtraData{ + PayloadType: "tron", + Payload: json.RawMessage(`{"feeLimit":"5000000"}`), + }, + } + got := chainExtra(tx) + if got == nil { + t.Fatal("expected extra data") + } + if got.FeeLimit != "5000000" { + t.Fatalf("unexpected feeLimit %q", got.FeeLimit) + } + }) +} + +func TestAccountChainExtra(t *testing.T) { + t.Run("valid", func(t *testing.T) { + addr := &api.Address{ + ChainExtraData: &api.AccountChainExtraData{ + PayloadType: "tron", + Payload: json.RawMessage(`{"availableStakedBandwidth":400,"totalStakedBandwidth":700,"availableFreeBandwidth":200,"totalFreeBandwidth":300,"availableEnergy":1234,"totalEnergy":9000}`), + }, + } + got := accountChainExtra(addr) + if got == nil { + t.Fatal("expected extra data") + } + if got.AvailableStakedBandwidth != 400 || got.TotalStakedBandwidth != 700 || got.AvailableFreeBandwidth != 200 || got.TotalFreeBandwidth != 300 { + t.Fatalf("unexpected bandwidth values %+v", got) + } + if got.AvailableEnergy != 1234 || got.TotalEnergy != 9000 { + t.Fatalf("unexpected energy values %+v", got) + } + }) + + t.Run("invalid json", func(t *testing.T) { + addr := &api.Address{ChainExtraData: &api.AccountChainExtraData{PayloadType: "tron", Payload: json.RawMessage("{")}} + if got := accountChainExtra(addr); got != nil { + t.Fatalf("expected nil for invalid json, got %+v", got) + } + }) + + t.Run("empty object", func(t *testing.T) { + addr := &api.Address{ChainExtraData: &api.AccountChainExtraData{PayloadType: "tron", Payload: json.RawMessage(`{}`)}} + if got := accountChainExtra(addr); got == nil { + t.Fatal("expected non-nil for valid empty extra") + } + }) +} diff --git a/server/websocket.go b/server/websocket.go index facaaa42df..4c6932b311 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -1,9 +1,15 @@ package server import ( + "context" "encoding/json" + "fmt" "math/big" + "net" "net/http" + "net/netip" + "net/url" + "os" "runtime/debug" "strconv" "strings" @@ -18,11 +24,24 @@ import ( "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/fiat" ) const upgradeFailed = "Upgrade failed: " const outChannelSize = 500 const defaultTimeout = 60 * time.Second +const unknownMethodLabel = "unknown" +const maxWebsocketMessageBytes int64 = 4 * 1024 * 1024 +const maxWebsocketPendingRequests = 48 +const maxWebsocketConnectionAttemptsPerIP = 64 +const maxWebsocketConnectionsPerIP = 128 +const maxWebsocketEstimateFeeBlocks = 32 +const maxWebsocketSubscribeAddresses = 1000 +const maxWebsocketSubscribeAddressesWithNewBlockTxs = 100 +const websocketConnectionAttemptWindow = time.Minute +const websocketConnectionLimiterTTL = 10 * time.Minute +const websocketConnectionLimiterCleanupInterval = time.Minute +const websocketLogPreviewBytes = 256 // allRates is a special "currency" parameter that means all available currencies const allFiatRates = "!ALL!" @@ -34,31 +53,26 @@ var ( connectionCounter uint64 ) -type websocketReq struct { - ID string `json:"id"` - Method string `json:"method"` - Params json.RawMessage `json:"params"` -} - -type websocketRes struct { - ID string `json:"id"` - Data interface{} `json:"data"` -} - type websocketChannel struct { - id uint64 - conn *websocket.Conn - out chan *websocketRes - ip string - requestHeader http.Header - alive bool - aliveLock sync.Mutex - addrDescs []string // subscribed address descriptors as strings + id uint64 + conn *websocket.Conn + out chan *WsRes + pendingRequests chan struct{} + ip string + requestHeader http.Header + alive bool + aliveLock sync.Mutex + closeReason string + addrDescs []string // subscribed address descriptors as strings + getAddressInfoDescriptorsMux sync.Mutex + getAddressInfoDescriptors map[string]struct{} } -type fiatRatesSubscription struct { - Currency string `json:"currency"` - Tokens []string `json:"tokens"` +type addressDetails struct { + requestID string + // publishNewBlockTxs enables notifications for confirmed transactions + // detected while processing newly connected blocks. + publishNewBlockTxs bool } // WebsocketServer is a handle to websocket server @@ -78,16 +92,41 @@ type WebsocketServer struct { newTransactionEnabled bool newTransactionSubscriptions map[*websocketChannel]string newTransactionSubscriptionsLock sync.Mutex - addressSubscriptions map[string]map[*websocketChannel]string + addressSubscriptions map[string]map[*websocketChannel]*addressDetails addressSubscriptionsLock sync.Mutex - fiatRatesSubscriptions map[string]map[*websocketChannel]string - fiatRatesTokenSubscriptions map[*websocketChannel][]string - fiatRatesSubscriptionsLock sync.Mutex + // newBlockTxsSubscriptionCount is a fast-path guard for OnNewBlock. + // It tracks how many address subscriptions requested newBlockTxs=true. + newBlockTxsSubscriptionCount int + fiatRatesSubscriptions map[string]map[*websocketChannel]string + fiatRatesTokenSubscriptions map[*websocketChannel][]string + fiatRatesSubscriptionsLock sync.Mutex + allowedOrigins map[string]struct{} + allowedRpcCallTo map[string]struct{} + trustedProxyPrefixes []netip.Prefix + websocketLimiter *websocketConnectionLimiter + // Shutdown coordination: protects shuttingDown + activeChannels and gates + // trackWork so RocksDB cannot be closed while a WS goroutine is mid-read. + shutdownMu sync.Mutex + shuttingDown bool + activeChannels map[*websocketChannel]struct{} + requestWg sync.WaitGroup +} + +type websocketClientLimit struct { + active int + attempts []time.Time + lastSeen time.Time +} + +type websocketConnectionLimiter struct { + mux sync.Mutex + clients map[string]*websocketClientLimit + lastCleanup time.Time } // NewWebsocketServer creates new websocket interface to blockbook and returns its handle -func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, metrics *common.Metrics, is *common.InternalState, enableSubNewTx bool) (*WebsocketServer, error) { - api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is) +func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, txCache *db.TxCache, metrics *common.Metrics, is *common.InternalState, fiatRates *fiat.FiatRates) (*WebsocketServer, error) { + api, err := api.NewWorker(db, chain, mempool, txCache, metrics, is, fiatRates) if err != nil { return nil, err } @@ -96,11 +135,6 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain. return nil, err } s := &WebsocketServer{ - upgrader: &websocket.Upgrader{ - ReadBufferSize: 1024 * 32, - WriteBufferSize: 1024 * 32, - CheckOrigin: checkOrigin, - }, db: db, txCache: txCache, chain: chain, @@ -111,46 +145,350 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain. api: api, block0hash: b0, newBlockSubscriptions: make(map[*websocketChannel]string), - newTransactionEnabled: enableSubNewTx, + newTransactionEnabled: is.EnableSubNewTx, newTransactionSubscriptions: make(map[*websocketChannel]string), - addressSubscriptions: make(map[string]map[*websocketChannel]string), + addressSubscriptions: make(map[string]map[*websocketChannel]*addressDetails), fiatRatesSubscriptions: make(map[string]map[*websocketChannel]string), fiatRatesTokenSubscriptions: make(map[*websocketChannel][]string), + websocketLimiter: newWebsocketConnectionLimiter(), + activeChannels: make(map[*websocketChannel]struct{}), + } + s.upgrader = &websocket.Upgrader{ + ReadBufferSize: 1024 * 32, + WriteBufferSize: 1024 * 32, + CheckOrigin: s.checkOrigin, + EnableCompression: true, + } + originEnvName := strings.ToUpper(is.GetNetwork()) + "_WS_ALLOWED_ORIGINS" + s.allowedOrigins = parseAllowedOrigins(originEnvName, os.Getenv(originEnvName)) + envRpcCall := os.Getenv(strings.ToUpper(is.GetNetwork()) + "_ALLOWED_RPC_CALL_TO") + if envRpcCall != "" { + s.allowedRpcCallTo = make(map[string]struct{}) + for _, c := range strings.Split(envRpcCall, ",") { + s.allowedRpcCallTo[strings.ToLower(c)] = struct{}{} + } + glog.Info("Support of rpcCall for these contracts: ", envRpcCall) + } + trustedEnvName := strings.ToUpper(is.GetNetwork()) + "_WS_TRUSTED_PROXIES" + prefixes, err := parseTrustedProxies(trustedEnvName, os.Getenv(trustedEnvName)) + if err != nil { + return nil, err + } + s.trustedProxyPrefixes = prefixes + if len(prefixes) > 0 { + glog.Info("Trusted proxy CIDRs: ", prefixes) + } + if s.metrics != nil { + s.metrics.WebsocketNewBlockTxsSubscriptions.Set(0) } + go s.websocketLimiter.runPeriodicCleanup(websocketConnectionLimiterCleanupInterval) return s, nil } -// allow all origins -func checkOrigin(r *http.Request) bool { - return true +func parseAllowedOrigins(originEnvName, envAllowedOrigins string) map[string]struct{} { + if envAllowedOrigins == "" { + glog.Warning("Websocket origin allowlist not configured (", originEnvName, "); all origins allowed") + return nil + } + allowedOrigins := make(map[string]struct{}) + for _, origin := range strings.Split(envAllowedOrigins, ",") { + origin = strings.TrimSpace(origin) + if origin == "" { + continue + } + normalizedOrigin, ok := normalizeOrigin(origin) + if !ok { + glog.Warning("Ignoring invalid websocket origin in ", originEnvName, ": ", origin) + continue + } + allowedOrigins[normalizedOrigin] = struct{}{} + } + if len(allowedOrigins) == 0 { + glog.Warning("Websocket origin allowlist is empty after parsing ", originEnvName, "; all origins allowed") + return nil + } + glog.Info("Websocket origin allowlist enabled: ", envAllowedOrigins) + return allowedOrigins +} + +// parseTrustedProxies parses a comma-separated list of CIDRs that augment the +// loopback/RFC1918/link-local defaults for trusting X-Real-Ip. Any prefix +// broad enough to cover meaningful chunks of the public internet is rejected +// with an error so misconfiguration fails fast at startup rather than +// silently turning X-Real-Ip into an IP-spoofing primitive. +func parseTrustedProxies(envName, value string) ([]netip.Prefix, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + const minIPv4Bits = 8 + const minIPv6Bits = 16 + var prefixes []netip.Prefix + for _, raw := range strings.Split(value, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + p, err := netip.ParsePrefix(raw) + if err != nil { + return nil, fmt.Errorf("%s: invalid CIDR %q: %w", envName, raw, err) + } + if p.Addr().Is4In6() { + return nil, fmt.Errorf("%s: refusing IPv4-mapped CIDR %q; use IPv4 CIDR notation", envName, raw) + } + bits := p.Bits() + if p.Addr().Is4() && bits < minIPv4Bits { + return nil, fmt.Errorf("%s: refusing CIDR %q: prefix /%d is too broad (minimum /%d for IPv4)", envName, raw, bits, minIPv4Bits) + } + if p.Addr().Is6() && !p.Addr().Is4In6() && bits < minIPv6Bits { + return nil, fmt.Errorf("%s: refusing CIDR %q: prefix /%d is too broad (minimum /%d for IPv6)", envName, raw, bits, minIPv6Bits) + } + prefixes = append(prefixes, p.Masked()) + } + return prefixes, nil +} + +func (s *WebsocketServer) checkOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if len(s.allowedOrigins) == 0 { + return true + } + normalizedOrigin, ok := normalizeOrigin(origin) + if !ok { + return false + } + _, ok = s.allowedOrigins[normalizedOrigin] + return ok +} + +func normalizeOrigin(origin string) (string, bool) { + u, err := url.Parse(origin) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", false + } + return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host), true +} + +func newWebsocketConnectionLimiter() *websocketConnectionLimiter { + return &websocketConnectionLimiter{ + clients: make(map[string]*websocketClientLimit), + } +} + +func (l *websocketConnectionLimiter) accept(ip string, now time.Time) (bool, string) { + l.mux.Lock() + defer l.mux.Unlock() + + l.cleanupLocked(now) + client := l.clients[ip] + if client == nil { + client = &websocketClientLimit{} + l.clients[ip] = client + } + client.lastSeen = now + client.trimAttempts(now) + + if client.active >= maxWebsocketConnectionsPerIP { + return false, "connection_limit" + } + if len(client.attempts) >= maxWebsocketConnectionAttemptsPerIP { + return false, "connection_attempt_limit" + } + + client.attempts = append(client.attempts, now) + client.active++ + return true, "" +} + +func (l *websocketConnectionLimiter) release(ip string, now time.Time) { + l.mux.Lock() + defer l.mux.Unlock() + + client := l.clients[ip] + if client == nil { + return + } + if client.active > 0 { + client.active-- + } + client.lastSeen = now + l.cleanupLocked(now) +} + +func (l *websocketConnectionLimiter) cleanupLocked(now time.Time) { + if !l.lastCleanup.IsZero() && now.Sub(l.lastCleanup) < websocketConnectionLimiterCleanupInterval { + return + } + l.sweepLocked(now) +} + +func (l *websocketConnectionLimiter) sweepLocked(now time.Time) { + l.lastCleanup = now + for ip, client := range l.clients { + client.trimAttempts(now) + if client.active == 0 && now.Sub(client.lastSeen) > websocketConnectionLimiterTTL { + delete(l.clients, ip) + } + } +} + +// sweep evicts TTL-expired idle entries unconditionally. Used by the +// background ticker so that idle servers don't retain stale entries. +func (l *websocketConnectionLimiter) sweep(now time.Time) { + l.mux.Lock() + defer l.mux.Unlock() + l.sweepLocked(now) +} + +// runPeriodicCleanup ticks every interval and sweeps the limiter. It does not +// terminate; it is started once per WebsocketServer at construction time and +// runs for the lifetime of the process. +func (l *websocketConnectionLimiter) runPeriodicCleanup(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for now := range ticker.C { + l.sweep(now) + } +} + +func (client *websocketClientLimit) trimAttempts(now time.Time) { + cutoff := now.Add(-websocketConnectionAttemptWindow) + i := 0 + for i < len(client.attempts) && client.attempts[i].Before(cutoff) { + i++ + } + if i > 0 { + copy(client.attempts, client.attempts[i:]) + client.attempts = client.attempts[:len(client.attempts)-i] + } +} + +func getIP(r *http.Request, trustedProxies []netip.Prefix) string { + if len(trustedProxies) == 0 { + if ip, ok := parseIP(r.Header.Get("CF-Connecting-IPv6")); ok { + return ip + } + if ip, ok := parseIP(r.Header.Get("CF-Connecting-IP")); ok { + return ip + } + } + + host := r.RemoteAddr + if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + host = h + } + remote, remoteOK := parseAddr(host) + + // Trust X-Real-Ip only when the TCP peer is on a private/loopback network + // (an upstream proxy on the same host or LAN) or in a configured trusted + // CIDR. For direct internet peers the header is attacker-controlled and + // would let any client spoof their IP past the per-IP rate limiter. + if remoteOK && isTrustedProxy(remote, trustedProxies) { + if ip, ok := parseIP(r.Header.Get("X-Real-Ip")); ok { + return ip + } + } + + if remoteOK { + return remote.String() + } + return strings.TrimSpace(r.RemoteAddr) +} + +func parseIP(value string) (string, bool) { + addr, ok := parseAddr(value) + if !ok { + return "", false + } + return addr.String(), true +} + +func parseAddr(value string) (netip.Addr, bool) { + value = strings.TrimSpace(value) + if value == "" { + return netip.Addr{}, false + } + addr, err := netip.ParseAddr(value) + if err != nil { + return netip.Addr{}, false + } + // Strip IPv6 zone identifier so that rate-limit keys are zone-free and + // netip.Prefix.Contains matches unzoned prefixes against link-local peers. + return addr.WithZone(""), true +} + +func isTrustedProxy(addr netip.Addr, extras []netip.Prefix) bool { + if addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() { + return true + } + for _, p := range extras { + if p.Contains(addr) { + return true + } + } + return false } -func getIP(r *http.Request) string { - ip := r.Header.Get("X-Real-Ip") - if ip != "" { - return ip +func getWebsocketPayloadPreview(d []byte) string { + if len(d) <= websocketLogPreviewBytes { + return string(d) } - return r.RemoteAddr + return string(d[:websocketLogPreviewBytes]) + "...(truncated)" } // ServeHTTP sets up handler of websocket channel func (s *WebsocketServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { - http.Error(w, upgradeFailed+ErrorMethodNotAllowed.Error(), 503) + http.Error(w, upgradeFailed+ErrorMethodNotAllowed.Error(), http.StatusServiceUnavailable) return } + s.shutdownMu.Lock() + shuttingDown := s.shuttingDown + s.shutdownMu.Unlock() + if shuttingDown { + http.Error(w, "Server shutting down", http.StatusServiceUnavailable) + return + } + ip := getIP(r, s.trustedProxyPrefixes) + limited := false + if s.websocketLimiter != nil { + ok, reason := s.websocketLimiter.accept(ip, time.Now()) + if !ok { + glog.Warning("Websocket connection rejected, ", ip, ", ", reason) + http.Error(w, "Too many websocket connections", http.StatusTooManyRequests) + return + } + limited = true + } conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { - http.Error(w, upgradeFailed+err.Error(), 503) + if limited { + s.websocketLimiter.release(ip, time.Now()) + } + http.Error(w, upgradeFailed+err.Error(), http.StatusServiceUnavailable) return } + conn.SetReadLimit(maxWebsocketMessageBytes) c := &websocketChannel{ - id: atomic.AddUint64(&connectionCounter, 1), - conn: conn, - out: make(chan *websocketRes, outChannelSize), - ip: getIP(r), - requestHeader: r.Header, - alive: true, + id: atomic.AddUint64(&connectionCounter, 1), + conn: conn, + out: make(chan *WsRes, outChannelSize), + pendingRequests: make(chan struct{}, maxWebsocketPendingRequests), + ip: ip, + requestHeader: r.Header, + alive: true, + } + if s.is.WsGetAccountInfoLimit > 0 { + c.getAddressInfoDescriptors = make(map[string]struct{}) + } + if !s.registerChannel(c) { + conn.Close() + if limited { + s.websocketLimiter.release(ip, time.Now()) + } + return } go s.inputLoop(c) go s.outputLoop(c) @@ -162,29 +500,113 @@ func (s *WebsocketServer) GetHandler() http.Handler { return s } -func (s *WebsocketServer) closeChannel(c *websocketChannel) { - if c.CloseOut() { +// registerChannel adds channel to activeChannels unless the server is shutting +// down. Returns false on shutdown so the caller can close the connection. +func (s *WebsocketServer) registerChannel(c *websocketChannel) bool { + s.shutdownMu.Lock() + defer s.shutdownMu.Unlock() + if s.shuttingDown { + return false + } + s.activeChannels[c] = struct{}{} + return true +} + +func (s *WebsocketServer) unregisterChannel(c *websocketChannel) { + s.shutdownMu.Lock() + defer s.shutdownMu.Unlock() + delete(s.activeChannels, c) +} + +// trackWork increments requestWg unless the server is shutting down. Callers +// that get true must invoke workDone exactly once when the goroutine they +// spawn returns. Used to gate goroutines that touch the DB/chain/api so that +// Shutdown can wait for them to drain before RocksDB is closed. +func (s *WebsocketServer) trackWork() bool { + s.shutdownMu.Lock() + defer s.shutdownMu.Unlock() + if s.shuttingDown { + return false + } + s.requestWg.Add(1) + return true +} + +func (s *WebsocketServer) workDone() { + s.requestWg.Done() +} + +// Shutdown initiates graceful WebSocket server shutdown: it refuses new +// connections, closes existing ones, and blocks until in-flight DB-touching +// goroutines finish or ctx is canceled. This must run before RocksDB is +// closed; otherwise a long-running getAccountInfo can race rocksdb_close in +// cgo and SIGSEGV the process. +func (s *WebsocketServer) Shutdown(ctx context.Context) error { + s.shutdownMu.Lock() + if s.shuttingDown { + s.shutdownMu.Unlock() + return nil + } + s.shuttingDown = true + chans := make([]*websocketChannel, 0, len(s.activeChannels)) + for c := range s.activeChannels { + chans = append(chans, c) + } + s.shutdownMu.Unlock() + + for _, c := range chans { + s.closeChannel(c, "server_shutdown") + } + + done := make(chan struct{}) + go func() { + s.requestWg.Wait() + close(done) + }() + select { + case <-done: + glog.Info("websocket: shutdown complete, all in-flight requests drained") + return nil + case <-ctx.Done(): + glog.Warning("websocket: shutdown timed out waiting for in-flight requests; waiting to avoid RocksDB close race") + <-done + glog.Info("websocket: shutdown complete after timeout") + return ctx.Err() + } +} + +func (s *WebsocketServer) closeChannel(c *websocketChannel, reason string) bool { + if closed, closeReason := c.CloseOut(reason); closed { + if s.metrics != nil { + s.metrics.WebsocketChannelCloses.With(common.Labels{"reason": closeReason}).Inc() + } c.conn.Close() s.onDisconnect(c) + return true } + return false } -func (c *websocketChannel) CloseOut() bool { +func (c *websocketChannel) CloseOut(reason string) (bool, string) { c.aliveLock.Lock() defer c.aliveLock.Unlock() if c.alive { c.alive = false + if c.closeReason == "" { + c.closeReason = reason + } + closeReason := c.closeReason //clean out close(c.out) for len(c.out) > 0 { <-c.out } - return true + return true, closeReason } - return false + return false, "" } -func (c *websocketChannel) DataOut(data *websocketRes) { +func (c *websocketChannel) DataOut(data *WsRes) { c.aliveLock.Lock() defer c.aliveLock.Unlock() if c.alive { @@ -192,6 +614,9 @@ func (c *websocketChannel) DataOut(data *websocketRes) { c.out <- data } else { glog.Warning("Channel ", c.id, " overflow, closing") + if c.closeReason == "" { + c.closeReason = "overflow" + } // close the connection but do not call CloseOut - would call duplicate c.aliveLock.Lock // CloseOut will be called because the closed connection will cause break in the inputLoop c.conn.Close() @@ -199,39 +624,65 @@ func (c *websocketChannel) DataOut(data *websocketRes) { } } +func (c *websocketChannel) acquireRequestSlot() bool { + select { + case c.pendingRequests <- struct{}{}: + return true + default: + return false + } +} + +func (c *websocketChannel) releaseRequestSlot() { + <-c.pendingRequests +} + func (s *WebsocketServer) inputLoop(c *websocketChannel) { defer func() { if r := recover(); r != nil { glog.Error("recovered from panic: ", r, ", ", c.id) debug.PrintStack() - s.closeChannel(c) + s.closeChannel(c, "panic") } }() for { t, d, err := c.conn.ReadMessage() if err != nil { - s.closeChannel(c) + s.closeChannel(c, "read_error") return } switch t { case websocket.TextMessage: - var req websocketReq + var req WsReq err := json.Unmarshal(d, &req) if err != nil { - glog.Error("Error parsing message from ", c.id, ", ", string(d), ", ", err) - s.closeChannel(c) + glog.Error("Error parsing message from ", c.id, ", len ", len(d), ", preview ", getWebsocketPayloadPreview(d), ", ", err) + s.closeChannel(c, "protocol_error") + return + } + if !c.acquireRequestSlot() { + glog.Warning("Client ", c.id, " exceeded pending websocket request limit, ", c.ip) + s.closeChannel(c, "pending_requests_limit") return } - go s.onRequest(c, &req) + if !s.trackWork() { + c.releaseRequestSlot() + s.closeChannel(c, "server_shutdown") + return + } + go func(req WsReq) { + defer s.workDone() + defer c.releaseRequestSlot() + s.onRequest(c, &req) + }(req) case websocket.BinaryMessage: glog.Error("Binary message received from ", c.id, ", ", c.ip) - s.closeChannel(c) + s.closeChannel(c, "protocol_error") return case websocket.PingMessage: c.conn.WriteControl(websocket.PongMessage, nil, time.Now().Add(defaultTimeout)) - break case websocket.CloseMessage: - s.closeChannel(c) + s.closeChannel(c, "client_close") return case websocket.PongMessage: // do nothing @@ -243,14 +694,15 @@ func (s *WebsocketServer) outputLoop(c *websocketChannel) { defer func() { if r := recover(); r != nil { glog.Error("recovered from panic: ", r, ", ", c.id) - s.closeChannel(c) + s.closeChannel(c, "panic") } }() for m := range c.out { + c.conn.SetWriteDeadline(time.Now().Add(defaultTimeout)) err := c.conn.WriteJSON(m) if err != nil { glog.Error("Error sending message to ", c.id, ", ", err) - s.closeChannel(c) + s.closeChannel(c, "write_error") return } } @@ -266,50 +718,76 @@ func (s *WebsocketServer) onDisconnect(c *websocketChannel) { s.unsubscribeNewTransaction(c) s.unsubscribeAddresses(c) s.unsubscribeFiatRates(c) + if s.websocketLimiter != nil { + s.websocketLimiter.release(c.ip, time.Now()) + } + s.unregisterChannel(c) glog.Info("Client disconnected ", c.id, ", ", c.ip) s.metrics.WebsocketClients.Dec() } -var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *websocketReq) (interface{}, error){ - "getAccountInfo": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { +var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *WsReq) (interface{}, error){ + "getAccountInfo": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { r, err := unmarshalGetAccountInfoRequest(req.Params) if err == nil { + if s.is.WsGetAccountInfoLimit > 0 { + c.getAddressInfoDescriptorsMux.Lock() + c.getAddressInfoDescriptors[r.Descriptor] = struct{}{} + l := len(c.getAddressInfoDescriptors) + c.getAddressInfoDescriptorsMux.Unlock() + if l > s.is.WsGetAccountInfoLimit { + if s.closeChannel(c, "limit_exceeded") { + glog.Info("Client ", c.id, " exceeded getAddressInfo limit, ", c.ip) + s.is.AddWsLimitExceedingIP(c.ip) + } + return + } + } rv, err = s.getAccountInfo(r) } return }, - "getInfo": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + "getContractInfo": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsContractInfoReq{} + err = json.Unmarshal(req.Params, &r) + if err == nil { + rv, err = s.getContractInfo(r.Contract, strings.ToLower(r.Currency), r.Protocols) + } + return + }, + "getInfo": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { return s.getInfo() }, - "getBlockHash": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Height int `json:"height"` - }{} + "getBlockHash": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsBlockHashReq{} err = json.Unmarshal(req.Params, &r) if err == nil { rv, err = s.getBlockHash(r.Height) } return }, - "getAccountUtxo": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Descriptor string `json:"descriptor"` - }{} + "getBlock": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + if !s.is.ExtendedIndex { + return nil, errors.New("Not supported") + } + r := WsBlockReq{} + err = json.Unmarshal(req.Params, &r) + if err == nil { + r.Page, r.PageSize = sanitizePagingParams(r.Page, r.PageSize, txsInAPI, maxWebsocketBlockPageSize) + rv, err = s.getBlock(r.Id, r.Page, r.PageSize) + } + return + }, + "getAccountUtxo": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsAccountUtxoReq{} err = json.Unmarshal(req.Params, &r) if err == nil { rv, err = s.getAccountUtxo(r.Descriptor) } return }, - "getBalanceHistory": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Descriptor string `json:"descriptor"` - From int64 `json:"from"` - To int64 `json:"to"` - Currencies []string `json:"currencies"` - Gap int `json:"gap"` - GroupBy uint32 `json:"groupBy"` - }{} + "getBalanceHistory": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsBalanceHistoryReq{} err = json.Unmarshal(req.Params, &r) if err == nil { if r.From <= 0 { @@ -328,108 +806,126 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs } return }, - "getTransaction": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Txid string `json:"txid"` - }{} + "getTransaction": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsTransactionReq{} err = json.Unmarshal(req.Params, &r) if err == nil { rv, err = s.getTransaction(r.Txid) } return }, - "getTransactionSpecific": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Txid string `json:"txid"` - }{} + "getTransactionSpecific": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsTransactionSpecificReq{} err = json.Unmarshal(req.Params, &r) if err == nil { rv, err = s.getTransactionSpecific(r.Txid) } return }, - "estimateFee": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - return s.estimateFee(c, req.Params) + "estimateFee": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + return s.estimateFee(req.Params) + }, + "longTermFeeRate": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + return s.longTermFeeRate() + }, + "sendTransaction": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsSendTransactionReq{} + err = json.Unmarshal(req.Params, &r) + if err == nil { + rv, err = s.sendTransaction(r.Hex, r.DisableAlternativeRPC) + } + return + }, + + "getMempoolFilters": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsMempoolFiltersReq{} + err = json.Unmarshal(req.Params, &r) + if err == nil { + rv, err = s.getMempoolFilters(&r) + } + return + }, + "getBlockFilter": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsBlockFilterReq{} + err = json.Unmarshal(req.Params, &r) + if err == nil { + rv, err = s.getBlockFilter(&r) + } + return }, - "sendTransaction": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Hex string `json:"hex"` - }{} + "getBlockFiltersBatch": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsBlockFiltersBatchReq{} err = json.Unmarshal(req.Params, &r) if err == nil { - rv, err = s.sendTransaction(r.Hex) + rv, err = s.getBlockFiltersBatch(&r) } return }, - "subscribeNewBlock": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + "rpcCall": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsRpcCallReq{} + err = json.Unmarshal(req.Params, &r) + if err == nil { + rv, err = s.rpcCall(&r) + } + return + }, + "subscribeNewBlock": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { return s.subscribeNewBlock(c, req) }, - "unsubscribeNewBlock": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + "unsubscribeNewBlock": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { return s.unsubscribeNewBlock(c) }, - "subscribeNewTransaction": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + "subscribeNewTransaction": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { return s.subscribeNewTransaction(c, req) }, - "unsubscribeNewTransaction": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + "unsubscribeNewTransaction": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { return s.unsubscribeNewTransaction(c) }, - "subscribeAddresses": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - ad, err := s.unmarshalAddresses(req.Params) + "subscribeAddresses": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + ad, nbtxs, err := s.unmarshalAddresses(req.Params) if err == nil { - rv, err = s.subscribeAddresses(c, ad, req) + rv, err = s.subscribeAddresses(c, ad, nbtxs, req) } return }, - "unsubscribeAddresses": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + "unsubscribeAddresses": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { return s.unsubscribeAddresses(c) }, - "subscribeFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - var r fiatRatesSubscription + "subscribeFiatRates": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + var r WsSubscribeFiatRatesReq err = json.Unmarshal(req.Params, &r) if err != nil { return nil, err } r.Currency = strings.ToLower(r.Currency) - for i := range r.Tokens { - r.Tokens[i] = strings.ToLower(r.Tokens[i]) - } + return s.subscribeFiatRates(c, &r, req) }, - "unsubscribeFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + "unsubscribeFiatRates": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { return s.unsubscribeFiatRates(c) }, - "ping": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + "ping": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { r := struct{}{} return r, nil }, - "getCurrentFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Currencies []string `json:"currencies"` - Token string `json:"token"` - }{} + "getCurrentFiatRates": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsCurrentFiatRatesReq{} err = json.Unmarshal(req.Params, &r) if err == nil { rv, err = s.getCurrentFiatRates(r.Currencies, r.Token) } return }, - "getFiatRatesForTimestamps": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Timestamps []int64 `json:"timestamps"` - Currencies []string `json:"currencies"` - Token string `json:"token"` - }{} + "getFiatRatesForTimestamps": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsFiatRatesForTimestampsReq{} err = json.Unmarshal(req.Params, &r) if err == nil { rv, err = s.getFiatRatesForTimestamps(r.Timestamps, r.Currencies, r.Token) } return }, - "getFiatRatesTickersList": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Timestamp int64 `json:"timestamp"` - Token string `json:"token"` - }{} + "getFiatRatesTickersList": func(s *WebsocketServer, c *websocketChannel, req *WsReq) (rv interface{}, err error) { + r := WsFiatRatesTickersListReq{} err = json.Unmarshal(req.Params, &r) if err == nil { rv, err = s.getAvailableVsCurrencies(r.Timestamp, r.Token) @@ -438,9 +934,14 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs }, } -func (s *WebsocketServer) onRequest(c *websocketChannel, req *websocketReq) { +func (s *WebsocketServer) onRequest(c *websocketChannel, req *WsReq) { var err error var data interface{} + f, ok := requestHandlers[req.Method] + methodLabel := req.Method + if !ok { + methodLabel = unknownMethodLabel + } defer func() { if r := recover(); r != nil { glog.Error("Client ", c.id, ", onRequest ", req.Method, " recovered from panic: ", r) @@ -451,51 +952,41 @@ func (s *WebsocketServer) onRequest(c *websocketChannel, req *websocketReq) { } // nil data means no response if data != nil { - c.DataOut(&websocketRes{ + c.DataOut(&WsRes{ ID: req.ID, Data: data, }) } - s.metrics.WebsocketPendingRequests.With((common.Labels{"method": req.Method})).Dec() + s.metrics.WebsocketPendingRequests.With(common.Labels{"method": methodLabel}).Dec() }() t := time.Now() - s.metrics.WebsocketPendingRequests.With((common.Labels{"method": req.Method})).Inc() - defer s.metrics.WebsocketReqDuration.With(common.Labels{"method": req.Method}).Observe(float64(time.Since(t)) / 1e3) // in microseconds - f, ok := requestHandlers[req.Method] + s.metrics.WebsocketPendingRequests.With(common.Labels{"method": methodLabel}).Inc() + defer func() { + s.metrics.WebsocketReqDuration.With(common.Labels{"method": methodLabel}).Observe(float64(time.Since(t)) / 1e3) // in microseconds + }() if ok { data, err = f(s, c, req) if err == nil { glog.V(1).Info("Client ", c.id, " onRequest ", req.Method, " success") - s.metrics.WebsocketRequests.With(common.Labels{"method": req.Method, "status": "success"}).Inc() + s.metrics.WebsocketRequests.With(common.Labels{"method": methodLabel, "status": "success"}).Inc() } else { if apiErr, ok := err.(*api.APIError); !ok || !apiErr.Public { glog.Error("Client ", c.id, " onMessage ", req.Method, ": ", errors.ErrorStack(err), ", data ", string(req.Params)) } - s.metrics.WebsocketRequests.With(common.Labels{"method": req.Method, "status": "failure"}).Inc() + s.metrics.WebsocketRequests.With(common.Labels{"method": methodLabel, "status": "failure"}).Inc() e := resultError{} e.Error.Message = err.Error() data = e } } else { + s.metrics.WebsocketUnknownMethods.With(common.Labels{"method": methodLabel}).Inc() + s.metrics.WebsocketRequests.With(common.Labels{"method": methodLabel, "status": "failure"}).Inc() glog.V(1).Info("Client ", c.id, " onMessage ", req.Method, ": unknown method, data ", string(req.Params)) } } -type accountInfoReq struct { - Descriptor string `json:"descriptor"` - Details string `json:"details"` - Tokens string `json:"tokens"` - PageSize int `json:"pageSize"` - Page int `json:"page"` - FromHeight int `json:"from"` - ToHeight int `json:"to"` - ContractFilter string `json:"contractFilter"` - SecondaryCurrency string `json:"secondaryCurrency"` - Gap int `json:"gap"` -} - -func unmarshalGetAccountInfoRequest(params []byte) (*accountInfoReq, error) { - var r accountInfoReq +func unmarshalGetAccountInfoRequest(params []byte) (*WsAccountInfoReq, error) { + var r WsAccountInfoReq err := json.Unmarshal(params, &r) if err != nil { return nil, err @@ -503,7 +994,10 @@ func unmarshalGetAccountInfoRequest(params []byte) (*accountInfoReq, error) { return &r, nil } -func (s *WebsocketServer) getAccountInfo(req *accountInfoReq) (res *api.Address, err error) { +func (s *WebsocketServer) getAccountInfo(req *WsAccountInfoReq) (res *api.Address, err error) { + if err := s.api.ValidateProtocolsForChain(req.Protocols); err != nil { + return nil, err + } var opt api.AccountDetails switch req.Details { case "tokens": @@ -534,10 +1028,10 @@ func (s *WebsocketServer) getAccountInfo(req *accountInfoReq) (res *api.Address, Contract: req.ContractFilter, Vout: api.AddressFilterVoutOff, TokensToReturn: tokensToReturn, + Protocols: req.Protocols, } - if req.PageSize == 0 { - req.PageSize = txsOnPage - } + req.Page, req.PageSize = sanitizeAccountPagingParams(req.Page, req.PageSize, txsOnPage, txsInAPI) + req.Gap = validateIntValue(req.Gap, 0, 0, maxGapValue) a, err := s.api.GetXpubAddress(req.Descriptor, req.Page, req.PageSize, opt, &filter, req.Gap, strings.ToLower(req.SecondaryCurrency)) if err != nil { return s.api.GetAddress(req.Descriptor, req.Page, req.PageSize, opt, &filter, strings.ToLower(req.SecondaryCurrency)) @@ -545,7 +1039,11 @@ func (s *WebsocketServer) getAccountInfo(req *accountInfoReq) (res *api.Address, return a, nil } -func (s *WebsocketServer) getAccountUtxo(descriptor string) (interface{}, error) { +func (s *WebsocketServer) getContractInfo(contract string, currency string, protocols []string) (*api.ContractInfoResult, error) { + return s.api.GetContractInfoData(contract, currency, protocols) +} + +func (s *WebsocketServer) getAccountUtxo(descriptor string) (api.Utxos, error) { utxo, err := s.api.GetXpubUtxo(descriptor, false, 0) if err != nil { return s.api.GetAddressUtxo(descriptor, false) @@ -553,7 +1051,7 @@ func (s *WebsocketServer) getAccountUtxo(descriptor string) (interface{}, error) return utxo, nil } -func (s *WebsocketServer) getTransaction(txid string) (interface{}, error) { +func (s *WebsocketServer) getTransaction(txid string) (*api.Tx, error) { return s.api.GetTransaction(txid, false, false) } @@ -561,40 +1059,24 @@ func (s *WebsocketServer) getTransactionSpecific(txid string) (interface{}, erro return s.chain.GetTransactionSpecific(&bchain.Tx{Txid: txid}) } -func (s *WebsocketServer) getInfo() (interface{}, error) { +func (s *WebsocketServer) getInfo() (*WsInfoRes, error) { vi := common.GetVersionInfo() bi := s.is.GetBackendInfo() height, hash, err := s.db.GetBestBlock() if err != nil { return nil, err } - type backendInfo struct { - Version string `json:"version,omitempty"` - Subversion string `json:"subversion,omitempty"` - ConsensusVersion string `json:"consensus_version,omitempty"` - Consensus interface{} `json:"consensus,omitempty"` - } - type info struct { - Name string `json:"name"` - Shortcut string `json:"shortcut"` - Decimals int `json:"decimals"` - Version string `json:"version"` - BestHeight int `json:"bestHeight"` - BestHash string `json:"bestHash"` - Block0Hash string `json:"block0Hash"` - Testnet bool `json:"testnet"` - Backend backendInfo `json:"backend"` - } - return &info{ + return &WsInfoRes{ Name: s.is.Coin, Shortcut: s.is.CoinShortcut, + Network: s.is.GetNetwork(), Decimals: s.chainParser.AmountDecimals(), BestHeight: int(height), BestHash: hash, Version: vi.Version, Block0Hash: s.block0hash, Testnet: s.chain.IsTestnet(), - Backend: backendInfo{ + Backend: WsBackendInfo{ Version: bi.Version, Subversion: bi.Subversion, ConsensusVersion: bi.ConsensusVersion, @@ -603,35 +1085,57 @@ func (s *WebsocketServer) getInfo() (interface{}, error) { }, nil } -func (s *WebsocketServer) getBlockHash(height int) (interface{}, error) { +func (s *WebsocketServer) getBlockHash(height int) (*WsBlockHashRes, error) { h, err := s.db.GetBlockHash(uint32(height)) if err != nil { return nil, err } - type hash struct { - Hash string `json:"hash"` - } - return &hash{ + return &WsBlockHashRes{ Hash: h, }, nil } -func (s *WebsocketServer) estimateFee(c *websocketChannel, params []byte) (interface{}, error) { - type estimateFeeReq struct { - Blocks []int `json:"blocks"` - Specific map[string]interface{} `json:"specific"` +func (s *WebsocketServer) getBlock(id string, page, pageSize int) (interface{}, error) { + block, err := s.api.GetBlock(id, page, pageSize) + if err != nil { + return nil, err } - type estimateFeeRes struct { - FeePerTx string `json:"feePerTx,omitempty"` - FeePerUnit string `json:"feePerUnit,omitempty"` - FeeLimit string `json:"feeLimit,omitempty"` + return block, nil +} + +func eip1559FeesToApi(fee *bchain.Eip1559Fee) *api.Eip1559Fee { + if fee == nil { + return nil + } + apiFee := api.Eip1559Fee{} + apiFee.MaxFeePerGas = (*api.Amount)(fee.MaxFeePerGas) + apiFee.MaxPriorityFeePerGas = (*api.Amount)(fee.MaxPriorityFeePerGas) + apiFee.MaxWaitTimeEstimate = fee.MaxWaitTimeEstimate + apiFee.MinWaitTimeEstimate = fee.MinWaitTimeEstimate + return &apiFee +} + +func eip1559FeeRangeToApi(feeRange []*big.Int) []*api.Amount { + if feeRange == nil { + return nil + } + apiFeeRange := make([]*api.Amount, len(feeRange)) + for i := range feeRange { + apiFeeRange[i] = (*api.Amount)(feeRange[i]) } - var r estimateFeeReq + return apiFeeRange +} + +func (s *WebsocketServer) estimateFee(params []byte) (interface{}, error) { + var r WsEstimateFeeReq err := json.Unmarshal(params, &r) if err != nil { return nil, err } - res := make([]estimateFeeRes, len(r.Blocks)) + if len(r.Blocks) > maxWebsocketEstimateFeeBlocks { + return nil, api.NewAPIError("blocks max "+strconv.Itoa(maxWebsocketEstimateFeeBlocks), true) + } + res := make([]WsEstimateFeeRes, len(r.Blocks)) if s.chainParser.GetChainType() == bchain.ChainEthereumType { gas, err := s.chain.EthereumTypeEstimateGas(r.Specific) if err != nil { @@ -646,11 +1150,32 @@ func (s *WebsocketServer) estimateFee(c *websocketChannel, params []byte) (inter if err != nil { return nil, err } + feePerTx := new(big.Int) + feePerTx.Mul(&fee, new(big.Int).SetUint64(gas)) + eip1559, err := s.chain.EthereumTypeGetEip1559Fees() + if err != nil { + return nil, err + } + var eip1559Api *api.Eip1559Fees + if eip1559 != nil { + eip1559Api = &api.Eip1559Fees{} + eip1559Api.BaseFeePerGas = (*api.Amount)(eip1559.BaseFeePerGas) + eip1559Api.Instant = eip1559FeesToApi(eip1559.Instant) + eip1559Api.High = eip1559FeesToApi(eip1559.High) + eip1559Api.Medium = eip1559FeesToApi(eip1559.Medium) + eip1559Api.Low = eip1559FeesToApi(eip1559.Low) + eip1559Api.NetworkCongestion = eip1559.NetworkCongestion + eip1559Api.BaseFeeTrend = eip1559.BaseFeeTrend + eip1559Api.PriorityFeeTrend = eip1559.PriorityFeeTrend + eip1559Api.LatestPriorityFeeRange = eip1559FeeRangeToApi(eip1559.LatestPriorityFeeRange) + eip1559Api.HistoricalBaseFeeRange = eip1559FeeRangeToApi(eip1559.HistoricalBaseFeeRange) + eip1559Api.HistoricalPriorityFeeRange = eip1559FeeRangeToApi(eip1559.HistoricalPriorityFeeRange) + } for i := range r.Blocks { res[i].FeePerUnit = fee.String() res[i].FeeLimit = sg - fee.Mul(&fee, new(big.Int).SetUint64(gas)) - res[i].FeePerTx = fee.String() + res[i].FeePerTx = feePerTx.String() + res[i].Eip1559 = eip1559Api } } else { conservative := true @@ -686,8 +1211,19 @@ func (s *WebsocketServer) estimateFee(c *websocketChannel, params []byte) (inter return res, nil } -func (s *WebsocketServer) sendTransaction(tx string) (res resultSendTransaction, err error) { - txid, err := s.chain.SendRawTransaction(tx) +func (s *WebsocketServer) longTermFeeRate() (res interface{}, err error) { + feeRate, err := s.chain.LongTermFeeRate() + if err != nil { + return nil, err + } + return WsLongTermFeeRateRes{ + FeePerUnit: feeRate.FeePerUnit.String(), + Blocks: feeRate.Blocks, + }, nil +} + +func (s *WebsocketServer) sendTransaction(tx string, disableAlternativeRPC bool) (res resultSendTransaction, err error) { + txid, err := s.chain.SendRawTransaction(tx, disableAlternativeRPC) if err != nil { return res, err } @@ -695,6 +1231,83 @@ func (s *WebsocketServer) sendTransaction(tx string) (res resultSendTransaction, return } +func (s *WebsocketServer) getMempoolFilters(r *WsMempoolFiltersReq) (res interface{}, err error) { + type resMempoolFilters struct { + ParamP uint8 `json:"P"` + ParamM uint64 `json:"M"` + ZeroedKey bool `json:"zeroedKey"` + Entries map[string]string `json:"entries"` + } + filterEntries, err := s.mempool.GetTxidFilterEntries(r.ScriptType, r.FromTimestamp) + if err != nil { + return nil, err + } + return resMempoolFilters{ + ParamP: s.is.BlockGolombFilterP, + ParamM: bchain.GetGolombParamM(s.is.BlockGolombFilterP), + ZeroedKey: filterEntries.UsedZeroedKey, + Entries: filterEntries.Entries, + }, nil +} + +func (s *WebsocketServer) getBlockFilter(r *WsBlockFilterReq) (res interface{}, err error) { + type resBlockFilter struct { + ParamP uint8 `json:"P"` + ParamM uint64 `json:"M"` + ZeroedKey bool `json:"zeroedKey"` + BlockFilter string `json:"blockFilter"` + } + if s.is.BlockFilterScripts != r.ScriptType { + return nil, errors.Errorf("Unsupported script type %s", r.ScriptType) + } + blockFilter, err := s.db.GetBlockFilter(r.BlockHash) + if err != nil { + return nil, err + } + return resBlockFilter{ + ParamP: s.is.BlockGolombFilterP, + ParamM: bchain.GetGolombParamM(s.is.BlockGolombFilterP), + ZeroedKey: s.is.BlockFilterUseZeroedKey, + BlockFilter: blockFilter, + }, nil +} + +func (s *WebsocketServer) getBlockFiltersBatch(r *WsBlockFiltersBatchReq) (res interface{}, err error) { + type resBlockFiltersBatch struct { + ParamP uint8 `json:"P"` + ParamM uint64 `json:"M"` + ZeroedKey bool `json:"zeroedKey"` + BlockFiltersBatch []string `json:"blockFiltersBatch"` + } + if s.is.BlockFilterScripts != r.ScriptType { + return nil, errors.Errorf("Unsupported script type %s", r.ScriptType) + } + blockFiltersBatch, err := s.api.GetBlockFiltersBatch(r.BlockHash, r.PageSize) + if err != nil { + return nil, err + } + return resBlockFiltersBatch{ + ParamP: s.is.BlockGolombFilterP, + ParamM: bchain.GetGolombParamM(s.is.BlockGolombFilterP), + ZeroedKey: s.is.BlockFilterUseZeroedKey, + BlockFiltersBatch: blockFiltersBatch, + }, nil +} + +func (s *WebsocketServer) rpcCall(r *WsRpcCallReq) (*WsRpcCallRes, error) { + if s.allowedRpcCallTo != nil { + _, ok := s.allowedRpcCallTo[strings.ToLower(r.To)] + if !ok { + return nil, errors.New("Not supported") + } + } + data, err := s.chain.EthereumTypeRpcCall(r.Data, r.To, r.From) + if err != nil { + return nil, err + } + return &WsRpcCallRes{Data: data}, nil +} + type subscriptionResponse struct { Subscribed bool `json:"subscribed"` } @@ -703,11 +1316,11 @@ type subscriptionResponseMessage struct { Message string `json:"message"` } -func (s *WebsocketServer) subscribeNewBlock(c *websocketChannel, req *websocketReq) (res interface{}, err error) { +func (s *WebsocketServer) subscribeNewBlock(c *websocketChannel, req *WsReq) (res interface{}, err error) { s.newBlockSubscriptionsLock.Lock() defer s.newBlockSubscriptionsLock.Unlock() s.newBlockSubscriptions[c] = req.ID - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeNewBlock"})).Set(float64(len(s.newBlockSubscriptions))) + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeNewBlock"}).Set(float64(len(s.newBlockSubscriptions))) return &subscriptionResponse{true}, nil } @@ -715,18 +1328,18 @@ func (s *WebsocketServer) unsubscribeNewBlock(c *websocketChannel) (res interfac s.newBlockSubscriptionsLock.Lock() defer s.newBlockSubscriptionsLock.Unlock() delete(s.newBlockSubscriptions, c) - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeNewBlock"})).Set(float64(len(s.newBlockSubscriptions))) + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeNewBlock"}).Set(float64(len(s.newBlockSubscriptions))) return &subscriptionResponse{false}, nil } -func (s *WebsocketServer) subscribeNewTransaction(c *websocketChannel, req *websocketReq) (res interface{}, err error) { +func (s *WebsocketServer) subscribeNewTransaction(c *websocketChannel, req *WsReq) (res interface{}, err error) { s.newTransactionSubscriptionsLock.Lock() defer s.newTransactionSubscriptionsLock.Unlock() if !s.newTransactionEnabled { return &subscriptionResponseMessage{false, "subscribeNewTransaction not enabled, use -enablesubnewtx flag to enable."}, nil } s.newTransactionSubscriptions[c] = req.ID - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeNewTransaction"})).Set(float64(len(s.newTransactionSubscriptions))) + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeNewTransaction"}).Set(float64(len(s.newTransactionSubscriptions))) return &subscriptionResponse{true}, nil } @@ -737,36 +1350,45 @@ func (s *WebsocketServer) unsubscribeNewTransaction(c *websocketChannel) (res in return &subscriptionResponseMessage{false, "unsubscribeNewTransaction not enabled, use -enablesubnewtx flag to enable."}, nil } delete(s.newTransactionSubscriptions, c) - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeNewTransaction"})).Set(float64(len(s.newTransactionSubscriptions))) + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeNewTransaction"}).Set(float64(len(s.newTransactionSubscriptions))) return &subscriptionResponse{false}, nil } -func (s *WebsocketServer) unmarshalAddresses(params []byte) ([]string, error) { - r := struct { - Addresses []string `json:"addresses"` - }{} +func (s *WebsocketServer) unmarshalAddresses(params []byte) ([]string, bool, error) { + r := WsSubscribeAddressesReq{} err := json.Unmarshal(params, &r) if err != nil { - return nil, err + return nil, false, api.NewAPIError("Invalid subscribeAddresses params", true) + } + limit := maxWebsocketSubscribeAddresses + if r.NewBlockTxs { + limit = maxWebsocketSubscribeAddressesWithNewBlockTxs + } + if len(r.Addresses) > limit { + return nil, false, api.NewAPIError("addresses max "+strconv.Itoa(limit), true) } rv := make([]string, len(r.Addresses)) for i, a := range r.Addresses { ad, err := s.chainParser.GetAddrDescFromAddress(a) if err != nil { - return nil, err + return nil, false, api.NewAPIError("Invalid address "+strconv.Quote(a)+", "+err.Error(), true) } rv[i] = string(ad) } - return rv, nil + return rv, r.NewBlockTxs, nil } -// unsubscribe addresses without addressSubscriptionsLock - can be called only from subscribeAddresses and unsubscribeAddresses +// doUnsubscribeAddresses removes all address subscriptions for a channel. +// addressSubscriptionsLock must be held by the caller. func (s *WebsocketServer) doUnsubscribeAddresses(c *websocketChannel) { for _, ads := range c.addrDescs { sa, e := s.addressSubscriptions[ads] if e { - for sc := range sa { + for sc, details := range sa { if sc == c { + if details.publishNewBlockTxs { + s.newBlockTxsSubscriptionCount-- + } delete(sa, c) } } @@ -778,7 +1400,10 @@ func (s *WebsocketServer) doUnsubscribeAddresses(c *websocketChannel) { c.addrDescs = nil } -func (s *WebsocketServer) subscribeAddresses(c *websocketChannel, addrDesc []string, req *websocketReq) (res interface{}, err error) { +// subscribeAddresses replaces previous address subscriptions for the channel. +// If newBlockTxs is enabled, the channel receives both mempool notifications and +// confirmed notifications detected from newly connected blocks. +func (s *WebsocketServer) subscribeAddresses(c *websocketChannel, addrDesc []string, newBlockTxs bool, req *WsReq) (res interface{}, err error) { s.addressSubscriptionsLock.Lock() defer s.addressSubscriptionsLock.Unlock() // unsubscribe all previous subscriptions @@ -786,13 +1411,20 @@ func (s *WebsocketServer) subscribeAddresses(c *websocketChannel, addrDesc []str for _, ads := range addrDesc { as, ok := s.addressSubscriptions[ads] if !ok { - as = make(map[*websocketChannel]string) + as = make(map[*websocketChannel]*addressDetails) s.addressSubscriptions[ads] = as } - as[c] = req.ID + as[c] = &addressDetails{ + requestID: req.ID, + publishNewBlockTxs: newBlockTxs, + } + if newBlockTxs { + s.newBlockTxsSubscriptionCount++ + } } c.addrDescs = addrDesc - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeAddresses"})).Set(float64(len(s.addressSubscriptions))) + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeAddresses"}).Set(float64(len(s.addressSubscriptions))) + s.metrics.WebsocketNewBlockTxsSubscriptions.Set(float64(s.newBlockTxsSubscriptionCount)) return &subscriptionResponse{true}, nil } @@ -801,11 +1433,12 @@ func (s *WebsocketServer) unsubscribeAddresses(c *websocketChannel) (res interfa s.addressSubscriptionsLock.Lock() defer s.addressSubscriptionsLock.Unlock() s.doUnsubscribeAddresses(c) - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeAddresses"})).Set(float64(len(s.addressSubscriptions))) + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeAddresses"}).Set(float64(len(s.addressSubscriptions))) + s.metrics.WebsocketNewBlockTxsSubscriptions.Set(float64(s.newBlockTxsSubscriptionCount)) return &subscriptionResponse{false}, nil } -// unsubscribe fiat rates without fiatRatesSubscriptionsLock - can be called only from subscribeFiatRates and unsubscribeFiatRates +// doUnsubscribeFiatRates fiat rates without fiatRatesSubscriptionsLock - can be called only from subscribeFiatRates and unsubscribeFiatRates func (s *WebsocketServer) doUnsubscribeFiatRates(c *websocketChannel) { for fr, sa := range s.fiatRatesSubscriptions { for sc := range sa { @@ -821,7 +1454,7 @@ func (s *WebsocketServer) doUnsubscribeFiatRates(c *websocketChannel) { } // subscribeFiatRates subscribes all FiatRates subscriptions by this channel -func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, d *fiatRatesSubscription, req *websocketReq) (res interface{}, err error) { +func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, d *WsSubscribeFiatRatesReq, req *WsReq) (res interface{}, err error) { s.fiatRatesSubscriptionsLock.Lock() defer s.fiatRatesSubscriptionsLock.Unlock() // unsubscribe all previous subscriptions @@ -841,7 +1474,7 @@ func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, d *fiatRatesSu if len(d.Tokens) != 0 { s.fiatRatesTokenSubscriptions[c] = d.Tokens } - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeFiatRates"})).Set(float64(len(s.fiatRatesSubscriptions))) + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeFiatRates"}).Set(float64(len(s.fiatRatesSubscriptions))) return &subscriptionResponse{true}, nil } @@ -850,7 +1483,7 @@ func (s *WebsocketServer) unsubscribeFiatRates(c *websocketChannel) (res interfa s.fiatRatesSubscriptionsLock.Lock() defer s.fiatRatesSubscriptionsLock.Unlock() s.doUnsubscribeFiatRates(c) - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeFiatRates"})).Set(float64(len(s.fiatRatesSubscriptions))) + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeFiatRates"}).Set(float64(len(s.fiatRatesSubscriptions))) return &subscriptionResponse{false}, nil } @@ -865,7 +1498,7 @@ func (s *WebsocketServer) onNewBlockAsync(hash string, height uint32) { Hash: hash, } for c, id := range s.newBlockSubscriptions { - c.DataOut(&websocketRes{ + c.DataOut(&WsRes{ ID: id, Data: &data, }) @@ -873,16 +1506,176 @@ func (s *WebsocketServer) onNewBlockAsync(hash string, height uint32) { glog.Info("broadcasting new block ", height, " ", hash, " to ", len(s.newBlockSubscriptions), " channels") } +// setConfirmedBlockTxMetadata normalizes parsed block transactions. +// ParseBlock can return txs with zero confirmations; we force first-confirmed +// metadata so conversion does not take mempool-only branches. +func setConfirmedBlockTxMetadata(tx *bchain.Tx, blockTime int64) { + if tx.Confirmations == 0 { + tx.Confirmations = 1 + tx.Blocktime = blockTime + tx.Time = blockTime + } +} + +// getEthereumInternalTransfers safely extracts internal transfers from +// CoinSpecificData when present. +func getEthereumInternalTransfers(tx *bchain.Tx) []bchain.EthereumInternalTransfer { + esd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok || esd.InternalData == nil { + return nil + } + return esd.InternalData.Transfers +} + +// setEthereumReceiptIfAvailable adds receipt data to Ethereum txs on a +// best-effort basis; failures are logged and notifications continue. +func setEthereumReceiptIfAvailable(tx *bchain.Tx, getReceipt func(string) (*bchain.RpcReceipt, error)) string { + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + return "skipped_non_eth" + } + receipt, err := getReceipt(tx.Txid) + if err != nil { + glog.Error("EthereumTypeGetTransactionReceipt error ", err, " for ", tx.Txid) + return "error" + } + csd.Receipt = receipt + tx.CoinSpecificData = csd + return "success" +} + +func observeNewBlockTxDuration(metrics *common.Metrics, stage string, started time.Time) { + if metrics == nil { + return + } + metrics.WebsocketNewBlockTxsDuration.With(common.Labels{"stage": stage}).Observe(time.Since(started).Seconds()) +} + +func incNewBlockTxMetric(metrics *common.Metrics, stage, status string, value float64) { + if metrics == nil { + return + } + counter := metrics.WebsocketNewBlockTxs.With(common.Labels{"stage": stage, "status": status}) + if value == 1 { + counter.Inc() + } else { + counter.Add(value) + } +} + +// populateBitcoinVinAddrDescs fills missing vin address descriptors by loading +// previous outputs. This enables sender-side address subscription matching for +// Bitcoin transactions parsed from connected blocks. +func populateBitcoinVinAddrDescs(vins []bchain.MempoolVin, getAddrDesc func(string, uint32) (bchain.AddressDescriptor, error)) { + if getAddrDesc == nil { + return + } + for i := range vins { + if len(vins[i].AddrDesc) > 0 || vins[i].Txid == "" { + continue + } + addrDesc, err := getAddrDesc(vins[i].Txid, vins[i].Vout) + if err == nil && len(addrDesc) > 0 { + vins[i].AddrDesc = addrDesc + } + } +} + +// getBitcoinVinAddrDesc resolves an input outpoint to an address descriptor +// using txCache. It is best-effort and can return chain-level not-found errors. +func (s *WebsocketServer) getBitcoinVinAddrDesc(txid string, vout uint32) (bchain.AddressDescriptor, error) { + if s.txCache == nil { + return nil, bchain.ErrTxNotFound + } + prevTx, _, err := s.txCache.GetTransaction(txid) + if err != nil { + return nil, err + } + if int(vout) >= len(prevTx.Vout) { + return nil, bchain.ErrAddressMissing + } + return s.chainParser.GetAddrDescFromVout(&prevTx.Vout[vout]) +} + +// publishNewBlockTxsByAddr emits confirmed transaction notifications only for +// subscribed addresses touched by transactions in the connected block. +func (s *WebsocketServer) publishNewBlockTxsByAddr(block *bchain.Block) { + blockStart := time.Now() + defer observeNewBlockTxDuration(s.metrics, "per_block", blockStart) + chainType := s.chainParser.GetChainType() + for _, tx := range block.Txs { + incNewBlockTxMetric(s.metrics, "scanned", "success", 1) + setConfirmedBlockTxMetadata(&tx, block.Time) + var tokenTransfers bchain.TokenTransfers + var internalTransfers []bchain.EthereumInternalTransfer + if chainType == bchain.ChainEthereumType { + tokenTransfers, _ = s.chainParser.EthereumTypeGetTokenTransfersFromTx(&tx) + internalTransfers = getEthereumInternalTransfers(&tx) + } + vins := make([]bchain.MempoolVin, len(tx.Vin)) + for i, vin := range tx.Vin { + vins[i] = bchain.MempoolVin{Vin: vin} + } + if chainType == bchain.ChainBitcoinType { + populateBitcoinVinAddrDescs(vins, s.getBitcoinVinAddrDesc) + } + matchStart := time.Now() + subscribed := s.getNewTxSubscriptions(vins, tx.Vout, tokenTransfers, internalTransfers) + observeNewBlockTxDuration(s.metrics, "match", matchStart) + if len(subscribed) > 0 { + incNewBlockTxMetric(s.metrics, "matched", "success", 1) + if !s.trackWork() { + return + } + // Convert and publish asynchronously so heavy tx conversion does not + // block processing of other transactions in the same block. + go func(tx bchain.Tx, subscribed map[string]struct{}) { + defer s.workDone() + if chainType == bchain.ChainEthereumType { + receiptStatus := setEthereumReceiptIfAvailable(&tx, s.chain.EthereumTypeGetTransactionReceipt) + if s.metrics != nil { + s.metrics.WebsocketEthReceipt.With(common.Labels{"status": receiptStatus}).Inc() + } + } + convertStart := time.Now() + atx, err := s.api.GetTransactionFromBchainTx(&tx, int(block.Height), false, false, nil) + observeNewBlockTxDuration(s.metrics, "convert", convertStart) + if err != nil { + incNewBlockTxMetric(s.metrics, "converted", "failure", 1) + glog.Error("GetTransactionFromBchainTx error ", err, " for ", tx.Txid) + return + } + incNewBlockTxMetric(s.metrics, "converted", "success", 1) + for stringAddressDescriptor := range subscribed { + s.sendOnNewTxAddr(stringAddressDescriptor, atx, true) + } + incNewBlockTxMetric(s.metrics, "published", "success", float64(len(subscribed))) + }(tx, subscribed) + } + } +} + // OnNewBlock is a callback that broadcasts info about new block to subscribed clients -func (s *WebsocketServer) OnNewBlock(hash string, height uint32) { - go s.onNewBlockAsync(hash, height) +func (s *WebsocketServer) OnNewBlock(block *bchain.Block) { + s.addressSubscriptionsLock.Lock() + defer s.addressSubscriptionsLock.Unlock() + go s.onNewBlockAsync(block.Hash, block.Height) + if s.newBlockTxsSubscriptionCount > 0 { + // Skip per-tx address matching when nobody opted into newBlockTxs. + if s.trackWork() { + go func() { + defer s.workDone() + s.publishNewBlockTxsByAddr(block) + }() + } + } } func (s *WebsocketServer) sendOnNewTx(tx *api.Tx) { s.newTransactionSubscriptionsLock.Lock() defer s.newTransactionSubscriptionsLock.Unlock() for c, id := range s.newTransactionSubscriptions { - c.DataOut(&websocketRes{ + c.DataOut(&WsRes{ ID: id, Data: &tx, }) @@ -890,7 +1683,7 @@ func (s *WebsocketServer) sendOnNewTx(tx *api.Tx) { glog.Info("broadcasting new tx ", tx.Txid, " to ", len(s.newTransactionSubscriptions), " channels") } -func (s *WebsocketServer) sendOnNewTxAddr(stringAddressDescriptor string, tx *api.Tx) { +func (s *WebsocketServer) sendOnNewTxAddr(stringAddressDescriptor string, tx *api.Tx, newBlockTx bool) { addrDesc := bchain.AddressDescriptor(stringAddressDescriptor) addr, _, err := s.chainParser.GetAddressesFromAddrDesc(addrDesc) if err != nil { @@ -909,9 +1702,21 @@ func (s *WebsocketServer) sendOnNewTxAddr(stringAddressDescriptor string, tx *ap defer s.addressSubscriptionsLock.Unlock() as, ok := s.addressSubscriptions[stringAddressDescriptor] if ok { - for c, id := range as { - c.DataOut(&websocketRes{ - ID: id, + source := "mempool" + if newBlockTx { + source = "new_block" + } + for c, details := range as { + // Mempool notifications go to all address subscribers; confirmed + // block notifications only go to subscribers that requested them. + if newBlockTx && !details.publishNewBlockTxs { + continue + } + if s.metrics != nil { + s.metrics.WebsocketAddrNotifications.With(common.Labels{"source": source}).Inc() + } + c.DataOut(&WsRes{ + ID: details.requestID, Data: &data, }) } @@ -920,48 +1725,54 @@ func (s *WebsocketServer) sendOnNewTxAddr(stringAddressDescriptor string, tx *ap } } -func (s *WebsocketServer) getNewTxSubscriptions(tx *bchain.MempoolTx) map[string]struct{} { - // check if there is any subscription in inputs, outputs and token transfers +func (s *WebsocketServer) getNewTxSubscriptions(vins []bchain.MempoolVin, vouts []bchain.Vout, tokenTransfers bchain.TokenTransfers, internalTransfers []bchain.EthereumInternalTransfer) map[string]struct{} { + // check if there is any subscription in inputs, outputs and transfers s.addressSubscriptionsLock.Lock() defer s.addressSubscriptionsLock.Unlock() subscribed := make(map[string]struct{}) - for i := range tx.Vin { - sad := string(tx.Vin[i].AddrDesc) - if len(sad) > 0 { - as, ok := s.addressSubscriptions[sad] - if ok && len(as) > 0 { + processAddress := func(address string) { + if addrDesc, err := s.chainParser.GetAddrDescFromAddress(address); err == nil && len(addrDesc) > 0 { + sad := string(addrDesc) + if as, ok := s.addressSubscriptions[sad]; ok && len(as) > 0 { subscribed[sad] = struct{}{} } } } - for i := range tx.Vout { - addrDesc, err := s.chainParser.GetAddrDescFromVout(&tx.Vout[i]) - if err == nil && len(addrDesc) > 0 { + processVout := func(vout bchain.Vout) { + if addrDesc, err := s.chainParser.GetAddrDescFromVout(&vout); err == nil && len(addrDesc) > 0 { sad := string(addrDesc) - as, ok := s.addressSubscriptions[sad] - if ok && len(as) > 0 { + if as, ok := s.addressSubscriptions[sad]; ok && len(as) > 0 { subscribed[sad] = struct{}{} } } } - for i := range tx.TokenTransfers { - addrDesc, err := s.chainParser.GetAddrDescFromAddress(tx.TokenTransfers[i].From) - if err == nil && len(addrDesc) > 0 { - sad := string(addrDesc) - as, ok := s.addressSubscriptions[sad] - if ok && len(as) > 0 { + for i := range vins { + if sad := string(vins[i].AddrDesc); len(sad) > 0 { + if as, ok := s.addressSubscriptions[sad]; ok && len(as) > 0 { subscribed[sad] = struct{}{} } - } - addrDesc, err = s.chainParser.GetAddrDescFromAddress(tx.TokenTransfers[i].To) - if err == nil && len(addrDesc) > 0 { - sad := string(addrDesc) - as, ok := s.addressSubscriptions[sad] - if ok && len(as) > 0 { - subscribed[sad] = struct{}{} + } else if s.chainParser.GetChainType() == bchain.ChainBitcoinType { + vout := int(vins[i].Vout) + if vout >= 0 && vout < len(vouts) { + processVout(vouts[vins[i].Vout]) + } + } else if s.chainParser.GetChainType() == bchain.ChainEthereumType { + if len(vins[i].Addresses) > 0 { + processAddress(vins[i].Addresses[0]) } } } + for i := range vouts { + processVout(vouts[i]) + } + for i := range tokenTransfers { + processAddress(tokenTransfers[i].From) + processAddress(tokenTransfers[i].To) + } + for i := range internalTransfers { + processAddress(internalTransfers[i].From) + processAddress(internalTransfers[i].To) + } return subscribed } @@ -973,15 +1784,20 @@ func (s *WebsocketServer) onNewTxAsync(tx *bchain.MempoolTx, subscribed map[stri } s.sendOnNewTx(atx) for stringAddressDescriptor := range subscribed { - s.sendOnNewTxAddr(stringAddressDescriptor, atx) + s.sendOnNewTxAddr(stringAddressDescriptor, atx, false) } } // OnNewTx is a callback that broadcasts info about a tx affecting subscribed address func (s *WebsocketServer) OnNewTx(tx *bchain.MempoolTx) { - subscribed := s.getNewTxSubscriptions(tx) + subscribed := s.getNewTxSubscriptions(tx.Vin, tx.Vout, tx.TokenTransfers, nil) if len(s.newTransactionSubscriptions) > 0 || len(subscribed) > 0 { - go s.onNewTxAsync(tx, subscribed) + if s.trackWork() { + go func() { + defer s.workDone() + s.onNewTxAsync(tx, subscribed) + }() + } } } @@ -1012,12 +1828,12 @@ func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]floa dataWithTokens.TokenRates[token] = rate } } - c.DataOut(&websocketRes{ + c.DataOut(&WsRes{ ID: id, Data: &dataWithTokens, }) } else { - c.DataOut(&websocketRes{ + c.DataOut(&WsRes{ ID: id, Data: &data, }) @@ -1037,17 +1853,17 @@ func (s *WebsocketServer) OnNewFiatRatesTicker(ticker *common.CurrencyRatesTicke s.broadcastTicker(allFiatRates, ticker.Rates, nil) } -func (s *WebsocketServer) getCurrentFiatRates(currencies []string, token string) (interface{}, error) { - ret, err := s.api.GetCurrentFiatRates(currencies, strings.ToLower(token)) +func (s *WebsocketServer) getCurrentFiatRates(currencies []string, token string) (*api.FiatTicker, error) { + ret, err := s.api.GetCurrentFiatRates(currencies, token) return ret, err } -func (s *WebsocketServer) getFiatRatesForTimestamps(timestamps []int64, currencies []string, token string) (interface{}, error) { - ret, err := s.api.GetFiatRatesForTimestamps(timestamps, currencies, strings.ToLower(token)) +func (s *WebsocketServer) getFiatRatesForTimestamps(timestamps []int64, currencies []string, token string) (*api.FiatTickers, error) { + ret, err := s.api.GetFiatRatesForTimestamps(timestamps, currencies, token) return ret, err } -func (s *WebsocketServer) getAvailableVsCurrencies(timestamp int64, token string) (interface{}, error) { - ret, err := s.api.GetAvailableVsCurrencies(timestamp, strings.ToLower(token)) +func (s *WebsocketServer) getAvailableVsCurrencies(timestamp int64, token string) (*api.AvailableVsCurrencies, error) { + ret, err := s.api.GetAvailableVsCurrencies(timestamp, token) return ret, err } diff --git a/server/websocket_test.go b/server/websocket_test.go new file mode 100644 index 0000000000..2fd440008d --- /dev/null +++ b/server/websocket_test.go @@ -0,0 +1,844 @@ +//go:build unittest +// +build unittest + +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/netip" + "strings" + "testing" + "time" + + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +func TestCheckOriginAllowAll(t *testing.T) { + s := &WebsocketServer{} + tests := []struct { + name string + origin string + want bool + }{ + { + name: "no origin", + want: true, + }, + { + name: "valid origin", + origin: "https://example.com", + want: true, + }, + { + name: "invalid origin", + origin: "://bad", + want: true, + }, + { + name: "null origin", + origin: "null", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &http.Request{Header: make(http.Header)} + if tt.origin != "" { + r.Header.Set("Origin", tt.origin) + } + got := s.checkOrigin(r) + if got != tt.want { + t.Fatalf("checkOrigin() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCheckOriginAllowlist(t *testing.T) { + allowedOrigins := make(map[string]struct{}) + for _, origin := range []string{"https://example.com", "http://localhost:3000"} { + normalizedOrigin, ok := normalizeOrigin(origin) + if !ok { + t.Fatalf("normalizeOrigin(%q) failed", origin) + } + allowedOrigins[normalizedOrigin] = struct{}{} + } + s := &WebsocketServer{allowedOrigins: allowedOrigins} + + tests := []struct { + name string + origin string + want bool + }{ + { + name: "no origin", + want: true, + }, + { + name: "allowed origin", + origin: "https://example.com", + want: true, + }, + { + name: "allowed origin different case", + origin: "HTTP://LOCALHOST:3000", + want: true, + }, + { + name: "disallowed origin", + origin: "https://evil.com", + want: false, + }, + { + name: "invalid origin", + origin: "://bad", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &http.Request{Header: make(http.Header)} + if tt.origin != "" { + r.Header.Set("Origin", tt.origin) + } + got := s.checkOrigin(r) + if got != tt.want { + t.Fatalf("checkOrigin() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseAllowedOrigins(t *testing.T) { + tests := []struct { + name string + env string + want []string + }{ + { + name: "empty", + env: "", + want: nil, + }, + { + name: "valid entries", + env: "https://example.com,http://localhost:3000", + want: []string{"https://example.com", "http://localhost:3000"}, + }, + { + name: "trims and normalizes", + env: " HTTPS://Example.com:9130 , http://LOCALHOST:3000 ", + want: []string{"https://example.com:9130", "http://localhost:3000"}, + }, + { + name: "invalid filtered", + env: "https://example.com,://bad,", + want: []string{"https://example.com"}, + }, + { + name: "all invalid", + env: "://bad,not-a-url", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseAllowedOrigins("FAKE_WS_ALLOWED_ORIGINS", tt.env) + if len(got) != len(tt.want) { + t.Fatalf("parseAllowedOrigins() len = %d, want %d", len(got), len(tt.want)) + } + for _, origin := range tt.want { + if _, ok := got[origin]; !ok { + t.Fatalf("parseAllowedOrigins() missing %q", origin) + } + } + }) + } +} + +func TestParseTrustedProxies(t *testing.T) { + tests := []struct { + name string + value string + want []string + wantErr bool + errSubstr string + }{ + {name: "empty value yields nil", value: "", want: nil}, + {name: "whitespace only yields nil", value: " , , ", want: nil}, + {name: "single ipv4 cidr", value: "203.0.113.0/24", want: []string{"203.0.113.0/24"}}, + {name: "multiple cidrs with spaces", value: " 203.0.113.0/24 , 2001:db8::/32 ", want: []string{"203.0.113.0/24", "2001:db8::/32"}}, + {name: "single host as /32 is fine", value: "10.0.0.5/32", want: []string{"10.0.0.5/32"}}, + {name: "rejects 0.0.0.0/0", value: "0.0.0.0/0", wantErr: true, errSubstr: "too broad"}, + {name: "rejects ::/0", value: "::/0", wantErr: true, errSubstr: "too broad"}, + {name: "rejects ipv4 broader than /8", value: "10.0.0.0/4", wantErr: true, errSubstr: "too broad"}, + {name: "rejects ipv6 broader than /16", value: "2000::/8", wantErr: true, errSubstr: "too broad"}, + {name: "rejects broad ipv4-mapped cidr", value: "::ffff:0.0.0.0/0", wantErr: true, errSubstr: "IPv4-mapped"}, + {name: "rejects specific ipv4-mapped cidr", value: "::ffff:192.0.2.0/120", wantErr: true, errSubstr: "IPv4-mapped"}, + {name: "rejects malformed cidr", value: "not-a-cidr", wantErr: true, errSubstr: "invalid CIDR"}, + {name: "rejects bare ip without prefix", value: "10.0.0.5", wantErr: true, errSubstr: "invalid CIDR"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTrustedProxies("TEST_ENV", tt.value) + if tt.wantErr { + if err == nil { + t.Fatalf("parseTrustedProxies(%q) = nil err, want error containing %q", tt.value, tt.errSubstr) + } + if !strings.Contains(err.Error(), tt.errSubstr) { + t.Fatalf("parseTrustedProxies(%q) err = %q, want substring %q", tt.value, err.Error(), tt.errSubstr) + } + return + } + if err != nil { + t.Fatalf("parseTrustedProxies(%q) unexpected error: %v", tt.value, err) + } + if len(got) != len(tt.want) { + t.Fatalf("parseTrustedProxies(%q) = %v, want %v", tt.value, got, tt.want) + } + for i, p := range got { + if p.String() != tt.want[i] { + t.Errorf("parseTrustedProxies(%q)[%d] = %q, want %q", tt.value, i, p.String(), tt.want[i]) + } + } + }) + } +} + +func TestGetIP(t *testing.T) { + tests := []struct { + name string + headers map[string]string + remoteAddr string + trusted []netip.Prefix + want string + }{ + { + name: "cloudflare ipv6 is preferred", + headers: map[string]string{ + "CF-Connecting-IPv6": "2001:db8::1", + "CF-Connecting-IP": "192.0.2.10", + }, + remoteAddr: "198.51.100.1:12345", + want: "2001:db8::1", + }, + { + name: "cloudflare ip is canonicalized", + headers: map[string]string{ + "CF-Connecting-IP": " 192.0.2.10 ", + }, + remoteAddr: "198.51.100.1:12345", + want: "192.0.2.10", + }, + { + name: "invalid cloudflare ip falls back to remote address", + headers: map[string]string{ + "CF-Connecting-IP": "not-an-ip", + "X-Real-Ip": "203.0.113.10", + }, + remoteAddr: "198.51.100.1:12345", + want: "198.51.100.1", + }, + { + name: "remote ipv6 address strips port", + remoteAddr: "[2001:db8::2]:443", + want: "2001:db8::2", + }, + { + name: "remote address without port is accepted", + remoteAddr: "198.51.100.2", + want: "198.51.100.2", + }, + { + name: "x-real-ip honored when remote is loopback", + headers: map[string]string{ + "X-Real-Ip": "203.0.113.10", + }, + remoteAddr: "127.0.0.1:54321", + want: "203.0.113.10", + }, + { + name: "x-real-ip honored when remote is private network", + headers: map[string]string{ + "X-Real-Ip": "203.0.113.11", + }, + remoteAddr: "10.0.0.5:54321", + want: "203.0.113.11", + }, + { + name: "x-real-ip ignored when remote is public", + headers: map[string]string{ + "X-Real-Ip": "203.0.113.12", + }, + remoteAddr: "198.51.100.3:54321", + want: "198.51.100.3", + }, + { + name: "invalid x-real-ip from trusted proxy falls back to remote", + headers: map[string]string{ + "X-Real-Ip": "not-an-ip", + }, + remoteAddr: "127.0.0.1:54321", + want: "127.0.0.1", + }, + { + name: "x-real-ip honored when remote matches configured public CIDR", + headers: map[string]string{ + "X-Real-Ip": "203.0.113.50", + }, + remoteAddr: "198.51.100.5:54321", + trusted: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}, + want: "203.0.113.50", + }, + { + name: "custom trusted proxy ignores spoofed cloudflare header", + headers: map[string]string{ + "CF-Connecting-IP": "192.0.2.99", + "X-Real-Ip": "203.0.113.52", + }, + remoteAddr: "198.51.100.5:54321", + trusted: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}, + want: "203.0.113.52", + }, + { + name: "custom trusted proxy ignores cloudflare header without x-real-ip", + headers: map[string]string{ + "CF-Connecting-IP": "192.0.2.100", + }, + remoteAddr: "198.51.100.5:54321", + trusted: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}, + want: "198.51.100.5", + }, + { + name: "x-real-ip ignored for public remote outside configured CIDRs", + headers: map[string]string{ + "X-Real-Ip": "203.0.113.51", + }, + remoteAddr: "198.51.100.6:54321", + trusted: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, + want: "198.51.100.6", + }, + { + name: "link-local IPv6 peer with zone is trusted and zone is stripped from key", + headers: map[string]string{ + "X-Real-Ip": "203.0.113.60", + }, + remoteAddr: "[fe80::1%eth0]:12345", + want: "203.0.113.60", + }, + { + name: "link-local IPv6 zone identifier is stripped from returned address", + remoteAddr: "[fe80::1%eth0]:12345", + want: "fe80::1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &http.Request{ + Header: make(http.Header), + RemoteAddr: tt.remoteAddr, + } + for k, v := range tt.headers { + r.Header.Set(k, v) + } + + got := getIP(r, tt.trusted) + if got != tt.want { + t.Fatalf("getIP() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestWebsocketConnectionLimiterConnectionAttempts(t *testing.T) { + limiter := newWebsocketConnectionLimiter() + now := time.Unix(1700000000, 0) + ip := "192.0.2.10" + + for i := 0; i < maxWebsocketConnectionAttemptsPerIP; i++ { + ok, reason := limiter.accept(ip, now) + if !ok { + t.Fatalf("accept(%d) rejected with %q", i, reason) + } + limiter.release(ip, now) + } + + ok, reason := limiter.accept(ip, now) + if ok || reason != "connection_attempt_limit" { + t.Fatalf("accept() = %v, %q, want false, connection_attempt_limit", ok, reason) + } + + ok, reason = limiter.accept(ip, now.Add(websocketConnectionAttemptWindow+time.Second)) + if !ok { + t.Fatalf("accept() after window rejected with %q", reason) + } +} + +func TestWebsocketConnectionLimiterActiveConnections(t *testing.T) { + limiter := newWebsocketConnectionLimiter() + now := time.Unix(1700000000, 0) + ip := "192.0.2.20" + + for i := 0; i < maxWebsocketConnectionsPerIP; i++ { + if i > 0 && i%maxWebsocketConnectionAttemptsPerIP == 0 { + now = now.Add(websocketConnectionAttemptWindow + time.Second) + } + ok, reason := limiter.accept(ip, now) + if !ok { + t.Fatalf("accept(%d) rejected with %q", i, reason) + } + } + + ok, reason := limiter.accept(ip, now) + if ok || reason != "connection_limit" { + t.Fatalf("accept() = %v, %q, want false, connection_limit", ok, reason) + } + + limiter.release(ip, now) + ok, reason = limiter.accept(ip, now.Add(websocketConnectionAttemptWindow+time.Second)) + if !ok { + t.Fatalf("accept() after release rejected with %q", reason) + } +} + +func TestWebsocketConnectionLimiterSweepEvictsIdleEntries(t *testing.T) { + limiter := newWebsocketConnectionLimiter() + now := time.Unix(1700000000, 0) + idle := "192.0.2.40" + active := "192.0.2.41" + + if ok, reason := limiter.accept(idle, now); !ok { + t.Fatalf("accept(idle) rejected with %q", reason) + } + limiter.release(idle, now) + if ok, reason := limiter.accept(active, now); !ok { + t.Fatalf("accept(active) rejected with %q", reason) + } + + // sweep() is what the periodic-cleanup goroutine calls; verify it evicts + // TTL-expired idle entries while keeping entries with active connections. + limiter.sweep(now.Add(websocketConnectionLimiterTTL + time.Second)) + + limiter.mux.Lock() + _, idleStillTracked := limiter.clients[idle] + _, activeStillTracked := limiter.clients[active] + limiter.mux.Unlock() + if idleStillTracked { + t.Fatal("idle TTL-expired entry was not evicted by sweep") + } + if !activeStillTracked { + t.Fatal("entry with active connection was evicted by sweep") + } +} + +func TestWebsocketConnectionLimiterCleanup(t *testing.T) { + limiter := newWebsocketConnectionLimiter() + now := time.Unix(1700000000, 0) + ip := "192.0.2.30" + + ok, reason := limiter.accept(ip, now) + if !ok { + t.Fatalf("accept() rejected with %q", reason) + } + limiter.release(ip, now) + + _, _ = limiter.accept("192.0.2.31", now.Add(websocketConnectionLimiterTTL+websocketConnectionLimiterCleanupInterval+time.Second)) + if _, ok := limiter.clients[ip]; ok { + t.Fatal("idle client limit entry was not cleaned up") + } +} + +func TestEstimateFeeRejectsTooManyBlocks(t *testing.T) { + blocks := make([]int, maxWebsocketEstimateFeeBlocks+1) + params, err := json.Marshal(WsEstimateFeeReq{Blocks: blocks}) + if err != nil { + t.Fatal(err) + } + + s := &WebsocketServer{} + _, err = s.estimateFee(params) + if err == nil { + t.Fatal("expected error") + } + apiErr, ok := err.(*api.APIError) + if !ok { + t.Fatalf("expected *api.APIError, got %T", err) + } + if !apiErr.Public { + t.Fatal("expected public api error") + } + if !strings.Contains(apiErr.Error(), "blocks max 32") { + t.Fatalf("unexpected error message %q", apiErr.Error()) + } +} + +func TestUnmarshalAddressesRejectsTooManyAddresses(t *testing.T) { + addresses := make([]string, maxWebsocketSubscribeAddresses+1) + params, err := json.Marshal(WsSubscribeAddressesReq{Addresses: addresses}) + if err != nil { + t.Fatal(err) + } + + s := &WebsocketServer{} + _, _, err = s.unmarshalAddresses(params) + if err == nil { + t.Fatal("expected error") + } + apiErr, ok := err.(*api.APIError) + if !ok { + t.Fatalf("expected *api.APIError, got %T", err) + } + if !apiErr.Public { + t.Fatal("expected public api error") + } + if !strings.Contains(apiErr.Error(), "addresses max 1000") { + t.Fatalf("unexpected error message %q", apiErr.Error()) + } +} + +func TestUnmarshalAddressesRejectsTooManyNewBlockTxAddresses(t *testing.T) { + addresses := make([]string, maxWebsocketSubscribeAddressesWithNewBlockTxs+1) + params, err := json.Marshal(WsSubscribeAddressesReq{ + Addresses: addresses, + NewBlockTxs: true, + }) + if err != nil { + t.Fatal(err) + } + + s := &WebsocketServer{} + _, _, err = s.unmarshalAddresses(params) + if err == nil { + t.Fatal("expected error") + } + apiErr, ok := err.(*api.APIError) + if !ok { + t.Fatalf("expected *api.APIError, got %T", err) + } + if !apiErr.Public { + t.Fatal("expected public api error") + } + if !strings.Contains(apiErr.Error(), "addresses max 100") { + t.Fatalf("unexpected error message %q", apiErr.Error()) + } +} + +func TestSetConfirmedBlockTxMetadataSetsConfirmedFields(t *testing.T) { + tx := bchain.Tx{ + Confirmations: 0, + Blocktime: 0, + Time: 0, + } + + setConfirmedBlockTxMetadata(&tx, 123456) + + if tx.Confirmations != 1 { + t.Fatalf("Confirmations = %d, want 1", tx.Confirmations) + } + if tx.Blocktime != 123456 { + t.Fatalf("Blocktime = %d, want 123456", tx.Blocktime) + } + if tx.Time != 123456 { + t.Fatalf("Time = %d, want 123456", tx.Time) + } +} + +func TestUnmarshalAddressesReturnsPublicAPIError(t *testing.T) { + s := &WebsocketServer{ + chainParser: eth.NewEthereumParser(0, false), + } + + _, _, err := s.unmarshalAddresses([]byte(`{"addresses":[""]}`)) + if err == nil { + t.Fatal("expected error") + } + apiErr, ok := err.(*api.APIError) + if !ok { + t.Fatalf("expected *api.APIError, got %T", err) + } + if !apiErr.Public { + t.Fatal("expected public api error") + } + if !strings.Contains(apiErr.Error(), "Address missing") { + t.Fatalf("unexpected error message %q", apiErr.Error()) + } +} + +func TestSetConfirmedBlockTxMetadataLeavesConfirmedTxUnchanged(t *testing.T) { + tx := bchain.Tx{ + Confirmations: 3, + Blocktime: 100, + Time: 200, + } + + setConfirmedBlockTxMetadata(&tx, 123456) + + if tx.Confirmations != 3 { + t.Fatalf("Confirmations = %d, want 3", tx.Confirmations) + } + if tx.Blocktime != 100 { + t.Fatalf("Blocktime = %d, want 100", tx.Blocktime) + } + if tx.Time != 200 { + t.Fatalf("Time = %d, want 200", tx.Time) + } +} + +func TestGetEthereumInternalTransfersMissingData(t *testing.T) { + tx := bchain.Tx{} + + transfers := getEthereumInternalTransfers(&tx) + + if len(transfers) != 0 { + t.Fatalf("len(transfers) = %d, want 0", len(transfers)) + } +} + +func TestGetEthereumInternalTransfersReturnsTransfers(t *testing.T) { + expected := []bchain.EthereumInternalTransfer{ + {From: "0x111", To: "0x222"}, + } + tx := bchain.Tx{ + CoinSpecificData: bchain.EthereumSpecificData{ + InternalData: &bchain.EthereumInternalData{ + Transfers: expected, + }, + }, + } + + transfers := getEthereumInternalTransfers(&tx) + + if len(transfers) != len(expected) { + t.Fatalf("len(transfers) = %d, want %d", len(transfers), len(expected)) + } + if transfers[0].From != expected[0].From || transfers[0].To != expected[0].To { + t.Fatalf("transfers[0] = %+v, want %+v", transfers[0], expected[0]) + } +} + +func TestSetEthereumReceiptIfAvailableKeepsTxWhenReceiptFails(t *testing.T) { + tx := bchain.Tx{ + Txid: "0xabc", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{Hash: "0xabc"}, + }, + } + + setEthereumReceiptIfAvailable(&tx, func(string) (*bchain.RpcReceipt, error) { + return nil, errors.New("rpc failure") + }) + + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + t.Fatal("CoinSpecificData has unexpected type") + } + if csd.Receipt != nil { + t.Fatalf("Receipt = %+v, want nil", csd.Receipt) + } +} + +func TestSetEthereumReceiptIfAvailableSetsReceipt(t *testing.T) { + tx := bchain.Tx{ + Txid: "0xdef", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{Hash: "0xdef"}, + }, + } + wantReceipt := &bchain.RpcReceipt{GasUsed: "0x5208"} + + setEthereumReceiptIfAvailable(&tx, func(string) (*bchain.RpcReceipt, error) { + return wantReceipt, nil + }) + + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if !ok { + t.Fatal("CoinSpecificData has unexpected type") + } + if csd.Receipt != wantReceipt { + t.Fatalf("Receipt = %+v, want %+v", csd.Receipt, wantReceipt) + } +} + +func TestSendOnNewTxAddrFiltersNewBlockTxSubscriptions(t *testing.T) { + parser, _ := setupChain(t) + s := &WebsocketServer{ + chainParser: parser, + addressSubscriptions: make(map[string]map[*websocketChannel]*addressDetails), + } + addrDesc, err := parser.GetAddrDescFromAddress(dbtestdata.Addr1) + if err != nil { + t.Fatal(err) + } + stringAddrDesc := string(addrDesc) + onlyMempool := &websocketChannel{out: make(chan *WsRes, 1), alive: true} + withNewBlockTxs := &websocketChannel{out: make(chan *WsRes, 1), alive: true} + s.addressSubscriptions[stringAddrDesc] = map[*websocketChannel]*addressDetails{ + onlyMempool: { + requestID: "mempool-only", + publishNewBlockTxs: false, + }, + withNewBlockTxs: { + requestID: "with-new-block-txs", + publishNewBlockTxs: true, + }, + } + + s.sendOnNewTxAddr(stringAddrDesc, &api.Tx{Txid: "new-block-tx"}, true) + + if len(onlyMempool.out) != 0 { + t.Fatalf("mempool-only subscriber received %d messages, want 0", len(onlyMempool.out)) + } + if len(withNewBlockTxs.out) != 1 { + t.Fatalf("newBlockTxs subscriber received %d messages, want 1", len(withNewBlockTxs.out)) + } +} + +func TestPopulateBitcoinVinAddrDescsEnablesSenderOnlyMatching(t *testing.T) { + parser, _ := setupChain(t) + block := dbtestdata.GetTestBitcoinTypeBlock2(parser) + tx := block.Txs[0] // spends Addr3/Addr2 and pays Addr6/Addr7 + + vins := make([]bchain.MempoolVin, len(tx.Vin)) + for i := range tx.Vin { + vins[i] = bchain.MempoolVin{Vin: tx.Vin[i]} + } + addr3Desc, err := parser.GetAddrDescFromAddress(dbtestdata.Addr3) + if err != nil { + t.Fatal(err) + } + addr2Desc, err := parser.GetAddrDescFromAddress(dbtestdata.Addr2) + if err != nil { + t.Fatal(err) + } + dummy := &websocketChannel{} + s := &WebsocketServer{ + chainParser: parser, + addressSubscriptions: map[string]map[*websocketChannel]*addressDetails{ + string(addr3Desc): {dummy: {requestID: "sender", publishNewBlockTxs: true}}, + }, + } + + withoutResolvedVins := s.getNewTxSubscriptions(vins, tx.Vout, nil, nil) + if _, ok := withoutResolvedVins[string(addr3Desc)]; ok { + t.Fatal("sender subscription unexpectedly matched before vin descriptor resolution") + } + + populateBitcoinVinAddrDescs(vins, func(txid string, vout uint32) (bchain.AddressDescriptor, error) { + switch { + case txid == dbtestdata.TxidB1T2 && vout == 0: + return addr3Desc, nil + case txid == dbtestdata.TxidB1T1 && vout == 1: + return addr2Desc, nil + default: + return nil, errors.New("not found") + } + }) + + withResolvedVins := s.getNewTxSubscriptions(vins, tx.Vout, nil, nil) + if _, ok := withResolvedVins[string(addr3Desc)]; !ok { + t.Fatal("sender subscription did not match after vin descriptor resolution") + } +} + +func newShutdownTestServer() *WebsocketServer { + return &WebsocketServer{activeChannels: make(map[*websocketChannel]struct{})} +} + +func TestWebsocketShutdownWaitsForInFlightWork(t *testing.T) { + s := newShutdownTestServer() + if !s.trackWork() { + t.Fatal("trackWork() returned false before shutdown") + } + + finished := make(chan struct{}) + go func() { + // Simulate a DB-touching goroutine that takes some time. + time.Sleep(50 * time.Millisecond) + s.workDone() + close(finished) + }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + start := time.Now() + if err := s.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown() = %v, want nil", err) + } + elapsed := time.Since(start) + if elapsed < 50*time.Millisecond { + t.Fatalf("Shutdown returned in %v, expected to wait for in-flight work (~50ms)", elapsed) + } + select { + case <-finished: + default: + t.Fatal("Shutdown returned before tracked goroutine finished") + } +} + +func TestWebsocketShutdownTimesOutOnStuckWork(t *testing.T) { + s := newShutdownTestServer() + if !s.trackWork() { + t.Fatal("trackWork() returned false before shutdown") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + start := time.Now() + finished := make(chan error) + go func() { + finished <- s.Shutdown(ctx) + }() + + time.Sleep(60 * time.Millisecond) + select { + case err := <-finished: + t.Fatalf("Shutdown returned before tracked work finished: %v", err) + default: + } + s.workDone() + if err := <-finished; err == nil { + t.Fatal("Shutdown() = nil, want context deadline error") + } + if elapsed := time.Since(start); elapsed < 60*time.Millisecond { + t.Fatalf("Shutdown returned in %v, expected to wait for tracked work after timeout", elapsed) + } +} + +func TestWebsocketShutdownRefusesNewWork(t *testing.T) { + s := newShutdownTestServer() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown() = %v, want nil", err) + } + if s.trackWork() { + t.Fatal("trackWork() returned true after shutdown") + } + dummy := &websocketChannel{} + if s.registerChannel(dummy) { + t.Fatal("registerChannel() returned true after shutdown") + } +} + +func TestWebsocketShutdownIsIdempotent(t *testing.T) { + s := newShutdownTestServer() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := s.Shutdown(ctx); err != nil { + t.Fatalf("first Shutdown() = %v, want nil", err) + } + if err := s.Shutdown(ctx); err != nil { + t.Fatalf("second Shutdown() = %v, want nil", err) + } +} diff --git a/server/ws_types.go b/server/ws_types.go new file mode 100644 index 0000000000..6c765ac616 --- /dev/null +++ b/server/ws_types.go @@ -0,0 +1,203 @@ +package server + +import ( + "encoding/json" + + "github.com/trezor/blockbook/api" +) + +// WsReq represents a generic WebSocket request with an ID, method, and raw parameters. +type WsReq struct { + ID string `json:"id" ts_doc:"Unique request identifier."` + Method string `json:"method" ts_type:"'getAccountInfo' | 'getContractInfo' | 'getInfo' | 'getBlockHash'| 'getBlock' | 'getAccountUtxo' | 'getBalanceHistory' | 'getTransaction' | 'getTransactionSpecific' | 'estimateFee' | 'sendTransaction' | 'subscribeNewBlock' | 'unsubscribeNewBlock' | 'subscribeNewTransaction' | 'unsubscribeNewTransaction' | 'subscribeAddresses' | 'unsubscribeAddresses' | 'subscribeFiatRates' | 'unsubscribeFiatRates' | 'ping' | 'getCurrentFiatRates' | 'getFiatRatesForTimestamps' | 'getFiatRatesTickersList' | 'getMempoolFilters'" ts_doc:"Requested method name."` + Params json.RawMessage `json:"params" ts_type:"any" ts_doc:"Parameters for the requested method in raw JSON format."` +} + +// WsRes represents a generic WebSocket response with an ID and arbitrary data. +type WsRes struct { + ID string `json:"id" ts_doc:"Corresponding request identifier."` + Data interface{} `json:"data" ts_doc:"Payload of the response, structure depends on the request."` +} + +type resultError struct { + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +// WsAccountInfoReq carries parameters for the 'getAccountInfo' method. +type WsAccountInfoReq struct { + Descriptor string `json:"descriptor" ts_doc:"Address or XPUB descriptor to query."` + Details string `json:"details,omitempty" ts_type:"'basic' | 'tokens' | 'tokenBalances' | 'txids' | 'txslight' | 'txs'" ts_doc:"Level of detail to retrieve about the account."` + Tokens string `json:"tokens,omitempty" ts_type:"'derived' | 'used' | 'nonzero'" ts_doc:"Which tokens to include in the account info."` + Protocols []string `json:"protocols,omitempty" ts_doc:"Optional protocol enrichments to include. Supported values currently include 'erc4626'."` + PageSize int `json:"pageSize,omitempty" ts_doc:"Number of items per page, if paging is used."` + Page int `json:"page,omitempty" ts_doc:"Requested page index, if paging is used."` + FromHeight int `json:"from,omitempty" ts_doc:"Starting block height for transaction filtering."` + ToHeight int `json:"to,omitempty" ts_doc:"Ending block height for transaction filtering."` + ContractFilter string `json:"contractFilter,omitempty" ts_doc:"Filter by specific contract address (for token data)."` + SecondaryCurrency string `json:"secondaryCurrency,omitempty" ts_doc:"Currency code to convert values into (e.g. 'USD')."` + Gap int `json:"gap,omitempty" ts_doc:"Gap limit for XPUB scanning, if relevant."` +} + +// WsContractInfoReq carries parameters for the 'getContractInfo' method. +type WsContractInfoReq struct { + Contract string `json:"contract" ts_doc:"Contract address to query."` + Currency string `json:"currency,omitempty" ts_doc:"Optional secondary currency code used to include fiat pricing information."` + Protocols []string `json:"protocols,omitempty" ts_doc:"Optional protocol enrichments to include. Supported values currently include 'erc4626'."` +} + +// WsBackendInfo holds extended info about the connected backend node. +type WsBackendInfo struct { + Version string `json:"version,omitempty" ts_doc:"Backend version string."` + Subversion string `json:"subversion,omitempty" ts_doc:"Backend sub-version string."` + ConsensusVersion string `json:"consensus_version,omitempty" ts_doc:"Consensus protocol version in use."` + Consensus interface{} `json:"consensus,omitempty" ts_doc:"Additional consensus details, structure depends on blockchain."` +} + +// WsInfoRes is returned by 'getInfo' requests, containing basic blockchain info. +type WsInfoRes struct { + Name string `json:"name" ts_doc:"Human-readable blockchain name."` + Shortcut string `json:"shortcut" ts_doc:"Short code for the blockchain (e.g. BTC, ETH)."` + Network string `json:"network" ts_doc:"Network identifier (e.g. mainnet, testnet)."` + Decimals int `json:"decimals" ts_doc:"Number of decimals in the base unit of the coin."` + Version string `json:"version" ts_doc:"Version of the blockbook or backend service."` + BestHeight int `json:"bestHeight" ts_doc:"Current best chain height according to the backend."` + BestHash string `json:"bestHash" ts_doc:"Block hash of the best (latest) block."` + Block0Hash string `json:"block0Hash" ts_doc:"Genesis block hash or identifier."` + Testnet bool `json:"testnet" ts_doc:"Indicates if this is a test network."` + Backend WsBackendInfo `json:"backend" ts_doc:"Additional backend-related information."` +} + +// WsBlockHashReq holds a single integer for querying the block hash at that height. +type WsBlockHashReq struct { + Height int `json:"height" ts_doc:"Block height for which the hash is requested."` +} + +// WsBlockHashRes returns the block hash for a requested height. +type WsBlockHashRes struct { + Hash string `json:"hash" ts_doc:"Block hash at the requested height."` +} + +// WsBlockReq is used to request details of a block (by ID) with paging options. +type WsBlockReq struct { + Id string `json:"id" ts_doc:"Block identifier (hash)."` + PageSize int `json:"pageSize,omitempty" ts_doc:"Number of transactions per page in the block. Defaults to 1000 and is capped at 10000."` + Page int `json:"page,omitempty" ts_doc:"1-based page index to retrieve if multiple pages of transactions are available. Values above the safe internal limit are clamped."` +} + +// WsAccountUtxoReq is used to request unspent transaction outputs (UTXOs) for a given xpub/address. +type WsAccountUtxoReq struct { + Descriptor string `json:"descriptor" ts_doc:"Address or XPUB descriptor to retrieve UTXOs for."` +} + +// WsBalanceHistoryReq is used to retrieve a historical balance chart or intervals for an account. +type WsBalanceHistoryReq struct { + Descriptor string `json:"descriptor" ts_doc:"Address or XPUB descriptor to query history for."` + From int64 `json:"from,omitempty" ts_doc:"Unix timestamp from which to start the history."` + To int64 `json:"to,omitempty" ts_doc:"Unix timestamp at which to end the history."` + Currencies []string `json:"currencies,omitempty" ts_doc:"List of currency codes for which to fetch exchange rates at each interval."` + Gap int `json:"gap,omitempty" ts_doc:"Gap limit for XPUB scanning, if relevant."` + GroupBy uint32 `json:"groupBy,omitempty" ts_doc:"Size of each aggregated time window in seconds."` +} + +// WsTransactionReq requests details for a specific transaction by its txid. +type WsTransactionReq struct { + Txid string `json:"txid" ts_doc:"Transaction ID to retrieve details for."` +} + +// WsMempoolFiltersReq requests mempool filters for scripts of a specific type, after a given timestamp. +type WsMempoolFiltersReq struct { + ScriptType string `json:"scriptType" ts_doc:"Type of script we are filtering for (e.g., P2PKH, P2SH)."` + FromTimestamp uint32 `json:"fromTimestamp" ts_doc:"Only retrieve filters for mempool txs after this timestamp."` + ParamM uint64 `json:"M,omitempty" ts_doc:"Optional parameter for certain filter logic (e.g., n-bloom)."` +} + +// WsBlockFilterReq requests a filter for a given block hash and script type. +type WsBlockFilterReq struct { + ScriptType string `json:"scriptType" ts_doc:"Type of script filter (e.g., P2PKH, P2SH)."` + BlockHash string `json:"blockHash" ts_doc:"Block hash for which we want the filter."` + ParamM uint64 `json:"M,omitempty" ts_doc:"Optional parameter for certain filter logic."` +} + +// WsBlockFiltersBatchReq is used to request batch filters for consecutive blocks. +type WsBlockFiltersBatchReq struct { + ScriptType string `json:"scriptType" ts_doc:"Type of script filter (e.g., P2PKH, P2SH)."` + BlockHash string `json:"bestKnownBlockHash" ts_doc:"Hash of the latest known block. Filters will be retrieved backward from here."` + PageSize int `json:"pageSize,omitempty" ts_doc:"Number of block filters per request."` + ParamM uint64 `json:"M,omitempty" ts_doc:"Optional parameter for certain filter logic."` +} + +// WsTransactionSpecificReq requests blockchain-specific transaction info that might go beyond standard fields. +type WsTransactionSpecificReq struct { + Txid string `json:"txid" ts_doc:"Transaction ID for the detailed blockchain-specific data."` +} + +// WsEstimateFeeReq requests an estimation of transaction fees for a set of blocks or with specific parameters. +type WsEstimateFeeReq struct { + Blocks []int `json:"blocks,omitempty" ts_doc:"Block confirmations targets for which fees should be estimated."` + Specific map[string]interface{} `json:"specific,omitempty" ts_type:"{conservative?: boolean; txsize?: number; from?: string; to?: string; data?: string; value?: string;}" ts_doc:"Additional chain-specific parameters (e.g. for Ethereum)."` +} + +// WsEstimateFeeRes is returned in response to a fee estimation request. +type WsEstimateFeeRes struct { + FeePerTx string `json:"feePerTx,omitempty" ts_doc:"Estimated total fee per transaction, if relevant."` + FeePerUnit string `json:"feePerUnit,omitempty" ts_doc:"Estimated fee per unit (sat/byte, Wei/gas, etc.)."` + FeeLimit string `json:"feeLimit,omitempty" ts_doc:"Max fee limit for blockchains like Ethereum."` + Eip1559 *api.Eip1559Fees `json:"eip1559,omitempty"` +} + +// WsLongTermFeeRateRes is returned in response to a long term fee rate request. +type WsLongTermFeeRateRes struct { + FeePerUnit string `json:"feePerUnit" ts_doc:"Long term fee rate (in sat/kByte)."` + Blocks uint64 `json:"blocks" ts_doc:"Amount of blocks used for the long term fee rate estimation."` +} + +// WsSendTransactionReq is used to broadcast a transaction to the network. +type WsSendTransactionReq struct { + Hex string `json:"hex,omitempty" ts_doc:"Hex-encoded transaction data to broadcast (string format)."` + DisableAlternativeRPC bool `json:"disableAlternativeRpc" ts_doc:"Use alternative RPC method to broadcast transaction."` +} + +// WsSubscribeAddressesReq is used to subscribe to updates on a list of addresses. +type WsSubscribeAddressesReq struct { + Addresses []string `json:"addresses" ts_doc:"List of addresses to subscribe for updates (e.g., new transactions)."` + NewBlockTxs bool `json:"newBlockTxs,omitempty" ts_doc:"If true, also publish confirmed transactions for subscribed addresses when new blocks are connected."` +} + +// WsSubscribeFiatRatesReq subscribes to updates of fiat rates for a specific currency or set of tokens. +type WsSubscribeFiatRatesReq struct { + Currency string `json:"currency,omitempty" ts_doc:"Fiat currency code (e.g. 'USD')."` + Tokens []string `json:"tokens,omitempty" ts_doc:"List of token symbols or IDs to get fiat rates for."` +} + +// WsCurrentFiatRatesReq requests the current fiat rates for specified currencies (and optionally a token). +type WsCurrentFiatRatesReq struct { + Currencies []string `json:"currencies,omitempty" ts_doc:"List of fiat currencies, e.g. ['USD','EUR']."` + Token string `json:"token,omitempty" ts_doc:"Token symbol or ID if asking for token fiat rates (e.g. 'ETH')."` +} + +// WsFiatRatesForTimestampsReq requests historical fiat rates for given timestamps. +type WsFiatRatesForTimestampsReq struct { + Timestamps []int64 `json:"timestamps" ts_doc:"List of Unix timestamps for which to retrieve fiat rates."` + Currencies []string `json:"currencies,omitempty" ts_doc:"List of fiat currencies, e.g. ['USD','EUR']."` + Token string `json:"token,omitempty" ts_doc:"Token symbol or ID if asking for token fiat rates."` +} + +// WsFiatRatesTickersListReq requests a list of tickers for a given timestamp (and possibly a token). +type WsFiatRatesTickersListReq struct { + Timestamp int64 `json:"timestamp,omitempty" ts_doc:"Timestamp for which the list of available tickers is needed."` + Token string `json:"token,omitempty" ts_doc:"Token symbol or ID if asking for token-specific fiat rates."` +} + +// WsRpcCallReq is used for raw RPC calls (for example, on an Ethereum-like backend). +type WsRpcCallReq struct { + From string `json:"from,omitempty" ts_doc:"Address from which the RPC call is originated (if relevant)."` + To string `json:"to" ts_doc:"Contract or address to which the RPC call is made."` + Data string `json:"data" ts_doc:"Hex-encoded call data (function signature + parameters)."` +} + +// WsRpcCallRes returns the result of an RPC call in hex form. +type WsRpcCallRes struct { + Data string `json:"data" ts_doc:"Hex-encoded return data from the call."` +} diff --git a/shell.nix b/shell.nix index 89b2034ce5..ee60043428 100644 --- a/shell.nix +++ b/shell.nix @@ -11,6 +11,7 @@ stdenv.mkDerivation { snappy zeromq zlib + gcc ]; shellHook = '' export CGO_LDFLAGS="-L${stdenv.cc.cc.lib}/lib -lrocksdb -lz -lbz2 -lsnappy -llz4 -lm -lstdc++" diff --git a/static/css/bootstrap.5.2.2.min.css b/static/css/bootstrap.5.2.2.min.css new file mode 100644 index 0000000000..1359b3b721 --- /dev/null +++ b/static/css/bootstrap.5.2.2.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.2.2 (https://getbootstrap.com/) + * Copyright 2011-2022 The Bootstrap Authors + * Copyright 2011-2022 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:var(--bs-link-color);text-decoration:underline}a:hover{color:var(--bs-link-hover-color)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid var(--bs-border-color);border-radius:.375rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color:var(--bs-body-color);--bs-table-bg:transparent;--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-body-color);--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:var(--bs-body-color);--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:var(--bs-body-color);--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:var(--bs-table-color);vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:2px solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#bacbe6;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#cbccce;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#bcd0c7;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#badce3;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#e6dbb9;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#dfc2c4;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#dfe0e1;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#373b3e;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:calc(1.5em + .75rem + 2px);padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:.375rem}.form-control-color::-webkit-color-swatch{border-radius:.375rem}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + 2px)}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + 2px)}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.25rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.5rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;width:100%;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:1px 0}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.375rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.5rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.375rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.375rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:#212529;--bs-btn-bg:transparent;--bs-btn-border-width:1px;--bs-btn-border-color:transparent;--bs-btn-border-radius:0.375rem;--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:none;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:0.5rem}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:0.25rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:#212529;--bs-dropdown-bg:#fff;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:0.375rem;--bs-dropdown-border-width:1px;--bs-dropdown-inner-border-radius:calc(0.375rem - 1px);--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-dropdown-link-color:#212529;--bs-dropdown-link-hover-color:#1e2125;--bs-dropdown-link-hover-bg:#e9ecef;--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:.375rem}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:#6c757d;display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link.disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:1px;--bs-nav-tabs-border-color:#dee2e6;--bs-nav-tabs-border-radius:0.375rem;--bs-nav-tabs-link-hover-border-color:#e9ecef #e9ecef #dee2e6;--bs-nav-tabs-link-active-color:#495057;--bs-nav-tabs-link-active-bg:#fff;--bs-nav-tabs-link-active-border-color:#dee2e6 #dee2e6 #fff;border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));background:0 0;border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.disabled,.nav-tabs .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:0.375rem;--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{background:0 0;border:0;border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(0, 0, 0, 0.55);--bs-navbar-hover-color:rgba(0, 0, 0, 0.7);--bs-navbar-disabled-color:rgba(0, 0, 0, 0.3);--bs-navbar-active-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-hover-color:rgba(0, 0, 0, 0.9);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(0, 0, 0, 0.1);--bs-navbar-toggler-border-radius:0.375rem;--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .show>.nav-link{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-border-width:1px;--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:0.375rem;--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(0.375rem - 1px);--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(0, 0, 0, 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:#fff;--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:#212529;--bs-accordion-bg:#fff;--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:1px;--bs-accordion-border-radius:0.375rem;--bs-accordion-inner-border-radius:calc(0.375rem - 1px);--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:#212529;--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-focus-border-color:#86b7fe;--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:#0c63e4;--bs-accordion-active-bg:#e7f1ff}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:var(--bs-accordion-btn-focus-border-color);outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button,.accordion-flush .accordion-item .accordion-button.collapsed{border-radius:0}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:#6c757d;--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:#6c757d;display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:#fff;--bs-pagination-border-width:1px;--bs-pagination-border-color:#dee2e6;--bs-pagination-border-radius:0.375rem;--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:#e9ecef;--bs-pagination-hover-border-color:#dee2e6;--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:#e9ecef;--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:#6c757d;--bs-pagination-disabled-bg:#fff;--bs-pagination-disabled-border-color:#dee2e6;display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:0.5rem}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:0.25rem}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:0.375rem;display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:1px solid var(--bs-alert-border-color);--bs-alert-border-radius:0.375rem;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:#084298;--bs-alert-bg:#cfe2ff;--bs-alert-border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{--bs-alert-color:#41464b;--bs-alert-bg:#e2e3e5;--bs-alert-border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{--bs-alert-color:#0f5132;--bs-alert-bg:#d1e7dd;--bs-alert-border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{--bs-alert-color:#055160;--bs-alert-bg:#cff4fc;--bs-alert-border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{--bs-alert-color:#664d03;--bs-alert-bg:#fff3cd;--bs-alert-border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{--bs-alert-color:#842029;--bs-alert-bg:#f8d7da;--bs-alert-border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{--bs-alert-color:#636464;--bs-alert-bg:#fefefe;--bs-alert-border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{--bs-alert-color:#141619;--bs-alert-bg:#d3d3d4;--bs-alert-border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:#e9ecef;--bs-progress-border-radius:0.375rem;--bs-progress-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:#212529;--bs-list-group-bg:#fff;--bs-list-group-border-color:rgba(0, 0, 0, 0.125);--bs-list-group-border-width:1px;--bs-list-group-border-radius:0.375rem;--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:#495057;--bs-list-group-action-hover-color:#495057;--bs-list-group-action-hover-bg:#f8f9fa;--bs-list-group-action-active-color:#212529;--bs-list-group-action-active-bg:#e9ecef;--bs-list-group-disabled-color:#6c757d;--bs-list-group-disabled-bg:#fff;--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(255, 255, 255, 0.85);--bs-toast-border-width:1px;--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:0.375rem;--bs-toast-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-toast-header-color:#6c757d;--bs-toast-header-bg:rgba(255, 255, 255, 0.85);--bs-toast-header-border-color:rgba(0, 0, 0, 0.05);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:#fff;--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:1px;--bs-modal-border-radius:0.5rem;--bs-modal-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-modal-inner-border-radius:calc(0.5rem - 1px);--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:1px;--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:1px;position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:#fff;--bs-tooltip-bg:#000;--bs-tooltip-border-radius:0.375rem;--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;padding:var(--bs-tooltip-arrow-height);margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:#fff;--bs-popover-border-width:1px;--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:0.5rem;--bs-popover-inner-border-radius:calc(0.5rem - 1px);--bs-popover-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color: ;--bs-popover-header-bg:#f0f0f0;--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:#212529;--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color: ;--bs-offcanvas-bg:#fff;--bs-offcanvas-border-width:1px;--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:575.98px){.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}}@media (max-width:575.98px){.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:767.98px){.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}}@media (max-width:767.98px){.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:991.98px){.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}}@media (max-width:991.98px){.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1199.98px){.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}}@media (max-width:1199.98px){.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}}@media (max-width:1399.98px){.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y))}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(13,110,253,var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(108,117,125,var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(25,135,84,var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(13,202,240,var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(255,193,7,var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(220,53,69,var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(248,249,250,var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(33,37,41,var(--bs-bg-opacity,1))!important}.link-primary{color:#0d6efd!important}.link-primary:focus,.link-primary:hover{color:#0a58ca!important}.link-secondary{color:#6c757d!important}.link-secondary:focus,.link-secondary:hover{color:#565e64!important}.link-success{color:#198754!important}.link-success:focus,.link-success:hover{color:#146c43!important}.link-info{color:#0dcaf0!important}.link-info:focus,.link-info:hover{color:#3dd5f3!important}.link-warning{color:#ffc107!important}.link-warning:focus,.link-warning:hover{color:#ffcd39!important}.link-danger{color:#dc3545!important}.link-danger:focus,.link-danger:hover{color:#b02a37!important}.link-light{color:#f8f9fa!important}.link-light:focus,.link-light:hover{color:#f9fafb!important}.link-dark{color:#212529!important}.link-dark:focus,.link-dark:hover{color:#1a1e21!important}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-1{--bs-border-width:1px}.border-2{--bs-border-width:2px}.border-3{--bs-border-width:3px}.border-4{--bs-border-width:4px}.border-5{--bs-border-width:5px}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-semibold{font-weight:600!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-2xl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/static/css/main.css b/static/css/main.css index b02f915c58..e708ac188c 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -200,6 +200,10 @@ span.btn-paging:hover { color: #757575; } +.btn-paging.active:hover { + background-color: white; +} + .paging-group { border: 1px solid #e2e2e2; border-radius: 0.5rem; @@ -694,4 +698,4 @@ span.btn-paging:hover { .btn { --bs-btn-font-size: 1rem; } -} \ No newline at end of file +} diff --git a/static/css/main.min.2.css b/static/css/main.min.2.css deleted file mode 100644 index dbafe0842b..0000000000 --- a/static/css/main.min.2.css +++ /dev/null @@ -1 +0,0 @@ -@import "TTHoves/TTHoves.css";* {margin: 0;padding: 0;outline: none;font-family: "TT Hoves", -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;}html, body {height: 100%;}body {min-height: 100%;margin: 0;background: linear-gradient(to bottom, #f6f6f6 360px, #e5e5e5 0), #e5e5e5;background-repeat: no-repeat;}a {color: #00854d;text-decoration: none;}a:hover {color: #00854d;text-decoration: underline;}select {border-radius: 0.5rem;padding-left: 0.5rem;border: 1px solid #ced4da;color: var(--bs-body-color);min-height: 45px;}#header {position: fixed;top: 0;left: 0;width: 100%;margin: 0;padding-bottom: 0;padding-top: 0;background-color: white;border-bottom: 1px solid #f6f6f6;z-index: 10;}#header a {color: var(--bs-navbar-brand-color);}#header a:hover {color: var(--bs-navbar-brand-hover-color);}#header .navbar {--bs-navbar-padding-y: 0.7rem;}#header .form-control-lg {font-size: 1rem;padding: 0.75rem 1rem;}#header .container {min-height: 50px;}#header .btn.dropdown-toggle {padding-right: 0;}#header .dropdown-menu {--bs-dropdown-min-width: 13rem;}#header .dropdown-menu[data-bs-popper] {left: initial;right: 0;}#header .dropdown-menu.show {display: flex;}.form-control:focus {outline: 0;box-shadow: none;border-color: #00854d;}.base-value {color: #757575 !important;padding-left: 0.5rem;font-weight: normal;}.badge {vertical-align: middle;text-transform: uppercase;letter-spacing: 0.15em;--bs-badge-padding-x: 0.8rem;--bs-badge-font-weight: normal;--bs-badge-border-radius: 0.6rem;}.bg-secondary {background-color: #757575 !important;}.accordion {--bs-accordion-border-radius: 10px;--bs-accordion-inner-border-radius: calc(10px - 1px);--bs-accordion-color: var(--bs-body-color);--bs-accordion-active-color: var(--bs-body-color);--bs-accordion-active-bg: white;--bs-accordion-btn-active-icon: url("data:image/svg+xml,");}.accordion-button:focus {outline: 0;box-shadow: none;}.accordion-body {letter-spacing: -0.01em;}.bb-group {border: 0.6rem solid #f6f6f6;background-color: #f6f6f6;border-radius: 0.5rem;position: relative;display: inline-flex;vertical-align: middle;}.bb-group>.btn {--bs-btn-padding-x: 0.5rem;--bs-btn-padding-y: 0.22rem;--bs-btn-border-radius: 0.3rem;--bs-btn-border-width: 0;color: #545454;}.bb-group>.btn-check:checked+.btn, .bb-group .btn.active {color: black;font-weight: bold;background-color: white;}.paging {display: flex;}.paging .bb-group>.btn {min-width: 2rem;margin-left: 0.1rem;margin-right: 0.1rem;}.paging .bb-group>.btn:hover {background-color: white;}.paging a {text-decoration: none;}.btn-paging {--bs-btn-color: #757575;--bs-btn-border-color: #e2e2e2;--bs-btn-hover-color: black;--bs-btn-hover-bg: #f6f6f6;--bs-btn-hover-border-color: #e2e2e2;--bs-btn-focus-shadow-rgb: 108, 117, 125;--bs-btn-active-color: #fff;--bs-btn-active-bg: #e2e2e2;--bs-btn-active-border-color: #e2e2e2;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-gradient: none;--bs-btn-padding-y: 0.75rem;--bs-btn-padding-x: 1.1rem;--bs-btn-border-radius: 0.5rem;--bs-btn-font-weight: bold;background-color: #f6f6f6;}span.btn-paging {cursor: initial;}span.btn-paging:hover {color: #757575;}.paging-group {border: 1px solid #e2e2e2;border-radius: 0.5rem;}.paging-group>.bb-group {border: 0.53rem solid #f6f6f6;}#wrap {min-height: 100%;height: auto;padding: 112px 0 75px 0;margin: 0 auto -56px;}#footer {background-color: black;color: #757575;height: 56px;overflow: hidden;}.navbar-form {width: 60%;}.navbar-form button {margin-left: -50px;position: relative;}.search-icon {width: 16px;height: 16px;position: absolute;top: 16px;background-size: cover;background-image: url("data:image/svg+xml, %3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7.24976 12.5C10.1493 12.5 12.4998 10.1495 12.4998 7.25C12.4998 4.35051 10.1493 2 7.24976 2C4.35026 2 1.99976 4.35051 1.99976 7.25C1.99976 10.1495 4.35026 12.5 7.24976 12.5Z' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3Cpath d='M10.962 10.9625L13.9996 14.0001' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E");}.navbar-form ::placeholder {color: #e2e2e2;}.ellipsis {overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}.data-table {table-layout: fixed;overflow-wrap: anywhere;margin-left: 8px;margin-top: 2rem;margin-bottom: 2rem;width: calc(100% - 16px);}.data-table thead {padding-bottom: 20px;}.table.data-table> :not(caption)>*>* {padding: 0.8rem 0.8rem;background-color: var(--bs-table-bg);border-bottom-width: 1px;box-shadow: inset 0 0 0 9999px var(--bs-table-accent-bg);}.table.data-table>thead>*>* {padding-bottom: 1.5rem;}.table.data-table>*>*:last-child>* {border-bottom: none;}.data-table thead, .data-table thead tr, .data-table thead th {color: #757575;border: none;font-weight: normal;}.data-table tbody th {color: #757575;font-weight: normal;}.data-table tbody {background: white;border-radius: 8px;box-shadow: 0 0 0 8px white;}.data-table h3, .data-table h5, .data-table h6 {margin-bottom: 0;}.data-table h3, .data-table h5 {color: var(--bs-body-color);}.accordion .table.data-table>thead>*>* {padding-bottom: 0;}.info-table tbody {display: inline-table;width: 100%;}.info-table td {font-weight: bold;}.info-table tr>td:first-child {font-weight: normal;color: #757575;}.ns:before {content: " ";}.nc:before {content: ",";}.trezor-logo {width: 128px;height: 32px;position: absolute;top: 16px;background-size: cover;background-image: url("data:image/svg+xml,%3Csvg style='width: 128px%3B' version='1.1' xmlns='http://www.w3.org/2000/svg' x='0px' y='0px' viewBox='0 0 163.7 41.9' space='preserve'%3E%3Cpolygon points='101.1 12.8 118.2 12.8 118.2 17.3 108.9 29.9 118.2 29.9 118.2 35.2 101.1 35.2 101.1 30.7 110.4 18.1 101.1 18.1'%3E%3C/polygon%3E%3Cpath d='M158.8 26.9c2.1-0.8 4.3-2.9 4.3-6.6c0-4.5-3.1-7.4-7.7-7.4h-10.5v22.3h5.8v-7.5h2.2l4.1 7.5h6.7L158.8 26.9z M154.7 22.5h-4V18h4c1.5 0 2.5 0.9 2.5 2.2C157.2 21.6 156.2 22.5 154.7 22.5z'%3E%3C/path%3E%3Cpath d='M130.8 12.5c-6.8 0-11.6 4.9-11.6 11.5s4.9 11.5 11.6 11.5s11.7-4.9 11.7-11.5S137.6 12.5 130.8 12.5z M130.8 30.3c-3.4 0-5.7-2.6-5.7-6.3c0-3.8 2.3-6.3 5.7-6.3c3.4 0 5.8 2.6 5.8 6.3C136.6 27.7 134.2 30.3 130.8 30.3z'%3E%3C/path%3E%3Cpolygon points='82.1 12.8 98.3 12.8 98.3 18 87.9 18 87.9 21.3 98 21.3 98 26.4 87.9 26.4 87.9 30 98.3 30 98.3 35.2 82.1 35.2'%3E%3C/polygon%3E%3Cpath d='M24.6 9.7C24.6 4.4 20 0 14.4 0S4.2 4.4 4.2 9.7v3.1H0v22.3h0l14.4 6.7l14.4-6.7h0V12.9h-4.2V9.7z M9.4 9.7c0-2.5 2.2-4.5 5-4.5s5 2 5 4.5v3.1H9.4V9.7z M23 31.5l-8.6 4l-8.6-4V18.1H23V31.5z'%3E%3C/path%3E%3Cpath d='M79.4 20.3c0-4.5-3.1-7.4-7.7-7.4H61.2v22.3H67v-7.5h2.2l4.1 7.5H80l-4.9-8.3C77.2 26.1 79.4 24 79.4 20.3z M71 22.5h-4V18h4c1.5 0 2.5 0.9 2.5 2.2C73.5 21.6 72.5 22.5 71 22.5z'%3E%3C/path%3E%3Cpolygon points='40.5 12.8 58.6 12.8 58.6 18.1 52.4 18.1 52.4 35.2 46.6 35.2 46.6 18.1 40.5 18.1'%3E%3C/polygon%3E%3C/svg%3E");}.copyable::before, .copied::before {width: 18px;height: 16px;margin: 3px -18px;content: "";position: absolute;background-size: cover;}.copyable::before {display: none;cursor: copy;background-image: url("data:image/svg+xml,%3Csvg width='18' height='16' viewBox='0 0 18 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.5 10.4996H13.5V2.49963H5.5V5.49963' stroke='%2300854D' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M10.4998 5.49976H2.49976V13.4998H10.4998V5.49976Z' stroke='%2300854D' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");}.copyable:hover::before {display: inline-block;}.copied::before {transition: all 0.4s ease;transform: scale(1.2);background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='16' viewBox='-30 -30 330 330'%3E%3Cpath d='M 30,180 90,240 240,30' style='stroke:%2300854D;stroke-width:32;fill:none'/%3E%3C/svg%3E");}.h-data {letter-spacing: 0.12em;font-weight: normal !important;}.tx-detail {background: #f6f6f6;color: #757575;border-radius: 10px;box-shadow: 0 0 0 10px white;width: calc(100% - 20px);margin-left: 10px;margin-top: 3rem;overflow-wrap: break-word;}.tx-detail:first-child {margin-top: 1rem;}.tx-detail:last-child {margin-bottom: 2rem;}.tx-detail span.ellipsis, .tx-detail a.ellipsis {display: block;float: left;max-width: 100%;}.tx-detail>.head, .tx-detail>.footer {padding: 1.5rem;--bs-gutter-x: 0;}.tx-detail>.head {border-radius: 10px 10px 0 0;}.tx-detail .txid {font-size: 106%;letter-spacing: -0.01em;}.tx-detail>.body {padding: 0 1.5rem;--bs-gutter-x: 0;letter-spacing: -0.01em;}.tx-detail>.subhead {padding: 1.5rem 1.5rem 0.4rem 1.5rem;--bs-gutter-x: 0;letter-spacing: 0.1em;text-transform: uppercase;color: var(--bs-body-color);}.tx-detail>.subhead-2 {padding: 0.3rem 1.5rem 0 1.5rem;--bs-gutter-x: 0;font-size: .875em;color: var(--bs-body-color);}.tx-in .col-12, .tx-out .col-12, .tx-addr .col-12 {background-color: white;padding: 1.2rem 1.3rem;border-bottom: 1px solid #f6f6f6;}.amt-out {padding: 1.2rem 0 1.2rem 1rem;text-align: right;overflow-wrap: break-word;}.tx-in .col-12:last-child, .tx-out .col-12:last-child {border-bottom: none;}.tx-own {background-color: #fff9e3 !important;}.tx-amt {float: right !important;}.spent {color: #dc3545 !important;}.unspent {color: #28a745 !important;}.outpoint {color: #757575 !important;}.spent, .unspent, .outpoint {display: inline-block;text-align: right;min-width: 18px;text-decoration: none !important;}.octicon {height: 24px;width: 24px;margin-left: -12px;margin-top: 19px;position: absolute;background-size: cover;background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 4.5L16.5 12L9 19.5' stroke='%23AFAFAF' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");}.txvalue {color: var(--bs-body-color);font-weight: bold;}.txerror {color: #c51f13;}.txerror a, .txerror .txvalue {color: #c51f13;}.txerror .copyable::before, .txerror .copied::before {filter: invert(86%) sepia(43%) saturate(732%) hue-rotate(367deg) brightness(84%);}.tx-amt .amt:hover, .tx-amt.amt:hover, .amt-out>.amt:hover {color: var(--bs-body-color);}.prim-amt {display: initial;}.sec-amt {display: none;}.csec-amt {display: none;}.base-amt {display: none;}.cbase-amt {display: none;}.tooltip {--bs-tooltip-opacity: 1;--bs-tooltip-max-width: 380px;--bs-tooltip-bg: #fff;--bs-tooltip-color: var(--bs-body-color);--bs-tooltip-padding-x: 1rem;--bs-tooltip-padding-y: 0.8rem;filter: drop-shadow(0px 24px 64px rgba(22, 27, 45, 0.25));}.l-tooltip {text-align: start;display: inline-block;}.l-tooltip .prim-amt, .l-tooltip .sec-amt, .l-tooltip .csec-amt, .l-tooltip .base-amt, .l-tooltip .cbase-amt {display: initial;float: right;}.l-tooltip .amt-time {padding-right: 3rem;float: left;}.amt-dec {font-size: 95%;}.unconfirmed {color: white;background-color: #c51e13;padding: 0.7rem 1.2rem;border-radius: 1.4rem;}.json {word-wrap: break-word;font-size: smaller;background: #002b31;border-radius: 8px;}#raw {padding: 1.5rem 2rem;color: #ffffff;letter-spacing: 0.02em;}#raw .string {color: #2bca87;}#raw .number, #raw .boolean {color: #efc941;}#raw .null {color: red;}@media (max-width: 768px) {body {font-size: 0.8rem;background: linear-gradient(to bottom, #f6f6f6 500px, #e5e5e5 0), #e5e5e5;}.container {padding-left: 2px;padding-right: 2px;}.accordion-body {padding: var(--bs-accordion-body-padding-y) 0;}.octicon {scale: 60% !important;margin-top: -2px;}.unconfirmed {padding: 0.1rem 0.8rem;}.btn {--bs-btn-font-size: 0.8rem;}}@media (max-width: 991px) {#header .container {min-height: 40px;}#header .dropdown-menu[data-bs-popper] {left: 0;right: initial;}.trezor-logo {top: 10px;}.octicon {scale: 80%;}.table.data-table>:not(caption)>*>* {padding: 0.8rem 0.4rem;}.tx-in .col-12, .tx-out .col-12, .tx-addr .col-12 {padding: 0.7rem 1.1rem;}.amt-out {padding: 0.7rem 0 0.7rem 1rem }}@media (min-width: 769px) {body {font-size: 0.9rem;}.btn {--bs-btn-font-size: 0.9rem;}}@media (min-width: 1200px) {.h1, h1 {font-size: 2.4rem;}body {font-size: 1rem;}.btn {--bs-btn-font-size: 1rem;}} \ No newline at end of file diff --git a/static/css/main.min.4.css b/static/css/main.min.4.css new file mode 100644 index 0000000000..54dd88a23d --- /dev/null +++ b/static/css/main.min.4.css @@ -0,0 +1 @@ +@import "TTHoves/TTHoves.css";* {margin: 0;padding: 0;outline: none;font-family: "TT Hoves", -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;}html, body {height: 100%;}body {min-height: 100%;margin: 0;background: linear-gradient(to bottom, #f6f6f6 360px, #e5e5e5 0), #e5e5e5;background-repeat: no-repeat;}a {color: #00854d;text-decoration: none;}a:hover {color: #00854d;text-decoration: underline;}select {border-radius: 0.5rem;padding-left: 0.5rem;border: 1px solid #ced4da;color: var(--bs-body-color);min-height: 45px;}#header {position: fixed;top: 0;left: 0;width: 100%;margin: 0;padding-bottom: 0;padding-top: 0;background-color: white;border-bottom: 1px solid #f6f6f6;z-index: 10;}#header a {color: var(--bs-navbar-brand-color);}#header a:hover {color: var(--bs-navbar-brand-hover-color);}#header .navbar {--bs-navbar-padding-y: 0.7rem;}#header .form-control-lg {font-size: 1rem;padding: 0.75rem 1rem;}#header .container {min-height: 50px;}#header .btn.dropdown-toggle {padding-right: 0;}#header .dropdown-menu {--bs-dropdown-min-width: 13rem;}#header .dropdown-menu[data-bs-popper] {left: initial;right: 0;}#header .dropdown-menu.show {display: flex;}.form-control:focus {outline: 0;box-shadow: none;border-color: #00854d;}.base-value {color: #757575 !important;padding-left: 0.5rem;font-weight: normal;}.badge {vertical-align: middle;text-transform: uppercase;letter-spacing: 0.15em;--bs-badge-padding-x: 0.8rem;--bs-badge-font-weight: normal;--bs-badge-border-radius: 0.6rem;}.bg-secondary {background-color: #757575 !important;}.accordion {--bs-accordion-border-radius: 10px;--bs-accordion-inner-border-radius: calc(10px - 1px);--bs-accordion-color: var(--bs-body-color);--bs-accordion-active-color: var(--bs-body-color);--bs-accordion-active-bg: white;--bs-accordion-btn-active-icon: url("data:image/svg+xml,");}.accordion-button:focus {outline: 0;box-shadow: none;}.accordion-body {letter-spacing: -0.01em;}.bb-group {border: 0.6rem solid #f6f6f6;background-color: #f6f6f6;border-radius: 0.5rem;position: relative;display: inline-flex;vertical-align: middle;}.bb-group>.btn {--bs-btn-padding-x: 0.5rem;--bs-btn-padding-y: 0.22rem;--bs-btn-border-radius: 0.3rem;--bs-btn-border-width: 0;color: #545454;}.bb-group>.btn-check:checked+.btn, .bb-group .btn.active {color: black;font-weight: bold;background-color: white;}.paging {display: flex;}.paging .bb-group>.btn {min-width: 2rem;margin-left: 0.1rem;margin-right: 0.1rem;}.paging .bb-group>.btn:hover {background-color: white;}.paging a {text-decoration: none;}.btn-paging {--bs-btn-color: #757575;--bs-btn-border-color: #e2e2e2;--bs-btn-hover-color: black;--bs-btn-hover-bg: #f6f6f6;--bs-btn-hover-border-color: #e2e2e2;--bs-btn-focus-shadow-rgb: 108, 117, 125;--bs-btn-active-color: #fff;--bs-btn-active-bg: #e2e2e2;--bs-btn-active-border-color: #e2e2e2;--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-gradient: none;--bs-btn-padding-y: 0.75rem;--bs-btn-padding-x: 1.1rem;--bs-btn-border-radius: 0.5rem;--bs-btn-font-weight: bold;background-color: #f6f6f6;}span.btn-paging {cursor: initial;}span.btn-paging:hover {color: #757575;}.btn-paging.active:hover {background-color: white;}.paging-group {border: 1px solid #e2e2e2;border-radius: 0.5rem;}.paging-group>.bb-group {border: 0.53rem solid #f6f6f6;}#wrap {min-height: 100%;height: auto;padding: 112px 0 75px 0;margin: 0 auto -56px;}#footer {background-color: black;color: #757575;height: 56px;overflow: hidden;}.navbar-form {width: 60%;}.navbar-form button {margin-left: -50px;position: relative;}.search-icon {width: 16px;height: 16px;position: absolute;top: 16px;background-size: cover;background-image: url("data:image/svg+xml, %3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7.24976 12.5C10.1493 12.5 12.4998 10.1495 12.4998 7.25C12.4998 4.35051 10.1493 2 7.24976 2C4.35026 2 1.99976 4.35051 1.99976 7.25C1.99976 10.1495 4.35026 12.5 7.24976 12.5Z' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3Cpath d='M10.962 10.9625L13.9996 14.0001' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' /%3E%3C/svg%3E");}.navbar-form ::placeholder {color: #e2e2e2;}.ellipsis {overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}.data-table {table-layout: fixed;overflow-wrap: anywhere;margin-left: 8px;margin-top: 2rem;margin-bottom: 2rem;width: calc(100% - 16px);}.data-table thead {padding-bottom: 20px;}.table.data-table> :not(caption)>*>* {padding: 0.8rem 0.8rem;background-color: var(--bs-table-bg);border-bottom-width: 1px;box-shadow: inset 0 0 0 9999px var(--bs-table-accent-bg);}.table.data-table>thead>*>* {padding-bottom: 1.5rem;}.table.data-table>*>*:last-child>* {border-bottom: none;}.data-table thead, .data-table thead tr, .data-table thead th {color: #757575;border: none;font-weight: normal;}.data-table tbody th {color: #757575;font-weight: normal;}.data-table tbody {background: white;border-radius: 8px;box-shadow: 0 0 0 8px white;}.data-table h3, .data-table h5, .data-table h6 {margin-bottom: 0;}.data-table h3, .data-table h5 {color: var(--bs-body-color);}.accordion .table.data-table>thead>*>* {padding-bottom: 0;}.info-table tbody {display: inline-table;width: 100%;}.info-table td {font-weight: bold;}.info-table tr>td:first-child {font-weight: normal;color: #757575;}.ns:before {content: " ";}.nc:before {content: ",";}.trezor-logo {width: 128px;height: 32px;position: absolute;top: 16px;background-size: cover;background-image: url("data:image/svg+xml,%3Csvg style='width: 128px%3B' version='1.1' xmlns='http://www.w3.org/2000/svg' x='0px' y='0px' viewBox='0 0 163.7 41.9' space='preserve'%3E%3Cpolygon points='101.1 12.8 118.2 12.8 118.2 17.3 108.9 29.9 118.2 29.9 118.2 35.2 101.1 35.2 101.1 30.7 110.4 18.1 101.1 18.1'%3E%3C/polygon%3E%3Cpath d='M158.8 26.9c2.1-0.8 4.3-2.9 4.3-6.6c0-4.5-3.1-7.4-7.7-7.4h-10.5v22.3h5.8v-7.5h2.2l4.1 7.5h6.7L158.8 26.9z M154.7 22.5h-4V18h4c1.5 0 2.5 0.9 2.5 2.2C157.2 21.6 156.2 22.5 154.7 22.5z'%3E%3C/path%3E%3Cpath d='M130.8 12.5c-6.8 0-11.6 4.9-11.6 11.5s4.9 11.5 11.6 11.5s11.7-4.9 11.7-11.5S137.6 12.5 130.8 12.5z M130.8 30.3c-3.4 0-5.7-2.6-5.7-6.3c0-3.8 2.3-6.3 5.7-6.3c3.4 0 5.8 2.6 5.8 6.3C136.6 27.7 134.2 30.3 130.8 30.3z'%3E%3C/path%3E%3Cpolygon points='82.1 12.8 98.3 12.8 98.3 18 87.9 18 87.9 21.3 98 21.3 98 26.4 87.9 26.4 87.9 30 98.3 30 98.3 35.2 82.1 35.2'%3E%3C/polygon%3E%3Cpath d='M24.6 9.7C24.6 4.4 20 0 14.4 0S4.2 4.4 4.2 9.7v3.1H0v22.3h0l14.4 6.7l14.4-6.7h0V12.9h-4.2V9.7z M9.4 9.7c0-2.5 2.2-4.5 5-4.5s5 2 5 4.5v3.1H9.4V9.7z M23 31.5l-8.6 4l-8.6-4V18.1H23V31.5z'%3E%3C/path%3E%3Cpath d='M79.4 20.3c0-4.5-3.1-7.4-7.7-7.4H61.2v22.3H67v-7.5h2.2l4.1 7.5H80l-4.9-8.3C77.2 26.1 79.4 24 79.4 20.3z M71 22.5h-4V18h4c1.5 0 2.5 0.9 2.5 2.2C73.5 21.6 72.5 22.5 71 22.5z'%3E%3C/path%3E%3Cpolygon points='40.5 12.8 58.6 12.8 58.6 18.1 52.4 18.1 52.4 35.2 46.6 35.2 46.6 18.1 40.5 18.1'%3E%3C/polygon%3E%3C/svg%3E");}.copyable::before, .copied::before {width: 18px;height: 16px;margin: 3px -18px;content: "";position: absolute;background-size: cover;}.copyable::before {display: none;cursor: copy;background-image: url("data:image/svg+xml,%3Csvg width='18' height='16' viewBox='0 0 18 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.5 10.4996H13.5V2.49963H5.5V5.49963' stroke='%2300854D' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M10.4998 5.49976H2.49976V13.4998H10.4998V5.49976Z' stroke='%2300854D' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");}.copyable:hover::before {display: inline-block;}.copied::before {transition: all 0.4s ease;transform: scale(1.2);background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='16' viewBox='-30 -30 330 330'%3E%3Cpath d='M 30,180 90,240 240,30' style='stroke:%2300854D;stroke-width:32;fill:none'/%3E%3C/svg%3E");}.h-data {letter-spacing: 0.12em;font-weight: normal !important;}.tx-detail {background: #f6f6f6;color: #757575;border-radius: 10px;box-shadow: 0 0 0 10px white;width: calc(100% - 20px);margin-left: 10px;margin-top: 3rem;overflow-wrap: break-word;}.tx-detail:first-child {margin-top: 1rem;}.tx-detail:last-child {margin-bottom: 2rem;}.tx-detail span.ellipsis, .tx-detail a.ellipsis {display: block;float: left;max-width: 100%;}.tx-detail>.head, .tx-detail>.footer {padding: 1.5rem;--bs-gutter-x: 0;}.tx-detail>.head {border-radius: 10px 10px 0 0;}.tx-detail .txid {font-size: 106%;letter-spacing: -0.01em;}.tx-detail>.body {padding: 0 1.5rem;--bs-gutter-x: 0;letter-spacing: -0.01em;}.tx-detail>.subhead {padding: 1.5rem 1.5rem 0.4rem 1.5rem;--bs-gutter-x: 0;letter-spacing: 0.1em;text-transform: uppercase;color: var(--bs-body-color);}.tx-detail>.subhead-2 {padding: 0.3rem 1.5rem 0 1.5rem;--bs-gutter-x: 0;font-size: .875em;color: var(--bs-body-color);}.tx-in .col-12, .tx-out .col-12, .tx-addr .col-12 {background-color: white;padding: 1.2rem 1.3rem;border-bottom: 1px solid #f6f6f6;}.amt-out {padding: 1.2rem 0 1.2rem 1rem;text-align: right;overflow-wrap: break-word;}.tx-in .col-12:last-child, .tx-out .col-12:last-child {border-bottom: none;}.tx-own {background-color: #fff9e3 !important;}.tx-amt {float: right !important;}.spent {color: #dc3545 !important;}.unspent {color: #28a745 !important;}.outpoint {color: #757575 !important;}.spent, .unspent, .outpoint {display: inline-block;text-align: right;min-width: 18px;text-decoration: none !important;}.octicon {height: 24px;width: 24px;margin-left: -12px;margin-top: 19px;position: absolute;background-size: cover;background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9 4.5L16.5 12L9 19.5' stroke='%23AFAFAF' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A");}.txvalue {color: var(--bs-body-color);font-weight: bold;}.txerror {color: #c51f13;}.txerror a, .txerror .txvalue {color: #c51f13;}.txerror .copyable::before, .txerror .copied::before {filter: invert(86%) sepia(43%) saturate(732%) hue-rotate(367deg) brightness(84%);}.tx-amt .amt:hover, .tx-amt.amt:hover, .amt-out>.amt:hover {color: var(--bs-body-color);}.prim-amt {display: initial;}.sec-amt {display: none;}.csec-amt {display: none;}.base-amt {display: none;}.cbase-amt {display: none;}.tooltip {--bs-tooltip-opacity: 1;--bs-tooltip-max-width: 380px;--bs-tooltip-bg: #fff;--bs-tooltip-color: var(--bs-body-color);--bs-tooltip-padding-x: 1rem;--bs-tooltip-padding-y: 0.8rem;filter: drop-shadow(0px 24px 64px rgba(22, 27, 45, 0.25));}.l-tooltip {text-align: start;display: inline-block;}.l-tooltip .prim-amt, .l-tooltip .sec-amt, .l-tooltip .csec-amt, .l-tooltip .base-amt, .l-tooltip .cbase-amt {display: initial;float: right;}.l-tooltip .amt-time {padding-right: 3rem;float: left;}.amt-dec {font-size: 95%;}.unconfirmed {color: white;background-color: #c51e13;padding: 0.7rem 1.2rem;border-radius: 1.4rem;}.json {word-wrap: break-word;font-size: smaller;background: #002b31;border-radius: 8px;}#raw {padding: 1.5rem 2rem;color: #ffffff;letter-spacing: 0.02em;}#raw .string {color: #2bca87;}#raw .number, #raw .boolean {color: #efc941;}#raw .null {color: red;}@media (max-width: 768px) {body {font-size: 0.8rem;background: linear-gradient(to bottom, #f6f6f6 500px, #e5e5e5 0), #e5e5e5;}.container {padding-left: 2px;padding-right: 2px;}.accordion-body {padding: var(--bs-accordion-body-padding-y) 0;}.octicon {scale: 60% !important;margin-top: -2px;}.unconfirmed {padding: 0.1rem 0.8rem;}.btn {--bs-btn-font-size: 0.8rem;}}@media (max-width: 991px) {#header .container {min-height: 40px;}#header .dropdown-menu[data-bs-popper] {left: 0;right: initial;}.trezor-logo {top: 10px;}.octicon {scale: 80%;}.table.data-table>:not(caption)>*>* {padding: 0.8rem 0.4rem;}.tx-in .col-12, .tx-out .col-12, .tx-addr .col-12 {padding: 0.7rem 1.1rem;}.amt-out {padding: 0.7rem 0 0.7rem 1rem }}@media (min-width: 769px) {body {font-size: 0.9rem;}.btn {--bs-btn-font-size: 0.9rem;}}@media (min-width: 1200px) {.h1, h1 {font-size: 2.4rem;}body {font-size: 1rem;}.btn {--bs-btn-font-size: 1rem;}} \ No newline at end of file diff --git a/static/internal_templates/base.html b/static/internal_templates/base.html new file mode 100644 index 0000000000..86d6fd40ef --- /dev/null +++ b/static/internal_templates/base.html @@ -0,0 +1,28 @@ + + + + + + + + Blockbook {{.CoinLabel}} Internal Admin + + + + +
+
+ {{- template "specific" . -}} +
+
+ + + \ No newline at end of file diff --git a/static/internal_templates/block_internal_data_errors.html b/static/internal_templates/block_internal_data_errors.html new file mode 100644 index 0000000000..2301f94362 --- /dev/null +++ b/static/internal_templates/block_internal_data_errors.html @@ -0,0 +1,35 @@ +{{define "specific"}} +

Blocks with errors from fetching internal data

+
+
Count: {{len .InternalDataErrors}}
+
+ {{if .RefetchingInternalData}}Fetching...{{else}} +
+ +
+ {{end}} +
+ +
+ + + + + + + + + + + {{range $e := .InternalDataErrors}} + + + + + + + {{end}} + +
HeightHashRetriesError Message
{{formatUint32 $e.Height}}{{$e.Hash}}{{$e.Retries}}{{$e.ErrorMessage}}
+
+{{end}} \ No newline at end of file diff --git a/static/internal_templates/contract_info.html b/static/internal_templates/contract_info.html new file mode 100644 index 0000000000..57cbfece24 --- /dev/null +++ b/static/internal_templates/contract_info.html @@ -0,0 +1,39 @@ +{{define "specific"}} {{if eq .ChainType 1}} + +
+
+
+ +
+
+ +
+
+
+
+ To update contract, use POST request to /admin/contract-info/ endpoint. Example: +
+
+            curl -k -v  \
+            'https://<internaladdress>/admin/contract-info/' \
+            -H 'Content-Type: application/json' \
+            --data '[{ContractInfo},{ContractInfo},...]'        
+        
+
+
+{{else}} Not supported {{end}}{{end}} diff --git a/static/internal_templates/error.html b/static/internal_templates/error.html new file mode 100644 index 0000000000..0b75378bcf --- /dev/null +++ b/static/internal_templates/error.html @@ -0,0 +1,4 @@ +{{define "specific"}} +

Error

+

{{.Error.Text}}

+{{end}} \ No newline at end of file diff --git a/static/internal_templates/index.html b/static/internal_templates/index.html new file mode 100644 index 0000000000..7a94bce8f0 --- /dev/null +++ b/static/internal_templates/index.html @@ -0,0 +1,14 @@ +{{define "specific"}} + +{{if eq .ChainType 1}} + + +{{end}}{{end}} diff --git a/static/internal_templates/ws_limit_exceeding_ips.html b/static/internal_templates/ws_limit_exceeding_ips.html new file mode 100644 index 0000000000..081431fb1b --- /dev/null +++ b/static/internal_templates/ws_limit_exceeding_ips.html @@ -0,0 +1,29 @@ +{{define "specific"}} +

IP addresses disconnected for exceeding websocket limit

+
+
Distinct ip addresses that exceeded limit of {{.WsGetAccountInfoLimit}} requests since last reset: {{len .WsLimitExceedingIPs}}
+
+
+ +
+
+ +
+ + + + + + + + + {{range $d := .WsLimitExceedingIPs}} + + + + + {{end}} + +
IPCount
{{$d.IP}}{{$d.Count}}
+
+{{end}} \ No newline at end of file diff --git a/static/js/bootstrap.bundle.5.2.2.min.js b/static/js/bootstrap.bundle.5.2.2.min.js new file mode 100644 index 0000000000..1d138863be --- /dev/null +++ b/static/js/bootstrap.bundle.5.2.2.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.2.2 (https://getbootstrap.com/) + * Copyright 2011-2022 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e},i=t=>{const i=e(t);return i&&document.querySelector(i)?i:null},n=t=>{const i=e(t);return i?document.querySelector(i):null},s=e=>{e.dispatchEvent(new Event(t))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(t):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},m=t=>{"function"==typeof t&&t()},_=(e,i,n=!0)=>{if(!n)return void m(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(i)+5;let r=!1;const a=({target:n})=>{n===i&&(r=!0,i.removeEventListener(t,a),m(e))};i.addEventListener(t,a),setTimeout((()=>{r||s(i)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return C.has(o)||(o=t),[n,s,o]}function D(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return j(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return j(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function S(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const o of Object.keys(s))if(o.includes(n)){const n=s[o];S(t,e,i,n.callable,n.delegationSelector)}}function N(t){return t=t.replace(y,""),T[t]||t}const P={on(t,e,i,n){D(t,e,i,n,!1)},one(t,e,i,n){D(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))I(t,l,i,e.slice(1));for(const i of Object.keys(c)){const n=i.replace(w,"");if(!a||e.includes(n)){const e=c[i];S(t,l,r,e.callable,e.delegationSelector)}}}else{if(!Object.keys(c).length)return;S(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==N(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());let l=new Event(e,{bubbles:o,cancelable:!0});return l=j(l,i),a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function j(t,e){for(const[i,n]of Object.entries(e||{}))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}const M=new Map,H={set(t,e,i){M.has(t)||M.set(t,new Map);const n=M.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>M.has(t)&&M.get(t).get(e)||null,remove(t,e){if(!M.has(t))return;const i=M.get(t);i.delete(e),0===i.size&&M.delete(t)}};function $(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function W(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const B={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${W(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${W(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=$(t.dataset[n])}return e},getDataAttribute:(t,e)=>$(t.getAttribute(`data-bs-${W(e)}`))};class F{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?B.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?B.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const n of Object.keys(e)){const s=e[n],r=t[n],a=o(r)?"element":null==(i=r)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(a))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${s}".`)}var i}}class z extends F{constructor(t,e){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(e),H.set(this._element,this.constructor.DATA_KEY,this))}dispose(){H.remove(this._element,this.constructor.DATA_KEY),P.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return H.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.2.2"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const q=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;P.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const o=n(this)||this.closest(`.${s}`);t.getOrCreateInstance(o)[e]()}))};class R extends z{static get NAME(){return"alert"}close(){if(P.trigger(this._element,"close.bs.alert").defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),P.trigger(this._element,"closed.bs.alert"),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=R.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}q(R,"close"),g(R);const V='[data-bs-toggle="button"]';class K extends z{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=K.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}P.on(document,"click.bs.button.data-api",V,(t=>{t.preventDefault();const e=t.target.closest(V);K.getOrCreateInstance(e).toggle()})),g(K);const Q={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))}},X={endCallback:null,leftCallback:null,rightCallback:null},Y={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class U extends F{constructor(t,e){super(),this._element=t,t&&U.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return X}static get DefaultType(){return Y}static get NAME(){return"swipe"}dispose(){P.off(this._element,".bs.swipe")}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),m(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&m(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(P.on(this._element,"pointerdown.bs.swipe",(t=>this._start(t))),P.on(this._element,"pointerup.bs.swipe",(t=>this._end(t))),this._element.classList.add("pointer-event")):(P.on(this._element,"touchstart.bs.swipe",(t=>this._start(t))),P.on(this._element,"touchmove.bs.swipe",(t=>this._move(t))),P.on(this._element,"touchend.bs.swipe",(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const G="next",J="prev",Z="left",tt="right",et="slid.bs.carousel",it="carousel",nt="active",st={ArrowLeft:tt,ArrowRight:Z},ot={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},rt={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class at extends z{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=Q.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===it&&this.cycle()}static get Default(){return ot}static get DefaultType(){return rt}static get NAME(){return"carousel"}next(){this._slide(G)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(J)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?P.one(this._element,et,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void P.one(this._element,et,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?G:J;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&P.on(this._element,"keydown.bs.carousel",(t=>this._keydown(t))),"hover"===this._config.pause&&(P.on(this._element,"mouseenter.bs.carousel",(()=>this.pause())),P.on(this._element,"mouseleave.bs.carousel",(()=>this._maybeEnableCycle()))),this._config.touch&&U.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of Q.find(".carousel-item img",this._element))P.on(t,"dragstart.bs.carousel",(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(Z)),rightCallback:()=>this._slide(this._directionToOrder(tt)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new U(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=st[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=Q.findOne(".active",this._indicatorsElement);e.classList.remove(nt),e.removeAttribute("aria-current");const i=Q.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(nt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===G,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>P.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r("slide.bs.carousel").defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(nt),i.classList.remove(nt,c,l),this._isSliding=!1,r(et)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return Q.findOne(".active.carousel-item",this._element)}_getItems(){return Q.find(".carousel-item",this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===Z?J:G:t===Z?G:J}_orderToDirection(t){return p()?t===J?Z:tt:t===J?tt:Z}static jQueryInterface(t){return this.each((function(){const e=at.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}P.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",(function(t){const e=n(this);if(!e||!e.classList.contains(it))return;t.preventDefault();const i=at.getOrCreateInstance(e),s=this.getAttribute("data-bs-slide-to");return s?(i.to(s),void i._maybeEnableCycle()):"next"===B.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),P.on(window,"load.bs.carousel.data-api",(()=>{const t=Q.find('[data-bs-ride="carousel"]');for(const e of t)at.getOrCreateInstance(e)})),g(at);const lt="show",ct="collapse",ht="collapsing",dt='[data-bs-toggle="collapse"]',ut={parent:null,toggle:!0},ft={parent:"(null|element)",toggle:"boolean"};class pt extends z{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const n=Q.find(dt);for(const t of n){const e=i(t),n=Q.find(e).filter((t=>t===this._element));null!==e&&n.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return ut}static get DefaultType(){return ft}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>pt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(P.trigger(this._element,"show.bs.collapse").defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(ct),this._element.classList.add(ht),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct,lt),this._element.style[e]="",P.trigger(this._element,"shown.bs.collapse")}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(P.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(ht),this._element.classList.remove(ct,lt);for(const t of this._triggerArray){const e=n(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct),P.trigger(this._element,"hidden.bs.collapse")}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(lt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(dt);for(const e of t){const t=n(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=Q.find(":scope .collapse .collapse",this._config.parent);return Q.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=pt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}P.on(document,"click.bs.collapse.data-api",dt,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();const e=i(this),n=Q.find(e);for(const t of n)pt.getOrCreateInstance(t,{toggle:!1}).toggle()})),g(pt);var gt="top",mt="bottom",_t="right",bt="left",vt="auto",yt=[gt,mt,_t,bt],wt="start",At="end",Et="clippingParents",Tt="viewport",Ct="popper",Ot="reference",xt=yt.reduce((function(t,e){return t.concat([e+"-"+wt,e+"-"+At])}),[]),kt=[].concat(yt,[vt]).reduce((function(t,e){return t.concat([e,e+"-"+wt,e+"-"+At])}),[]),Lt="beforeRead",Dt="read",St="afterRead",It="beforeMain",Nt="main",Pt="afterMain",jt="beforeWrite",Mt="write",Ht="afterWrite",$t=[Lt,Dt,St,It,Nt,Pt,jt,Mt,Ht];function Wt(t){return t?(t.nodeName||"").toLowerCase():null}function Bt(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function Ft(t){return t instanceof Bt(t).Element||t instanceof Element}function zt(t){return t instanceof Bt(t).HTMLElement||t instanceof HTMLElement}function qt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof Bt(t).ShadowRoot||t instanceof ShadowRoot)}const Rt={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];zt(s)&&Wt(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});zt(n)&&Wt(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function Vt(t){return t.split("-")[0]}var Kt=Math.max,Qt=Math.min,Xt=Math.round;function Yt(){var t=navigator.userAgentData;return null!=t&&t.brands?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ut(){return!/^((?!chrome|android).)*safari/i.test(Yt())}function Gt(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&zt(t)&&(s=t.offsetWidth>0&&Xt(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&Xt(n.height)/t.offsetHeight||1);var r=(Ft(t)?Bt(t):window).visualViewport,a=!Ut()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Jt(t){var e=Gt(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Zt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&qt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function te(t){return Bt(t).getComputedStyle(t)}function ee(t){return["table","td","th"].indexOf(Wt(t))>=0}function ie(t){return((Ft(t)?t.ownerDocument:t.document)||window.document).documentElement}function ne(t){return"html"===Wt(t)?t:t.assignedSlot||t.parentNode||(qt(t)?t.host:null)||ie(t)}function se(t){return zt(t)&&"fixed"!==te(t).position?t.offsetParent:null}function oe(t){for(var e=Bt(t),i=se(t);i&&ee(i)&&"static"===te(i).position;)i=se(i);return i&&("html"===Wt(i)||"body"===Wt(i)&&"static"===te(i).position)?e:i||function(t){var e=/firefox/i.test(Yt());if(/Trident/i.test(Yt())&&zt(t)&&"fixed"===te(t).position)return null;var i=ne(t);for(qt(i)&&(i=i.host);zt(i)&&["html","body"].indexOf(Wt(i))<0;){var n=te(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function re(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function ae(t,e,i){return Kt(t,Qt(e,i))}function le(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function ce(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const he={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=Vt(i.placement),l=re(a),c=[bt,_t].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return le("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:ce(t,yt))}(s.padding,i),d=Jt(o),u="y"===l?gt:bt,f="y"===l?mt:_t,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],g=r[l]-i.rects.reference[l],m=oe(o),_=m?"y"===l?m.clientHeight||0:m.clientWidth||0:0,b=p/2-g/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=ae(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Zt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function de(t){return t.split("-")[1]}var ue={top:"auto",right:"auto",bottom:"auto",left:"auto"};function fe(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,g=void 0===p?0:p,m="function"==typeof h?h({x:f,y:g}):{x:f,y:g};f=m.x,g=m.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=bt,y=gt,w=window;if(c){var A=oe(i),E="clientHeight",T="clientWidth";A===Bt(i)&&"static"!==te(A=ie(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===gt||(s===bt||s===_t)&&o===At)&&(y=mt,g-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,g*=l?1:-1),s!==bt&&(s!==gt&&s!==mt||o!==At)||(v=_t,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&ue),x=!0===h?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:Xt(e*n)/n||0,y:Xt(i*n)/n||0}}({x:f,y:g}):{x:f,y:g};return f=x.x,g=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+g+"px)":"translate3d("+f+"px, "+g+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?g+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const pe={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:Vt(e.placement),variation:de(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,fe(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,fe(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ge={passive:!0};const me={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=Bt(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ge)})),a&&l.addEventListener("resize",i.update,ge),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ge)})),a&&l.removeEventListener("resize",i.update,ge)}},data:{}};var _e={left:"right",right:"left",bottom:"top",top:"bottom"};function be(t){return t.replace(/left|right|bottom|top/g,(function(t){return _e[t]}))}var ve={start:"end",end:"start"};function ye(t){return t.replace(/start|end/g,(function(t){return ve[t]}))}function we(t){var e=Bt(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ae(t){return Gt(ie(t)).left+we(t).scrollLeft}function Ee(t){var e=te(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Te(t){return["html","body","#document"].indexOf(Wt(t))>=0?t.ownerDocument.body:zt(t)&&Ee(t)?t:Te(ne(t))}function Ce(t,e){var i;void 0===e&&(e=[]);var n=Te(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=Bt(n),r=s?[o].concat(o.visualViewport||[],Ee(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Ce(ne(r)))}function Oe(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function xe(t,e,i){return e===Tt?Oe(function(t,e){var i=Bt(t),n=ie(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ut();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ae(t),y:l}}(t,i)):Ft(e)?function(t,e){var i=Gt(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Oe(function(t){var e,i=ie(t),n=we(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=Kt(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=Kt(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ae(t),l=-n.scrollTop;return"rtl"===te(s||i).direction&&(a+=Kt(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(ie(t)))}function ke(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?Vt(s):null,r=s?de(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case gt:e={x:a,y:i.y-n.height};break;case mt:e={x:a,y:i.y+i.height};break;case _t:e={x:i.x+i.width,y:l};break;case bt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?re(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case wt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case At:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function Le(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Et:a,c=i.rootBoundary,h=void 0===c?Tt:c,d=i.elementContext,u=void 0===d?Ct:d,f=i.altBoundary,p=void 0!==f&&f,g=i.padding,m=void 0===g?0:g,_=le("number"!=typeof m?m:ce(m,yt)),b=u===Ct?Ot:Ct,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Ce(ne(t)),i=["absolute","fixed"].indexOf(te(t).position)>=0&&zt(t)?oe(t):t;return Ft(i)?e.filter((function(t){return Ft(t)&&Zt(t,i)&&"body"!==Wt(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=xe(t,i,n);return e.top=Kt(s.top,e.top),e.right=Qt(s.right,e.right),e.bottom=Qt(s.bottom,e.bottom),e.left=Kt(s.left,e.left),e}),xe(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(Ft(y)?y:y.contextElement||ie(t.elements.popper),l,h,r),A=Gt(t.elements.reference),E=ke({reference:A,element:v,strategy:"absolute",placement:s}),T=Oe(Object.assign({},v,E)),C=u===Ct?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Ct&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[_t,mt].indexOf(t)>=0?1:-1,i=[gt,mt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function De(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?kt:l,h=de(n),d=h?a?xt:xt.filter((function(t){return de(t)===h})):yt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=Le(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[Vt(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const Se={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,g=i.allowedAutoPlacements,m=e.options.placement,_=Vt(m),b=l||(_!==m&&p?function(t){if(Vt(t)===vt)return[];var e=be(t);return[ye(t),e,ye(e)]}(m):[be(m)]),v=[m].concat(b).reduce((function(t,i){return t.concat(Vt(i)===vt?De(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:g}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,D=L?"width":"height",S=Le(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),I=L?k?_t:bt:k?mt:gt;y[D]>w[D]&&(I=be(I));var N=be(I),P=[];if(o&&P.push(S[x]<=0),a&&P.push(S[I]<=0,S[N]<=0),P.every((function(t){return t}))){T=O,E=!1;break}A.set(O,P)}if(E)for(var j=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},M=p?3:1;M>0&&"break"!==j(M);M--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function Ie(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Ne(t){return[gt,_t,mt,bt].some((function(e){return t[e]>=0}))}const Pe={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=Le(e,{elementContext:"reference"}),a=Le(e,{altBoundary:!0}),l=Ie(r,n),c=Ie(a,s,o),h=Ne(l),d=Ne(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},je={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=kt.reduce((function(t,i){return t[i]=function(t,e,i){var n=Vt(t),s=[bt,gt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[bt,_t].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},Me={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ke({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},He={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,g=void 0===p?0:p,m=Le(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=Vt(e.placement),b=de(e.placement),v=!b,y=re(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof g?g(Object.assign({},e.rects,{placement:e.placement})):g,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,D="y"===y?gt:bt,S="y"===y?mt:_t,I="y"===y?"height":"width",N=A[y],P=N+m[D],j=N-m[S],M=f?-T[I]/2:0,H=b===wt?E[I]:T[I],$=b===wt?-T[I]:-E[I],W=e.elements.arrow,B=f&&W?Jt(W):{width:0,height:0},F=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=F[D],q=F[S],R=ae(0,E[I],B[I]),V=v?E[I]/2-M-R-z-O.mainAxis:H-R-z-O.mainAxis,K=v?-E[I]/2+M+R+q+O.mainAxis:$+R+q+O.mainAxis,Q=e.elements.arrow&&oe(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=N+K-Y,G=ae(f?Qt(P,N+V-Y-X):P,N,f?Kt(j,U):j);A[y]=G,k[y]=G-N}if(a){var J,Z="x"===y?gt:bt,tt="x"===y?mt:_t,et=A[w],it="y"===w?"height":"width",nt=et+m[Z],st=et-m[tt],ot=-1!==[gt,bt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=ae(t,e,i);return n>i?i:n}(at,et,lt):ae(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function $e(t,e,i){void 0===i&&(i=!1);var n,s,o=zt(e),r=zt(e)&&function(t){var e=t.getBoundingClientRect(),i=Xt(e.width)/t.offsetWidth||1,n=Xt(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=ie(e),l=Gt(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==Wt(e)||Ee(a))&&(c=(n=e)!==Bt(n)&&zt(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:we(n)),zt(e)?((h=Gt(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ae(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function We(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var Be={placement:"bottom",modifiers:[],strategy:"absolute"};function Fe(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(B.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..."function"==typeof this._config.popperConfig?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:t,target:e}){const i=Q.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ye,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=Q.find(ti);for(const i of e){const e=hi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Xe,Ye].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ze)?this:Q.prev(this,Ze)[0]||Q.next(this,Ze)[0]||Q.findOne(Ze,t.delegateTarget.parentNode),o=hi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}P.on(document,Ge,Ze,hi.dataApiKeydownHandler),P.on(document,Ge,ei,hi.dataApiKeydownHandler),P.on(document,Ue,hi.clearMenus),P.on(document,"keyup.bs.dropdown.data-api",hi.clearMenus),P.on(document,Ue,Ze,(function(t){t.preventDefault(),hi.getOrCreateInstance(this).toggle()})),g(hi);const di=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",ui=".sticky-top",fi="padding-right",pi="margin-right";class gi{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,fi,(e=>e+t)),this._setElementAttributes(di,fi,(e=>e+t)),this._setElementAttributes(ui,pi,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,fi),this._resetElementAttributes(di,fi),this._resetElementAttributes(ui,pi)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&B.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=B.getDataAttribute(t,e);null!==i?(B.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of Q.find(t,this._element))e(i)}}const mi="show",_i="mousedown.bs.backdrop",bi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},vi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class yi extends F{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return bi}static get DefaultType(){return vi}static get NAME(){return"backdrop"}show(t){if(!this._config.isVisible)return void m(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(mi),this._emulateAnimation((()=>{m(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(mi),this._emulateAnimation((()=>{this.dispose(),m(t)}))):m(t)}dispose(){this._isAppended&&(P.off(this._element,_i),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),P.on(t,_i,(()=>{m(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const wi=".bs.focustrap",Ai="backward",Ei={autofocus:!0,trapElement:null},Ti={autofocus:"boolean",trapElement:"element"};class Ci extends F{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return Ei}static get DefaultType(){return Ti}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),P.off(document,wi),P.on(document,"focusin.bs.focustrap",(t=>this._handleFocusin(t))),P.on(document,"keydown.tab.bs.focustrap",(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,P.off(document,wi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=Q.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===Ai?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Ai:"forward")}}const Oi="hidden.bs.modal",xi="show.bs.modal",ki="modal-open",Li="show",Di="modal-static",Si={backdrop:!0,focus:!0,keyboard:!0},Ii={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ni extends z{constructor(t,e){super(t,e),this._dialog=Q.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new gi,this._addEventListeners()}static get Default(){return Si}static get DefaultType(){return Ii}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||P.trigger(this._element,xi,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(ki),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(P.trigger(this._element,"hide.bs.modal").defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Li),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){for(const t of[window,this._dialog])P.off(t,".bs.modal");this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new yi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Ci({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=Q.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(Li),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,P.trigger(this._element,"shown.bs.modal",{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){P.on(this._element,"keydown.dismiss.bs.modal",(t=>{if("Escape"===t.key)return this._config.keyboard?(t.preventDefault(),void this.hide()):void this._triggerBackdropTransition()})),P.on(window,"resize.bs.modal",(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),P.on(this._element,"mousedown.dismiss.bs.modal",(t=>{P.one(this._element,"click.dismiss.bs.modal",(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(ki),this._resetAdjustments(),this._scrollBar.reset(),P.trigger(this._element,Oi)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(P.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Di)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Di),this._queueCallback((()=>{this._element.classList.remove(Di),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}P.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=n(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),P.one(e,xi,(t=>{t.defaultPrevented||P.one(e,Oi,(()=>{a(this)&&this.focus()}))}));const i=Q.findOne(".modal.show");i&&Ni.getInstance(i).hide(),Ni.getOrCreateInstance(e).toggle(this)})),q(Ni),g(Ni);const Pi="show",ji="showing",Mi="hiding",Hi=".offcanvas.show",$i="hidePrevented.bs.offcanvas",Wi="hidden.bs.offcanvas",Bi={backdrop:!0,keyboard:!0,scroll:!1},Fi={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class zi extends z{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return Bi}static get DefaultType(){return Fi}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||P.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new gi).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(ji),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Pi),this._element.classList.remove(ji),P.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(P.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(Mi),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Pi,Mi),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new gi).reset(),P.trigger(this._element,Wi)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new yi({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():P.trigger(this._element,$i)}:null})}_initializeFocusTrap(){return new Ci({trapElement:this._element})}_addEventListeners(){P.on(this._element,"keydown.dismiss.bs.offcanvas",(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():P.trigger(this._element,$i))}))}static jQueryInterface(t){return this.each((function(){const e=zi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}P.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(t){const e=n(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;P.one(e,Wi,(()=>{a(this)&&this.focus()}));const i=Q.findOne(Hi);i&&i!==e&&zi.getInstance(i).hide(),zi.getOrCreateInstance(e).toggle(this)})),P.on(window,"load.bs.offcanvas.data-api",(()=>{for(const t of Q.find(Hi))zi.getOrCreateInstance(t).show()})),P.on(window,"resize.bs.offcanvas",(()=>{for(const t of Q.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&zi.getOrCreateInstance(t).hide()})),q(zi),g(zi);const qi=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Ri=/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i,Vi=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,Ki=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!qi.has(i)||Boolean(Ri.test(t.nodeValue)||Vi.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Qi={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Xi={allowList:Qi,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Yi={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Ui={entry:"(string|element|function|null)",selector:"(string|element)"};class Gi extends F{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Ui)}_setContent(t,e,i){const n=Q.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Ki(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return"function"==typeof t?t(this):t}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Ji=new Set(["sanitize","allowList","sanitizeFn"]),Zi="fade",tn="show",en=".modal",nn="hide.bs.modal",sn="hover",on="focus",rn={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},an={allowList:Qi,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,0],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ln={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cn extends z{constructor(t,e){if(void 0===Ke)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return an}static get DefaultType(){return ln}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),P.off(this._element.closest(en),nn,this._hideModalHandler),this.tip&&this.tip.remove(),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=P.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this.tip&&(this.tip.remove(),this.tip=null);const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),P.trigger(this._element,this.constructor.eventName("inserted"))),this._popper?this._popper.update():this._popper=this._createPopper(i),i.classList.add(tn),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))P.on(t,"mouseover",h);this._queueCallback((()=>{P.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(!this._isShown())return;if(P.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented)return;const t=this._getTipElement();if(t.classList.remove(tn),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))P.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||t.remove(),this._element.removeAttribute("aria-describedby"),P.trigger(this._element,this.constructor.eventName("hidden")),this._disposePopper())}),this.tip,this._isAnimated())}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Zi,tn),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(Zi),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Gi({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Zi)}_isShown(){return this.tip&&this.tip.classList.contains(tn)}_createPopper(t){const e="function"==typeof this._config.placement?this._config.placement.call(this,t,this._element):this._config.placement,i=rn[e.toUpperCase()];return Ve(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return"function"==typeof t?t.call(this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,..."function"==typeof this._config.popperConfig?this._config.popperConfig(e):this._config.popperConfig}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)P.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===sn?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===sn?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");P.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?on:sn]=!0,e._enter()})),P.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?on:sn]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},P.on(this._element.closest(en),nn,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=B.getDataAttributes(this._element);for(const t of Object.keys(e))Ji.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null)}static jQueryInterface(t){return this.each((function(){const e=cn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(cn);const hn={...cn.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},dn={...cn.DefaultType,content:"(null|string|element|function)"};class un extends cn{static get Default(){return hn}static get DefaultType(){return dn}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=un.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(un);const fn="click.bs.scrollspy",pn="active",gn="[href]",mn={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},_n={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class bn extends z{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return mn}static get DefaultType(){return _n}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(P.off(this._config.target,fn),P.on(this._config.target,fn,gn,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=Q.find(gn,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=Q.findOne(e.hash,this._element);a(t)&&(this._targetLinks.set(e.hash,e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(pn),this._activateParents(t),P.trigger(this._element,"activate.bs.scrollspy",{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))Q.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(pn);else for(const e of Q.parents(t,".nav, .list-group"))for(const t of Q.prev(e,".nav-link, .nav-item > .nav-link, .list-group-item"))t.classList.add(pn)}_clearActiveClass(t){t.classList.remove(pn);const e=Q.find("[href].active",t);for(const t of e)t.classList.remove(pn)}static jQueryInterface(t){return this.each((function(){const e=bn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(window,"load.bs.scrollspy.data-api",(()=>{for(const t of Q.find('[data-bs-spy="scroll"]'))bn.getOrCreateInstance(t)})),g(bn);const vn="ArrowLeft",yn="ArrowRight",wn="ArrowUp",An="ArrowDown",En="active",Tn="fade",Cn="show",On='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',xn=`.nav-link:not(.dropdown-toggle), .list-group-item:not(.dropdown-toggle), [role="tab"]:not(.dropdown-toggle), ${On}`;class kn extends z{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),P.on(this._element,"keydown.bs.tab",(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?P.trigger(e,"hide.bs.tab",{relatedTarget:t}):null;P.trigger(t,"show.bs.tab",{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(En),this._activate(n(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),P.trigger(t,"shown.bs.tab",{relatedTarget:e})):t.classList.add(Cn)}),t,t.classList.contains(Tn)))}_deactivate(t,e){t&&(t.classList.remove(En),t.blur(),this._deactivate(n(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),P.trigger(t,"hidden.bs.tab",{relatedTarget:e})):t.classList.remove(Cn)}),t,t.classList.contains(Tn)))}_keydown(t){if(![vn,yn,wn,An].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=[yn,An].includes(t.key),i=b(this._getChildren().filter((t=>!l(t))),t.target,e,!0);i&&(i.focus({preventScroll:!0}),kn.getOrCreateInstance(i).show())}_getChildren(){return Q.find(xn,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=n(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`#${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=Q.findOne(t,i);s&&s.classList.toggle(n,e)};n(".dropdown-toggle",En),n(".dropdown-menu",Cn),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(En)}_getInnerElement(t){return t.matches(xn)?t:Q.findOne(xn,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=kn.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(document,"click.bs.tab",On,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||kn.getOrCreateInstance(this).show()})),P.on(window,"load.bs.tab",(()=>{for(const t of Q.find('.active[data-bs-toggle="tab"], .active[data-bs-toggle="pill"], .active[data-bs-toggle="list"]'))kn.getOrCreateInstance(t)})),g(kn);const Ln="hide",Dn="show",Sn="showing",In={animation:"boolean",autohide:"boolean",delay:"number"},Nn={animation:!0,autohide:!0,delay:5e3};class Pn extends z{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return Nn}static get DefaultType(){return In}static get NAME(){return"toast"}show(){P.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(Ln),d(this._element),this._element.classList.add(Dn,Sn),this._queueCallback((()=>{this._element.classList.remove(Sn),P.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(P.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.add(Sn),this._queueCallback((()=>{this._element.classList.add(Ln),this._element.classList.remove(Sn,Dn),P.trigger(this._element,"hidden.bs.toast")}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(Dn),super.dispose()}isShown(){return this._element.classList.contains(Dn)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){P.on(this._element,"mouseover.bs.toast",(t=>this._onInteraction(t,!0))),P.on(this._element,"mouseout.bs.toast",(t=>this._onInteraction(t,!1))),P.on(this._element,"focusin.bs.toast",(t=>this._onInteraction(t,!0))),P.on(this._element,"focusout.bs.toast",(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Pn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return q(Pn),g(Pn),{Alert:R,Button:K,Carousel:at,Collapse:pt,Dropdown:hi,Modal:Ni,Offcanvas:zi,Popover:un,ScrollSpy:bn,Tab:kn,Toast:Pn,Tooltip:cn}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index 3efffd4704..b1d31408f8 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -28,10 +28,10 @@ function syntaxHighlight(json) { } function getCoinCookie() { - return document.cookie - .split("; ") - .find((row) => row.startsWith("secondary_coin=")) - ?.split("="); + if(hasSecondary) return document.cookie + .split("; ") + .find((row) => row.startsWith("secondary_coin=")) + ?.split("="); } function changeCSSStyle(selector, cssProp, cssVal) { @@ -85,6 +85,79 @@ function addressAliasTooltip() { return `${type}
${address}
`; } +function handleTxPage(rawData, txId) { + const rawOutput = document.getElementById('raw'); + const rawButton = document.getElementById('raw-button'); + const rawHexButton = document.getElementById('raw-hex-button'); + + rawOutput.innerHTML = syntaxHighlight(rawData); + + let isShowingHexData = false; + + const memoizedResponses = {}; + + async function getTransactionHex(txId) { + // BTC-like coins have a 'hex' field in the raw data + if (rawData['hex']) { + return rawData['hex']; + } + if (memoizedResponses[txId]) { + return memoizedResponses[txId]; + } + const fetchedData = await fetchTransactionHex(txId); + memoizedResponses[txId] = fetchedData; + return fetchedData; + } + + async function fetchTransactionHex(txId) { + const response = await fetch(`/api/rawtx/${txId}`); + if (!response.ok) { + throw new Error(`Error fetching data: ${response.status}`); + } + const txHex = await response.text(); + const hexWithoutQuotes = txHex.replace(/"/g, ''); + return hexWithoutQuotes; + } + + function updateButtonStyles() { + if (isShowingHexData) { + rawButton.classList.add('active'); + rawButton.style.fontWeight = 'normal'; + rawHexButton.classList.remove('active'); + rawHexButton.style.fontWeight = 'bold'; + } else { + rawButton.classList.remove('active'); + rawButton.style.fontWeight = 'bold'; + rawHexButton.classList.add('active'); + rawHexButton.style.fontWeight = 'normal'; + } + } + + updateButtonStyles(); + + rawHexButton.addEventListener('click', async () => { + if (!isShowingHexData) { + try { + const txHex = await getTransactionHex(txId); + rawOutput.textContent = txHex; + } catch (error) { + console.error('Error fetching raw transaction hex:', error); + rawOutput.textContent = `Error fetching raw transaction hex: ${error.message}`; + } + isShowingHexData = true; + updateButtonStyles(); + } + }); + + rawButton.addEventListener('click', () => { + if (isShowingHexData) { + rawOutput.innerHTML = syntaxHighlight(rawData); + isShowingHexData = false; + updateButtonStyles(); + } + }); +} + window.addEventListener("DOMContentLoaded", () => { const a = getCoinCookie(); if (a?.length === 3) { @@ -127,7 +200,8 @@ window.addEventListener("DOMContentLoaded", () => { if (e.clientX < e.target.getBoundingClientRect().x) { let t = e.target.getAttribute("cc"); if (!t) t = e.target.innerText; - navigator.clipboard.writeText(t); + const textToCopy = t.trim(); + navigator.clipboard.writeText(textToCopy); e.target.className = e.target.className.replace("copyable", "copied"); setTimeout( () => diff --git a/static/js/main.min.2.js b/static/js/main.min.2.js deleted file mode 100644 index 271d4598ff..0000000000 --- a/static/js/main.min.2.js +++ /dev/null @@ -1 +0,0 @@ -function syntaxHighlight(t){return(t=(t=JSON.stringify(t,void 0,2)).replace(/&/g,"&").replace(//g,">")).length>1e6?`${t}`:t.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,t=>{let e="number";return/^"/.test(t)?e=/:$/.test(t)?"key":"string":/true|false/.test(t)?e="boolean":/null/.test(t)&&(e="null"),`${t}`})}function getCoinCookie(){return document.cookie.split("; ").find(t=>t.startsWith("secondary_coin="))?.split("=")}function changeCSSStyle(t,e,l){let a=document.all?"rules":"cssRules";for(i=0,len=document.styleSheets[1][a].length;i`;if(a){let n=a.getAttribute("tm");n||(n="now"),r+=`${n}${a.outerHTML}
`}if(s&&(r+=`now${s.outerHTML}
`),e){let o=e.getAttribute("tm");o||(o="now"),r+=`${o}${e.outerHTML}
`}return l&&(r+=`now${l.outerHTML}
`),`${r}`}function addressAliasTooltip(){let t=this.getAttribute("alias-type"),e=this.getAttribute("cc");return`${t}
${e}
`}window.addEventListener("DOMContentLoaded",()=>{let t=getCoinCookie();t?.length===3&&("true"===t[2]&&(changeCSSStyle(".prim-amt","display","none"),changeCSSStyle(".sec-amt","display","initial")),document.querySelectorAll(".amt").forEach(t=>new bootstrap.Tooltip(t,{title:amountTooltip,html:!0}))),document.querySelectorAll("[alias-type]").forEach(t=>new bootstrap.Tooltip(t,{title:addressAliasTooltip,html:!0})),document.querySelectorAll("[tt]").forEach(t=>new bootstrap.Tooltip(t,{title:t.getAttribute("tt")})),document.querySelectorAll("#header .bb-group>.btn-check").forEach(t=>t.addEventListener("click",t=>{let e=getCoinCookie(),l="secondary-coin"===t.target.id;e?.length===3&&"true"===e[2]!==l&&(document.cookie=`${e[0]}=${e[1]}=${l}; Path=/`,changeCSSStyle(".prim-amt","display",l?"none":"initial"),changeCSSStyle(".sec-amt","display",l?"initial":"none"))})),document.querySelectorAll(".copyable").forEach(t=>t.addEventListener("click",t=>{if(t.clientXt.target.className=t.target.className.replace("copied","copyable"),1e3),t.preventDefault()}}))}); \ No newline at end of file diff --git a/static/js/main.min.4.js b/static/js/main.min.4.js new file mode 100644 index 0000000000..5e237185ab --- /dev/null +++ b/static/js/main.min.4.js @@ -0,0 +1 @@ +function syntaxHighlight(t){return(t=(t=JSON.stringify(t,void 0,2)).replace(/&/g,"&").replace(//g,">")).length>1e6?`${t}`:t.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,(t=>{let e="number";return/^"/.test(t)?e=/:$/.test(t)?"key":"string":/true|false/.test(t)?e="boolean":/null/.test(t)&&(e="null"),`${t}`}))}function getCoinCookie(){if(hasSecondary)return document.cookie.split("; ").find((t=>t.startsWith("secondary_coin=")))?.split("=")}function changeCSSStyle(t,e,n){const a=document.all?"rules":"cssRules";for(i=0,len=document.styleSheets[1][a].length;i`;if(a){let t=a.getAttribute("tm");t||(t="now"),i+=`${t}${a.outerHTML}
`}if(o&&(i+=`now${o.outerHTML}
`),e){let t=e.getAttribute("tm");t||(t="now"),i+=`${t}${e.outerHTML}
`}return n&&(i+=`now${n.outerHTML}
`),`${i}`}function addressAliasTooltip(){return`${this.getAttribute("alias-type")}
${this.getAttribute("cc")}
`}function handleTxPage(t,e){const n=document.getElementById("raw"),a=document.getElementById("raw-button"),o=document.getElementById("raw-hex-button");n.innerHTML=syntaxHighlight(t);let i=!1;const r={};async function s(e){if(t.hex)return t.hex;if(r[e])return r[e];const n=await async function(t){const e=await fetch(`/api/rawtx/${t}`);if(!e.ok)throw new Error(`Error fetching data: ${e.status}`);const n=await e.text();return n.replace(/"/g,"")}(e);return r[e]=n,n}function l(){i?(a.classList.add("active"),a.style.fontWeight="normal",o.classList.remove("active"),o.style.fontWeight="bold"):(a.classList.remove("active"),a.style.fontWeight="bold",o.classList.add("active"),o.style.fontWeight="normal")}l(),o.addEventListener("click",(async()=>{if(!i){try{const t=await s(e);n.textContent=t}catch(t){console.error("Error fetching raw transaction hex:",t),n.textContent=`Error fetching raw transaction hex: ${t.message}`}i=!0,l()}})),a.addEventListener("click",(()=>{i&&(n.innerHTML=syntaxHighlight(t),i=!1,l())}))}window.addEventListener("DOMContentLoaded",(()=>{const t=getCoinCookie();3===t?.length&&("true"===t[2]&&(changeCSSStyle(".prim-amt","display","none"),changeCSSStyle(".sec-amt","display","initial")),document.querySelectorAll(".amt").forEach((t=>new bootstrap.Tooltip(t,{title:amountTooltip,html:!0})))),document.querySelectorAll("[alias-type]").forEach((t=>new bootstrap.Tooltip(t,{title:addressAliasTooltip,html:!0}))),document.querySelectorAll("[tt]").forEach((t=>new bootstrap.Tooltip(t,{title:t.getAttribute("tt")}))),document.querySelectorAll("#header .bb-group>.btn-check").forEach((t=>t.addEventListener("click",(t=>{const e=getCoinCookie(),n="secondary-coin"===t.target.id;3===e?.length&&"true"===e[2]!==n&&(document.cookie=`${e[0]}=${e[1]}=${n}; Path=/`,changeCSSStyle(".prim-amt","display",n?"none":"initial"),changeCSSStyle(".sec-amt","display",n?"initial":"none"))})))),document.querySelectorAll(".copyable").forEach((t=>t.addEventListener("click",(t=>{if(t.clientXt.target.className=t.target.className.replace("copied","copyable")),1e3),t.preventDefault()}}))))})); \ No newline at end of file diff --git a/static/templates/address.html b/static/templates/address.html index 2ae5bb301e..80563ef13e 100644 --- a/static/templates/address.html +++ b/static/templates/address.html @@ -19,7 +19,7 @@

@@ -50,11 +50,12 @@

Nonce {{$addr.Nonce}} + {{template "addressChainExtra" .}} {{if $addr.ContractInfo}} - {{if $addr.ContractInfo.Type}} + {{if $addr.ContractInfo.Standard}} - Contract type - {{$addr.ContractInfo.Type}} + Standard + {{$addr.ContractInfo.Standard}} {{end}} {{if $addr.ContractInfo.CreatedInBlock}} @@ -109,13 +110,13 @@

{{end}} {{if eq .ChainType 1}} -{{if tokenCount $addr.Tokens "ERC20"}} +{{if tokenCount $addr.Tokens .FungibleTokenName}}
@@ -131,7 +132,7 @@
{{summaryValuesSpa Transfers# {{range $t := $addr.Tokens}} - {{if eq $t.Type "ERC20"}} + {{if eq $t.Standard $.FungibleTokenName}} {{if $t.Name}}{{$t.Name}}{{else}}{{$t.Contract}}{{end}} {{formattedAmountSpan $t.BalanceSat $t.Decimals $t.Symbol $data "copyable"}} @@ -147,13 +148,13 @@
{{summaryValuesSpa
{{end}} -{{if tokenCount $addr.Tokens "ERC721"}} +{{if tokenCount $addr.Tokens .NonFungibleTokenName}}
@@ -167,7 +168,7 @@
ERC721 Tokens {{toke Transfers# {{range $t := $addr.Tokens}} - {{if eq $t.Type "ERC721"}} + {{if eq $t.Standard $.NonFungibleTokenName}} {{if $t.Name}}{{$t.Name}}{{else}}{{$t.Contract}}{{end}} @@ -184,13 +185,13 @@
ERC721 Tokens {{toke
{{end}} -{{if tokenCount $addr.Tokens "ERC1155"}} +{{if tokenCount $addr.Tokens .MultiTokenName}}
@@ -204,7 +205,7 @@
ERC1155 Tokens {{tok Transfers# {{range $t := $addr.Tokens}} - {{if eq $t.Type "ERC1155"}} + {{if eq $t.Standard $.MultiTokenName}} {{if $t.Name}}{{$t.Name}}{{else}}{{$t.Contract}}{{end}} @@ -221,6 +222,60 @@
ERC1155 Tokens {{tok
{{end}} +{{if $addr.StakingPools }} +
+
+
+ +
+
+
+ {{range $sp := $addr.StakingPools}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{$sp.Name}} {{$sp.Contract}}
Pending Balance{{amountSpan $sp.PendingBalance $data "copyable"}}
Pending Deposited Balance{{amountSpan $sp.PendingDepositedBalance $data "copyable"}}
Deposited Balance{{amountSpan $sp.DepositedBalance $data "copyable"}}
Withdrawal Total Amount{{amountSpan $sp.WithdrawTotalAmount $data "copyable"}}
Claimable Amount{{amountSpan $sp.ClaimableAmount $data "copyable"}}
Restaked Reward{{amountSpan $sp.RestakedReward $data "copyable"}}
Autocompound Balance{{amountSpan $sp.AutocompoundBalance $data "copyable"}}
+ {{end}} +
+
+
+
+{{end}} {{end}} {{if or $addr.Transactions $addr.Filter}}
@@ -234,18 +289,18 @@

Transactions

{{range $t := $addr.Tokens}} - {{if eq $t.Type "ERC20"}} - + {{if eq $t.Standard $.FungibleTokenName}} + {{end}} {{end}} {{range $t := $addr.Tokens}} - {{if eq $t.Type "ERC721"}} - + {{if eq $t.Standard $.NonFungibleTokenName}} + {{end}} {{end}} {{range $t := $addr.Tokens}} - {{if eq $t.Type "ERC1155"}} - + {{if eq $t.Standard $.MultiTokenName}} + {{end}} {{end}} {{end}} @@ -259,4 +314,4 @@

Transactions

{{range $tx := $addr.Transactions}}{{$data := setTxToTemplateData $data $tx}}{{template "txdetail" $data}}{{end}}
{{template "paging" $data }} -{{end}}{{end}} \ No newline at end of file +{{end}}{{end}} diff --git a/static/templates/address_chainextra.html b/static/templates/address_chainextra.html new file mode 100644 index 0000000000..0a693085d2 --- /dev/null +++ b/static/templates/address_chainextra.html @@ -0,0 +1 @@ +{{define "addressChainExtra"}}{{end}} diff --git a/static/templates/address_chainextra_tron.html b/static/templates/address_chainextra_tron.html new file mode 100644 index 0000000000..7b90489148 --- /dev/null +++ b/static/templates/address_chainextra_tron.html @@ -0,0 +1,20 @@ +{{define "addressChainExtra"}}{{$addr := .Address}}{{$data := .}}{{$chainExtra := accountChainExtra $addr}} +{{if $chainExtra}} + +
Resources
+ + + + Staked Bandwidth + {{formatInt64 $chainExtra.AvailableStakedBandwidth}} / {{formatInt64 $chainExtra.TotalStakedBandwidth}} + + + Free Bandwidth + {{formatInt64 $chainExtra.AvailableFreeBandwidth}} / {{formatInt64 $chainExtra.TotalFreeBandwidth}} + + + Energy + {{formatInt64 $chainExtra.AvailableEnergy}} / {{formatInt64 $chainExtra.TotalEnergy}} + +{{end}} +{{end}} diff --git a/static/templates/base.html b/static/templates/base.html index 86f2631e52..9156d494e1 100644 --- a/static/templates/base.html +++ b/static/templates/base.html @@ -4,9 +4,10 @@ - + - + + @@ -53,7 +54,7 @@ {{range $c := .SecondaryCurrencies}} {{end}} -
+ {{end}} diff --git a/static/templates/index.html b/static/templates/index.html index 36d81fe5cc..3454943f35 100644 --- a/static/templates/index.html +++ b/static/templates/index.html @@ -64,6 +64,12 @@

{{$data.ContractInfo.Contract}}
{{$data.ContractInfo.Name}} - Contract type - {{$data.ContractInfo.Type}} + Standard + {{$data.ContractInfo.Standard}} @@ -51,7 +51,7 @@
Metadata
} async function getMetadata(url) { try { - const uri={{ jsStr $data.URI }}; + const uri={{ $data.URI }}; if(uri) { const response = await fetch(uri); const contentType=response.headers.get('content-type'); @@ -89,4 +89,4 @@
Metadata
} getMetadata(); -{{end}} \ No newline at end of file +{{end}} diff --git a/static/templates/tx_bitcointype.html b/static/templates/tx_bitcointype.html new file mode 100644 index 0000000000..38d484c3a2 --- /dev/null +++ b/static/templates/tx_bitcointype.html @@ -0,0 +1,94 @@ +{{define "specific"}}{{$tx := .Tx}}{{$data := .}} +
+

Transaction

+
+
+
{{$tx.Txid}}
+
+ + + {{if $tx.Confirmations}} + + + + + {{end}} + + + + + {{if $tx.Confirmations}} + + + + + {{end}} + + + + + + + + + {{if $tx.VSize}} + + + + + {{else}} + {{if $tx.Size}} + + + + + {{end}} + {{end}} + {{if $tx.FeesSat}} + + + + + {{end}} + {{if not $tx.Confirmations}} + {{if $tx.ConfirmationETABlocks}} + + + + + {{end}} + + + + + {{end}} + +
Mined Time{{unixTimeSpan $tx.Blocktime}}
In Block{{if $tx.Confirmations}}{{$tx.Blockhash}}{{else}}Unconfirmed{{end}}
In Block Height{{formatInt $tx.Blockheight}}
Total Input{{amountSpan $tx.ValueInSat $data "copyable"}}
Total Output{{amountSpan $tx.ValueOutSat $data "copyable"}}
Size / vSize{{formatInt $tx.Size}} / {{formatInt $tx.VSize}}
Size{{formatInt $tx.Size}}
Fees{{amountSpan $tx.FeesSat $data "copyable"}}{{if $tx.Size}} ({{feePerByte $tx}}){{end}}
Confirmation ETA + in approx. {{relativeTime $tx.ConfirmationETASeconds}} ({{$tx.ConfirmationETABlocks}} blocks) +
RBF + {{if $tx.Rbf}} + ON + {{else}} + OFF️ + {{end}} +
+
+ {{template "txdetail" .}} +
+
+ + +
+

+    
+ +
+{{end}} diff --git a/static/templates/tx.html b/static/templates/tx_ethereumtype.html similarity index 60% rename from static/templates/tx.html rename to static/templates/tx_ethereumtype.html index 396d6c14de..1c3da7937b 100644 --- a/static/templates/tx.html +++ b/static/templates/tx_ethereumtype.html @@ -1,4 +1,4 @@ -{{define "specific"}}{{$tx := .Tx}}{{$data := .}} +{{define "specific"}}{{$tx := .Tx}}{{$data := .}}{{$eth := $tx.EthereumSpecific}}

Transaction

@@ -9,7 +9,7 @@
{{$tx.Txid}} {{if $tx.Confirmations}} - Mined Time + Included at {{unixTimeSpan $tx.Blocktime}} {{end}} @@ -21,22 +21,23 @@
{{$tx.Txid}} In Block Height {{formatInt $tx.Blockheight}} - {{end}} - {{if $tx.EthereumSpecific}} + + {{end}} + {{if $eth}} Status - {{if $tx.EthereumSpecific.Status}} - {{if eq $tx.EthereumSpecific.Status 1}} + {{if $eth.Status}} + {{if eq $eth.Status 1}} Success {{else}} - {{if eq $tx.EthereumSpecific.Status -1}} + {{if eq $eth.Status -1}} Pending {{else}} Unknown {{end}} {{end}} {{else}} - Failed{{if $tx.EthereumSpecific.Error}} {{$tx.EthereumSpecific.Error}}{{end}} + Failed{{if $eth.Error}} {{$eth.Error}}{{end}} {{end}} @@ -45,40 +46,55 @@
{{$tx.Txid}} Gas Used / Limit - {{if $tx.EthereumSpecific.GasUsed}}{{formatBigInt $tx.EthereumSpecific.GasUsed}}{{else}}pending{{end}} / {{formatBigInt $tx.EthereumSpecific.GasLimit}} + {{if $eth.GasUsed}}{{formatBigInt $eth.GasUsed}}{{else}}pending{{end}} / {{formatBigInt $eth.GasLimit}} Gas Price - {{amountSpan $tx.EthereumSpecific.GasPrice $data "copyable"}} ({{amountSatsSpan $tx.EthereumSpecific.GasPrice $data "copyable"}} Gwei) + {{amountSpan $eth.GasPrice $data "copyable"}} ({{amountSatsSpan $eth.GasPrice $data "copyable"}} Gwei) - {{else}} + {{if $eth.MaxPriorityFeePerGas}} - Total Input - {{amountSpan $tx.ValueInSat $data "copyable"}} + Max Priority Fee Per Gas + {{amountSpan $eth.MaxPriorityFeePerGas $data "copyable"}} ({{amountSatsSpan $eth.MaxPriorityFeePerGas $data "copyable"}} Gwei) + {{end}} + {{if $eth.MaxFeePerGas}} - Total Output - {{amountSpan $tx.ValueOutSat $data "copyable"}} + Max Fee Per Gas + {{amountSpan $eth.MaxFeePerGas $data "copyable"}} ({{amountSatsSpan $eth.MaxFeePerGas $data "copyable"}} Gwei) - {{if $tx.VSize}} + {{end}} + {{if $eth.BaseFeePerGas}} - Size / vSize - {{formatInt $tx.Size}} / {{formatInt $tx.VSize}} + Base Fee Per Gas + {{amountSpan $eth.BaseFeePerGas $data "copyable"}} ({{amountSatsSpan $eth.BaseFeePerGas $data "copyable"}} Gwei) - {{else}} - {{if $tx.Size}} + {{end}} + {{if $eth.L1GasUsed}} - Size - {{formatInt $tx.Size}} + L1 Gas Used + {{formatBigInt $eth.L1GasUsed}} {{end}} + {{if $eth.L1GasPrice}} + + L1 Gas Price + {{amountSpan $eth.L1GasPrice $data "copyable"}} ({{amountSatsSpan $eth.L1GasPrice $data "copyable"}} Gwei) + + {{end}} + {{if $eth.L1FeeScalar}} + + L1 Fee Scalar + {{$eth.L1FeeScalar}} + {{end}} {{end}} {{if $tx.FeesSat}} Fees {{amountSpan $tx.FeesSat $data "copyable"}}{{if $tx.Size}} ({{feePerByte $tx}}){{end}} - {{end}} + + {{end}} {{if not $tx.Confirmations}} {{if $tx.ConfirmationETABlocks}} @@ -99,29 +115,34 @@
{{$tx.Txid}} {{end}} + {{if $eth}} + + Nonce + {{$eth.Nonce}} + + {{end}}
{{template "txdetail" .}}
-{{if eq .ChainType 1}} -{{if $tx.EthereumSpecific.ParsedData}} -{{if $tx.EthereumSpecific.ParsedData.Function }} +{{if and $eth $eth.ParsedData}} +{{if $eth.ParsedData.Function }}
Input Data

-
{{$tx.EthereumSpecific.Data}}
-
{{$tx.EthereumSpecific.ParsedData.Function}}
- {{if $tx.EthereumSpecific.ParsedData.Params}} +
{{$eth.Data}}
+
{{$eth.ParsedData.Function}}
+ {{if $eth.ParsedData.Params}}
@@ -132,7 +153,7 @@
{{if $tx.EthereumSpecific.ParsedData.Name}}{{$tx.EthereumSpecif
- {{range $i,$p := $tx.EthereumSpecific.ParsedData.Params}} + {{range $i,$p := $eth.ParsedData.Params}} @@ -156,15 +177,20 @@
{{if $tx.EthereumSpecific.ParsedData.Name}}{{$tx.EthereumSpecif {{end}} {{end}} -{{end}}
-
Raw Transaction
-
-

+    
+    
+    
+

     
-
{{end}} diff --git a/static/templates/tx_tron.html b/static/templates/tx_tron.html new file mode 100644 index 0000000000..8ff5422fce --- /dev/null +++ b/static/templates/tx_tron.html @@ -0,0 +1,244 @@ +{{define "specific"}}{{$tx := .Tx}}{{$data := .}}{{$eth := $tx.EthereumSpecific}}{{$chainExtra := chainExtra $tx}} +
+

Transaction

+
+
+
{{$tx.Txid}}
+
+
{{$i}} {{$p.Type}}
+ + {{if $tx.Confirmations}} + + + + + {{end}} + + + + + {{if $tx.Confirmations}} + + + + + {{end}} + + + {{if $eth.Status}} + {{if eq $eth.Status 1}} + + {{else}} + {{if eq $eth.Status -1}} + + {{else}} + + {{end}} + {{end}} + {{else}} + + {{end}} + + + + + + {{if $chainExtra.Operation}} + + + + + {{end}} + {{if $chainExtra.ContractType}} + + + + + {{end}} + {{if $chainExtra.Resource}} + + + + + {{end}} + {{if $chainExtra.DelegateTo}} + + + + + {{end}} + {{if $chainExtra.Votes}} + + + + + {{end}} + {{if $chainExtra.StakeAmountValue}} + + + + + {{else if $chainExtra.StakeAmount}} + + + + + {{end}} + {{if $chainExtra.UnstakeAmountValue}} + + + + + {{else if $chainExtra.UnstakeAmount}} + + + + + {{end}} + {{if $chainExtra.DelegateAmountValue}} + + + + + {{else if $chainExtra.DelegateAmount}} + + + + + {{end}} + {{if $chainExtra.ClaimedVoteRewardValue}} + + + + + {{else if $chainExtra.ClaimedVoteReward}} + + + + + {{end}} + {{if $chainExtra.AssetIssueID}} + + + + + {{end}} + {{if $chainExtra.Result}} + + + + + {{end}} + {{if $chainExtra.EnergyUsageTotal}} + + + + + {{end}} + {{if $chainExtra.EnergyUsage}} + + + + + {{end}} + {{if $chainExtra.EnergyFeeAmount}} + + + + + {{end}} + + + + + {{if $chainExtra.BandwidthFeeAmount}} + + + + + {{end}} + {{if $chainExtra.TotalFeeAmount}} + + + + + {{end}} + +
Included at{{unixTimeSpan $tx.Blocktime}}
In Block{{if $tx.Confirmations}}{{$tx.Blockhash}}{{else}}Unconfirmed{{end}}
In Block Height{{formatInt $tx.Blockheight}}
StatusSuccessPendingUnknownFailed{{if $eth.Error}} {{$eth.Error}}{{end}}
Value{{amountSpan $tx.ValueOutSat $data "copyable"}}
Operation{{$chainExtra.Operation}}
Contract Type{{$chainExtra.ContractType}}
Resource{{$chainExtra.Resource}}
Delegate To{{addressAliasSpan $chainExtra.DelegateTo $data}}
Votes + {{range $i, $vote := $chainExtra.Votes}} + {{if $i}}
{{end}} + {{if $vote.Count}}{{$vote.Count}}{{end}} + {{if $vote.Address}}{{if $vote.Count}} for {{end}}{{addressAliasSpan $vote.Address $data}}{{end}} + {{end}} +
Stake Amount{{formattedAmountSpan $chainExtra.StakeAmountValue 6 "TRX" $data "copyable"}}
Stake Amount{{$chainExtra.StakeAmount}} sun
Unstake Amount{{formattedAmountSpan $chainExtra.UnstakeAmountValue 6 "TRX" $data "copyable"}}
Unstake Amount{{$chainExtra.UnstakeAmount}} sun
Delegate Amount{{formattedAmountSpan $chainExtra.DelegateAmountValue 6 "TRX" $data "copyable"}}
Delegate Amount{{$chainExtra.DelegateAmount}} sun
Withdraw Reward Amount{{formattedAmountSpan $chainExtra.ClaimedVoteRewardValue 6 "TRX" $data "copyable"}}
Withdraw Reward Amount{{$chainExtra.ClaimedVoteReward}} sun
TRC10 Asset ID{{$chainExtra.AssetIssueID}}
Result{{$chainExtra.Result}}
Energy Used / Limit{{$chainExtra.EnergyUsageTotal}} / {{$chainExtra.FeeLimit}}
Energy Usage{{$chainExtra.EnergyUsage}}
Energy Fee{{formattedAmountSpan $chainExtra.EnergyFeeAmount 6 "TRX" $data "copyable"}}
Bandwidth Usage{{$chainExtra.BandwidthUsage}}
Bandwidth Fee{{formattedAmountSpan $chainExtra.BandwidthFeeAmount 6 "TRX" $data "copyable"}}
Burned Fee{{formattedAmountSpan $chainExtra.TotalFeeAmount 6 "TRX" $data "copyable"}}
+
+ {{template "txdetail" .}} +
+{{if and $eth $eth.ParsedData}} +{{if $eth.ParsedData.Function }} +
+
Input Data
+
+
+

+ +

+
+
+
+
{{$eth.Data}}
+
{{$eth.ParsedData.Function}}
+ {{if $eth.ParsedData.Params}} +
+ + + + + + + + + + {{range $i,$p := $eth.ParsedData.Params}} + + + + + + {{end}} + +
#TypeData
{{$i}}{{$p.Type}} + {{range $j,$v := $p.Values}} + {{if $j}}
{{end}} + {{if hasPrefix $p.Type "address"}}{{addressAliasSpan $v $data}}{{else}}{{$v}}{{end}} + {{end}} +
+
+ {{end}} +
+
+
+
+
+
+{{end}} +{{end}} +
+ + +
+

+    
+ +
+{{end}} diff --git a/static/templates/txdetail.html b/static/templates/txdetail.html index a5f53758fd..3472323e6d 100644 --- a/static/templates/txdetail.html +++ b/static/templates/txdetail.html @@ -59,8 +59,8 @@